Compare commits
No files matched your search
@@ -0,0 +1,53 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6
|
||||
|
||||
# ─── Workflows ───────────────────────────────────────────────────────────────
|
||||
# Branch strategy mirrors the QRun-IO orb convention:
|
||||
# test_only → all feature / non-protected branches
|
||||
# publish_snapshot → develop
|
||||
# publish_release_candidate → release/*
|
||||
# publish_release → main / version tags
|
||||
# publish_hotfix_release → hotfix/*
|
||||
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/node_app_test_only:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ develop ]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ /release\/.*/ ]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ main ]
|
||||
tags:
|
||||
only: [ /v.*/ ]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ /hotfix\/.*/ ]
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(which pnpm:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(pnpm create:*)",
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(pnpm approve-builds:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(pnpm tsc:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(git:*)",
|
||||
"Bash(pnpm dlx:*)",
|
||||
"Bash(echo killed:*)",
|
||||
"Bash(cp:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(sleep 1:*)",
|
||||
"Bash(pnpm list:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(pnpm lint:*)",
|
||||
"Bash(pnpm test:e2e:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(NEXT_PUBLIC_MOCK_API=true pnpm test:e2e:*)",
|
||||
"Bash(pnpm test:coverage:*)",
|
||||
"Bash(pnpm audit:*)",
|
||||
"Bash(pnpm up:*)",
|
||||
"Bash(pnpm remove:*)",
|
||||
"Bash(./node_modules/.bin/next lint:*)",
|
||||
"Bash(NEXT_PUBLIC_MOCK_API=true pnpm dev:*)",
|
||||
"mcp__circleci-mcp-server__list_followed_projects",
|
||||
"Bash(echo circleci CLI not found:*)",
|
||||
"Bash(circleci config:*)",
|
||||
"Bash(tail:*)",
|
||||
"mcp__circleci-mcp-server__get_latest_pipeline_status",
|
||||
"Bash(circleci orb:*)",
|
||||
"Bash(circleci diagnostic:*)",
|
||||
"mcp__circleci-mcp-server__run_pipeline",
|
||||
"mcp__circleci-mcp-server__get_build_failure_logs",
|
||||
"Bash(cd /Users/james.maes/Git.Local/QRun-IO/qqq-orb && make validate 2>&1)",
|
||||
"mcp__circleci-mcp-server__get_job_test_results",
|
||||
"Bash(pnpm typecheck:*)",
|
||||
"Bash(pnpm dev:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(echo Exit: $?:*)",
|
||||
"Bash(gh api:*)",
|
||||
"Bash(pnpm why:*)",
|
||||
"Bash(npm view:*)",
|
||||
"Bash(chmod +x:*)",
|
||||
"Bash(done)",
|
||||
"Bash(pnpm exec:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.next
|
||||
node_modules
|
||||
test-results
|
||||
playwright-report
|
||||
coverage
|
||||
.env*
|
||||
*.log
|
||||
@@ -0,0 +1,8 @@
|
||||
# Mock API environment — copy to .env.local to run without a real backend
|
||||
#
|
||||
# cp .env.mock .env.local && pnpm dev
|
||||
# OR pass inline:
|
||||
# NEXT_PUBLIC_MOCK_API=true pnpm dev
|
||||
|
||||
NEXT_PUBLIC_MOCK_API=true
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/qqq/v1
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# .githooks/pre-commit
|
||||
#
|
||||
# Enforces the Apache 2.0 license header on all staged TypeScript/TSX files.
|
||||
# Registered automatically via "prepare" in package.json.
|
||||
#
|
||||
# To install hooks manually:
|
||||
# git config core.hooksPath .githooks
|
||||
|
||||
pnpm lint:license
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Dashboard image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/qrun-io/qqq-frontend-next
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
if: github.event_name == 'push'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/quickstart/Dockerfile
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
load: ${{ github.event_name == 'pull_request' }}
|
||||
tags: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=dashboard-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=dashboard-${{ matrix.arch }}
|
||||
- name: Verify runnable dashboard
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
TAG: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
|
||||
run: |
|
||||
container=$(docker run -d --rm -p 127.0.0.1:3000:3000 "$TAG")
|
||||
trap 'docker rm -f "$container"' EXIT
|
||||
curl -fsS --max-time 3 --retry 30 --retry-delay 1 --retry-all-errors \
|
||||
--retry-max-time 90 http://127.0.0.1:3000/app/person >/dev/null \
|
||||
|| { docker logs "$container"; exit 1; }
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push'
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish versioned multi-architecture image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag "$IMAGE:${GITHUB_REF_NAME#v}" \
|
||||
"$IMAGE:$GITHUB_SHA-amd64" "$IMAGE:$GITHUB_SHA-arm64"
|
||||
@@ -0,0 +1,37 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.claude/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-docs',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/nextjs',
|
||||
options: {},
|
||||
},
|
||||
webpackFinal: async (config) => {
|
||||
if (config.resolve) {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@': fileURLToPath(new URL('../src', import.meta.url)),
|
||||
}
|
||||
}
|
||||
return config
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Preview } from '@storybook/react'
|
||||
import '../src/styles/globals.css'
|
||||
|
||||
const preview: Preview = {
|
||||
tags: ['autodocs'],
|
||||
initialGlobals: { backgrounds: { value: 'light' } },
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
options: {
|
||||
light: { name: 'light', value: '#ffffff' },
|
||||
dark: { name: 'dark', value: '#0f172a' },
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default preview
|
||||
@@ -0,0 +1,184 @@
|
||||
# CLAUDE.md — QQQ Frontend Next
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is the **QQQ Admin UI Modernization** project — a complete rewrite of the QQQ admin frontend from React + Material UI to Next.js + Tailwind CSS + shadcn/ui.
|
||||
|
||||
QQQ is a metadata-driven, low-code application framework. The admin UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded. The backend declares metadata (tables, processes, apps, widgets), and the frontend renders it dynamically.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 15 (App Router) with React 19
|
||||
- **Language:** TypeScript 5.x (`strict: true` — no `any` in component props)
|
||||
- **Styling:** Tailwind CSS 4.x with CSS custom properties for QQQ theme tokens
|
||||
- **Components:** shadcn/ui (Radix primitives + Tailwind)
|
||||
- **Data Grid:** TanStack Table v8 (replaces MUI X DataGrid Pro)
|
||||
- **Forms:** React Hook Form v7 + Zod validation (replaces Formik + Yup)
|
||||
- **Data Fetching:** TanStack Query v5
|
||||
- **Charts:** Recharts
|
||||
- **Testing:** Vitest (unit), Playwright (E2E), axe-core (a11y)
|
||||
- **Package Manager:** pnpm
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ ├── (auth)/ # Auth routes (login, callback)
|
||||
│ ├── (dashboard)/ # Authenticated routes
|
||||
│ │ ├── layout.tsx # Dashboard layout (sidebar + nav + banners)
|
||||
│ │ └── app/ # All app routes
|
||||
│ │ ├── [appName]/page.tsx # App home (dashboard)
|
||||
│ │ ├── [tableName]/page.tsx # Record query
|
||||
│ │ ├── [tableName]/create/page.tsx # Create record
|
||||
│ │ ├── [tableName]/[recordId]/page.tsx # Record view
|
||||
│ │ ├── [tableName]/[recordId]/edit/page.tsx # Edit record
|
||||
│ │ └── [processName]/page.tsx # Process execution
|
||||
│ ├── layout.tsx # Root layout (providers)
|
||||
│ ├── error.tsx # Global error boundary
|
||||
│ └── not-found.tsx # 404
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui primitives
|
||||
│ ├── layout/ # Sidebar, Header, Breadcrumbs, Banner
|
||||
│ ├── records/ # RecordView, FieldValue
|
||||
│ ├── forms/ # DynamicForm, EntityForm, field components
|
||||
│ ├── query/ # DataGrid, FilterBuilder, ColumnConfig, Pagination
|
||||
│ ├── process/ # StepWizard, ValidationReview, BulkLoad
|
||||
│ ├── widgets/ # Widget containers, charts, blocks, grids
|
||||
│ └── feedback/ # Alerts, Toasts, Modals, CommandMenu
|
||||
├── lib/
|
||||
│ ├── api/ # Typed API client modules
|
||||
│ │ ├── client.ts # Base axios client with 401 interceptor
|
||||
│ │ ├── auth.ts # Auth endpoints
|
||||
│ │ ├── metadata.ts # Metadata endpoints
|
||||
│ │ ├── tables.ts # CRUD + query/count
|
||||
│ │ ├── processes.ts # Process lifecycle
|
||||
│ │ ├── widgets.ts # Widget data
|
||||
│ │ └── possible-values.ts
|
||||
│ ├── auth/ # Auth providers (OAuth2, Auth0, Anonymous)
|
||||
│ ├── hooks/ # Shared React hooks
|
||||
│ ├── utils/ # Pure utility functions
|
||||
│ ├── context/ # React contexts (QContext)
|
||||
│ └── theme/ # Theme provider, CSS tokens
|
||||
├── types/ # TypeScript type definitions
|
||||
└── styles/ # Global styles, theme tokens
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Metadata-Driven Rendering
|
||||
- ALL components that render from metadata accept it as a prop — never fetch internally
|
||||
- No hardcoded table names, field names, or process names anywhere
|
||||
- User-facing text uses `label` from metadata, never `name`
|
||||
- UI structure derives entirely from backend metadata
|
||||
|
||||
### API Integration
|
||||
- All API calls go through typed functions in `src/lib/api/` — never raw `fetch`
|
||||
- TanStack Query manages server state (caching, deduplication, revalidation)
|
||||
- Query keys follow the factory pattern in `src/lib/query-client.ts`
|
||||
- 401 responses intercepted globally → redirect to login
|
||||
|
||||
### Component Patterns
|
||||
- Server Components: root layouts, metadata-fetching pages
|
||||
- Client Components: interactive UI (forms, grids, filters) — mark with `"use client"`
|
||||
- Every interactive component has a `data-qqq-id` attribute for CSS customization
|
||||
- Naming: `data-qqq-id="{component-type}-{identifier}"` (e.g., `data-qqq-id="button-save"`)
|
||||
|
||||
### State Management
|
||||
- Server state: TanStack Query
|
||||
- UI state: React context (QContext)
|
||||
- Form state: React Hook Form
|
||||
- URL state: Next.js searchParams (filters, pagination — shareable URLs)
|
||||
- Local persistence: localStorage (density, column widths, saved views)
|
||||
|
||||
### File Naming
|
||||
- Components: PascalCase (`RecordQuery.tsx`)
|
||||
- Hooks: camelCase with `use-` prefix (`use-metadata.ts`)
|
||||
- Utilities: kebab-case (`filter-utils.ts`)
|
||||
- Types: kebab-case (`metadata.ts`)
|
||||
- Tests: `*.test.ts(x)` co-located with source
|
||||
|
||||
### Import Order
|
||||
```typescript
|
||||
// 1. React/Next.js
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// 2. External libraries
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
// 3. Types
|
||||
import type { QTableMetaData } from '@/types'
|
||||
|
||||
// 4. Lib (API, hooks, utils)
|
||||
import { queryRecords } from '@/lib/api/tables'
|
||||
|
||||
// 5. Components
|
||||
import { Button } from '@/components/ui/button'
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- Error boundaries at route level (`error.tsx`) and widget level
|
||||
- API errors via TanStack Query `onError` → toast notification
|
||||
- Form validation: Zod schemas from field metadata
|
||||
- Loading: skeleton components matching final layout
|
||||
- Empty: descriptive messages with action buttons
|
||||
|
||||
### Accessibility (WCAG 2.1 AA)
|
||||
- All form inputs: `<label>` with `htmlFor`, `aria-required`, `aria-invalid`
|
||||
- All buttons: descriptive text or `aria-label`
|
||||
- Focus trapped in modals, restored on close
|
||||
- Keyboard navigation for all interactive elements
|
||||
- Color contrast: 4.5:1 normal, 3:1 large text
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Production build
|
||||
pnpm test # Run Vitest unit tests
|
||||
pnpm test:e2e # Run Playwright E2E tests
|
||||
pnpm tsc --noEmit # Type check without emitting
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
```
|
||||
|
||||
## Implementation Plans
|
||||
|
||||
Full implementation plans are in `docs/implementation-plans/`. The project is built in 6 work packages:
|
||||
|
||||
1. **Package 1** — Scaffold, Auth, Layout Shell (foundation — must be first)
|
||||
2. **Package 2** — Record Query (data grid, filters, pagination, saved views)
|
||||
3. **Package 3** — Record View + CRUD (forms, field types, validation)
|
||||
4. **Package 4** — Process Execution (step wizard, async polling, bulk load)
|
||||
5. **Package 5** — Dashboard + Widgets (charts, statistics, blocks, grids)
|
||||
6. **Package 6** — Polish + Parity (responsive, a11y, perf, Storybook, E2E)
|
||||
|
||||
Shared context for all agents: `docs/implementation-plans/shared-context/`
|
||||
|
||||
## Reference Codebase
|
||||
|
||||
The current frontend being replaced lives in the sibling `qqq-frontend-material-dashboard` repo. Key files to reference for behavior parity:
|
||||
- `App.tsx` — route generation, auth flow, layout
|
||||
- `src/qqq/pages/records/query/RecordQuery.tsx` — most complex page (260+ state vars)
|
||||
- `src/qqq/pages/records/view/RecordView.tsx` — record detail
|
||||
- `src/qqq/pages/processes/ProcessRun.tsx` — process execution
|
||||
- `src/qqq/components/widgets/` — 60+ widget components
|
||||
|
||||
## API Base URL
|
||||
|
||||
Set via `NEXT_PUBLIC_API_BASE_URL` environment variable. Defaults to `/qqq/v1`.
|
||||
The API uses cookie-based session auth (`sessionUUID` cookie).
|
||||
|
||||
## Knowledge base
|
||||
|
||||
Durable, cross-session knowledge about this repo and the wider QQQ platform lives in
|
||||
the second-brain vault (`R:/Git.Local/KofTwentyTwo/second-brain`):
|
||||
|
||||
- Platform hub: `knowledge/qqq/qqq-hub.md`
|
||||
- This repo's dossier: `knowledge/qqq/repos/qqq-frontend-next.md`
|
||||
(reviewed at commit `41cd6d2034a8` on branch `main`, 2026-07-04)
|
||||
|
||||
Consult the dossier before deep-diving the code — it covers the API contract this
|
||||
frontend targets (including versioned-middleware endpoint gaps), maturity assessment,
|
||||
known parity gaps, and its relationship to `qqq-frontend-material-dashboard`.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
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.
|
||||
@@ -1,3 +1,108 @@
|
||||
# PR assets: SQL Workbench theme
|
||||
# QQQ Frontend Next
|
||||
|
||||
Before (main) and after screenshots of the SQL Workbench, light and dark, desktop and phone (mocked API, production build).
|
||||
A metadata-driven admin UI for the QQQ low-code application framework, rewritten from React + Material UI to Next.js 16 + Tailwind CSS + shadcn/ui. The UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded in the frontend.
|
||||
|
||||
## QQQ 4.0 compatibility
|
||||
|
||||
With published QQQ 4.0.0, Next renders canonical statistics, HTML and bar/line/pie charts through the framework's authenticated `/widget/{name}` and full `/metaData` routes; deployments must forward these routes at the same application root as `/qqq/v1`. Next does not yet have full Material dashboard widget parity: multi-statistics, tables, stacked bars, steppers, small line charts and canonical composite children remain limited ([#550](https://github.com/QRun-IO/qqq/issues/550), deferred). Full dashboard parity remains a follow-up; the published QQQ 4.0.0 release does not imply that every Material widget is supported by Next.
|
||||
|
||||
## Run the local QQQ sample
|
||||
|
||||
Use the [QQQ quickstart](https://github.com/QRun-IO/qqq/blob/main/qqq-sample-project/README.md#quickstart-with-next) to clone editable Java sample source, compile against published QQQ 4.0.0, and launch this dashboard by default. It checks JDK 21+, Git, curl, unzip, and Docker; no Node.js or Maven installation is needed. Sample data is local and resets on restart.
|
||||
|
||||
The versioned image `ghcr.io/qrun-io/qqq-frontend-next:0.1.0` supplies Next on port 3000 and proxies the sample's API on host port 8000. It is built from `docker/quickstart/Dockerfile` for Linux amd64 and arm64 with browser mocks disabled. The launcher publishes the dashboard on localhost and removes its container on Ctrl+C. This image is configured for the local sample; application-specific deployments should set their backend during the build.
|
||||
|
||||
## Frontend development prerequisites
|
||||
|
||||
- **Node.js** 20.19+ or 22.12+ (required by the Storybook developer tooling)
|
||||
- **pnpm** (install with `npm install -g pnpm` if not already present)
|
||||
|
||||
## Frontend development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QRun-IO/qqq-frontend-next.git
|
||||
cd qqq-frontend-next
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The dev server starts at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | `/qqq/v1` | Base URL for all QQQ backend API calls |
|
||||
| `QQQ_BACKEND_URL` | _(unset)_ | Build-time backend origin for same-origin API rewrites; the quickstart image uses `http://host.docker.internal:8000` |
|
||||
| `NEXT_PUBLIC_MOCK_API` | _(unset)_ | Set to `true` to activate MSW browser mocks for dev/demo without a live backend |
|
||||
|
||||
Copy `.env.example` to `.env.local` and adjust as needed.
|
||||
|
||||
## Available Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pnpm dev` | Start the Next.js development server (Turbopack) |
|
||||
| `pnpm build` | Compile a production build |
|
||||
| `pnpm start` | Serve the production build |
|
||||
| `pnpm test` | Run Vitest unit tests (single pass) |
|
||||
| `pnpm test:watch` | Run Vitest in watch mode |
|
||||
| `pnpm test:coverage` | Run Vitest with V8 coverage report |
|
||||
| `pnpm test:e2e` | Run Playwright end-to-end tests |
|
||||
| `pnpm typecheck` | Run `tsc --noEmit` (type check without emitting files) |
|
||||
| `pnpm lint` | Run ESLint + Apache license header check |
|
||||
| `pnpm format` | Run Prettier over all source files |
|
||||
| `pnpm storybook` | Start Storybook component explorer on port 6006 |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ ├── (auth)/ # Login and OAuth callback routes
|
||||
│ ├── (dashboard)/ # Authenticated shell (sidebar + nav)
|
||||
│ │ └── app/ # All app routes (tables, records, processes)
|
||||
│ ├── layout.tsx # Root layout — providers
|
||||
│ ├── error.tsx # Global error boundary
|
||||
│ └── not-found.tsx # 404 page
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui primitives (Button, Input, Dialog, …)
|
||||
│ ├── layout/ # Sidebar, Header, Breadcrumbs, Banner
|
||||
│ ├── records/ # RecordView, FieldValue
|
||||
│ ├── forms/ # DynamicForm, EntityForm, field components
|
||||
│ ├── query/ # DataGrid, FilterBuilder, ColumnConfig, Pagination
|
||||
│ ├── process/ # StepWizard, ValidationReview, BulkLoad
|
||||
│ ├── widgets/ # Widget containers, charts, stat blocks, grids
|
||||
│ └── feedback/ # Alerts, Toasts, Modals, CommandMenu
|
||||
├── lib/
|
||||
│ ├── api/ # Typed API client modules (client.ts, tables.ts, …)
|
||||
│ ├── auth/ # Auth providers (OAuth2, Auth0, Anonymous)
|
||||
│ ├── hooks/ # Shared React hooks (useLocalStorage, useMetadata, …)
|
||||
│ ├── utils/ # Pure utility functions
|
||||
│ ├── context/ # React contexts (QContext)
|
||||
│ └── theme/ # Theme provider and CSS custom property tokens
|
||||
├── types/ # TypeScript type definitions
|
||||
└── styles/ # Global styles and theme tokens
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Purpose |
|
||||
|---|---|
|
||||
| [`CLAUDE.md`](./CLAUDE.md) | Full architecture reference — conventions, patterns, key decisions |
|
||||
| [`docs/STATE-MANAGEMENT.md`](./docs/STATE-MANAGEMENT.md) | When to use each state container (TanStack Query, RHF, URL params, localStorage, QContext) |
|
||||
| [`docs/API-ERROR-HANDLING.md`](./docs/API-ERROR-HANDLING.md) | Global error handler, 401 interceptor, per-status guidance, code examples |
|
||||
| [`docs/implementation-plans/`](./docs/implementation-plans/) | Work-package implementation plans (6 packages) |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 15 (App Router) with React 19
|
||||
- **Language:** TypeScript 5.x (`strict: true`)
|
||||
- **Styling:** Tailwind CSS 4.x with CSS custom properties
|
||||
- **Components:** shadcn/ui (Radix primitives + Tailwind)
|
||||
- **Data Grid:** TanStack Table v8
|
||||
- **Forms:** React Hook Form v7 + Zod
|
||||
- **Data Fetching:** TanStack Query v5
|
||||
- **Charts:** Recharts
|
||||
- **Testing:** Vitest (unit), Playwright (E2E), axe-core (a11y)
|
||||
- **Package Manager:** pnpm
|
||||
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,22 @@
|
||||
FROM registry-1.docker.io/library/node:22-bookworm-slim AS frontend
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
ENV QQQ_BACKEND_URL=http://host.docker.internal:8000
|
||||
ENV NEXT_PUBLIC_MOCK_API=false
|
||||
RUN pnpm build
|
||||
|
||||
FROM registry-1.docker.io/library/node:22-bookworm-slim AS runtime
|
||||
LABEL org.opencontainers.image.source="https://github.com/QRun-IO/qqq-frontend-next"
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
|
||||
COPY --from=frontend --chown=node:node /app/.next/standalone ./
|
||||
COPY --from=frontend --chown=node:node /app/.next/static ./.next/static
|
||||
COPY --from=frontend --chown=node:node /app/public ./public
|
||||
COPY LICENSE ./LICENSE
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,293 @@
|
||||
# Master Agent Execution Plan — QQQ Frontend Next
|
||||
|
||||
## Your Role
|
||||
|
||||
You are the **Master Orchestrator Agent**. Your job is to build the entire QQQ admin UI replacement by spawning and controlling implementation sub-agents, one per work package, in the correct dependency order. You delegate all coding work to sub-agents. You verify their output, handle failures, and ensure the full project compiles and integrates at the end.
|
||||
|
||||
## Repository
|
||||
|
||||
You are working in `qqq-frontend-next` — a new repo for the Next.js replacement of the QQQ admin UI.
|
||||
|
||||
### Critical Files in This Repo
|
||||
|
||||
```
|
||||
docs/
|
||||
├── qqq-admin-modernization-requirements.md # Full requirements spec
|
||||
├── master-architect-agent-prompt.md # How the plans were designed
|
||||
├── 00-master-execution-plan.md # THIS FILE — your instructions
|
||||
└── implementation-plans/
|
||||
├── 00-project-overview.md # Tech stack, directory structure, conventions
|
||||
├── 01-scaffold-and-auth.md # Work Package 1 plan
|
||||
├── 02-record-query.md # Work Package 2 plan
|
||||
├── 03-record-view-and-crud.md # Work Package 3 plan
|
||||
├── 04-process-execution.md # Work Package 4 plan
|
||||
├── 05-dashboard-and-widgets.md # Work Package 5 plan
|
||||
├── 06-polish-and-parity.md # Work Package 6 plan
|
||||
├── cross-check-report.md # Requirements coverage verification
|
||||
├── shared-context/
|
||||
│ ├── api-contract.md # API spec — give to every agent
|
||||
│ ├── type-definitions.md # TypeScript types — give to every agent
|
||||
│ └── coding-conventions.md # Coding standards — give to every agent
|
||||
└── review-checklists/
|
||||
├── review-01-scaffold.md through review-06-polish.md
|
||||
```
|
||||
|
||||
### Reference Repos (Read-Only)
|
||||
|
||||
If accessible, the agents should also reference:
|
||||
- `qqq-frontend-material-dashboard` — The current UI being replaced (behavior parity)
|
||||
- `qqq-frontend-core` — TypeScript model types and API client (types to port)
|
||||
- `qqq` — Backend middleware (API endpoint source of truth)
|
||||
|
||||
---
|
||||
|
||||
## Execution Phases
|
||||
|
||||
### Phase 1: Foundation (BLOCKING — must complete before any other phase)
|
||||
|
||||
**Spawn Agent 1: Package 1 — Scaffold + Auth + Layout Shell**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/01-scaffold-and-auth.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/api-contract.md`
|
||||
> - `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `docs/implementation-plans/00-project-overview.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementations in `qqq-frontend-material-dashboard`:
|
||||
> - `src/App.tsx` — Route generation, auth dispatch, layout
|
||||
> - `src/qqq/components/horseshoe/` — SideNav, NavBar, Breadcrumbs
|
||||
>
|
||||
> And from `qqq-frontend-core`:
|
||||
> - `src/model/**/*.ts` — All TypeScript types to port
|
||||
> - `src/controllers/QController.ts` — Auth methods
|
||||
>
|
||||
> Execute every step in the implementation plan (Steps 1-11).
|
||||
> After each step, verify: `pnpm tsc --noEmit` passes.
|
||||
> When done, verify ALL acceptance criteria in Section 9.
|
||||
> Commit your work.
|
||||
|
||||
**Verification gate before proceeding:**
|
||||
```bash
|
||||
pnpm dev # Must start without errors
|
||||
pnpm build # Must succeed
|
||||
pnpm tsc --noEmit # Zero errors
|
||||
pnpm test # All tests pass
|
||||
```
|
||||
|
||||
If the agent fails, fix the issues before moving to Phase 2.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Core Pages (CAN BE PARALLEL — no file conflicts)
|
||||
|
||||
**Spawn Agent 2: Package 2 — Record Query**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/02-record-query.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/api-contract.md`
|
||||
> - `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `docs/implementation-plans/00-project-overview.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementation:
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/pages/records/query/RecordQuery.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/query/` (all files)
|
||||
> - `qqq-frontend-core/src/controllers/QControllerV1.ts` (query/count methods)
|
||||
>
|
||||
> Execute every step in the implementation plan.
|
||||
> The foundation from Package 1 is already in place — import from it.
|
||||
> After each step: `pnpm tsc --noEmit`
|
||||
> When done: verify ALL acceptance criteria in Section 9.
|
||||
> Commit your work.
|
||||
|
||||
**Spawn Agent 3: Package 3 — Record View + CRUD**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/03-record-view-and-crud.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/api-contract.md`
|
||||
> - `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `docs/implementation-plans/00-project-overview.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementation:
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/pages/records/view/RecordView.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/forms/` (all files)
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/forms/EntityForm.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/forms/DynamicForm.tsx`
|
||||
> - `qqq-frontend-core/src/controllers/QController.ts` (CRUD methods: get, create, update, delete)
|
||||
>
|
||||
> Execute every step in the implementation plan.
|
||||
> The foundation from Package 1 is already in place — import from it.
|
||||
> After each step: `pnpm tsc --noEmit`
|
||||
> When done: verify ALL acceptance criteria in Section 9.
|
||||
> Commit your work.
|
||||
|
||||
**Verification gate before proceeding:**
|
||||
Both agents must complete. Then:
|
||||
```bash
|
||||
pnpm tsc --noEmit # Zero errors (both packages together)
|
||||
pnpm test # All tests pass
|
||||
pnpm build # Must succeed
|
||||
```
|
||||
|
||||
If running in parallel on branches, merge both to main and resolve conflicts before Phase 3.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Process Execution (SEQUENTIAL — needs forms from Package 3)
|
||||
|
||||
**Spawn Agent 4: Package 4 — Process Execution**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/04-process-execution.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/api-contract.md`
|
||||
> - `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementation:
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/pages/processes/ProcessRun.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/processes/` (all files)
|
||||
> - `qqq-frontend-core/src/controllers/QController.ts` (process methods)
|
||||
>
|
||||
> IMPORTANT: You must reuse form components from Package 3:
|
||||
> - Import `EntityForm` from `@/components/forms/EntityForm`
|
||||
> - Import `DynamicFormField` from `@/components/forms/DynamicFormField`
|
||||
> - Import form hooks and possible-values utilities
|
||||
>
|
||||
> Execute every step. After each: `pnpm tsc --noEmit`.
|
||||
> When done: verify ALL acceptance criteria.
|
||||
> Commit your work.
|
||||
|
||||
**Verification gate:**
|
||||
```bash
|
||||
pnpm tsc --noEmit && pnpm test && pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Dashboard + Widgets (needs DataGrid from P2, forms from P3)
|
||||
|
||||
**Spawn Agent 5: Package 5 — Dashboard and Widgets**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/05-dashboard-and-widgets.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/api-contract.md`
|
||||
> - `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementation:
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/widgets/` (all files — 60+)
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/pages/apps/AppHome.tsx`
|
||||
>
|
||||
> IMPORTANT: Reuse components from previous packages:
|
||||
> - DataGrid from Package 2 (`@/components/query/DataGrid`) for RecordGridWidget
|
||||
> - EntityForm from Package 3 (`@/components/forms/EntityForm`) for DynamicFormWidget
|
||||
> - Cell renderers from Package 2 for field value display in widgets
|
||||
>
|
||||
> Execute every step. After each: `pnpm tsc --noEmit`.
|
||||
> When done: verify ALL acceptance criteria.
|
||||
> Commit your work.
|
||||
|
||||
**Verification gate:**
|
||||
```bash
|
||||
pnpm tsc --noEmit && pnpm test && pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Polish + Parity (needs EVERYTHING)
|
||||
|
||||
**Spawn Agent 6: Package 6 — Polish, Parity, and Production Readiness**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/06-polish-and-parity.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementation for features unique to this package:
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/misc/CommandMenu.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/audits/AuditBody.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/pages/records/developer/`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/scripts/ScriptViewer.tsx`
|
||||
>
|
||||
> This is the final package. ALL previous packages are complete.
|
||||
> Your job is: responsive audit, accessibility fixes, command palette, keyboard shortcuts,
|
||||
> audit trail, developer tools, Storybook, Playwright E2E, performance optimization,
|
||||
> theme tokens, and customCss injection.
|
||||
>
|
||||
> Execute every step. After each: `pnpm tsc --noEmit`.
|
||||
> When done: verify ALL acceptance criteria.
|
||||
> Run: `pnpm build && pnpm test && pnpm lint`
|
||||
> Commit your work.
|
||||
|
||||
---
|
||||
|
||||
## Final Verification (Master Agent Does This)
|
||||
|
||||
After all 6 agents complete:
|
||||
|
||||
1. **Full build check:**
|
||||
```bash
|
||||
pnpm tsc --noEmit # Zero errors
|
||||
pnpm build # Production build succeeds
|
||||
pnpm test # All unit tests pass
|
||||
pnpm lint # Zero lint errors
|
||||
```
|
||||
|
||||
2. **Review checklists:** Read and verify each file in `docs/implementation-plans/review-checklists/`:
|
||||
- `review-01-scaffold.md` through `review-06-polish.md`
|
||||
- Every checkbox should be satisfiable
|
||||
|
||||
3. **Cross-check report:** Read `docs/implementation-plans/cross-check-report.md` — all requirements should show ✅
|
||||
|
||||
4. **Integration smoke test:** If a backend is available, verify the app end-to-end:
|
||||
- Login → sidebar loads → click table → query page loads → filter → paginate → click row → record view → edit → save → delete → navigate to process → run process → dashboard loads widgets
|
||||
|
||||
5. **Commit everything and push:**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "Complete QQQ Admin UI Modernization — all 6 work packages implemented"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Recovery
|
||||
|
||||
If an agent fails mid-execution:
|
||||
1. Read its error output
|
||||
2. Fix the immediate issue (missing import, type error, etc.)
|
||||
3. Resume the agent from where it left off (it should be able to continue from the last successful step)
|
||||
4. If the failure is architectural (wrong approach), read the relevant section of the implementation plan and correct course
|
||||
|
||||
If agents produce conflicting code (e.g., both define the same component):
|
||||
1. The implementation plans are designed to avoid this — each package owns specific files
|
||||
2. If it happens, the later package's version wins (it has more context)
|
||||
3. Verify the merged result compiles: `pnpm tsc --noEmit`
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints for ALL Agents
|
||||
|
||||
- **pnpm only** (not npm, not yarn)
|
||||
- **TypeScript strict** — no `any` in component props
|
||||
- **Every interactive element** gets `data-qqq-id` attribute
|
||||
- **All API calls** through `src/lib/api/` — never raw fetch/axios
|
||||
- **Metadata-driven** — no hardcoded table/field/process names
|
||||
- **Labels not names** — user-facing text uses `label` from metadata
|
||||
- **Import order** — React/Next → external libs → types → lib → components
|
||||
- **Error boundaries** at route level and widget level
|
||||
- **Loading skeletons** for all async content
|
||||
- **Empty states** with action buttons for all list/grid views
|
||||
@@ -0,0 +1,338 @@
|
||||
# Accessibility Reference — QQQ Frontend Next
|
||||
|
||||
**Standard:** WCAG 2.1 Level AA
|
||||
**Testing tools:** axe-core (automated), Playwright E2E assertions, manual keyboard testing
|
||||
|
||||
---
|
||||
|
||||
## WCAG 2.1 AA Compliance Status
|
||||
|
||||
The sections below summarise what has been implemented and what remains outstanding,
|
||||
based on the design review conducted 2026-03-01 (`docs/DESIGN-REVIEW-2026-03-01.md`).
|
||||
|
||||
---
|
||||
|
||||
## Key Implementations (Resolved Items)
|
||||
|
||||
### Skip-to-Content Link (D-L-1)
|
||||
|
||||
A visually hidden skip link is rendered as the first element in the dashboard layout
|
||||
(`src/app/(dashboard)/layout.tsx`). It becomes visible on keyboard focus and links to
|
||||
`#main-content`, allowing keyboard users to bypass the sidebar on every page navigation.
|
||||
|
||||
```tsx
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 ..."
|
||||
data-qqq-id="skip-to-content"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
```
|
||||
|
||||
The target `<main id="main-content" data-qqq-id="main-content">` is present in the same layout.
|
||||
|
||||
---
|
||||
|
||||
### `aria-sort` on DataGrid Column Headers (D-Q-1)
|
||||
|
||||
`src/components/query/DataGrid.tsx` computes an `aria-sort` value for every `<th>` based on
|
||||
the current TanStack Table sort state:
|
||||
|
||||
- Sorted ascending → `aria-sort="ascending"`
|
||||
- Sorted descending → `aria-sort="descending"`
|
||||
- Not sorted → `aria-sort="none"` (only on sortable columns; unsortable columns omit the attribute)
|
||||
|
||||
Screen readers announce the sort direction when the user focuses or activates a column header.
|
||||
|
||||
---
|
||||
|
||||
### Row Selection `aria-label` (D-Q-2)
|
||||
|
||||
Each row's selection checkbox carries a descriptive label:
|
||||
|
||||
```tsx
|
||||
aria-label={`Select ${row.original.recordLabel ?? 'record'}`}
|
||||
```
|
||||
|
||||
The "select all" header checkbox uses `aria-label="Select all rows on this page"`.
|
||||
|
||||
---
|
||||
|
||||
### Keyboard-Accessible Column Resize Handles (D-Q-3)
|
||||
|
||||
The resize handle on each column header responds to `ArrowLeft` and `ArrowRight` keyboard
|
||||
events, adjusting the column width by ±10 px per keypress. The handler is implemented in
|
||||
`handleResizeKeyDown` inside `DataGrid.tsx`:
|
||||
|
||||
```typescript
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
const delta = e.key === 'ArrowRight' ? 10 : -10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `aria-live` Region for Fetch-in-Progress (D-Q-6)
|
||||
|
||||
The DataGrid wraps the loading progress bar and result count in an `aria-live="polite"` region
|
||||
so that background re-fetches triggered by sort or filter changes are announced to screen readers
|
||||
without interrupting the current reading flow.
|
||||
|
||||
---
|
||||
|
||||
### Focus Management in ProcessRun (D-P-1)
|
||||
|
||||
`src/components/process/ProcessRun.tsx` uses a `stepHeadingRef` and a `useEffect` keyed on the
|
||||
active step name to move focus to the step heading whenever the wizard advances or retreats.
|
||||
This satisfies WCAG 2.4.3 (Focus Order) by ensuring screen reader users are positioned at the
|
||||
start of each new step:
|
||||
|
||||
```typescript
|
||||
const stepHeadingRef = useRef<HTMLHeadingElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRenderedFirstStep.current) {
|
||||
stepHeadingRef.current?.focus()
|
||||
}
|
||||
hasRenderedFirstStep.current = true
|
||||
}, [activeStepName])
|
||||
```
|
||||
|
||||
The target `<h2>` carries `tabIndex={-1}` and is referenced by `ref={stepHeadingRef}`.
|
||||
|
||||
---
|
||||
|
||||
### Screen-Reader Text for Trend Indicators (D-W-1)
|
||||
|
||||
`src/components/widgets/StatisticsWidget.tsx` renders a visually hidden `<span>` next to each
|
||||
trend arrow icon so the direction is announced by screen readers:
|
||||
|
||||
```tsx
|
||||
<span className="sr-only">{config.srLabel}</span>
|
||||
// srLabel values: 'trending up,' | 'trending down,' | 'no change,'
|
||||
```
|
||||
|
||||
The icon itself is `aria-hidden="true"` so it is not double-announced.
|
||||
|
||||
---
|
||||
|
||||
### Submit Button Disabled While Submitting (D-F-1)
|
||||
|
||||
`src/components/forms/EntityForm.tsx` reads `isSubmitting` from React Hook Form's `formState`
|
||||
and applies it to all action buttons, preventing duplicate submissions:
|
||||
|
||||
```tsx
|
||||
disabled={disabled || isSubmitting}
|
||||
// Label also changes: isSubmitting ? 'Saving...' : saveButtonLabel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Baseline Form Accessibility
|
||||
|
||||
All form inputs across `DynamicForm`, `EntityForm`, and individual field components have:
|
||||
|
||||
- `<label>` with `htmlFor` wired to the input `id`
|
||||
- `aria-required={field.isRequired}`
|
||||
- `aria-invalid={!!errors[field.name]}`
|
||||
- `aria-describedby` pointing to the error message element
|
||||
- Required asterisk marked `aria-hidden="true"` to avoid screen-reader repetition
|
||||
- Help tooltips use Radix Tooltip, which is keyboard-accessible via `Tab` / `Escape`
|
||||
|
||||
---
|
||||
|
||||
### CSS Media Queries
|
||||
|
||||
#### `prefers-reduced-motion` (D-L-2 / D-T-1)
|
||||
|
||||
`src/styles/globals.css` collapses all animation and transition durations to near-zero
|
||||
when the user's OS has reduced motion enabled. This resolves skeleton pulse, sidebar
|
||||
slide-in, toast fade, and all other CSS transitions:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `prefers-contrast: more` (D-T-2)
|
||||
|
||||
A high-contrast media block increases border weights and forces text colors to fully opaque
|
||||
black, targeting users who have enabled "Increase Contrast" in their OS settings:
|
||||
|
||||
```css
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--color-border: #000000;
|
||||
--color-input: #000000;
|
||||
--color-muted-foreground: #000000;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
border-color: var(--color-border) !important;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid #000000 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Mobile 16px Font-Size Fix (D-R-1)
|
||||
|
||||
iOS Safari auto-zooms any focused input whose `font-size` is below 16px. A mobile-scoped rule
|
||||
prevents this without affecting desktop appearance:
|
||||
|
||||
```css
|
||||
@media screen and (max-width: 767px) {
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Dark-Mode Focus Ring Fix (D-T-5)
|
||||
|
||||
The default `color-mix`-based focus ring can disappear on dark backgrounds. A dark-mode
|
||||
override forces the ring to a solid, fully opaque color:
|
||||
|
||||
```css
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:focus-visible {
|
||||
outline-color: var(--color-ring);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Additional Resolved Items (from `What's Working Well`)
|
||||
|
||||
| Item | Implementation |
|
||||
|------|----------------|
|
||||
| `aria-required`, `aria-invalid`, `aria-describedby` on all form inputs | `DynamicFormField.tsx`, `EntityForm.tsx` |
|
||||
| `role="status"` + `aria-busy="true"` on skeleton components | All skeleton components |
|
||||
| `role="status"` + `aria-live="polite"` on `EmptyState` | `EmptyState.tsx` |
|
||||
| Full keyboard nav in SearchDialog (ArrowUp/Down/Enter/Escape) | `SearchDialog.tsx` |
|
||||
| Breadcrumbs: `aria-label="Breadcrumb"` + `aria-current="page"` | `Breadcrumbs.tsx` |
|
||||
| `aria-expanded` on mobile drawer | `Sidebar.tsx` |
|
||||
| DOMPurify sanitization on all user-controlled HTML output | `FieldValue.tsx`, `BlockWidget.tsx`, `ProcessHtmlStep.tsx` |
|
||||
| Unsaved-changes guard: `beforeunload` + confirm modal | `EntityForm.tsx` |
|
||||
| Focus rings on all interactive elements via `:focus-visible` | `globals.css` |
|
||||
| Muted text darkened to `#5a6270` for WCAG AA contrast margin | `globals.css` |
|
||||
|
||||
---
|
||||
|
||||
## Automated Testing
|
||||
|
||||
### Running axe-core Scans
|
||||
|
||||
The helper in `tests/e2e/a11y-helpers.ts` wraps `@axe-core/playwright` and asserts zero
|
||||
violations against the `wcag2a`, `wcag2aa`, and `wcag21aa` rule sets:
|
||||
|
||||
```typescript
|
||||
import { checkA11y } from './a11y-helpers'
|
||||
|
||||
test('page is accessible', async ({ page }) => {
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
await checkA11y(page)
|
||||
})
|
||||
```
|
||||
|
||||
`checkA11y` runs on every navigation E2E test in `navigation.spec.ts`. To run all E2E tests
|
||||
including accessibility assertions:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
To run a single spec with axe output in the terminal:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e -- tests/e2e/navigation.spec.ts
|
||||
```
|
||||
|
||||
### Known Suppressed Violations (with TODOs)
|
||||
|
||||
Three rule violations are currently disabled in `checkA11y` because the underlying issues
|
||||
are not yet fixed. Each has a TODO comment in `a11y-helpers.ts`:
|
||||
|
||||
| Rule | Reason suppressed | TODO |
|
||||
|------|-------------------|------|
|
||||
| `color-contrast` | Amber banner background (#f59e0b) and indigo sidebar/button states yield contrast ratios just below 4.5:1 | Audit all Tailwind color pairs in Banner, Sidebar, and Button and replace with WCAG AA-compliant pairs |
|
||||
| `aria-input-field-name` | Some form inputs rendered by `DynamicForm` may have broken `<label htmlFor>` associations when IDs are generated dynamically | Ensure every `<input>` has either a matching `htmlFor` or an `aria-label` |
|
||||
| `scrollable-region-focusable` | The TanStack Table scrollable wrapper `<div>` has no `tabIndex`, blocking keyboard scrolling | Add `tabIndex={0}` to the scrollable DataGrid wrapper |
|
||||
|
||||
---
|
||||
|
||||
## Outstanding Work
|
||||
|
||||
The following items from the design review are **not yet resolved** and require future work.
|
||||
|
||||
### High Priority (WCAG 2.1 AA gaps)
|
||||
|
||||
| ID | Area | Description |
|
||||
|----|------|-------------|
|
||||
| D-V-1 | Record View | Edit/Delete buttons render regardless of user permissions; they fail with 403 on action instead of being hidden/disabled based on `editPermission`/`deletePermission`. |
|
||||
| D-L-3 | Layout | Global search is hidden on mobile (`hidden md:block`); mobile users have no access to search. |
|
||||
|
||||
### Medium Priority (UX gaps)
|
||||
|
||||
| ID | Area | Description |
|
||||
|----|------|-------------|
|
||||
| D-L-5 | Layout | Mobile sidebar `aria-expanded` state may not be consistently set on the nav landmark. |
|
||||
| D-Q-4 | FilterBuilder | Async combobox shows no loading indicator while possible-value options are fetching. |
|
||||
| D-Q-5 | Pagination | "Go to page" input silently ignores out-of-range values with no user feedback. |
|
||||
| D-Q-7 | DataGrid | Column visibility toggle changes are not announced via a `role="status"` region. |
|
||||
| D-P-2 | Process | Completed step indicators in the wizard are not keyboard-navigable for back-navigation. |
|
||||
| D-P-3 | Process | No timeout message if backend polling runs beyond ~60 seconds. |
|
||||
| D-P-4 | Process | Validation review result list has no severity filter or field-name search. |
|
||||
| D-P-5 | Process | File upload step shows no byte-level progress during multipart upload. |
|
||||
| D-W-2 | Widgets | Widget API call failures show blank space with no error boundary or fallback UI. |
|
||||
| D-FB-1 | Feedback | Keyboard Shortcuts dialog is not discoverable (no visible `?` hint in the header). |
|
||||
| D-FB-2 | Feedback | Toasts have no dismiss button; users must wait for auto-timeout. |
|
||||
| D-FB-3 | Feedback | CommandMenu icon buttons may be below the 44px minimum touch target size. |
|
||||
| D-R-2 | Responsive | Multiple interactive elements (pagination controls, density buttons) below 44px touch target. |
|
||||
|
||||
### Items Deferred Pending Backend Support
|
||||
|
||||
| ID | Area | Description |
|
||||
|----|------|-------------|
|
||||
| D-V-6 | Record View | "Last modified by / at" audit footer requires backend to expose this data on the record endpoint. |
|
||||
| D-T-3 | Theme | Dual theming systems (`--qqq-primary-color` and `--color-primary`) need consolidation; depends on coordinating with backend-supplied theme values. |
|
||||
|
||||
---
|
||||
|
||||
## Checklist for New Component Authors
|
||||
|
||||
When adding a new interactive component, verify the following before opening a pull request:
|
||||
|
||||
- [ ] Every `<input>`, `<select>`, and `<textarea>` has a `<label htmlFor>` that matches its `id`, or an `aria-label` / `aria-labelledby` attribute.
|
||||
- [ ] Required inputs have `aria-required={true}`.
|
||||
- [ ] Invalid inputs have `aria-invalid={true}` and an `aria-describedby` pointing to the error message element.
|
||||
- [ ] All icon-only buttons have a descriptive `aria-label` (e.g. `aria-label="Close dialog"`).
|
||||
- [ ] Icon elements that are purely decorative are marked `aria-hidden="true"`.
|
||||
- [ ] Modals/dialogs trap focus while open and restore it to the triggering element on close (use Radix Dialog for this automatically).
|
||||
- [ ] All interactive elements are reachable and operable via `Tab` / `Shift+Tab` / `Enter` / `Space` / `Escape`.
|
||||
- [ ] Interactive elements have a visible `:focus-visible` ring (do not add `outline: none` without a replacement).
|
||||
- [ ] Any element that announces dynamic state changes uses `aria-live="polite"` (or `role="status"`).
|
||||
- [ ] Touch targets are at least 44x44px (`min-h-[44px] min-w-[44px]` or equivalent padding).
|
||||
- [ ] Color is not the only means of conveying information (pair color with text or icon).
|
||||
- [ ] Text color meets a minimum 4.5:1 contrast ratio against its background for normal-weight text at normal size.
|
||||
- [ ] The component does not break at 320px viewport width.
|
||||
- [ ] The component respects `prefers-reduced-motion` (animations driven by CSS transitions inherit the global rule in `globals.css`; JS-driven animations need a manual check).
|
||||
- [ ] Any rendered HTML that originates from user input or the backend is sanitized with DOMPurify before being set via `dangerouslySetInnerHTML`.
|
||||
- [ ] Run `pnpm test:e2e` and confirm `checkA11y` passes for any page that includes the new component.
|
||||
@@ -0,0 +1,310 @@
|
||||
# API Error Handling Guide
|
||||
|
||||
This document describes how API errors flow through the QQQ Frontend Next application and what, if anything, individual components need to do about them.
|
||||
|
||||
The short answer is: **most errors require no per-call handling**. The global error handler in the query client toasts an appropriate message automatically, and the 401 interceptor in the Axios client redirects to login. Individual components only need to add their own handling when the generic behavior is insufficient for a specific UX requirement.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
There are two independent error interception layers:
|
||||
|
||||
1. **Axios response interceptor** (`src/lib/api/client.ts`) — fires synchronously on every HTTP response before the promise settles. Handles 401 (unauthorized) only.
|
||||
2. **TanStack Query global handlers** (`src/lib/query-client.ts`) — `QueryCache.onError` for reads, `MutationCache.onError` for writes. Fire after the interceptor. Handle all other errors by toasting a message.
|
||||
|
||||
```
|
||||
API response
|
||||
│
|
||||
▼
|
||||
Axios interceptor
|
||||
│ 401 → call unauthorizedCallback() → redirect to login
|
||||
│ all others → pass through as rejected promise
|
||||
▼
|
||||
TanStack Query cache (onError)
|
||||
│ 401 → silently ignored (already handled)
|
||||
│ 403 → toast "Permission denied" / "You do not have permission…"
|
||||
│ 404 → toast "Resource not found"
|
||||
│ 5xx → toast "Server error — retrying…" / "Server error — please try again"
|
||||
│ network → toast "Something went wrong"
|
||||
▼
|
||||
Component (optional per-query onError)
|
||||
│ Only needed when generic toast is wrong for the specific UX
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The 401 Interceptor
|
||||
|
||||
**Location:** `src/lib/api/client.ts`
|
||||
|
||||
The singleton `APIClient` registers a response interceptor that fires on every request:
|
||||
|
||||
```typescript
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401 && this.unauthorizedCallback) {
|
||||
this.unauthorizedCallback()
|
||||
}
|
||||
return Promise.reject(error) // always re-reject so TanStack Query also sees it
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
The `unauthorizedCallback` is wired to the auth provider's logout/redirect function during initialization. When a 401 arrives:
|
||||
|
||||
1. The callback fires → clears auth state → redirects to the login page.
|
||||
2. The error is still re-rejected, so TanStack Query's `onError` also fires.
|
||||
3. The global handler in `query-client.ts` explicitly silences 401s to avoid a redundant toast on top of the redirect.
|
||||
|
||||
**Implication for components:** Never handle 401 in a component. The interceptor + auth provider handle it globally. Writing per-component 401 handling will race with the redirect and confuse users.
|
||||
|
||||
---
|
||||
|
||||
## The Global Query Error Handler
|
||||
|
||||
**Location:** `src/lib/query-client.ts` — `handleQueryError` function
|
||||
|
||||
```typescript
|
||||
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
|
||||
const status = getErrorStatusCode(error)
|
||||
|
||||
if (status === 401) return // handled by axios interceptor
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(
|
||||
context === 'mutation'
|
||||
? 'You do not have permission to perform this action'
|
||||
: 'Permission denied'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
toast.error('Resource not found')
|
||||
return
|
||||
}
|
||||
|
||||
if (status !== undefined && status >= 500) {
|
||||
toast.error(
|
||||
context === 'mutation'
|
||||
? 'Server error — please try again'
|
||||
: 'Server error — retrying…'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Network failure or unexpected status
|
||||
toast.error('Something went wrong')
|
||||
}
|
||||
```
|
||||
|
||||
This handler is registered on both `QueryCache` and `MutationCache`:
|
||||
|
||||
```typescript
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => handleQueryError(error, 'query'),
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => handleQueryError(error, 'mutation'),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
**Implication for components:** For most queries and mutations, you do not need `onError` at all. The global handler covers toasting. Add per-query handling only when you need to do something the global handler cannot — such as mapping a 400 error's field messages back to form fields.
|
||||
|
||||
---
|
||||
|
||||
## Extracting the Status Code
|
||||
|
||||
**Location:** `src/lib/utils/error-utils.ts`
|
||||
|
||||
```typescript
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
|
||||
const status = getErrorStatusCode(error)
|
||||
```
|
||||
|
||||
`getErrorStatusCode` handles two shapes:
|
||||
- `AxiosError` — reads `error.response?.status`
|
||||
- Plain objects with a numeric `status` property (e.g. fetch-based wrappers)
|
||||
|
||||
Returns `undefined` for anything else (non-object errors, null, network failures with no response).
|
||||
|
||||
---
|
||||
|
||||
## Status-by-Status Guide
|
||||
|
||||
### 400 — Validation Error
|
||||
|
||||
The global handler does NOT toast on 400 because 400s typically carry field-level validation messages that must be mapped back to individual form fields. Handle 400 explicitly wherever you submit a form.
|
||||
|
||||
```typescript
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FieldValues) => saveRecord('order', data),
|
||||
onError: (error) => {
|
||||
if (getErrorStatusCode(error) === 400) {
|
||||
// Map backend validation messages to React Hook Form fields
|
||||
const fieldErrors = extractFieldErrors(error) // your helper
|
||||
for (const [field, message] of Object.entries(fieldErrors)) {
|
||||
form.setError(field, { type: 'server', message })
|
||||
}
|
||||
}
|
||||
// Non-400 errors are already toasted by the global handler
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
If a 400 arrives outside of a form context, add a toast yourself:
|
||||
|
||||
```typescript
|
||||
onError: (error) => {
|
||||
if (getErrorStatusCode(error) === 400) {
|
||||
toast.error('Invalid request — please check your input')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 403 — Permission Denied
|
||||
|
||||
The global handler toasts a generic permission-denied message. If the 403 should instead silently hide an action (e.g. a button that should not appear for users without permission), suppress the default behavior by checking permissions from metadata before rendering rather than relying on the API to reject:
|
||||
|
||||
```typescript
|
||||
// Preferred: hide the action before the user tries it
|
||||
{tableMetaData.capabilities.includes('insert') && (
|
||||
<Button onClick={openCreateModal}>New Record</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
If you need custom handling on a 403, use `onError` and call `toast` yourself — or do nothing if the generic toast is acceptable.
|
||||
|
||||
### 404 — Not Found
|
||||
|
||||
The global handler toasts "Resource not found". For record-view pages where 404 means the record was deleted, render an empty state instead:
|
||||
|
||||
```typescript
|
||||
const { data: record, error } = useQuery({
|
||||
queryKey: queryKeys.tableRecord(tableName, recordId),
|
||||
queryFn: () => getRecord(tableName, recordId),
|
||||
})
|
||||
|
||||
if (getErrorStatusCode(error) === 404) {
|
||||
return <EmptyState message="This record no longer exists" />
|
||||
}
|
||||
```
|
||||
|
||||
The global handler will also toast, which is acceptable — the empty state just provides additional UI context.
|
||||
|
||||
### 5xx — Server Error
|
||||
|
||||
The global handler toasts "Server error — retrying…" for queries and "Server error — please try again" for mutations. Queries are automatically retried up to 3 times with exponential back-off (1 s → 2 s → 4 s, max 30 s) before the error surfaces.
|
||||
|
||||
No additional per-component handling is needed for 5xx unless you want a custom retry UI or a structured error state for a specific page.
|
||||
|
||||
### Network Failure (no response)
|
||||
|
||||
Treated as non-4xx by the retry logic — queries retry up to 3 times. The global handler toasts "Something went wrong" after all retries are exhausted. No per-component handling needed for the common case.
|
||||
|
||||
---
|
||||
|
||||
## When to Add Per-Query Error Handling
|
||||
|
||||
Add `onError` to a specific `useQuery` or `useMutation` call when:
|
||||
|
||||
| Scenario | What to do |
|
||||
|---|---|
|
||||
| 400 with field-level validation messages | Map errors to form fields with `form.setError()` |
|
||||
| 403 should render empty state instead of toast | Catch 403, render empty UI, call `toast` yourself if the global one also fires |
|
||||
| 404 should render a structured empty state page | Check `getErrorStatusCode(error) === 404` and render `<EmptyState>` |
|
||||
| Error should navigate away (e.g. deleted parent record) | `useRouter().push('/...')` in `onError` |
|
||||
| The generic toast message is wrong for this specific action | Supply a custom `onError` that calls `toast.error(customMessage)` |
|
||||
|
||||
For all other cases, rely on the global handler and write no error-handling code in the component.
|
||||
|
||||
---
|
||||
|
||||
## Retry Behavior
|
||||
|
||||
The `smartRetry` function in `src/lib/query-client.ts` controls when TanStack Query retries:
|
||||
|
||||
```typescript
|
||||
function smartRetry(failureCount: number, error: unknown): boolean {
|
||||
const status = getErrorStatusCode(error)
|
||||
// Never retry client errors — they are deterministic
|
||||
if (status !== undefined && status >= 400 && status < 500) return false
|
||||
// Retry server errors and network failures up to 3 times
|
||||
return failureCount < 3
|
||||
}
|
||||
```
|
||||
|
||||
Back-off schedule: 1 s → 2 s → 4 s, capped at 30 s.
|
||||
|
||||
4xx errors are never retried. There is no per-component way to opt into retrying 4xx errors — that would be intentional since a 400 with bad field data will not resolve itself on retry.
|
||||
|
||||
---
|
||||
|
||||
## Complete Example — Form with 400 Handling
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { saveRecord } from '@/lib/api/tables'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
|
||||
export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const form = useForm({ resolver: zodResolver(schema), defaultValues })
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FieldValues) => saveRecord('order', orderId, data),
|
||||
onSuccess: () => {
|
||||
// Invalidate so the record view refreshes
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord('order', orderId) })
|
||||
toast.success('Order saved')
|
||||
},
|
||||
onError: (error) => {
|
||||
if (getErrorStatusCode(error) === 400) {
|
||||
// Map field errors from the server response body to form fields.
|
||||
// The global handler does NOT toast on 400, so we must do it here or
|
||||
// rely on inline field error messages in the form UI.
|
||||
const body = (error as AxiosError<{ errors: Record<string, string> }>).response?.data
|
||||
if (body?.errors) {
|
||||
for (const [field, message] of Object.entries(body.errors)) {
|
||||
form.setError(field, { type: 'server', message })
|
||||
}
|
||||
} else {
|
||||
toast.error('Please correct the highlighted fields')
|
||||
}
|
||||
return
|
||||
}
|
||||
// 401, 403, 404, 5xx, network — already toasted by the global handler
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))}>
|
||||
{/* field components */}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Layer | File | Handles |
|
||||
|---|---|---|
|
||||
| Axios interceptor | `src/lib/api/client.ts` | 401 → redirect to login |
|
||||
| Global query handler | `src/lib/query-client.ts` | 403, 404, 5xx, network → toast |
|
||||
| Per-component `onError` | Your component | 400 field errors, custom empty states, navigation |
|
||||
| `getErrorStatusCode` | `src/lib/utils/error-utils.ts` | Normalize `AxiosError` and plain-object errors to HTTP status code |
|
||||
@@ -0,0 +1,316 @@
|
||||
# Code Quality Review — QQQ Frontend Next
|
||||
|
||||
**Date:** 2026-02-27
|
||||
**Scope:** DRY, KISS, component design, hooks, TypeScript quality, naming, structure
|
||||
**Note:** Security/runtime bugs are documented separately in `CODE-REVIEW-2026-02-27.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The codebase demonstrates **strong architectural foundations** with excellent separation of concerns and metadata-driven rendering patterns. However, there are **significant code quality issues** across multiple dimensions: substantial DRY violations in filter/combobox logic, an overengineered god-hook, inconsistent error handling approaches, and TypeScript practices that miss opportunities for stronger typing. The `use-record-query` hook is an anti-pattern — a 540-line hook mixing data fetching, URL synchronization, localStorage persistence, and UI state. FilterBuilder has two combobox implementations that are ~95% identical. Several utilities and constants are defined inline or scattered across files. While the component structure is clean and the metadata-driven approach is correct, the implementation needs targeted refactoring to reduce coupling, eliminate duplication, and simplify abstractions.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL Quality Issues (blocks maintainability)
|
||||
|
||||
### CQ-CRIT-1: `use-record-query` — God Hook Anti-Pattern (540 lines)
|
||||
**File:** `src/lib/hooks/use-record-query.ts`
|
||||
|
||||
This hook violates SRP catastrophically. It manages:
|
||||
- Pagination state (pageNum, pageSize)
|
||||
- Filter state (userFilter, quickSearchTerm, filterMode)
|
||||
- Column configuration (columnVisibility, columnOrder, columnWidths)
|
||||
- Row selection state
|
||||
- UI panel toggles (filterPanelOpen, columnConfigOpen)
|
||||
- localStorage persistence (5 separate useEffect blocks)
|
||||
- URL params synchronization (1 useEffect block)
|
||||
- TanStack Query integration (2 queries)
|
||||
- Saved views (saveView, loadView, deleteView callbacks)
|
||||
- 18+ action dispatchers
|
||||
|
||||
The hook returns 40+ properties. Components must destructure dozens of items they don't need. The reducer pattern for 24+ action types is overkill for this state complexity.
|
||||
|
||||
**Why it's Critical:**
|
||||
- Testing requires mocking localStorage, URL manipulation, TanStack Query, and multiple state branches simultaneously
|
||||
- Any change to one concern risks breaking another
|
||||
- Components using this hook are tightly coupled to its entire API shape
|
||||
- The localStorage sync fires 5 separate effects independently
|
||||
|
||||
**Recommended Fix:**
|
||||
Split into 3-4 focused hooks:
|
||||
- `useRecordPagination()` — pageNum, pageSize, totalPages, setPage, setPageSize
|
||||
- `useRecordFilter()` — userFilter, quickSearchTerm, setFilter, resetFilter
|
||||
- `useRecordSelection()` — rowSelection, setRowSelection, clearSelection
|
||||
- `useRecordUIState()` — columnConfigOpen, filterPanelOpen, toggles
|
||||
|
||||
Let RecordQuery coordinate these together.
|
||||
|
||||
---
|
||||
|
||||
### CQ-CRIT-2: FilterBuilder — Two Comboboxes That Are 95% Identical
|
||||
**File:** `src/components/query/FilterBuilder.tsx`, lines 508-927
|
||||
|
||||
`PossibleValueSingleSelect` (lines 508-698) and `PossibleValueMultiSelect` (lines 713-927) are nearly identical. Both:
|
||||
- Fetch options via `fetchTablePossibleValues()`
|
||||
- Debounce search input (300ms) — copied at lines 557-563 and 771-777
|
||||
- Manage AbortController for stale request cancellation — copied at lines 585-590 and 799-804
|
||||
- Implement click-outside-to-close
|
||||
- Render search input, loading state, option list
|
||||
|
||||
Differences: only selection model (single vs. multi) and tag display.
|
||||
|
||||
**Why it's Critical:**
|
||||
- Bug fix in one requires manual sync to the other
|
||||
- 400+ lines of duplicated code
|
||||
- Missing memoization: callbacks re-created on every render
|
||||
|
||||
**Recommended Fix:**
|
||||
Extract a `useAsyncCombobox` hook:
|
||||
```typescript
|
||||
function useAsyncCombobox(tableName: string, fieldName: string) {
|
||||
// Shared debounce, abort, cleanup logic
|
||||
return { options, isLoading, debouncedFetch, abortRef }
|
||||
}
|
||||
```
|
||||
Then SingleSelect and MultiSelect become thin wrappers managing only the selection model.
|
||||
|
||||
---
|
||||
|
||||
### CQ-CRIT-3: `zod-from-metadata` — Repeated Required/Optional Pattern x10
|
||||
**File:** `src/lib/utils/zod-from-metadata.ts`, lines 11-114
|
||||
|
||||
STRING, TEXT, HTML, and PASSWORD cases (lines 19-60, 99-110) all repeat:
|
||||
```typescript
|
||||
let schema = z.string()
|
||||
if (isRequired) schema = schema.min(1, `${label} is required`)
|
||||
if (maxLength) schema = schema.max(maxLength, ...)
|
||||
return isRequired ? schema : schema.optional()
|
||||
```
|
||||
|
||||
The required/optional wrapping pattern is written identically across all 10+ field types.
|
||||
|
||||
**Recommended Fix:**
|
||||
```typescript
|
||||
function buildStringSchema(isRequired: boolean, maxLength?: number, label?: string) {
|
||||
let schema = z.string()
|
||||
if (isRequired) schema = schema.min(1, `${label} is required`)
|
||||
if (maxLength) schema = schema.max(maxLength, `Must be ${maxLength} characters or fewer`)
|
||||
return isRequired ? schema : schema.optional()
|
||||
}
|
||||
|
||||
case 'STRING':
|
||||
case 'TEXT':
|
||||
case 'HTML':
|
||||
case 'PASSWORD':
|
||||
return buildStringSchema(isRequired, field.maxLength, field.label)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CQ-CRIT-4: Inconsistent Error Status Detection Across Components
|
||||
**Files:**
|
||||
- `src/components/records/RecordView.tsx` lines 68-83 — custom `getErrorStatusCode()`, handles 403/404/500
|
||||
- `src/components/query/RecordQuery.tsx` lines 493-512 — generic "Failed to load records" for all errors
|
||||
|
||||
Error handling is implemented differently in the two main page components. If the API changes, fixes are needed in multiple places.
|
||||
|
||||
**Recommended Fix:**
|
||||
```typescript
|
||||
// src/lib/utils/error-utils.ts
|
||||
export function getErrorStatusCode(error: unknown): number | undefined { ... }
|
||||
export function getErrorMessage(error: unknown, context: string): string { ... }
|
||||
```
|
||||
Use this in both RecordView and RecordQuery.
|
||||
|
||||
---
|
||||
|
||||
## HIGH Quality Issues (significant tech debt)
|
||||
|
||||
### CQ-HIGH-1: RecordQuery — Too Many Responsibilities (586 lines)
|
||||
**File:** `src/components/query/RecordQuery.tsx`
|
||||
|
||||
This component manages: quick search debounce, view mode toggle, mobile filter sheet state, density selector, and renders: toolbar (150+ lines inline), filter panel (desktop + mobile variants), bulk action bar, error/empty states, DataGrid, RecordCardView, and process launcher coordination.
|
||||
|
||||
**Extractable subcomponents:**
|
||||
- `<RecordQueryToolbar />` — all toolbar state and rendering
|
||||
- `<DensitySelector />` — the dropdown (lines 291-339)
|
||||
- `<ViewModeToggle />` — button group (lines 258-288)
|
||||
- `<QuickSearchInput />` — search field with debounce
|
||||
|
||||
---
|
||||
|
||||
### CQ-HIGH-2: FilterBuilder — No Memoization on Recursive Components
|
||||
**File:** `src/components/query/FilterBuilder.tsx`
|
||||
|
||||
The recursive chain FilterBuilder → FilterGroup → CriteriaRow → FilterValueInput doesn't memoize callbacks or components. Every filter state change re-renders all sibling rows, and PossibleValueSingleSelect's useEffect (line 566) re-fires unnecessarily — potentially resetting combobox search input during user input.
|
||||
|
||||
**Fix:**
|
||||
```typescript
|
||||
export const CriteriaRow = React.memo(function CriteriaRow({ ... }) { ... })
|
||||
export const FilterValueInput = React.memo(function FilterValueInput({ ... }) { ... })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CQ-HIGH-3: Field Adornments Typed as `Record<string, unknown>`
|
||||
**File:** `src/components/records/FieldValue.tsx`, lines 65-66, 119-120
|
||||
|
||||
```typescript
|
||||
const href = (linkAdornment?.values?.['linkURL'] ?? String(value)) as string
|
||||
const colorMap = (chipAdornment?.values?.['colorMap'] ?? {}) as Record<string, string>
|
||||
```
|
||||
|
||||
These `as` casts are necessary because adornment `values` is `Record<string, unknown>`. The type information exists in the backend but isn't modeled.
|
||||
|
||||
**Recommended Fix:** Discriminated union types for adornments:
|
||||
```typescript
|
||||
type LinkAdornment = { type: 'LINK'; values: { linkURL: string } }
|
||||
type ChipAdornment = { type: 'CHIP'; values: { colorMap: Record<string, string> } }
|
||||
export type FieldAdornment = LinkAdornment | ChipAdornment | IconAdornment | ...
|
||||
```
|
||||
Eliminates all `as` casts in FieldValue.
|
||||
|
||||
---
|
||||
|
||||
### CQ-HIGH-4: Magic Numbers and Strings Scattered Throughout
|
||||
- `use-record-query.ts:246` — `[10, 25, 50, 100].includes(n)` — hardcoded page sizes
|
||||
- `RecordQuery.tsx:75` — `400` ms debounce
|
||||
- `FilterBuilder.tsx:560` — `300` ms debounce
|
||||
- `DataGrid.tsx:41-51` — density class maps inline
|
||||
- `FieldValue.tsx:1024` — bytes-per-KB hardcoded
|
||||
|
||||
**Fix:**
|
||||
```typescript
|
||||
// src/lib/constants.ts
|
||||
export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const
|
||||
export const SEARCH_DEBOUNCE_MS = 400
|
||||
export const COMBOBOX_DEBOUNCE_MS = 300
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CQ-HIGH-5: `use-process` — Stale Closure via Manual `stateRef`
|
||||
**File:** `src/lib/hooks/use-process.ts`, lines 105-128
|
||||
|
||||
```typescript
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
// later: stateRef.current.stepValues
|
||||
```
|
||||
|
||||
Manually syncing a ref to shadow state is a sign that the polling subscription (lines 249-268) is too coupled to mutable state. This pattern is fragile and indicates the state management design needs simplification.
|
||||
|
||||
---
|
||||
|
||||
### CQ-HIGH-6: Dead Toolbar Button
|
||||
**File:** `src/components/query/RecordQuery.tsx`, lines 394-401
|
||||
|
||||
A "Settings / more" button has an aria-label but no onClick handler — it does nothing. Either wire it up or remove it.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM Quality Issues
|
||||
|
||||
### CQ-MED-1: `use-record-query` — `effectiveFilter` Depends on Unrelated State
|
||||
**File:** `src/lib/hooks/use-record-query.ts`, lines 317-332
|
||||
|
||||
`effectiveFilter` is recomputed when `state.columnVisibility` changes, but column visibility has nothing to do with filters. The dependency array is over-inclusive.
|
||||
|
||||
---
|
||||
|
||||
### CQ-MED-2: Boolean Operator Type is Raw String Cast
|
||||
**File:** `src/components/query/FilterBuilder.tsx`, lines 196-198
|
||||
|
||||
```typescript
|
||||
onChange({ ...filter, booleanOperator: e.target.value as 'AND' | 'OR' })
|
||||
```
|
||||
|
||||
Should use a const assertion or enum to avoid typo risk:
|
||||
```typescript
|
||||
const BOOLEAN_OPERATORS = ['AND', 'OR'] as const
|
||||
type BooleanOperator = typeof BOOLEAN_OPERATORS[number]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CQ-MED-3: FieldValue — Repeated URL/Email Pattern Matching
|
||||
**File:** `src/components/records/FieldValue.tsx`, lines 294, 339, 358
|
||||
|
||||
Three separate URL/email regex checks scattered across the file. Extract to:
|
||||
```typescript
|
||||
// src/lib/utils/string-utils.ts
|
||||
export const isUrl = (s: string) => /^https?:\/\//i.test(s) || s.startsWith('/')
|
||||
export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CQ-MED-4: RecordView — Redundant Section Fallback Logic
|
||||
**File:** `src/components/records/RecordView.tsx`, lines 234-289
|
||||
|
||||
```typescript
|
||||
const primarySections = visibleSections.filter(s => !s.tier || s.tier === 'T1' || s.tier === 'basic')
|
||||
// ...
|
||||
const t1Sections = primarySections.length > 0 ? primarySections : visibleSections
|
||||
```
|
||||
|
||||
The fallback `? primarySections : visibleSections` is redundant if all sections without explicit tiers are already included in `primarySections`. Simplify or add a comment explaining when this fallback fires.
|
||||
|
||||
---
|
||||
|
||||
### CQ-MED-5: Missing Error Boundaries Below Route Level
|
||||
Only `src/app/error.tsx` provides an error boundary. Forms and record views should have their own boundaries to prevent full-page crashes from single component errors.
|
||||
|
||||
---
|
||||
|
||||
### CQ-MED-6: `FilterBuilder` — `subs` Variable Name
|
||||
**File:** `src/components/query/FilterBuilder.tsx`, line 178
|
||||
|
||||
```typescript
|
||||
const subs = filter.subFilters ?? []
|
||||
```
|
||||
|
||||
Should be `subFilters` for clarity.
|
||||
|
||||
---
|
||||
|
||||
## LOW / Style Issues
|
||||
|
||||
- **`data-qqq-id` inconsistency** — Mix of `grid-select-row-${row.index}` (underscores), `filter-field-${depth}-${index}` (hyphens). Standardize to kebab-case throughout.
|
||||
- **Abbreviations:** `initVals` → `initialValues` (`use-record-query.ts:257`), `ej` → `exposedJoin` (`use-record-query.ts:348`), `pk` → `primaryKey` (DataGrid, RecordView), `subs` → `subFilters`
|
||||
- **Import ordering:** `FilterBuilder.tsx` imports lucide-react after custom hooks. Project convention is: React → External → Types → Lib → Components.
|
||||
- **Missing JSDoc:** `buildQuickFilterFromState()` (use-record-query.ts), `getErrorStatusCode()` (RecordView.tsx), `zodFieldFromMetadata()` (zod-from-metadata.ts) — all complex enough to warrant a one-liner doc comment.
|
||||
- **Unnecessary `useMemo`:** `joins` calculation in `use-record-query.ts:345-354` is a simple object map — too cheap to justify memoization overhead.
|
||||
- **Unused import:** `SelectField` in `DynamicFormField.tsx` (identified in prior review).
|
||||
- **`window.innerWidth` in callback** — `RecordQuery.tsx:127-134` — not reactive to resize events.
|
||||
|
||||
---
|
||||
|
||||
## Positive Patterns Worth Preserving
|
||||
|
||||
1. **Metadata-Driven Rendering** — entire UI derived from backend metadata, zero hardcoded field/table names.
|
||||
2. **Query Key Factory Pattern** — `queryKeys.tableRecords(tableName)` prevents string-based cache bugs.
|
||||
3. **TanStack Query Usage** — proper staleTime, placeholderData, manual pagination, invalidation. No over-fetching.
|
||||
4. **Typed API Layer** — all `src/lib/api/` functions properly typed, good use of discriminated unions for responses.
|
||||
5. **Filter Serialization** — `serializeFilter()` / `deserializeFilter()` correctly handle shareable URLs.
|
||||
6. **Debouncing** — SearchInput and FilterBuilder correctly debounce and clean up.
|
||||
7. **Tailwind + CSS Custom Properties** — consistent theming without inline styles.
|
||||
8. **Accessibility Fundamentals** — ARIA labels, roles, keyboard handling are present throughout.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Fix Priority
|
||||
|
||||
| Priority | Item | Effort |
|
||||
|----------|------|--------|
|
||||
| 1 | CQ-CRIT-2: Extract `useAsyncCombobox` hook (FilterBuilder) | Medium |
|
||||
| 2 | CQ-CRIT-3: `buildStringSchema()` helper in zod-from-metadata | Small |
|
||||
| 3 | CQ-CRIT-4: Centralize `getErrorStatusCode()` utility | Small |
|
||||
| 4 | CQ-HIGH-3: Discriminated union types for adornments | Medium |
|
||||
| 5 | CQ-HIGH-4: Extract constants to `src/lib/constants.ts` | Small |
|
||||
| 6 | CQ-HIGH-6: Remove or wire dead toolbar button | Trivial |
|
||||
| 7 | CQ-HIGH-2: `React.memo` on FilterBuilder subcomponents | Small |
|
||||
| 8 | CQ-MED-3: Extract URL/email utils | Small |
|
||||
| 9 | CQ-CRIT-1: Split `use-record-query` into focused hooks | Large |
|
||||
| 10 | CQ-HIGH-1: Extract RecordQuery toolbar subcomponents | Medium |
|
||||
@@ -0,0 +1,390 @@
|
||||
# QQQ Frontend Next — Code Review
|
||||
**Date:** 2026-02-27
|
||||
**Reviewer:** Claude (automated)
|
||||
**Scope:** Full `src/` codebase audit
|
||||
**Commit:** df8e2e6
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL (7 findings)
|
||||
|
||||
---
|
||||
|
||||
### CRIT-1: XSS — unsanitized `dangerouslySetInnerHTML` in `FieldValue`
|
||||
|
||||
**File:** `src/components/records/FieldValue.tsx:136–143`
|
||||
|
||||
Raw backend HTML value injected into DOM with zero sanitization:
|
||||
```tsx
|
||||
dangerouslySetInnerHTML={{ __html: String(value) }}
|
||||
```
|
||||
|
||||
**Fix:** Use DOMPurify:
|
||||
```tsx
|
||||
import DOMPurify from 'dompurify'
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(value)) }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CRIT-2: XSS — unsanitized `dangerouslySetInnerHTML` in `BlockWidget` (two locations)
|
||||
|
||||
**File:** `src/components/widgets/BlockWidget.tsx:40–48, 278–285`
|
||||
|
||||
Both the legacy `data.html` path and the `case 'html'` block renderer pass backend HTML directly to the DOM.
|
||||
|
||||
**Fix:** Apply `DOMPurify.sanitize()` to both HTML rendering paths.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-3: XSS — regex script-stripping in `ProcessHtmlStep` is not real sanitization
|
||||
|
||||
**File:** `src/components/process/ProcessHtmlStep.tsx:14–17, 100`
|
||||
|
||||
```ts
|
||||
// TODO: Replace with DOMPurify for full sanitization
|
||||
function stripScripts(html: string): string {
|
||||
return html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
}
|
||||
```
|
||||
|
||||
Bypassed by: `<img onerror="...">`, `<svg onload="...">`, `<a href="javascript:...">`, and dozens of other vectors.
|
||||
|
||||
**Fix:** Replace `stripScripts` with `DOMPurify.sanitize()`.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-4: Auth bypass — `handleOAuthCallback` sets `isAuthenticated=true` without token exchange
|
||||
|
||||
**File:** `src/lib/auth/auth-provider.tsx:164–176`
|
||||
|
||||
```tsx
|
||||
async function handleOAuthCallback(code: string, state: string) {
|
||||
// For now, just mark as authenticated
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
```
|
||||
|
||||
`code` and `state` params are accepted but unused. No CSRF state validation, no token exchange, no session creation.
|
||||
|
||||
**Fix:** Implement PKCE: validate `state` against `sessionStorage`, exchange `code`+`code_verifier` for tokens via backend `/manageSession`, then set `isAuthenticated` only on success.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-5: Auth bypass — Auth0/OAuth2 sessions authenticate without server validation
|
||||
|
||||
**File:** `src/lib/auth/auth-provider.tsx:123–138`
|
||||
|
||||
Both `setupAuth0Session` and `setupOAuth2Session` call no protected endpoint. Any app configured with AUTH_0 or OAUTH2 shows as authenticated for any page load. Default user is hardcoded: `{ name: 'User', email: 'user@example.com' }`.
|
||||
|
||||
**Fix:** Call a protected endpoint (e.g., `GET /session`) to validate existing session cookie. Redirect to provider if 401.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-6: Auth state corruption — 401 interceptor doesn't clear auth metadata cache
|
||||
|
||||
**File:** `src/lib/auth/auth-provider.tsx:65–70`
|
||||
|
||||
The 401 interceptor redirects to login but does NOT call `clearAuthMetadataCache()`, leaving stale auth metadata. On next page load the cached auth type governs initialization without a fresh server check.
|
||||
|
||||
**Fix:** Call `clearAuthMetadataCache()` inside the 401 callback before redirect.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-7: Silent export failure — `ExportButton` swallows errors with no user feedback
|
||||
|
||||
**File:** `src/components/query/ExportButton.tsx:88–89`
|
||||
|
||||
```tsx
|
||||
} catch (err) {
|
||||
console.error('[ExportButton] Export failed:', err)
|
||||
}
|
||||
```
|
||||
|
||||
Users believe export succeeded when it silently failed.
|
||||
|
||||
**Fix:** Add `toast.error('Export failed. Please try again.')` in the catch block.
|
||||
|
||||
---
|
||||
|
||||
## HIGH (8 findings)
|
||||
|
||||
---
|
||||
|
||||
### HIGH-1: Stale closures — missing deps in URL sync + column persistence effects
|
||||
|
||||
**File:** `src/lib/hooks/use-record-query.ts:280–311`
|
||||
|
||||
Four `useEffect` hooks suppress `react-hooks/exhaustive-deps`. The URL-sync effect is missing `router` and `pathname` — stale closures can fire URL updates on the wrong route in concurrent mode.
|
||||
|
||||
**Fix:** Add `router` and `pathname` to the URL-sync effect deps. Use `useRef` for stable callback references if needed.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-2: Performance — `JSON.stringify` key comparison on every query cache event in `useProcess`
|
||||
|
||||
**File:** `src/lib/hooks/use-process.ts:251–268`
|
||||
|
||||
```ts
|
||||
if (JSON.stringify(key) !== JSON.stringify(expectedKey)) return
|
||||
```
|
||||
|
||||
This serializes two arrays on every cache event during rapid polling intervals.
|
||||
|
||||
**Fix:** Use `queryClient.getQueryData(expectedKey)` or shallow key comparison utility.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-3: Logic bug — `processUUID` unsafe cast falls back to empty string
|
||||
|
||||
**File:** `src/lib/hooks/use-process.ts:274–278`
|
||||
|
||||
```ts
|
||||
const pUUID = (response as QJobResponse & { processUUID?: string }).processUUID ?? ''
|
||||
```
|
||||
|
||||
If `processUUID` is absent, all subsequent step calls use `''` as the UUID, producing 404 API calls.
|
||||
|
||||
**Fix:** Add `processUUID` to `QJobResponse` type. Validate it is non-empty before proceeding.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-4: Logic bug — type guard fallthrough on unexpected `QJobResponse` shapes
|
||||
|
||||
**File:** `src/lib/hooks/use-process.ts:76–82`
|
||||
|
||||
A response with both `error` and `values` fields, or with both `jobUUID` and `values`, falls through all type guards silently. Process state freezes indefinitely.
|
||||
|
||||
**Fix:** Use a discriminated union with an explicit `type` field. Add a default catch-all error case.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-5: Stale auth — metadata cached globally without API base URL scoping
|
||||
|
||||
**File:** `src/lib/api/auth.ts:29–37`
|
||||
|
||||
Auth metadata cached for 1 hour under a single key `qqqAuthMetadata` regardless of API base URL. Backend auth type changes are ignored for up to 1 hour. Multi-instance deployments cross-contaminate caches.
|
||||
|
||||
**Fix:** Include API base URL in cache key. Consider shorter TTL (5–10 min) or TanStack Query instead.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-6: Auth bypass — `globalSearch` silently swallows 401/403 errors
|
||||
|
||||
**File:** `src/lib/api/tables.ts:132–136`
|
||||
|
||||
```ts
|
||||
} catch {
|
||||
// If /search endpoint is not available (404), return empty results
|
||||
return []
|
||||
}
|
||||
```
|
||||
|
||||
Catches all errors including 401 (session expired). A search 401 bypasses the global logout interceptor.
|
||||
|
||||
**Fix:**
|
||||
```ts
|
||||
} catch (err) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) return []
|
||||
throw err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HIGH-7: Wrong record selection — `selectedRecordIds` computed from array index, not stable PK
|
||||
|
||||
**File:** `src/lib/hooks/use-record-query.ts:411–421`
|
||||
|
||||
`rowSelection` is keyed by array index but records can reorder after sort/filter/pagination. Index 5 after a filter may point to a completely different record than before.
|
||||
|
||||
**Fix:** Key `rowSelection` by primary key string (same as `getRowId` in DataGrid). Update the `selectedRecordIds` computation to use PK-based lookup.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-8: Dead retry button — `setPage(rq.pageNum)` is a no-op
|
||||
|
||||
**File:** `src/components/query/RecordQuery.tsx:503–508`
|
||||
|
||||
The error-state retry button dispatches `SET_PAGE` with the current page number — TanStack Query sees the same query key and does not re-fetch.
|
||||
|
||||
**Fix:** Use `queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(tableName) })`.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM (14 findings)
|
||||
|
||||
---
|
||||
|
||||
### MED-1: `AuthProvider` effect suppresses `onAuthError` dep without documentation
|
||||
|
||||
**File:** `src/lib/auth/auth-provider.tsx:121`
|
||||
|
||||
**Fix:** Document intent: `// intentional: runs once on mount — auth initialization is not repeatable`
|
||||
|
||||
---
|
||||
|
||||
### MED-2: CSS injection — raw `customCss` injected into `<style>` tag without validation
|
||||
|
||||
**File:** `src/app/(dashboard)/layout.tsx:108–119`
|
||||
|
||||
Arbitrary CSS from the backend can overlay phishing forms, exfiltrate keystrokes via CSS selectors, or perform UI redressing.
|
||||
|
||||
**Fix:** Use a CSS sanitizer or restrict to CSS custom property overrides only.
|
||||
|
||||
---
|
||||
|
||||
### MED-3: CSS injection — `accentColor` set on CSS custom properties without format validation
|
||||
|
||||
**File:** `src/app/(dashboard)/layout.tsx:71–89`
|
||||
|
||||
`accentColor: 'red; --qqq-primary: url(https://evil.com)'` would be applied as-is.
|
||||
|
||||
**Fix:** Validate `accentColor` matches `/^#[0-9a-fA-F]{3,8}$|^rgb|^hsl/` before applying.
|
||||
|
||||
---
|
||||
|
||||
### MED-4: Stale `initialValue` reference in `useLocalStorage.removeValue`
|
||||
|
||||
**File:** `src/lib/hooks/use-local-storage.ts:36–43`
|
||||
|
||||
`initialValue` is an object passed inline at call sites — new reference on every render. Causes `removeValue` re-creation and downstream `useEffect` churn.
|
||||
|
||||
**Fix:** Stabilize with `useRef`: `const initialValueRef = useRef(initialValue)`, use `initialValueRef.current` in `removeValue`, remove `initialValue` from deps.
|
||||
|
||||
---
|
||||
|
||||
### MED-5: `filterUtils` `btoa`/`atob` may produce overly long base64 for Unicode filter values
|
||||
|
||||
**File:** `src/lib/utils/filter-utils.ts:224–256`
|
||||
|
||||
Double-encoding (`encodeURIComponent` + `btoa`) produces unnecessarily long strings that may exceed URL limits.
|
||||
|
||||
**Fix:** Use `btoa(unescape(encodeURIComponent(json)))` for true Unicode-safe base64.
|
||||
|
||||
---
|
||||
|
||||
### MED-6: Open redirect — `fromPath` URL param used as `href` without origin validation
|
||||
|
||||
**File:** `src/components/records/RecordView.tsx:409–416`
|
||||
|
||||
`/app/person/1?from=javascript:alert(1)` creates a clickable XSS link.
|
||||
|
||||
**Fix:**
|
||||
```ts
|
||||
const safeFromPath = fromPath?.startsWith('/') ? fromPath : null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MED-7: Hydration mismatch — `window.innerWidth` in `useState` initializer
|
||||
|
||||
**File:** `src/components/query/RecordQuery.tsx:92–97`
|
||||
|
||||
Server renders one view mode, client may initialize another, causing React hydration warning.
|
||||
|
||||
**Fix:** Initialize from `preferences.tableDefaultViewMode`, apply mobile override in `useEffect`.
|
||||
|
||||
---
|
||||
|
||||
### MED-8: `goBack` in `useProcess` is client-only, not a server step submission
|
||||
|
||||
**File:** `src/lib/hooks/use-process.ts:341–355`
|
||||
|
||||
Server-side state is not rolled back. If the server expects a `_goBack` step, the process goes out of sync.
|
||||
|
||||
**Fix:** Submit `processStep(processName, processUUID, '_goBack', {})` and let server dictate `nextStep`.
|
||||
|
||||
---
|
||||
|
||||
### MED-9: Missing `useCallback` on `EntityForm.onSubmit`
|
||||
|
||||
**File:** `src/components/forms/EntityForm.tsx:195–197`
|
||||
|
||||
`onSubmit` re-created on every render, causing unnecessary re-renders of all form fields.
|
||||
|
||||
**Fix:** `const onSubmit = useCallback(async (values) => { await activeMutation.mutateAsync(values) }, [activeMutation])`
|
||||
|
||||
---
|
||||
|
||||
### MED-10: Unvalidated `JSON.parse` from localStorage in `getStoredUser`
|
||||
|
||||
**File:** `src/lib/auth/auth-provider.tsx:150–161`
|
||||
|
||||
**Fix:** Add type guard or schema validation.
|
||||
|
||||
---
|
||||
|
||||
### MED-11: `TOGGLE_COLUMN` initializes from `undefined` for never-toggled columns
|
||||
|
||||
**File:** `src/lib/hooks/use-record-query.ts:119–126`
|
||||
|
||||
`!undefined === true` means first toggle always shows column, losing the "default" state distinction.
|
||||
|
||||
**Fix:**
|
||||
```ts
|
||||
const currentVal = state.columnVisibility[action.fieldName] ?? true
|
||||
return { ...state, columnVisibility: { ...state.columnVisibility, [action.fieldName]: !currentVal } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MED-12: Memory leak — column resize `mousemove`/`mouseup` listeners not removed on unmount
|
||||
|
||||
**File:** `src/components/query/DataGrid.tsx:231–253`
|
||||
|
||||
If `DataGrid` unmounts during a resize drag, the document event listeners remain.
|
||||
|
||||
**Fix:** Track active listeners in a `useRef`; clean them up in a `useEffect` cleanup.
|
||||
|
||||
---
|
||||
|
||||
### MED-13: Premature error state when `record` is undefined but `isError` is false
|
||||
|
||||
**File:** `src/components/records/RecordView.tsx:120–121`
|
||||
|
||||
```ts
|
||||
if (isError || !record) { /* renders error UI */ }
|
||||
```
|
||||
|
||||
Shows error flash before loading state.
|
||||
|
||||
**Fix:** Split: `if (isError) { ... }` then `if (!record && !isLoading) { ... }`.
|
||||
|
||||
---
|
||||
|
||||
### MED-14: Stateful regex in `HighlightedText` — `gi` flag causes incorrect `test()` after `split()`
|
||||
|
||||
**File:** `src/components/feedback/SearchDialog.tsx:31–47`
|
||||
|
||||
`split(regex)` advances `lastIndex`, making subsequent `regex.test(part)` calls unreliable.
|
||||
|
||||
**Fix:** Create a new regex for `test()`, or use `part.toLowerCase() === query.toLowerCase()`.
|
||||
|
||||
---
|
||||
|
||||
## LOW (8 findings)
|
||||
|
||||
- **LOW-1** `auth.ts:64` — Duplicate `clearAuthMetadataCache()` call (harmless)
|
||||
- **LOW-2** `query-client.ts:55` — Widget cache key unstable with unordered `params` objects
|
||||
- **LOW-3** `Sidebar.tsx:128` — Missing comment explaining intentional dep omission
|
||||
- **LOW-4** `RecordView.tsx:37–44` — `getInitials('')` throws TypeError (empty label)
|
||||
- **LOW-5** `DynamicFormField.tsx` — Unused `SelectField` import (dead code)
|
||||
- **LOW-6** `RecordQuery.tsx:127–134` — `window.innerWidth` in callback not reactive to resize
|
||||
- **LOW-7** `ExportButton.tsx:81–86` — Legacy DOM insertion pattern for file download trigger
|
||||
- **LOW-8** `process-utils.ts`, `use-routes.ts` — Verify `hasPermission`/`readPermission` filtering for nav items
|
||||
|
||||
---
|
||||
|
||||
## Priority Order for Fixes
|
||||
|
||||
1. **CRIT-1, CRIT-2, CRIT-3** — Install DOMPurify, apply to all 4 HTML rendering sites
|
||||
2. **CRIT-4, CRIT-5** — Auth stub implementations need proper server validation
|
||||
3. **CRIT-6** — One-line fix: add `clearAuthMetadataCache()` to 401 callback
|
||||
4. **HIGH-6** — One-line fix: re-throw non-404 errors in `globalSearch`
|
||||
5. **HIGH-7** — Fix record selection to use stable PKs
|
||||
6. **HIGH-8** — Fix retry button to call `invalidateQueries`
|
||||
7. **CRIT-7** — Add error toast to `ExportButton`
|
||||
8. **MED-6** — Validate `fromPath` starts with `/`
|
||||
9. **HIGH-1** — Fix stale closures in `useRecordQuery` effects
|
||||
10. Remaining HIGH/MED items by team priority
|
||||
@@ -0,0 +1,398 @@
|
||||
# Component Patterns
|
||||
|
||||
This guide describes the recurring patterns used throughout the QQQ Frontend Next codebase. Understanding these patterns is essential before adding new components or modifying existing ones.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Metadata-Driven Rendering
|
||||
|
||||
QQQ is a metadata-driven framework. The backend declares all structure — table names, field lists, widget layouts, and navigation items — and the frontend renders from it dynamically. No table names, field names, or navigation entries are hardcoded anywhere in the component tree.
|
||||
|
||||
### How it works
|
||||
|
||||
**Server-side (or near-server) layers** fetch metadata and pass it down as props. **Client Components** never fetch metadata themselves — they receive it through their props and render from it.
|
||||
|
||||
The routing entry point is `src/app/(dashboard)/app/[slug]/page.tsx`. It is a Client Component that fetches the full QQQ instance metadata from the server, resolves the slug against the metadata, and dispatches to the appropriate page component:
|
||||
|
||||
```tsx
|
||||
// src/app/(dashboard)/app/[slug]/page.tsx (simplified)
|
||||
export default function SlugPage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const slug = params.slug
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||
})
|
||||
|
||||
// Resolve slug against apps, tables, processes, reports in priority order
|
||||
const isTable = Boolean(metaData?.tables?.[slug])
|
||||
const table = metaData?.tables?.[slug]
|
||||
|
||||
// Pass resolved metadata as props — the child component never fetches
|
||||
if (isTable && table) {
|
||||
const tableProcesses = getProcessesForTable(metaData, slug)
|
||||
return <RecordQuery tableName={slug} tableMetaData={table} processes={tableProcesses} />
|
||||
}
|
||||
// ... other cases
|
||||
}
|
||||
```
|
||||
|
||||
`RecordQuery` receives `tableMetaData` as a prop. It does not know or care how that data arrived — it renders from it:
|
||||
|
||||
```tsx
|
||||
// src/components/query/RecordQuery.tsx
|
||||
interface RecordQueryProps {
|
||||
tableName: string
|
||||
tableMetaData: QTableMetaData // always passed in, never fetched internally
|
||||
processes?: QProcessMetaData[]
|
||||
}
|
||||
|
||||
export function RecordQuery({ tableName, tableMetaData, processes }: RecordQueryProps) {
|
||||
// tableMetaData is already resolved — no fetching here
|
||||
const canCreate = tableMetaData.insertPermission
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Key rules
|
||||
|
||||
- `tableMetaData` (and any other metadata object) is `undefined` until TanStack Query resolves it. Every component must handle the loading state. The slug page renders a spinner until metadata is ready; it never passes `undefined` to its child components.
|
||||
- User-facing text always uses `label` from metadata, never `name`. For example, the page header is set to `table?.label ?? slug`, not the raw slug.
|
||||
- The `resolveSlugTarget` function in `SlugPage` is extracted as a pure function precisely so it can be tested in isolation without any rendering.
|
||||
|
||||
### Resolution priority
|
||||
|
||||
The slug page resolves in this order: **app** → **table** → **process** → **report**. The first match wins.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: data-qqq-id Attributes
|
||||
|
||||
Every significant or interactive element carries a `data-qqq-id` attribute. This attribute serves two purposes:
|
||||
|
||||
1. **CSS customization hook** — deployers can write `[data-qqq-id="button-save"] { ... }` in their own stylesheets to override the default styling without touching source code.
|
||||
2. **Test selector** — Playwright E2E tests and unit tests target elements by `data-qqq-id` rather than by class name (which is an implementation detail subject to change).
|
||||
|
||||
### Naming convention
|
||||
|
||||
The format is `{component-type}-{identifier}`:
|
||||
|
||||
| Pattern | Example |
|
||||
|---|---|
|
||||
| `record-query-{tableName}` | `record-query-orders` |
|
||||
| `widget-{name}` | `widget-ordersSummary` |
|
||||
| `widget-label-{name}` | `widget-label-ordersSummary` |
|
||||
| `widget-content-{name}` | `widget-content-ordersSummary` |
|
||||
| `button-widget-reload-{name}` | `button-widget-reload-ordersSummary` |
|
||||
| `button-widget-export-{name}` | `button-widget-export-ordersSummary` |
|
||||
| `button-widget-help-{name}` | `button-widget-help-ordersSummary` |
|
||||
| `button-save` | `button-save` |
|
||||
| `button-cancel` | `button-cancel` |
|
||||
| `field-value-{fieldName}` | `field-value-status` |
|
||||
| `filter-panel-close` | `filter-panel-close` |
|
||||
| `link-back-to-source` | `link-back-to-source` |
|
||||
| `unknown-slug-{slug}` | `unknown-slug-foo` |
|
||||
|
||||
### Examples from the actual code
|
||||
|
||||
`WidgetBlock.tsx` — the outer card, its label, and its icon buttons:
|
||||
```tsx
|
||||
<section
|
||||
data-qqq-id={`widget-${name}`}
|
||||
aria-label={label}
|
||||
>
|
||||
<h3 data-qqq-id={`widget-label-${name}`}>{label}</h3>
|
||||
<button data-qqq-id={`button-widget-help-${name}`}>...</button>
|
||||
<button data-qqq-id={`button-widget-export-${name}`}>...</button>
|
||||
<button data-qqq-id={`button-widget-reload-${name}`}>...</button>
|
||||
<div data-qqq-id={`widget-content-${name}`}>...</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
`RecordQuery.tsx` — the outer container, the filter close button, and the back link:
|
||||
```tsx
|
||||
<div data-qqq-id={`record-query-${tableName}`}>
|
||||
<Link data-qqq-id="link-back-to-source">...</Link>
|
||||
<button data-qqq-id="filter-panel-close">...</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
`EntityForm.tsx` — the form itself and its action buttons:
|
||||
```tsx
|
||||
<form data-qqq-id={`entity-form-${tableMetaData.name}`}>
|
||||
<button data-qqq-id="button-cancel">Cancel</button>
|
||||
<button data-qqq-id="button-save">Save</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
`FieldValue.tsx` — every field value element is tagged with its field name:
|
||||
```tsx
|
||||
<span data-qqq-id={`field-value-${field.name}`}>...</span>
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Do not use Tailwind class names as test selectors. If a test needs to click a button, it should use `page.getByTestId` or `[data-qqq-id="..."]` selectors, not `.bg-primary` or `.rounded-md`.
|
||||
- Identifier segments use the backend `name` field (e.g., `orders`), not the `label` (e.g., `"Customer Orders"`). Labels can contain spaces and special characters; names are safe identifiers.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Async Combobox (`useAsyncCombobox`)
|
||||
|
||||
Any dropdown that loads its options from the API with user-typed search uses the `useAsyncCombobox` hook (`src/lib/hooks/use-async-combobox.ts`). The hook encapsulates fetch, debounce, AbortController cancellation, and click-outside-to-close logic that would otherwise be duplicated in every combobox component.
|
||||
|
||||
### When to use it
|
||||
|
||||
Use `useAsyncCombobox` when:
|
||||
- The options come from a QQQ possible-value source (table-backed lookup).
|
||||
- The user can type to search and the list is fetched on demand.
|
||||
- The option list is too large to load all at once (more than ~50 items).
|
||||
|
||||
Use a plain `<select>` or a static shadcn/ui `<Select>` when:
|
||||
- The options are a small static list (e.g., sort direction: "Ascending / Descending").
|
||||
- The options are already available in memory (e.g., column names from `tableMetaData.fields`).
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import { useAsyncCombobox } from '@/lib/hooks/use-async-combobox'
|
||||
|
||||
function MyPossibleValueInput({ tableName, fieldName, onChange }) {
|
||||
const {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
options, // QPossibleValue[] — the fetched options to display
|
||||
isLoading,
|
||||
containerRef, // attach to outermost wrapper for click-outside close
|
||||
inputRef, // attach to the <input> for focus management
|
||||
debouncedFetch, // call in onChange handler to trigger debounced fetch
|
||||
} = useAsyncCombobox({
|
||||
tableName,
|
||||
fieldName,
|
||||
onOptionsFetched: (results) => {
|
||||
// optional: update a label map or other derived state
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
debouncedFetch(e.target.value)
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
/>
|
||||
{isOpen && (
|
||||
<ul role="listbox">
|
||||
{isLoading && <li>Loading...</li>}
|
||||
{options.map((opt) => (
|
||||
<li
|
||||
key={opt.id}
|
||||
role="option"
|
||||
onClick={() => {
|
||||
onChange(opt.id)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### What the hook manages internally
|
||||
|
||||
| Concern | Mechanism |
|
||||
|---|---|
|
||||
| Debouncing | `setTimeout` cleared on each call; fires after `COMBOBOX_DEBOUNCE_MS` (200 ms) |
|
||||
| Request cancellation | `AbortController` — aborts the previous fetch before starting a new one |
|
||||
| Click-outside close | `document.addEventListener('mousedown', handler)` attached to `containerRef` |
|
||||
| Auto-fetch on open | `useEffect` on `isOpen` — fetches the full list when the dropdown opens |
|
||||
| Cleanup on unmount | Clears pending timer and aborts any in-flight request |
|
||||
|
||||
### Return values
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `isOpen` | `boolean` | Drive conditional rendering of the dropdown list |
|
||||
| `setIsOpen` | `Dispatch` | Programmatically open/close (e.g., on focus, on select) |
|
||||
| `searchTerm` | `string` | Controlled value for the text input |
|
||||
| `setSearchTerm` | `Dispatch` | Update the text input value |
|
||||
| `options` | `QPossibleValue[]` | The fetched options — map these to `<li>` elements |
|
||||
| `isLoading` | `boolean` | Show a spinner or loading state while the fetch is in-flight |
|
||||
| `containerRef` | `RefObject<HTMLDivElement>` | Attach to the outermost wrapper element |
|
||||
| `inputRef` | `RefObject<HTMLInputElement>` | Attach to the text `<input>` element |
|
||||
| `fetchOptions` | `(term: string) => Promise<void>` | Immediate fetch (bypasses debounce) |
|
||||
| `debouncedFetch` | `(term: string) => void` | Debounced fetch — use this in `onChange` |
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Widget Rendering Pipeline
|
||||
|
||||
All dashboard widgets flow through a three-layer pipeline:
|
||||
|
||||
```
|
||||
ConnectedWidget
|
||||
└─ WidgetBlock (card chrome, loading, error, dropdown selects)
|
||||
└─ WidgetRenderer (type dispatcher)
|
||||
└─ StatisticsWidget | BarChartWidget | BlockWidget | ...
|
||||
```
|
||||
|
||||
### Layer 1: ConnectedWidget
|
||||
|
||||
`src/components/widgets/ConnectedWidget.tsx` is the primary entrypoint. It:
|
||||
|
||||
1. Reads `widgetMetaData.dropdowns` and initializes dropdown state.
|
||||
2. Fetches dropdown option lists from the possible-value API.
|
||||
3. Calls `useWidget(widgetMetaData.name, mergedParams)` to fetch the widget's data payload.
|
||||
4. Returns `null` immediately when `widgetMetaData.hasPermission === false`.
|
||||
5. Renders `<WidgetBlock>` with loading/error props and passes the fetched `data` to `<WidgetRenderer>`.
|
||||
|
||||
```tsx
|
||||
// ConnectedWidget orchestrates everything
|
||||
export function ConnectedWidget({ widgetMetaData, params, className }) {
|
||||
const { data, isLoading, isError, error, refetch } = useWidget(widgetMetaData.name, mergedParams)
|
||||
|
||||
return (
|
||||
<WidgetBlock
|
||||
widgetMetaData={widgetMetaData}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onReload={refetch}
|
||||
// ... dropdown props
|
||||
>
|
||||
{data && <WidgetRenderer widgetMetaData={widgetMetaData} data={data} />}
|
||||
</WidgetBlock>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 2: WidgetBlock
|
||||
|
||||
`src/components/widgets/WidgetBlock.tsx` provides the standard card shell:
|
||||
- Header with `label`, optional help tooltip, optional export button, optional reload button, and optional dropdown `<select>` elements.
|
||||
- Body area that renders a skeleton, an error state, or its `children`.
|
||||
- A `bare` prop that strips all chrome for use inside composite widgets.
|
||||
|
||||
The `WidgetBlock` does not know what type of widget it wraps. It only knows about loading/error state.
|
||||
|
||||
### Layer 3: WidgetRenderer
|
||||
|
||||
`src/components/widgets/WidgetRenderer.tsx` is the type dispatcher. It receives `widgetMetaData` and the raw `data` payload, reads `widgetMetaData.type`, and renders the appropriate typed component via a `switch` statement.
|
||||
|
||||
Supported types:
|
||||
|
||||
| `widgetMetaData.type` | Rendered component |
|
||||
|---|---|
|
||||
| `'statistics'` | `StatisticsWidget` |
|
||||
| `'barChart'` | `BarChartWidget` (lazy) |
|
||||
| `'lineChart'` | `LineChartWidget` (lazy) |
|
||||
| `'pieChart'` | `PieChartWidget` (lazy) |
|
||||
| `'recordGrid'` | `RecordGridWidget` |
|
||||
| `'html'` / `'block'` | `BlockWidget` |
|
||||
| `'divider'` | `DividerWidget` |
|
||||
| `'quickLinks'` | `QuickLinksWidget` |
|
||||
| `'alert'` | `AlertWidget` |
|
||||
| `'processSummary'` | `ProcessSummaryWidget` |
|
||||
| `'composite'` / `'parent'` | `CompositeWidget` |
|
||||
| `'chart'` | `ChartTypeDispatcher` (secondary `chartType` dispatch) |
|
||||
|
||||
Chart widgets (`barChart`, `lineChart`, `pieChart`) are loaded with `React.lazy` + `Suspense` to split the Recharts library into a separate bundle chunk. This keeps the initial JS bundle small on pages that contain no charts.
|
||||
|
||||
### How to add a new widget type
|
||||
|
||||
1. Create a new component file, e.g., `src/components/widgets/HeatmapWidget.tsx`. Export a typed payload interface and the component:
|
||||
|
||||
```tsx
|
||||
// src/components/widgets/HeatmapWidget.tsx
|
||||
export interface HeatmapWidgetPayload {
|
||||
rows: Array<{ label: string; cells: number[] }>
|
||||
columnLabels: string[]
|
||||
}
|
||||
|
||||
export function HeatmapWidget({ data, widgetName }: { data: HeatmapWidgetPayload; widgetName: string }) {
|
||||
// render the heatmap
|
||||
}
|
||||
```
|
||||
|
||||
2. Add a `case` to the `switch` in `WidgetRenderer.tsx`:
|
||||
|
||||
```tsx
|
||||
import { HeatmapWidget } from './HeatmapWidget'
|
||||
import type { HeatmapWidgetPayload } from './HeatmapWidget'
|
||||
|
||||
// Inside the switch:
|
||||
case 'heatmap':
|
||||
return <HeatmapWidget data={data as HeatmapWidgetPayload} widgetName={name} />
|
||||
```
|
||||
|
||||
3. The backend returns `widgetMetaData.type = 'heatmap'` for this widget. No other changes are needed — `ConnectedWidget` picks it up automatically.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5: Field Adornment Priority
|
||||
|
||||
`FieldValue` (`src/components/records/FieldValue.tsx`) renders a single QQQ field value in read-only mode. When a field has adornments (decorations declared in `field.adornments[]`), `FieldValue` applies them in strict priority order. The first matching adornment wins; subsequent ones are skipped.
|
||||
|
||||
### Priority order
|
||||
|
||||
1. **LINK** — renders the value as an `<a>` tag with an external link icon.
|
||||
2. **FILE_DOWNLOAD** — renders a download anchor using the display value as the URL.
|
||||
3. **SIZE** — formats the raw number as a human-readable byte size (e.g., `1.5 MB`).
|
||||
4. **CHIP** — renders the value as a colored badge using a `colorMap` from adornment values.
|
||||
5. **RENDER_HTML** (or field type `HTML`) — renders sanitized HTML via `DOMPurify.sanitize`.
|
||||
6. **CODE_EDITOR** — renders the value in a monospace `<pre><code>` block.
|
||||
7. **TOOLTIP** — wraps the value in a Radix tooltip. Falls through to type-based rendering when no tooltip text is resolved.
|
||||
8. **ERROR** — renders the value with a destructive icon and styling.
|
||||
9. Record reference link — when `field.possibleValueSourceName` matches a known table, renders as a Next.js `<Link>` with a `RecordHoverCard` preview.
|
||||
10. Type-based fallbacks — `BOOLEAN`, `PASSWORD`, `BLOB`, `TEXT` each have dedicated renderers.
|
||||
11. Default — auto-links bare `http(s)://` URLs and `mailto:` email addresses.
|
||||
|
||||
### How adornment detection works
|
||||
|
||||
Each adornment is checked with `Array.some()` before its rendering block. The discriminated union type `FieldAdornment` is narrowed inside each block using a type predicate:
|
||||
|
||||
```tsx
|
||||
// Detection
|
||||
const hasLink = field.adornments?.some((a) => a.type === 'LINK')
|
||||
|
||||
// Narrowing within the block
|
||||
if (hasLink) {
|
||||
const linkAdornment = field.adornments?.find(
|
||||
(a): a is Extract<FieldAdornment, { type: 'LINK' }> => a.type === 'LINK'
|
||||
)
|
||||
const href = linkAdornment?.values?.linkURL ?? String(value)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The `Extract<FieldAdornment, { type: 'LINK' }>` predicate gives TypeScript full knowledge of which `values` keys are available for that specific adornment type without a cast.
|
||||
|
||||
### TOOLTIP fall-through
|
||||
|
||||
The TOOLTIP case has one deliberate exception to the "first match wins" rule: if the `TOOLTIP` adornment is present but none of the `values` keys (`tooltipText`, `text`, `tooltip`) resolve to a non-empty string, the block falls through to type-based rendering. The value is still displayed — only the tooltip wrapper is omitted because there is nothing to show. This is documented with a comment in the source.
|
||||
|
||||
### DOMPurify sanitization
|
||||
|
||||
HTML values are sanitized once via `useMemo` that only re-runs when `value` changes:
|
||||
|
||||
```tsx
|
||||
const sanitizedHtml = useMemo(
|
||||
() => DOMPurify.sanitize(String(value)),
|
||||
[value]
|
||||
)
|
||||
```
|
||||
|
||||
This prevents unnecessary sanitization work on every render while also preventing XSS from backend-provided HTML content.
|
||||
@@ -0,0 +1,278 @@
|
||||
# Design & UX Review — QQQ Frontend Next
|
||||
**Date:** 2026-03-01
|
||||
**Reviewer:** Claude Code (automated static analysis)
|
||||
**Scope:** Full codebase UX/design audit — layout, pages, forms, process, widgets, feedback, theme, responsive, and a11y
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The new frontend has strong bones: metadata-driven rendering is correctly implemented, component composition is clean, TanStack Query manages server state well, and the base accessibility implementation (labels, aria-required, focus rings, skip nav on breadcrumbs) is solid. The primary gaps fall into three categories:
|
||||
|
||||
1. **Accessibility** — several WCAG 2.1 AA gaps in the data grid, animations, and motion preferences
|
||||
2. **Mobile/responsive polish** — search hidden on mobile, touch targets below 44px minimum in several places
|
||||
3. **Loading/error state coverage** — a handful of async operations show no spinner or fallback
|
||||
|
||||
No showstopper UX regressions from the Material UI frontend were found. The issues below are incremental improvements.
|
||||
|
||||
---
|
||||
|
||||
## Severity Legend
|
||||
|
||||
| Severity | Meaning |
|
||||
|----------|---------|
|
||||
| **CRIT** | WCAG 2.1 Level A violation or severe usability break |
|
||||
| **HIGH** | WCAG 2.1 AA violation or significant UX regression |
|
||||
| **MED** | Noticeable UX gap, inconsistency, or missing polish |
|
||||
| **LOW** | Minor polish, nice-to-have, or future consideration |
|
||||
|
||||
---
|
||||
|
||||
## 1. Layout Shell
|
||||
|
||||
**Files:** `src/components/layout/Sidebar.tsx`, `Header.tsx`, `Breadcrumbs.tsx`, `Banner.tsx`, `src/app/(dashboard)/layout.tsx`
|
||||
|
||||
### Strengths
|
||||
- Desktop sidebar + mobile drawer correctly split at `md` breakpoint
|
||||
- Breadcrumbs have `aria-label="Breadcrumb"` and `aria-current="page"`
|
||||
- Escape key closes mobile drawer; sidebar items have focus rings
|
||||
- Banner severity states (info/warning/error) correctly scoped with icons
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-L-1 | **HIGH** | **No skip-to-content link.** Keyboard users must tab through the entire sidebar before reaching page content. Add `<a href="#main-content" className="sr-only focus:not-sr-only">Skip to main content</a>` as the first child of `<body>`. |
|
||||
| D-L-2 | **HIGH** | **Animations ignore `prefers-reduced-motion`.** Sidebar slide-in, banner fade-in, and skeleton `animate-pulse` all run unconditionally. Add `@media (prefers-reduced-motion: reduce) { *, ::before, ::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }` to `globals.css`. |
|
||||
| D-L-3 | **MED** | **Header search hidden on mobile** (`hidden md:block`). Mobile users lose global search entirely. Add a search icon button on mobile that opens `SearchDialog`. |
|
||||
| D-L-4 | **MED** | **Dismissed banners not persisted.** Banners dismissed via local state reappear on reload. Save dismissed banner keys to `localStorage` under `qqq:dismissed-banners`. |
|
||||
| D-L-5 | **MED** | **Mobile sidebar `aria-expanded` not set.** The mobile drawer open/closed state isn't announced to screen readers. Add `aria-expanded={mobileOpen}` to the nav landmark element. |
|
||||
| D-L-6 | **LOW** | **Breadcrumb overflow on very narrow viewports** (<320px). Long breadcrumb chains don't truncate; last segment can overflow. Add `truncate` + `max-w-[8rem]` on intermediate segments. |
|
||||
| D-L-7 | **LOW** | **Sidebar nav has no loading skeleton.** During the initial metadata fetch the sidebar is empty with no placeholder. Add skeleton nav items matching the expected structure. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Record Query (List View)
|
||||
|
||||
**Files:** `src/components/query/DataGrid.tsx`, `FilterBuilder.tsx`, `Pagination.tsx`, `RecordQueryToolbar.tsx`, `RecordCardView.tsx`, `ColumnConfig.tsx`
|
||||
|
||||
### Strengths
|
||||
- Skeleton rows during fetch match final layout (no layout shift)
|
||||
- Three row density variants (compact/standard/comfortable) with persistence
|
||||
- Empty state with icon, title, and "Clear filters" CTA
|
||||
- Quick-search debounced at 300ms
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-Q-1 | **CRIT** | **DataGrid missing `aria-sort` on column headers.** When a column is sorted, screen readers don't announce direction. Add `aria-sort="ascending" \| "descending" \| "none"` to each `<th>` based on current sort state. |
|
||||
| D-Q-2 | **HIGH** | **Row selection checkboxes have no label.** Each row checkbox announces "checkbox" with no context. Add `aria-label={\`Select ${recordLabel}\`}` to each. |
|
||||
| D-Q-3 | **HIGH** | **Column resize handles not keyboard accessible.** Resize is mouse-drag only. Keyboard users cannot adjust column widths. Add `onKeyDown` handler supporting `ArrowLeft`/`ArrowRight` to the resize handle element. |
|
||||
| D-Q-4 | **MED** | **FilterBuilder async combobox has no loading indicator.** While possible-value options are fetching, the dropdown appears empty with no spinner. Add a `Loader2` icon while `isLoading` is true. |
|
||||
| D-Q-5 | **MED** | **"Go to page" input silently ignores out-of-range values.** Typing a page number beyond the last page does nothing with no feedback. Show an inline validation message like "Page must be between 1 and N". |
|
||||
| D-Q-6 | **MED** | **No `aria-live` region for fetch-in-progress.** Background re-fetches (sort/filter changes) are visually indicated by the progress bar but not announced. Add `aria-live="polite"` region announcing "Loading results". |
|
||||
| D-Q-7 | **MED** | **Column visibility toggle state not announced.** Toggling a column in ColumnConfig doesn't notify screen readers. Add a `role="status"` region that announces "Column X hidden" / "Column X visible". |
|
||||
| D-Q-8 | **LOW** | **No frozen/pinned columns.** The primary key / record label column scrolls out of view on wide tables. Consider pinning the first column on horizontal scroll. |
|
||||
| D-Q-9 | **LOW** | **RecordCardView touch targets may be too small.** Cards are clickable but no explicit 44px minimum hit area. Ensure card tap targets meet the 44px minimum on mobile. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Record View (Detail Page)
|
||||
|
||||
**Files:** `src/components/records/RecordView.tsx`, `RecordViewHeader.tsx`, `FieldValue.tsx`, `RecordViewAssociated.tsx`
|
||||
|
||||
### Strengths
|
||||
- DOMPurify sanitization applied before rendering HTML field values
|
||||
- 403/404/500 errors shown with contextual messages
|
||||
- Tab layout for T2/T3 sections with proper semantic structure
|
||||
- Field adornment priority correctly handled (LINK → FILE_DOWNLOAD → SIZE → CHIP)
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-V-1 | **HIGH** | **Edit/Delete buttons shown regardless of user permissions.** If table metadata indicates the user lacks UPDATE/DELETE rights, buttons should be hidden (or disabled with tooltip). Currently they render and fail with a 403 on action. |
|
||||
| D-V-2 | **MED** | **Associated records list not paginated.** Many-to-many joins can return thousands of records, all rendered to DOM. Add pagination (or a "Show more" pattern) capped at 25 by default. |
|
||||
| D-V-3 | **MED** | **Avatar initials fail for non-Latin scripts.** `getInitials()` splits on whitespace which produces empty strings for CJK/Arabic names. Add a fallback to a generic user icon when initials can't be derived. |
|
||||
| D-V-4 | **MED** | **No delete confirmation dialog.** The delete action currently triggers immediately. Add a confirm dialog ("Delete this record? This cannot be undone.") before the API call. |
|
||||
| D-V-5 | **LOW** | **No "Copy record ID" quick action.** Admin users frequently need the primary key for support/debugging. Add a small copy-to-clipboard icon next to the record ID. |
|
||||
| D-V-6 | **LOW** | **No audit trail footer.** No "Last modified by / at" metadata shown. If the backend provides this, surface it at the bottom of the record view. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Create / Edit Forms
|
||||
|
||||
**Files:** `src/components/forms/DynamicForm.tsx`, `EntityForm.tsx`, `DynamicFormField.tsx`, `src/components/forms/field-types/`
|
||||
|
||||
### Strengths
|
||||
- All inputs have `aria-required`, `aria-invalid`, `aria-describedby` wired correctly
|
||||
- Unsaved-changes guard (`beforeunload` + modal) prevents accidental data loss
|
||||
- Help tooltips use Radix Tooltip with keyboard support
|
||||
- Required asterisk is `aria-hidden="true"` to avoid screen-reader repetition
|
||||
- Disabled state styling consistent across field types
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-F-1 | **HIGH** | **Submit button stays enabled during mutation.** User can click Save multiple times before the first request completes, creating duplicate records. Add `disabled={isSubmitting}` and a loading spinner to the submit button. |
|
||||
| D-F-2 | **MED** | **Number fields don't display min/max constraints.** If field metadata defines `minValue`/`maxValue`, the user doesn't see them until a validation error fires. Show the range as hint text below the field (`0 – 100`). |
|
||||
| D-F-3 | **MED** | **DateTime fields don't indicate time zone.** The input accepts a time but doesn't show which time zone is assumed. Show the user's local TZ abbreviation next to the field. |
|
||||
| D-F-4 | **MED** | **Help tooltips not touch-friendly on mobile.** The `?` icon is hover-only and < 44px. On mobile, show the help text inline beneath the label instead. |
|
||||
| D-F-5 | **MED** | **File upload field has no drag-and-drop visual feedback.** The drop zone changes border on `dragover` but has no text like "Drop file here". Add clear drop-zone copy and a highlight color change. |
|
||||
| D-F-6 | **LOW** | **No "which fields changed" diff in edit mode.** Users editing a long form can't tell what they've modified at a glance. Consider highlighting changed fields with a left border accent. |
|
||||
| D-F-7 | **LOW** | **Large forms show all fields at once.** Sections with 20+ fields have no collapse/expand. Consider optional `<details>` sections for secondary field groups. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Process Execution
|
||||
|
||||
**Files:** `src/components/process/ProcessRun.tsx`, `StepWizard.tsx`, `ProcessFormStep.tsx`, `ValidationReviewStep.tsx`, `ProcessUploadFormStep.tsx`, `BulkLoadStep.tsx`
|
||||
|
||||
### Strengths
|
||||
- Step wizard gives clear numbered/checkmark visual progress
|
||||
- Cancellation confirm dialog prevents accidental process abort
|
||||
- Back navigation available when not on the first step
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-P-1 | **HIGH** | **Current step not auto-focused after "Next".** When advancing to a new step, focus remains on the Next button (now gone), leaving screen reader users disoriented. Move focus to the step heading or first field on step change. |
|
||||
| D-P-2 | **MED** | **Step wizard steps not keyboard-navigable (back).** Completed steps have no click handler to jump back. Add click-to-navigate on completed step indicators. |
|
||||
| D-P-3 | **MED** | **No process timeout message.** If the backend hangs, the polling spinner runs indefinitely. After ~60s show "This is taking longer than expected" with an option to wait or cancel. |
|
||||
| D-P-4 | **MED** | **Validation review results not filterable.** With 1000+ errors, the flat list is unmanageable. Add severity filter buttons (All / Errors / Warnings) and a field-name search. |
|
||||
| D-P-5 | **MED** | **File upload step has no progress bar.** Large uploads show no byte-level progress. Add a `<progress>` element or Radix Progress during multipart upload. |
|
||||
| D-P-6 | **MED** | **Step label truncation has no mobile tooltip.** Long step names are truncated at `max-w-[6rem]` with only a `title` attribute, which doesn't appear on touch. Add a Radix Tooltip wrapper. |
|
||||
| D-P-7 | **LOW** | **No "retry" on process step failure.** Users must restart the entire process from step 1 if one step fails. Add a "Retry this step" button where the backend supports re-running a step. |
|
||||
| D-P-8 | **LOW** | **Bulk load column mappings not saveable.** Users re-map the same CSV columns every run. Add a "Save this mapping" option persisted to localStorage. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Widgets & Dashboard
|
||||
|
||||
**Files:** `src/components/widgets/WidgetRenderer.tsx`, `StatisticsWidget.tsx`, `BlockWidget.tsx`, `BarChartWidget.tsx`, `LineChartWidget.tsx`, `PieChartWidget.tsx`, `RecordGridWidget.tsx`
|
||||
|
||||
### Strengths
|
||||
- Charts lazy-loaded via dynamic import (non-chart pages don't load Recharts)
|
||||
- Suspense skeleton placeholder shown while chart chunk loads
|
||||
- Exhaustive `switch` on widget type with unknown fallback
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-W-1 | **HIGH** | **Statistics trend arrows not accessible.** `TrendingUp`/`TrendingDown`/`Minus` icons are `aria-hidden` with no text alternative. Screen readers don't announce whether a metric is up or down. Add `<span className="sr-only">trending up</span>` next to each icon. |
|
||||
| D-W-2 | **MED** | **Widget data fetch errors show blank space.** If a widget's API call fails there is no error boundary or fallback UI. Add a per-widget error state (icon + "Could not load widget" message + retry). |
|
||||
| D-W-3 | **MED** | **Chart legends overflow horizontally on mobile.** Recharts legends don't reflow on narrow screens. Use `layout="vertical"` or hide the legend on mobile with a `<ResponsiveContainer>` width check. |
|
||||
| D-W-4 | **MED** | **RecordGrid widget not paginated.** Renders the full result set to DOM. Cap at 25 rows and add a "View all" link to the full record query page. |
|
||||
| D-W-5 | **MED** | **No manual widget refresh.** Stale widget data can't be refreshed without a full page reload. Add a small refresh icon button on each widget card header. |
|
||||
| D-W-6 | **LOW** | **Only 5 chart color tokens defined.** Dashboards with 6+ data series reuse colors, making series indistinguishable. Extend `--color-chart-*` to at least 10 distinct values. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Feedback Components
|
||||
|
||||
**Files:** `src/components/feedback/GlobalSearch.tsx`, `SearchDialog.tsx`, `CommandMenu.tsx`, `EmptyState.tsx`, `ErrorBoundary.tsx`, `KeyboardShortcutsDialog.tsx`
|
||||
|
||||
### Strengths
|
||||
- `EmptyState` uses `role="status"` + `aria-live="polite"` correctly
|
||||
- Skeleton components have `role="status"` + `aria-busy="true"`
|
||||
- SearchDialog has full ArrowUp/ArrowDown/Enter/Escape keyboard nav
|
||||
- `HighlightedText` now correctly uses `i % 2 !== 0` (recently fixed)
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-FB-1 | **MED** | **Keyboard Shortcuts dialog not discoverable.** `?` opens it but there is no visible hint in the UI. Add a `?` icon button in the header, or a footer note "Press ? for keyboard shortcuts". |
|
||||
| D-FB-2 | **MED** | **Toasts have no visible close/dismiss button.** Users must wait for auto-dismiss. Add an × button to each toast (sonner supports `closeButton` prop). |
|
||||
| D-FB-3 | **MED** | **CommandMenu icon buttons below 44px touch target.** Small icon-only buttons in the command menu are hard to tap on mobile. Ensure `min-h-[44px] min-w-[44px]` on interactive icons. |
|
||||
| D-FB-4 | **LOW** | **Search results missing table/app context in header.** Results are grouped by table in logic but group labels may not be visually prominent enough. Ensure group headers have sufficient weight/spacing. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Theme & Global Styles
|
||||
|
||||
**Files:** `src/styles/globals.css`, `src/styles/qqq-theme.css`, `src/lib/theme/theme-provider.tsx`, `src/lib/theme/tokens.ts`
|
||||
|
||||
### Strengths
|
||||
- 60+ CSS custom property tokens for colors, spacing, shadows
|
||||
- Complete dark mode override section
|
||||
- `:focus-visible` with proper outline and offset
|
||||
- Muted text color darkened to meet WCAG AA contrast
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-T-1 | **HIGH** | **No `prefers-reduced-motion` override.** All animations (skeleton pulse, slide-in transitions, toast fades) run unconditionally. See D-L-2 above — one global CSS rule resolves this. |
|
||||
| D-T-2 | **HIGH** | **No `prefers-contrast: more` support.** Users with OS high-contrast mode enabled get no enhanced styling. Add a `@media (prefers-contrast: more)` block that increases border weights and ensures 7:1 contrast ratios. |
|
||||
| D-T-3 | **MED** | **Dual theming systems create token confusion.** `--qqq-primary-color` and `--color-primary` both define the primary color. Components reference both inconsistently. Consolidate to one system (Tailwind v4 `--color-*` preferred) and alias the `--qqq-*` tokens to the canonical values for backwards compatibility. |
|
||||
| D-T-4 | **MED** | **Z-index tokens defined but not used consistently.** `--qqq-z-sidebar: 100`, `--qqq-z-modal: 1000` exist in the token file but some components use hardcoded values. Use the tokens everywhere. |
|
||||
| D-T-5 | **LOW** | **Focus ring may be invisible in dark mode.** The `color-mix`-based focus outline color may not contrast sufficiently on dark backgrounds. Test with browser dark mode enabled. |
|
||||
| D-T-6 | **LOW** | **No disabled-state semantic token.** All disabled states use `opacity-50`. A dedicated `--color-disabled` token would allow more nuanced disabled styling (e.g., different for text vs background). |
|
||||
|
||||
---
|
||||
|
||||
## 9. Responsive / Mobile
|
||||
|
||||
### Strengths
|
||||
- Sidebar correctly switches between desktop rail and mobile drawer at `md`
|
||||
- RecordQuery shows bottom-sheet filter panel on mobile
|
||||
- RecordCardView provides card layout alternative to data grid on mobile
|
||||
- Pagination uses `flex-wrap` to reflow on narrow screens
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-R-1 | **HIGH** | **Mobile `<input>` elements trigger browser zoom.** Inputs with `font-size < 16px` cause iOS Safari to auto-zoom on focus, breaking the layout. Ensure all form inputs have `text-base` (16px) as minimum font size. |
|
||||
| D-R-2 | **MED** | **Touch targets below 44px in multiple places.** Pagination size selector, density buttons, column config toggles. Audit all interactive elements and ensure `min-h-[44px]` or adequate padding. |
|
||||
| D-R-3 | **MED** | **No 320px (iPhone SE) layout testing.** Several components assume ≥375px. Run a responsive check at 320px and fix any overflows. |
|
||||
| D-R-4 | **LOW** | **Landscape tablet layout untested.** At ~768px landscape some components may show desktop layout with insufficient room. Test iPad Mini landscape. |
|
||||
|
||||
---
|
||||
|
||||
## Priority Matrix
|
||||
|
||||
### Must Fix (WCAG 2.1 AA / CRIT / HIGH)
|
||||
|
||||
| ID | Area | Fix |
|
||||
|----|------|-----|
|
||||
| D-L-1 | Layout | Skip-to-content link |
|
||||
| D-L-2 / D-T-1 | Layout / Theme | `prefers-reduced-motion` CSS rule |
|
||||
| D-T-2 | Theme | `prefers-contrast: more` support |
|
||||
| D-Q-1 | Record Query | `aria-sort` on DataGrid column headers |
|
||||
| D-Q-2 | Record Query | Row checkbox `aria-label` |
|
||||
| D-Q-3 | Record Query | Keyboard-accessible column resize |
|
||||
| D-V-1 | Record View | Permission-gated Edit/Delete buttons |
|
||||
| D-F-1 | Forms | Disable submit button while submitting |
|
||||
| D-P-1 | Process | Auto-focus step heading on step change |
|
||||
| D-W-1 | Widgets | Screen-reader text for trend arrows |
|
||||
| D-R-1 | Responsive | 16px minimum font size on inputs |
|
||||
|
||||
### Should Fix (MED — noticeable UX gaps)
|
||||
|
||||
D-L-3 (mobile search), D-L-4 (banner persistence), D-L-5 (sidebar aria-expanded), D-Q-4 (FilterBuilder spinner), D-Q-5 (pagination range feedback), D-Q-6 (aria-live for fetch), D-V-2 (associated records pagination), D-V-4 (delete confirmation), D-F-2 (number field min/max hint), D-F-3 (DateTime TZ indicator), D-F-4 (mobile tooltip → inline help), D-P-2 (step back navigation), D-P-3 (process timeout message), D-P-4 (validation review filter), D-P-5 (upload progress bar), D-W-2 (widget error boundary), D-W-3 (chart legend mobile), D-W-5 (widget refresh button), D-FB-1 (keyboard shortcuts discoverability), D-FB-2 (toast close button), D-T-3 (dual theming consolidation), D-T-4 (z-index token usage), D-R-2 (touch targets), D-R-3 (320px layout)
|
||||
|
||||
### Nice to Have (LOW — polish)
|
||||
|
||||
D-L-6 (breadcrumb truncation), D-L-7 (sidebar skeleton), D-Q-8 (frozen columns), D-V-5 (copy record ID), D-V-6 (audit trail), D-F-6 (edit diff), D-F-7 (collapsible form sections), D-P-7 (retry step), D-P-8 (save bulk load mapping), D-W-6 (chart color tokens), D-FB-4 (search result context), D-T-5 (dark mode focus ring), D-T-6 (disabled token), D-R-4 (landscape tablet)
|
||||
|
||||
---
|
||||
|
||||
## What's Working Well
|
||||
|
||||
The following areas are solid and don't require significant rework:
|
||||
|
||||
- **Metadata-driven rendering** — no hardcoded table/field names anywhere
|
||||
- **API layer** — all calls through typed `src/lib/api/` functions, consistent error handling
|
||||
- **Form accessibility baseline** — `aria-required`, `aria-invalid`, `aria-describedby` correctly wired
|
||||
- **Unsaved changes guard** — `beforeunload` + confirm modal works correctly
|
||||
- **TanStack Query** — caching, deduplication, and revalidation correctly configured
|
||||
- **Dark mode** — complete token coverage, no visual regressions found
|
||||
- **Skeleton loading** — components have `role="status"` and `aria-busy="true"`
|
||||
- **DOMPurify** — applied consistently on all user-controlled HTML output
|
||||
- **URL state** — filters, pagination, and sort are shareable via query params
|
||||
@@ -0,0 +1,194 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# QQQ Frontend Next — Documentation Review
|
||||
**Date:** 2026-03-01
|
||||
**Scope:** Full `src/` + `docs/` + project root
|
||||
**Commit:** 12edc4c
|
||||
|
||||
---
|
||||
|
||||
## Overall Score: 7.5 / 10
|
||||
|
||||
**Strengths:** JSDoc coverage is high across the API client, utilities, and most hooks. Type definitions are well-documented. `CLAUDE.md` and the implementation plans give a strong architectural overview.
|
||||
|
||||
**Key gaps:** `README.md` is essentially empty, `useRecordQuery` has no return-type documentation, and there are no architecture guides for state management or component composition patterns — the two things a new contributor needs most.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL — Must Address
|
||||
|
||||
### DOC-CRIT-1: README.md is empty
|
||||
|
||||
**File:** `README.md`
|
||||
**Issue:** The file contains only a title. No setup instructions, command list, project overview, or links to docs.
|
||||
**Impact:** A new developer cannot start contributing without reading `CLAUDE.md` and inferring the rest.
|
||||
|
||||
**Fix:** Add the following sections at minimum:
|
||||
- What this project is (2–3 sentences)
|
||||
- Prerequisites (Node.js version, pnpm)
|
||||
- Quick start (`git clone` → `pnpm install` → `pnpm dev`)
|
||||
- Available commands (dev, build, test, test:e2e, lint, tsc)
|
||||
- Pointer to `CLAUDE.md` for architecture and conventions
|
||||
- Pointer to `docs/` for implementation plans
|
||||
|
||||
---
|
||||
|
||||
### DOC-CRIT-2: `useRecordQuery` return type is undocumented
|
||||
|
||||
**File:** `src/lib/hooks/use-record-query.ts` (~line 60, main hook export)
|
||||
**Issue:** The hook returns a large composite object (pagination, filter, selection, sort, query state, plus delegated `columns` and `views` namespaces from sub-hooks). None of the return properties are documented. Only two components consume this hook today, but that will grow.
|
||||
**Impact:** Callers must trace through 685 lines of source to discover which properties exist.
|
||||
|
||||
**Fix:** Add a `@returns` block enumerating the top-level namespaces and key properties, or define an explicit `UseRecordQueryResult` interface with JSDoc on each field.
|
||||
|
||||
---
|
||||
|
||||
### DOC-CRIT-3: No state-management decision guide
|
||||
|
||||
**Issue:** The project uses five different state containers (React Context, TanStack Query, React Hook Form, URL search params, localStorage), each for different purposes. There is a brief table in `CLAUDE.md` but no guidance on the decision criteria.
|
||||
**Impact:** New contributors pick the wrong state container; inconsistent patterns accumulate over time.
|
||||
|
||||
**Fix:** Create `docs/STATE-MANAGEMENT.md` covering:
|
||||
- TanStack Query: all server state (records, metadata, widgets)
|
||||
- React Hook Form: form field state only
|
||||
- URL search params: filters, pagination, sort (shareable links)
|
||||
- localStorage: user preferences, column config, saved views
|
||||
- QContext: application-level UI state (modals, toast queue)
|
||||
- When NOT to use useState for server-derived state
|
||||
|
||||
---
|
||||
|
||||
## SIGNIFICANT — Should Address
|
||||
|
||||
### DOC-SIG-1: No component composition patterns guide
|
||||
|
||||
**Issue:** The codebase uses several non-obvious patterns not documented anywhere outside of scattered code comments:
|
||||
- Metadata-driven rendering (metadata always flows down; children never fetch)
|
||||
- `data-qqq-id` naming convention (`{component-type}-{identifier}`)
|
||||
- Async combobox pattern (`useAsyncCombobox` wrapping)
|
||||
- Field adornment priority chain (documented in `FieldValue.tsx` but not shared)
|
||||
|
||||
**Fix:** Create `docs/COMPONENT-PATTERNS.md` with each pattern described, the rationale, and a code example.
|
||||
|
||||
---
|
||||
|
||||
### DOC-SIG-2: No type glossary
|
||||
|
||||
**Issue:** The `src/types/` directory exports ~40 interfaces and type aliases. There is no index or glossary. Finding the right type requires `grep`-ing.
|
||||
**Impact:** Types are duplicated or approximated when developers can't find the canonical definition.
|
||||
|
||||
**Fix:** Create `docs/TYPE-GLOSSARY.md` with a table: Type | File | Purpose | Example usage. Focus on the ~15 most-used types (`QInstance`, `QTableMetaData`, `QFieldMetaData`, `SavedView`, `QProcessMetaData`, `StatTile`, `FilterModel`, etc.).
|
||||
|
||||
---
|
||||
|
||||
### DOC-SIG-3: API error handling patterns undocumented
|
||||
|
||||
**File:** `src/lib/api/client.ts` (referenced)
|
||||
**Issue:** The 401 interceptor is documented, but there is no guide to how callers should handle 400 (validation), 403 (permission), 404 (not found), and 500 (server error) responses. Components handle these inconsistently today.
|
||||
|
||||
**Fix:** Add a section to the API client file header or create `docs/API-ERROR-HANDLING.md` with the standard response patterns per HTTP status code.
|
||||
|
||||
---
|
||||
|
||||
### DOC-SIG-4: Zod schema generation lacks examples
|
||||
|
||||
**File:** `src/lib/utils/zod-from-metadata.ts`
|
||||
**Issue:** The function and helpers are well-documented, but there is no `@example` showing how a field metadata object maps to a generated Zod schema. This is the most opaque part of the form validation pipeline.
|
||||
|
||||
**Fix:** Add a 6-line `@example` to `zodFieldFromMetadata` showing an input `QFieldMetaData` object and the corresponding schema.
|
||||
|
||||
---
|
||||
|
||||
### DOC-SIG-5: Form validation strategy not centralized
|
||||
|
||||
**Issue:** Zod schema generation is in `zod-from-metadata.ts`, field-level validation rules are in individual field components, and server-side validation errors are mapped in `EntityForm.tsx`. No single document explains the end-to-end validation strategy.
|
||||
|
||||
**Fix:** Create `docs/FORM-VALIDATION.md` covering schema generation, custom refinements, and mapping server errors to form fields.
|
||||
|
||||
---
|
||||
|
||||
### DOC-SIG-6: TanStack Query key factory undocumented
|
||||
|
||||
**File:** `src/lib/query-client.ts`
|
||||
**Issue:** The query key factory pattern is used throughout the codebase but the naming convention and invalidation patterns are not documented. Developers risk creating overlapping keys.
|
||||
|
||||
**Fix:** Document the key structure and add `@example` tags showing how to invalidate a table's record list after a mutation.
|
||||
|
||||
---
|
||||
|
||||
## MINOR — Nice to Have
|
||||
|
||||
### DOC-MIN-1: Test file headers are sparse
|
||||
|
||||
Many test files (`*.test.tsx`) have no JSDoc file header explaining what behavior is under test and what is explicitly out of scope. This makes it harder to assess test coverage gaps at a glance.
|
||||
|
||||
**Files:** Most files in `src/components/**/*.test.tsx`, `src/lib/hooks/*.test.ts`
|
||||
|
||||
---
|
||||
|
||||
### DOC-MIN-2: Performance decisions lack inline rationale
|
||||
|
||||
Files like `use-record-query.ts` have several `useMemo` / `useCallback` / `useRef` optimizations with comments referencing ticket numbers but no explanation of the underlying performance problem being solved.
|
||||
|
||||
---
|
||||
|
||||
### DOC-MIN-3: Accessibility decisions not consolidated
|
||||
|
||||
WCAG compliance work is scattered across code comments and the design review document. A single `docs/ACCESSIBILITY.md` checklist would make it easy to see what has been done and what remains.
|
||||
|
||||
---
|
||||
|
||||
### DOC-MIN-4: Widget creation guide missing
|
||||
|
||||
The widget rendering pipeline (`WidgetBlock` → `WidgetRenderer` → specific widget component) is non-obvious. A short guide in `docs/` or a code comment in `WidgetRenderer` explaining how to add a new widget type would help.
|
||||
|
||||
---
|
||||
|
||||
## Already Excellent
|
||||
|
||||
| Area | Quality | Notes |
|
||||
|------|---------|-------|
|
||||
| API client (`src/lib/api/`) | 9/10 | 100% JSDoc; error conditions noted; param types clear |
|
||||
| Utilities (`src/lib/utils/`) | 8.5/10 | `filter-utils`, `process-utils`, `zod-from-metadata` all thorough |
|
||||
| Sub-hooks (`use-column-config`, `use-saved-views`, `use-async-combobox`) | 9/10 | Return shapes documented; edge cases noted |
|
||||
| Type definitions (`src/types/metadata.ts`) | 8.5/10 | All major interfaces have field-level JSDoc |
|
||||
| Core query components (`DataGrid`, `FilterBuilder`, `Pagination`, `ColumnConfig`) | 8/10 | Props and behavior well-described |
|
||||
| Records components (`FieldValue`, `RecordView`, `EntityForm`) | 7.5/10 | Good headers; adornment priority documented |
|
||||
| `CLAUDE.md` | 9/10 | Architecture, conventions, file structure all clear |
|
||||
| Implementation plans (`docs/implementation-plans/`) | 8/10 | Phased clearly; shared context well-organized |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Work Order
|
||||
|
||||
### Phase 1 — Unblock contributors (half day)
|
||||
1. **DOC-CRIT-1** — Expand README.md
|
||||
2. **DOC-CRIT-2** — Document `useRecordQuery` return type
|
||||
|
||||
### Phase 2 — Improve onboarding (1 day)
|
||||
3. **DOC-CRIT-3** — Create `docs/STATE-MANAGEMENT.md`
|
||||
4. **DOC-SIG-1** — Create `docs/COMPONENT-PATTERNS.md`
|
||||
5. **DOC-SIG-2** — Create `docs/TYPE-GLOSSARY.md`
|
||||
|
||||
### Phase 3 — Fill API/validation gaps (half day)
|
||||
6. **DOC-SIG-3** — API error handling guide
|
||||
7. **DOC-SIG-4** — Add `@example` to `zodFieldFromMetadata`
|
||||
8. **DOC-SIG-5** — Create `docs/FORM-VALIDATION.md`
|
||||
9. **DOC-SIG-6** — Document query key factory
|
||||
|
||||
### Phase 4 — Polish (as time allows)
|
||||
10–13. **DOC-MIN-1 through DOC-MIN-4** — Test headers, perf rationale, a11y checklist, widget guide
|
||||
@@ -0,0 +1,333 @@
|
||||
# Form Validation
|
||||
|
||||
This guide covers how forms are validated in QQQ Frontend Next. Validation is schema-driven: Zod schemas are generated automatically from the same backend field metadata that drives rendering, so there is no need to maintain a separate validation definition alongside the form.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The validation stack is:
|
||||
|
||||
- **Zod** — runtime schema definition and validation.
|
||||
- **React Hook Form** + `@hookform/resolvers/zod` — drives form state; the Zod schema is passed as the resolver.
|
||||
- **`zodFieldFromMetadata`** — the core function that maps a single `QFieldMetaData` to a Zod validator.
|
||||
- **`zodSchemaFromTableMetadata`** — builds a full `z.ZodObject` from all editable fields in a table.
|
||||
- **`zodSchemaFromFields`** — builds a `z.ZodObject` from a flat array of fields (used for process steps).
|
||||
|
||||
The source for the schema generation utilities is `src/lib/utils/zod-from-metadata.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Schema generation
|
||||
|
||||
### For table-based forms
|
||||
|
||||
`EntityForm` (`src/components/forms/EntityForm.tsx`) builds its schema on mount:
|
||||
|
||||
```tsx
|
||||
const schema = useMemo(
|
||||
() => zodSchemaFromTableMetadata(tableMetaData, fieldNamesToInclude),
|
||||
[tableMetaData, fieldNamesToInclude]
|
||||
)
|
||||
|
||||
const { register, control, handleSubmit, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: mergedDefaults,
|
||||
})
|
||||
```
|
||||
|
||||
`zodSchemaFromTableMetadata` iterates the table's fields, skips hidden (`isHidden`) and non-editable (`!isEditable`) fields, and calls `zodFieldFromMetadata` for each remaining field:
|
||||
|
||||
```tsx
|
||||
export function zodSchemaFromTableMetadata(
|
||||
tableMetaData: QTableMetaData,
|
||||
fieldNamesToInclude?: string[]
|
||||
): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {}
|
||||
|
||||
for (const fieldName of fieldsToProcess) {
|
||||
const field = tableMetaData.fields[fieldName]
|
||||
if (!field) continue
|
||||
if (field.isHidden) continue
|
||||
if (!field.isEditable) continue
|
||||
shape[fieldName] = zodFieldFromMetadata(field)
|
||||
}
|
||||
|
||||
return z.object(shape)
|
||||
}
|
||||
```
|
||||
|
||||
When `fieldNamesToInclude` is provided, only those fields are included. This is useful for partial-edit dialogs or multi-step wizards where only a subset of fields are shown per step.
|
||||
|
||||
### For process step forms
|
||||
|
||||
Process steps receive an ordered array of fields rather than a table metadata object. Use `zodSchemaFromFields`:
|
||||
|
||||
```tsx
|
||||
import { zodSchemaFromFields } from '@/lib/utils/zod-from-metadata'
|
||||
|
||||
const schema = zodSchemaFromFields(stepFields) // QFieldMetaData[]
|
||||
```
|
||||
|
||||
Hidden fields are excluded automatically. The resulting schema shape is identical to the table-based variant.
|
||||
|
||||
### For single fields
|
||||
|
||||
Call `zodFieldFromMetadata` directly when you need the Zod type for a single field:
|
||||
|
||||
```tsx
|
||||
import { zodFieldFromMetadata } from '@/lib/utils/zod-from-metadata'
|
||||
|
||||
const fieldSchema = zodFieldFromMetadata(tableMetaData.fields['email'])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field type to Zod type mapping
|
||||
|
||||
The table below shows exactly how each QQQ field type is converted. All mappings are implemented in `zodFieldFromMetadata`.
|
||||
|
||||
| QQQ field type | Zod type (required) | Zod type (optional) | Notes |
|
||||
|---|---|---|---|
|
||||
| `STRING` | `z.string().min(1, ...)` | `z.string().optional()` | `maxLength` adds `.max()` when set |
|
||||
| `TEXT` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
|
||||
| `HTML` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
|
||||
| `PASSWORD` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
|
||||
| `INTEGER` | `z.coerce.number().int(...)` | `z.union([z.literal(''), z.coerce.number().int(...)]).optional()` | Empty `<input type="number">` submits `""` |
|
||||
| `LONG` | `z.coerce.number().int(...)` | `z.union([z.literal(''), z.coerce.number().int(...)]).optional()` | Same as INTEGER |
|
||||
| `DECIMAL` | `z.coerce.number(...)` | `z.union([z.literal(''), z.coerce.number(...)]).optional()` | No `.int()` — allows fractional values |
|
||||
| `BOOLEAN` | `z.boolean().optional()` | `z.boolean().optional()` | Always optional — checkboxes are never "required" |
|
||||
| `DATE` | `z.string().min(1, ...)` | `z.string().optional()` | HTML date inputs submit ISO-8601 strings |
|
||||
| `TIME` | `z.string().min(1, ...)` | `z.string().optional()` | Same as DATE |
|
||||
| `DATE_TIME` | `z.string().min(1, ...)` | `z.string().optional()` | Same as DATE |
|
||||
| `BLOB` | `z.union([z.instanceof(File), z.string()]).optional()` | same | Accepts File objects or existing URL strings |
|
||||
|
||||
### Why numbers use `z.literal('')` in the optional case
|
||||
|
||||
An `<input type="number">` submits an empty string (`""`) when left blank, not `null` or `undefined`. A plain `z.coerce.number()` would coerce `""` to `0`, silently making an empty field appear as zero. The `z.union([z.literal(''), schema])` pattern allows the empty string to pass through as-is so the form can distinguish "nothing entered" from "zero entered".
|
||||
|
||||
### Why dates stay as strings
|
||||
|
||||
Date and time values come from `<input type="date">` and `<input type="datetime-local">` as ISO-8601 strings (`"2026-03-01"`, `"2026-03-01T14:30"`). The schema validates them as strings. Any coercion to JavaScript `Date` objects — if required by the API — happens in the submit handler, not in the Zod schema.
|
||||
|
||||
---
|
||||
|
||||
## Required vs optional
|
||||
|
||||
The `isRequired` flag on `QFieldMetaData` is the single source of truth for whether a field is required:
|
||||
|
||||
- **`isRequired: true`** → `.min(1, "${label} is required")` for string types; plain `z.coerce.number()` for numeric types (empty string fails coercion).
|
||||
- **`isRequired: false` or absent** → `.optional()` for string types; `z.union([z.literal(''), ...]).optional()` for numeric types.
|
||||
|
||||
All error messages use the human-readable `field.label` (not `field.name`), so they read naturally in the UI: `"First Name is required"`, not `"firstName is required"`.
|
||||
|
||||
---
|
||||
|
||||
## Number bounds
|
||||
|
||||
When a field's metadata declares `minValue` or `maxValue`, those bounds are applied to the Zod schema:
|
||||
|
||||
```tsx
|
||||
function buildNumberSchema(isRequired, label, isInteger, minValue, maxValue) {
|
||||
let schema = isInteger
|
||||
? z.coerce.number().int(`${label} must be a whole number`)
|
||||
: z.coerce.number({ message: `${label} must be a number` })
|
||||
|
||||
if (minValue !== undefined && minValue !== null) {
|
||||
schema = schema.min(Number(minValue), `${label} must be at least ${minValue}`)
|
||||
}
|
||||
if (maxValue !== undefined && maxValue !== null) {
|
||||
schema = schema.max(Number(maxValue), `${label} must be at most ${maxValue}`)
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The bounds come directly from metadata — no hardcoded ranges exist in the frontend. `DynamicFormField` also forwards `minValue` and `maxValue` as HTML attributes (`min`, `max`) on `<NumberField>` so the browser provides native range feedback, but Zod is the authoritative validator on submit.
|
||||
|
||||
---
|
||||
|
||||
## String length constraints
|
||||
|
||||
`maxLength` from field metadata maps to Zod's `.max()`:
|
||||
|
||||
```tsx
|
||||
function buildStringSchema(isRequired, label, maxLength) {
|
||||
let schema = z.string()
|
||||
if (isRequired) schema = schema.min(1, `${label} is required`)
|
||||
if (maxLength) schema = schema.max(maxLength, `${label} must be at most ${maxLength} characters`)
|
||||
return isRequired ? schema : schema.optional()
|
||||
}
|
||||
```
|
||||
|
||||
`DynamicFormField` also passes `maxLength` to `<TextField>` as an HTML `maxlength` attribute, providing in-browser character limiting, but the Zod schema validates the constraint on submit as well.
|
||||
|
||||
---
|
||||
|
||||
## Default values
|
||||
|
||||
`defaultValuesFromRecord` (`src/lib/utils/zod-from-metadata.ts`) builds the `defaultValues` object for `useForm`:
|
||||
|
||||
```tsx
|
||||
export function defaultValuesFromRecord(
|
||||
tableMetaData: QTableMetaData,
|
||||
recordValues: Record<string, unknown>
|
||||
): Record<string, unknown>
|
||||
```
|
||||
|
||||
For each editable, visible field it applies this priority:
|
||||
1. Use the record value (from the API response), with type coercion:
|
||||
- `DATE`, `TIME`, `DATE_TIME` → `String(value)` (ISO-8601 string for the HTML input).
|
||||
- `BOOLEAN` → `Boolean(value)`.
|
||||
- All other types → value as-is.
|
||||
2. Fall back to `field.defaultValue` from metadata.
|
||||
3. Fall back to `false` for `BOOLEAN` fields.
|
||||
4. Fall back to `''` (empty string) for everything else.
|
||||
|
||||
`EntityForm` calls this inside `useMemo` and passes the result to `useForm({ defaultValues })`. When the record changes (e.g., navigating between records), the form is reset via `reset(defaultValuesFromRecord(...))` in a `useEffect`.
|
||||
|
||||
---
|
||||
|
||||
## Client vs server validation
|
||||
|
||||
### Client-side (Zod)
|
||||
|
||||
Zod runs on the client when the user submits the form. React Hook Form calls the Zod resolver, collects all field errors, and populates `formState.errors`. Fields display their error messages inline — `DynamicFormField` renders a `<p role="alert">` with `id="${fieldId}-error"` below each failing input, and the input gains `aria-invalid="true"` and `aria-describedby` pointing at the error element.
|
||||
|
||||
```tsx
|
||||
{fieldError && (
|
||||
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{fieldError.message}
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
### Server-side (API)
|
||||
|
||||
The backend can return validation errors after a save attempt. `EntityForm` handles this via TanStack Query mutation `onError` callbacks. Currently, API errors are shown as a top-level error banner rather than per-field:
|
||||
|
||||
```tsx
|
||||
const insertMutation = useMutation({
|
||||
mutationFn: (values) => insertRecord(tableMetaData.name, values),
|
||||
onError: (err: Error) => {
|
||||
toast.error(`Failed to create ${tableMetaData.label}: ${err.message}`)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
When the API returns structured per-field errors (a map of `{ fieldName: errorMessage }`), map them to React Hook Form using `form.setError`:
|
||||
|
||||
```tsx
|
||||
onError: (err) => {
|
||||
const fieldErrors = parseApiFieldErrors(err)
|
||||
if (fieldErrors) {
|
||||
for (const [fieldName, message] of Object.entries(fieldErrors)) {
|
||||
form.setError(fieldName, { type: 'server', message })
|
||||
}
|
||||
} else {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This makes server validation errors appear in the same inline error elements as client-side Zod errors, giving the user a consistent experience.
|
||||
|
||||
---
|
||||
|
||||
## DynamicFormField dispatch
|
||||
|
||||
`DynamicFormField` (`src/components/forms/DynamicFormField.tsx`) is the component that renders a single field inside a metadata-driven form. It dispatches to a typed input component based on field properties in this priority order:
|
||||
|
||||
1. **`field.possibleValueSourceName` is set** → `<PossibleValueSelect>` (async combobox).
|
||||
2. **`FILE_UPLOAD` adornment or `BLOB` type** → `<FileUploadField>`.
|
||||
3. **`field.type` switch** → one of `TextField`, `NumberField`, `BooleanField`, `DateField`, `DateTimeField`, `PasswordField`, or inline `<textarea>` (TEXT), `<RichTextField>` (HTML), `<input type="time">` (TIME).
|
||||
4. **Default case** → `<TextField>` (treats unknown types as plain text).
|
||||
|
||||
Hidden fields (`isHidden: true`) and non-editable fields return `null` immediately.
|
||||
|
||||
### Dirty field indicator
|
||||
|
||||
When a field's value has changed from its default, `DynamicFormField` wraps the input in a left-border accent:
|
||||
|
||||
```tsx
|
||||
function DirtyWrapper({ isDirty, children }) {
|
||||
if (!isDirty) return <>{children}</>
|
||||
return (
|
||||
<div className="border-l-2 border-primary pl-2">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `isDirty` prop comes from `formState.dirtyFields[fieldName]` in `EntityForm`, which passes the full `dirtyFields` map to `DynamicForm`, which passes each field's individual dirty state to `DynamicFormField`.
|
||||
|
||||
---
|
||||
|
||||
## Adding custom Zod refinements
|
||||
|
||||
The generated schemas cover all constraints that can be expressed in QQQ field metadata. When you need a validation rule that metadata cannot express (for example, cross-field validation or a format check), add a `.superRefine()` or `.refine()` on the schema returned by `zodSchemaFromTableMetadata`:
|
||||
|
||||
```tsx
|
||||
// In the component that builds the form
|
||||
const baseSchema = zodSchemaFromTableMetadata(tableMetaData)
|
||||
|
||||
const schema = baseSchema.superRefine((values, ctx) => {
|
||||
// Cross-field: end date must be after start date
|
||||
if (values.startDate && values.endDate && values.endDate < values.startDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['endDate'],
|
||||
message: 'End date must be after start date',
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Prefer `.superRefine()` over `.refine()` when you need to report the error on a specific field path rather than at the root of the object, so React Hook Form can display it inline next to the correct input.
|
||||
|
||||
---
|
||||
|
||||
## Complete example
|
||||
|
||||
### Field metadata (from the API)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "quantity",
|
||||
"label": "Quantity",
|
||||
"type": "INTEGER",
|
||||
"isRequired": true,
|
||||
"isEditable": true,
|
||||
"isHidden": false,
|
||||
"minValue": 1,
|
||||
"maxValue": 9999
|
||||
}
|
||||
```
|
||||
|
||||
### Generated Zod schema for this field
|
||||
|
||||
`zodFieldFromMetadata` produces:
|
||||
|
||||
```ts
|
||||
z.coerce.number()
|
||||
.int("Quantity must be a whole number")
|
||||
.min(1, "Quantity must be at least 1")
|
||||
.max(9999, "Quantity must be at most 9999")
|
||||
```
|
||||
|
||||
Because `isRequired` is `true`, this is the final schema (no `z.literal('')` union wrapping). An empty `<input type="number">` submits `""`, `z.coerce.number()` coerces that to `NaN`, and `NaN` fails the `.int()` check — so the required constraint is enforced implicitly.
|
||||
|
||||
### Full table schema (example)
|
||||
|
||||
```ts
|
||||
const schema = zodSchemaFromTableMetadata(orderTableMetaData)
|
||||
// Produces:
|
||||
z.object({
|
||||
customerId: z.string().min(1, 'Customer is required'), // STRING, required
|
||||
quantity: z.coerce.number().int(...).min(1).max(9999), // INTEGER, required
|
||||
notes: z.string().optional(), // TEXT, optional
|
||||
shipDate: z.string().optional(), // DATE, optional
|
||||
isRush: z.boolean().optional(), // BOOLEAN, always optional
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,332 @@
|
||||
# State Management Guide
|
||||
|
||||
This document explains when to use each state container in the QQQ Frontend Next codebase. Each container has a specific purpose — choosing the wrong one leads to stale data, lost state on navigation, or unnecessary complexity.
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| If the state is… | Use… |
|
||||
|---|---|
|
||||
| Data fetched from the server (records, metadata, widgets, processes) | TanStack Query |
|
||||
| Form field values and validation errors | React Hook Form |
|
||||
| Filters, pagination, sort order — things a user should be able to bookmark or share | URL search params |
|
||||
| User preferences that survive a session (density, column widths, saved views) | `useLocalStorage` |
|
||||
| Application-wide UI state (modal stack, page header, accent color, branding) | QContext |
|
||||
| Ephemeral UI state local to one component (dropdown open, hover, toggle) | `useState` / `useReducer` |
|
||||
|
||||
---
|
||||
|
||||
## TanStack Query — Server State
|
||||
|
||||
**Rule:** Never use `useState` for data that comes from the server. TanStack Query owns all server state.
|
||||
|
||||
The query client is configured in `src/lib/query-client.ts` with:
|
||||
- 5-minute stale time — metadata and record lists stay fresh without excessive refetching
|
||||
- 10-minute GC time — recently visited pages are snappy when navigating back
|
||||
- Smart retry: never retries 4xx errors; retries 5xx and network failures up to 3 times with exponential back-off
|
||||
- Global `QueryCache.onError` and `MutationCache.onError` — most errors are automatically toasted without per-call handling
|
||||
|
||||
### Query key factory
|
||||
|
||||
All query keys are defined in `queryKeys` in `src/lib/query-client.ts`. Always use the factory, never inline string arrays.
|
||||
|
||||
```typescript
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
|
||||
// Fetch a single record
|
||||
useQuery({
|
||||
queryKey: queryKeys.tableRecord('order', recordId),
|
||||
queryFn: () => getRecord('order', recordId),
|
||||
})
|
||||
|
||||
// Invalidate all records for a table after a mutation
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords('order') })
|
||||
|
||||
// Invalidate everything (e.g. after logout)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.all() })
|
||||
```
|
||||
|
||||
### Key hierarchy (broadest → narrowest)
|
||||
|
||||
```
|
||||
['qqq']
|
||||
['qqq', 'metadata', 'all']
|
||||
['qqq', 'metadata', 'table', tableName]
|
||||
['qqq', 'records', tableName]
|
||||
['qqq', 'records', tableName, recordId]
|
||||
['qqq', 'records', tableName, 'count', filterHash]
|
||||
['qqq', 'processes', processName, processUUID, 'status', jobUUID]
|
||||
['qqq', 'widgets', widgetName, params]
|
||||
['qqq', 'possibleValues', 'table', tableName, fieldName, searchTerm]
|
||||
['qqq', 'search', searchTerm]
|
||||
```
|
||||
|
||||
Broad invalidations cascade down. For example, `queryKeys.tableRecords('order')` invalidates both the list query and all individual record queries for that table.
|
||||
|
||||
### What belongs in TanStack Query
|
||||
|
||||
- Record lists and counts
|
||||
- Single record fetches
|
||||
- All metadata (tables, processes, apps)
|
||||
- Widget data
|
||||
- Process status polling
|
||||
- Possible values / enum lookups
|
||||
- Global search results
|
||||
- Auth session metadata
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
Do not use TanStack Query for UI state (modal open/close, form field values, user preferences). Those belong in the containers described below.
|
||||
|
||||
---
|
||||
|
||||
## React Hook Form — Form Field State
|
||||
|
||||
**Rule:** React Hook Form owns field values and validation state for forms only. Do not use it for non-form UI, and do not reach into the form state from outside the form.
|
||||
|
||||
Forms are built with Zod schemas derived from field metadata. The schema is constructed by `zodFromMetadata()` in `src/lib/utils/zod-from-metadata.ts`.
|
||||
|
||||
```typescript
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { zodFromMetadata } from '@/lib/utils/zod-from-metadata'
|
||||
|
||||
function EntityForm({ tableMetaData, record }: Props) {
|
||||
const schema = zodFromMetadata(tableMetaData)
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: record ?? {},
|
||||
})
|
||||
|
||||
// Map a 400 validation error back to individual fields
|
||||
const onSubmit = async (data: FieldValues) => {
|
||||
try {
|
||||
await saveRecord('order', data)
|
||||
} catch (error) {
|
||||
if (getErrorStatusCode(error) === 400) {
|
||||
const fieldErrors = extractFieldErrors(error)
|
||||
for (const [field, message] of Object.entries(fieldErrors)) {
|
||||
form.setError(field, { message })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### What belongs in React Hook Form
|
||||
|
||||
- All form field values
|
||||
- Validation errors (field-level and form-level)
|
||||
- Dirty/touched state
|
||||
- Submit handling
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
Do not use React Hook Form for filter criteria in the query page — those live in URL params and reducer state managed by `useRecordQuery`.
|
||||
|
||||
---
|
||||
|
||||
## URL Search Params — Shareable State
|
||||
|
||||
**Rule:** Any state a user should be able to bookmark, share, or restore after a page refresh goes in the URL.
|
||||
|
||||
Use Next.js `useSearchParams` and `useRouter` to read and write URL state. This is the right home for:
|
||||
- Active filters and quick search term
|
||||
- Current page and page size
|
||||
- Sort column and direction
|
||||
|
||||
The `useRecordQuery` hook syncs filter/pagination state to and from the URL automatically. Components should read these values from the hook, not from `useSearchParams` directly.
|
||||
|
||||
```typescript
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
|
||||
// Reading
|
||||
const searchParams = useSearchParams()
|
||||
const page = Number(searchParams.get('page') ?? '1')
|
||||
const sort = searchParams.get('sort') ?? ''
|
||||
|
||||
// Writing — replace so the back button doesn't accumulate filter changes
|
||||
const router = useRouter()
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set('page', String(nextPage))
|
||||
router.replace(`?${params.toString()}`)
|
||||
```
|
||||
|
||||
### Key naming conventions in URL params
|
||||
|
||||
| Param | Values | Purpose |
|
||||
|---|---|---|
|
||||
| `page` | integer | Current page (1-based) |
|
||||
| `pageSize` | 10, 25, 50, 100 | Rows per page |
|
||||
| `sort` | `fieldName:asc` or `fieldName:desc` | Active sort |
|
||||
| `q` | string | Quick search term |
|
||||
| `filter` | base64-encoded JSON | Serialized filter criteria |
|
||||
|
||||
### What belongs in URL params
|
||||
|
||||
- Filters, quick search
|
||||
- Pagination (page, page size)
|
||||
- Sort order
|
||||
- Any state that makes a link shareable
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
Do not put ephemeral UI state (modal open, panel open) in the URL. Do not put server data in the URL.
|
||||
|
||||
---
|
||||
|
||||
## localStorage — User Preferences
|
||||
|
||||
**Rule:** Preferences that should survive across sessions and browser restarts go in localStorage via the `useLocalStorage` hook.
|
||||
|
||||
The hook lives at `src/lib/hooks/use-local-storage.ts`. It is SSR-safe (returns `initialValue` on the server), cross-tab synced (listens to `storage` events), and has a stable setter that accepts a value or an updater function matching the `useState` API.
|
||||
|
||||
```typescript
|
||||
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
||||
|
||||
const [density, setDensity, removeDensity] = useLocalStorage<'compact' | 'standard' | 'comfortable'>(
|
||||
'qqq-order-density',
|
||||
'standard'
|
||||
)
|
||||
```
|
||||
|
||||
### Higher-level hooks built on `useLocalStorage`
|
||||
|
||||
| Hook | Key | Contents |
|
||||
|---|---|---|
|
||||
| `useUserPreferences` | `qqq-user-preferences` | Default page size, density, view mode |
|
||||
| `useColumnConfig` | `qqq-{tableName}-columns`, `qqq-{tableName}-column-order`, `qqq-{tableName}-column-widths` | Column visibility, order, pixel widths |
|
||||
| `useSavedViews` | `qqq-{tableName}-saved-views` | Named filter snapshots |
|
||||
|
||||
### Key naming convention
|
||||
|
||||
```
|
||||
qqq-{tableName}-{purpose} # table-scoped
|
||||
qqq-user-preferences # global user preferences
|
||||
qqq:{scope}:{purpose} # alternative colon-separated form
|
||||
```
|
||||
|
||||
Always prefix with `qqq-` to avoid collisions with other apps that might share the same origin.
|
||||
|
||||
### What belongs in localStorage
|
||||
|
||||
- Table density (compact / standard / comfortable)
|
||||
- Column visibility and order per table
|
||||
- Column pixel widths per table
|
||||
- Saved views (named filter snapshots)
|
||||
- Global user preferences (default page size, default view mode)
|
||||
- Dismissed banners or one-time notices
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
Do not store server data in localStorage — use TanStack Query's cache. Do not store state that should be shareable — use URL params.
|
||||
|
||||
---
|
||||
|
||||
## QContext — Application-Wide UI State
|
||||
|
||||
**Rule:** Use QContext for state that must be accessible anywhere in the authenticated app tree and does not fit a more specific container.
|
||||
|
||||
The provider is `QContextProvider` in `src/lib/context/q-context.tsx`. Access it with the `useQContext()` hook. The hook throws at runtime if called outside a provider, which catches integration mistakes early.
|
||||
|
||||
```typescript
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
|
||||
function RecordQueryPage({ tableMetaData }: Props) {
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(tableMetaData.label)
|
||||
setTableMetaData(tableMetaData)
|
||||
}, [tableMetaData])
|
||||
}
|
||||
```
|
||||
|
||||
### What QContext contains
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `pageHeader` | `string \| ReactNode` | Content rendered in the page header area |
|
||||
| `accentColor` / `accentColorLight` | `string` | Theme accent colors (hex) |
|
||||
| `tableMetaData` | `QTableMetaData \| null` | Metadata for the currently viewed table |
|
||||
| `tableProcesses` | `QProcessMetaData[] \| null` | Processes for the current table |
|
||||
| `dotMenuOpen` | `boolean` | Whether the ellipsis action menu is open |
|
||||
| `modalStack` | `string[]` | Ordered list of open modal identifiers for keyboard nav |
|
||||
| `pushModalOnStack(id)` | function | Called when a modal opens |
|
||||
| `popModalOffStack(id)` | function | Called when a modal closes; warns if id is not on top |
|
||||
| `clearModalStack()` | function | Clears all modal identifiers (e.g. on navigation) |
|
||||
| `keyboardHelpOpen` | `boolean` | Keyboard shortcut overlay visibility |
|
||||
| `userId` | `string \| undefined` | Current authenticated user identifier |
|
||||
| `branding` | `QBrandingMetaData \| null` | Company name, app name, URLs from backend |
|
||||
| `pathToLabelMap` | `Record<string, string>` | URL path segment → human-readable breadcrumb label |
|
||||
|
||||
### Modal stack usage
|
||||
|
||||
The modal stack ensures that `Escape` closes only the top-most modal and that focus is restored correctly. Every modal or sheet component must push on open and pop on close.
|
||||
|
||||
```typescript
|
||||
const { pushModalOnStack, popModalOffStack, modalStack } = useQContext()
|
||||
const modalId = 'edit-record-modal'
|
||||
|
||||
// On open
|
||||
pushModalOnStack(modalId)
|
||||
|
||||
// On close
|
||||
popModalOffStack(modalId)
|
||||
|
||||
// Check if this modal is on top (for Escape handling)
|
||||
const isTopModal = modalStack[modalStack.length - 1] === modalId
|
||||
```
|
||||
|
||||
### What belongs in QContext
|
||||
|
||||
- Application-wide UI state that has no more specific home
|
||||
- Page header content (set by each page on mount)
|
||||
- Modal stack (for coordinated keyboard navigation)
|
||||
- Accent colors and branding
|
||||
- The currently active table metadata (consumed by header, breadcrumbs, action menus)
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
Do not put server data in QContext (use TanStack Query). Do not put form state in QContext (use React Hook Form). Do not put user preferences in QContext (use localStorage).
|
||||
|
||||
---
|
||||
|
||||
## useState / useReducer — Local Transient State
|
||||
|
||||
**Rule:** Use `useState` or `useReducer` only for state that is local to a single component or a small subtree, ephemeral (lost on unmount is acceptable), and neither server data nor shareable state.
|
||||
|
||||
```typescript
|
||||
// Good — ephemeral open/close that doesn't need to survive navigation
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
// Good — complex multi-field local state with clear action types
|
||||
const [state, dispatch] = useReducer(recordQueryReducer, initialState)
|
||||
```
|
||||
|
||||
### What belongs in useState / useReducer
|
||||
|
||||
- Dialog, dropdown, tooltip open/close
|
||||
- Hover and focus state
|
||||
- Local form wizard step (if not shareable)
|
||||
- Complex local state with many fields that transition together (handled by `useReducer`)
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
- Server data (use TanStack Query)
|
||||
- State that should survive navigation or page refresh (use URL params or localStorage)
|
||||
- State needed by distant parts of the tree (use QContext or lift appropriately)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
```
|
||||
Server data → TanStack Query (queryKeys factory, automatic toasts on error)
|
||||
Form fields → React Hook Form + Zod
|
||||
Shareable/bookmarkable → URL search params
|
||||
User preferences → useLocalStorage (key: qqq-{tableName}-{purpose})
|
||||
App-wide UI state → QContext (useQContext hook)
|
||||
Local/ephemeral UI → useState / useReducer
|
||||
```
|
||||
@@ -0,0 +1,190 @@
|
||||
# QQQ Frontend Type Glossary
|
||||
|
||||
All types listed here are re-exported from `@/types` (the `src/types/index.ts` barrel).
|
||||
Application code should always import from `@/types`, not from individual module files,
|
||||
so that internal type organisation can change without affecting import paths.
|
||||
|
||||
```typescript
|
||||
import type { QInstance, QTableMetaData, QRecord, QQueryFilter } from '@/types'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. App & Auth
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `QInstance` | `metadata.ts` | Top-level descriptor returned by `/metaData/instance`; the single source of truth for all apps, tables, processes, reports, widgets, and branding. **Key fields:** `apps`, `tables`, `processes`, `widgets`, `branding`, `appTree`, `environmentValues` |
|
||||
| `QAuthenticationMetaData` | `metadata.ts` | Describes the auth provider type (`AUTH_0`, `OAUTH2`, `FULLY_ANONYMOUS`, `MOCK`) and the provider-specific values (client ID, base URL, audience) needed to boot the correct auth flow. |
|
||||
| `QBrandingMetaData` | `metadata.ts` | Controls visual identity: company name, app name, logo URL, accent color, notification banners, and custom CSS injected via `data-qqq-id` selectors. |
|
||||
| `QThemeMetaData` | `metadata.ts` | Backend-supplied Tailwind CSS token overrides (`primaryColor`, `accentColor`, `mode`). Applied on top of the default theme by the theme provider. |
|
||||
| `Banner` | `metadata.ts` | A single notification banner (text, severity `info|warning|error`, optional color, dismissible flag) rendered at the top of the dashboard layout. |
|
||||
|
||||
### QInstance example
|
||||
|
||||
```typescript
|
||||
const instance: QInstance = {
|
||||
apps: { crm: { name: 'crm', label: 'CRM', children: [], ... } },
|
||||
appTree: [{ name: 'crm', label: 'CRM', type: 'APP', ... }],
|
||||
tables: { person: { name: 'person', label: 'People', fields: {}, ... } },
|
||||
processes: {},
|
||||
reports: {},
|
||||
widgets: {},
|
||||
branding: { companyName: 'Acme', companyUrl: 'https://acme.com', appName: 'Admin' },
|
||||
helpContents: {},
|
||||
environmentValues: {},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Metadata
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `QTableMetaData` | `metadata.ts` | Full descriptor for a QQQ table: fields, sections, join relationships, capabilities, and per-user CRUD permissions. Drives the entire record query, view, create, and edit experience. **Key fields:** `name`, `label`, `primaryKeyField`, `fields`, `sections`, `capabilities`, `readPermission`, `insertPermission`, `editPermission`, `deletePermission` |
|
||||
| `QFieldMetaData` | `metadata.ts` | Descriptor for a single table column: data type, required/editable/hidden flags, max length, numeric bounds, possible-value source, display format, and adornments. **Key fields:** `name`, `label`, `type`, `isRequired`, `isEditable`, `isHidden`, `possibleValueSourceName`, `adornments` |
|
||||
| `QProcessMetaData` | `metadata.ts` | Descriptor for a multi-step wizard process: identity, permissions, input record bounds, and the ordered list of `QFrontendStepMetaData` objects to render. |
|
||||
| `QFrontendStepMetaData` | `metadata.ts` | A single step in a process wizard: label, ordered component list, and optional form/view/record-list field arrays used by the step renderer. |
|
||||
| `QFrontendComponent` | `metadata.ts` | A single UI component descriptor within a process step — a `type` (from `QComponentType`) plus arbitrary `values` passed as props to the matching renderer. |
|
||||
| `QAppMetaData` | `metadata.ts` | A top-level app grouping: sidebar navigation children, dashboard widget names, and labeled sections grouping tables, processes, and reports. |
|
||||
| `QAppTreeNode` | `metadata.ts` | One node in the recursive sidebar navigation tree; the `type` field (`TABLE`, `PROCESS`, `REPORT`, `APP`) determines the route. |
|
||||
| `QAppSection` | `metadata.ts` | A labeled card group on an app dashboard page, listing the table, process, and report names belonging to that group. |
|
||||
| `QWidgetMetaData` | `metadata.ts` | Dashboard widget descriptor: renderer type string, permissions, grid column span, optional toolbar dropdowns, reload/export button flags, and help content. |
|
||||
| `QTableSection` | `metadata.ts` | A collapsible card section on record view/edit pages: field names to display, optional embedded widget name, display tier, and grid column span. |
|
||||
| `QExposedJoin` | `metadata.ts` | A join relationship made available for query filters and record views: cardinality (`isMany`), joined table metadata, and the path of `QJoinMetaData` hops. |
|
||||
| `FieldAdornment` | `metadata.ts` | Discriminated union of all adornment shapes (`LINK`, `CHIP`, `TOOLTIP`, `ERROR`, `FILE_DOWNLOAD`, `SIZE`, `REVEAL`, `CODE_EDITOR`, `RENDER_HTML`, `FILE_UPLOAD`). Narrow with `Extract<FieldAdornment, { type: 'LINK' }>`. |
|
||||
|
||||
### QFieldMetaData example
|
||||
|
||||
```typescript
|
||||
const field: QFieldMetaData = {
|
||||
name: 'firstName',
|
||||
label: 'First Name',
|
||||
type: 'STRING',
|
||||
isRequired: true,
|
||||
isEditable: true,
|
||||
isHidden: false,
|
||||
isHeavy: false,
|
||||
maxLength: 100,
|
||||
adornments: [],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Records & Queries
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `QRecord` | `records.ts` | A single backend record returned by the tables API. `values` holds raw typed data; `displayValues` holds pre-formatted strings ready for rendering. May include `associatedRecords` for joined/child data, and `errors`/`warnings` for bulk operations. |
|
||||
| `QPossibleValue` | `records.ts` | A single option from a possible-value source, used to populate autocomplete and select inputs. `id` is the stored value; `label` is the user-visible text. |
|
||||
| `QAuditRecord` | `records.ts` | An audit log entry for a record: who changed it, when, what action (`INSERT`/`UPDATE`/`DELETE`), and the list of `QAuditFieldChange` field-level diffs. |
|
||||
| `QQueryFilter` | `query.ts` | Root filter object sent to `/tables/{name}/query` and `/tables/{name}/count`. Combines `criteria` with `AND`/`OR`, supports nested `subFilters`, and carries `skip`/`limit` for pagination. |
|
||||
| `QFilterCriteria` | `query.ts` | A single field-level predicate: a `fieldName`, a `QCriteriaOperator`, and a `values` array that may contain literals or dynamic expressions (`NowExpression`, `NowWithOffsetExpression`, etc.). |
|
||||
| `QFilterOrderBy` | `query.ts` | A sort clause: `fieldName` and `isAscending`. Multiple entries form a compound sort. |
|
||||
| `QueryJoin` | `query.ts` | A join instruction sent alongside a `QQueryFilter` when fields from a related table are needed in query results. Specifies `joinTable`, join type (`INNER`/`LEFT`/etc.), and optional alias. |
|
||||
| `NowWithOffsetExpression` | `query.ts` | A dynamic filter value that resolves to "now minus/plus N days/weeks/months/years" at query time, enabling "last 7 days" style saved filters. |
|
||||
|
||||
### QQueryFilter example
|
||||
|
||||
```typescript
|
||||
const filter: QQueryFilter = {
|
||||
criteria: [
|
||||
{ fieldName: 'status', operator: 'EQUALS', values: ['ACTIVE'] },
|
||||
{ fieldName: 'createdDate', operator: 'GREATER_THAN', values: [{ type: 'NOW_WITH_OFFSET', offsetValue: 30, offsetUnit: 'DAY', isNegativeOffset: true }] },
|
||||
],
|
||||
booleanOperator: 'AND',
|
||||
orderBys: [{ fieldName: 'lastName', isAscending: true }],
|
||||
skip: 0,
|
||||
limit: 25,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Processes
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `QJobStarted` | `processes.ts` | Initial response after submitting a process step; provides `processUUID` and `jobUUID` for subsequent status polling. |
|
||||
| `QJobRunning` | `processes.ts` | Intermediate polling response while a job executes asynchronously; provides a `message` and optional `current`/`total` progress counts for the progress bar. |
|
||||
| `QJobComplete` | `processes.ts` | Terminal success response; carries output `values`, optional `nextStep`/`backStep` navigation hints, and any `processMetaDataAdjustment` that modifies remaining wizard steps. |
|
||||
| `QJobError` | `processes.ts` | Terminal failure response; provides a technical `error` string and an optional `userFacingError` message for display in the wizard UI. |
|
||||
| `QJobResponse` | `processes.ts` | Discriminated union of `QJobStarted \| QJobRunning \| QJobComplete \| QJobError`. Narrow by checking for the discriminating property (`jobUUID`, `message`, `values`, or `error`). |
|
||||
| `ProcessMetaDataAdjustment` | `processes.ts` | Runtime modifications to the process wizard applied after a step completes: `addedSteps`, `removedSteps`, and partial `modifiedFields` overrides. |
|
||||
|
||||
### QJobResponse narrowing example
|
||||
|
||||
```typescript
|
||||
function handleJobResponse(response: QJobResponse) {
|
||||
if ('error' in response) {
|
||||
// QJobError
|
||||
showToast(response.userFacingError ?? response.error)
|
||||
} else if ('values' in response) {
|
||||
// QJobComplete
|
||||
advanceToStep(response.nextStep)
|
||||
} else if ('message' in response) {
|
||||
// QJobRunning — update progress bar
|
||||
setProgress({ message: response.message, current: response.current, total: response.total })
|
||||
} else {
|
||||
// QJobStarted — store jobUUID and start polling
|
||||
setJobUUID(response.jobUUID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Widgets
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `WidgetData` | `widgets.ts` | Base shape for all widget data payloads; the `type` string is the discriminant used to select the correct renderer. |
|
||||
| `ChartWidgetData` | `widgets.ts` | Recharts widget payload: `chartType` (`line`/`bar`/`pie`/`area`), `labels` array, and `datasets` array of `ChartDataset` series. |
|
||||
| `ChartDataset` | `widgets.ts` | A single Recharts data series: human-readable `label`, ordered `data` values aligned with the parent chart's `labels`, and optional `color`. |
|
||||
| `StatisticsWidgetData` | `widgets.ts` | KPI tile payload: headline `title`, `value`, optional `unit`, and optional `trend` object with direction (`up`/`down`/`flat`), numeric magnitude, and period label. |
|
||||
| `HtmlWidgetData` | `widgets.ts` | Raw HTML widget payload; the `html` string **must** be sanitized with DOMPurify before use in `dangerouslySetInnerHTML`. |
|
||||
| `RecordGridWidgetData` | `widgets.ts` | Tabular record list widget: `tableName`, optional `columns` (field name strings) or `fields` (full `QFieldMetaData[]`), `records` array, and optional `totalCount`. |
|
||||
| `BlockData` | `widgets.ts` | Discriminated union of all block element types renderable inside a `BlockWidgetData`: `text`, `big_number`, `up_or_down`, `progress`, `button`, `icon`, `image`, `audio`, `divider`, `input`, `html`. |
|
||||
| `BlockWidgetData` | `widgets.ts` | Ordered heterogeneous block widget: a `blocks` array of `BlockData` variants and an optional `layout` (`vertical`/`horizontal`/`grid`). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Enums
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `QFieldType` | `enums.ts` | String-union of all primitive field data types: `STRING`, `INTEGER`, `LONG`, `DECIMAL`, `BOOLEAN`, `DATE`, `TIME`, `DATE_TIME`, `TEXT`, `HTML`, `PASSWORD`, `BLOB`. Determines which renderer, input component, and Zod validator to use. |
|
||||
| `QCriteriaOperator` | `enums.ts` | String-union of all filter comparison operators used in `QFilterCriteria.operator`: `EQUALS`, `NOT_EQUALS`, `IN`, `NOT_IN`, `CONTAINS`, `STARTS_WITH`, `LESS_THAN`, `GREATER_THAN`, `IS_BLANK`, `IS_NOT_BLANK`, `BETWEEN`, and more. |
|
||||
| `Capability` | `enums.ts` | String-union of backend-declared table capabilities (`TABLE_QUERY`, `TABLE_GET`, `TABLE_COUNT`, `TABLE_INSERT`, `TABLE_UPDATE`, `TABLE_DELETE`) that gate UI elements such as Create, Edit, and Delete buttons. |
|
||||
| `QAppNodeType` | `enums.ts` | String-union identifying the entity kind of a sidebar navigation node: `TABLE`, `PROCESS`, `REPORT`, `APP`. Drives route prefix selection in `QAppTreeNode`. |
|
||||
| `QComponentType` | `enums.ts` | String-union of all UI component types renderable inside a process step: `EDIT_FORM`, `VIEW_FORM`, `RECORD_LIST`, `VALIDATION_REVIEW_SCREEN`, `BULK_LOAD_FILE_MAPPING_FORM`, `PROCESS_SUMMARY_RESULTS`, `HTML`, `WIDGET`, and more. |
|
||||
| `AdornmentType` | `enums.ts` | String-union of all field adornment identifiers (`LINK`, `CHIP`, `SIZE`, `ERROR`, `RENDER_HTML`, `REVEAL`, `CODE_EDITOR`, `FILE_DOWNLOAD`, `FILE_UPLOAD`, `TOOLTIP`). Mirrors the discriminant property of the `FieldAdornment` union for use in switch statements and type-guard utilities. |
|
||||
|
||||
---
|
||||
|
||||
## Quick-Reference Import Cheat Sheet
|
||||
|
||||
```typescript
|
||||
// App & auth
|
||||
import type { QInstance, QAuthenticationMetaData, QBrandingMetaData, Banner } from '@/types'
|
||||
|
||||
// Metadata
|
||||
import type {
|
||||
QTableMetaData, QFieldMetaData, QProcessMetaData,
|
||||
QFrontendStepMetaData, QWidgetMetaData, FieldAdornment,
|
||||
} from '@/types'
|
||||
|
||||
// Records & queries
|
||||
import type { QRecord, QPossibleValue, QQueryFilter, QFilterCriteria } from '@/types'
|
||||
|
||||
// Processes
|
||||
import type { QJobResponse, QJobComplete, QJobError, ProcessMetaDataAdjustment } from '@/types'
|
||||
|
||||
// Widgets
|
||||
import type { WidgetData, ChartWidgetData, StatisticsWidgetData, BlockData } from '@/types'
|
||||
|
||||
// Enums
|
||||
import type { QFieldType, QCriteriaOperator, Capability } from '@/types'
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Prompt: Package 1 — Scaffold, Auth, and Layout Shell
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Your job is to execute the implementation plan for Work Package 1 and produce working, tested code. You do NOT make architectural decisions — the plan specifies everything. Follow it exactly.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your work package plan:** `docs/implementation-plans/01-scaffold-and-auth.md` — This is your primary instruction set. Execute every step.
|
||||
2. **Shared context — API contract:** `docs/implementation-plans/shared-context/api-contract.md` — Every API call must match this exactly.
|
||||
3. **Shared context — Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md` — Port these types into `/src/types/`.
|
||||
4. **Shared context — Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md` — Follow these patterns.
|
||||
5. **Project overview:** `docs/implementation-plans/00-project-overview.md` — Directory structure and shared conventions.
|
||||
6. **CLAUDE.md** — Project-level conventions.
|
||||
|
||||
## Reference Code (Read for Behavior Parity)
|
||||
|
||||
Look at the sibling repo `qqq-frontend-material-dashboard` for:
|
||||
- `src/App.tsx` — Route generation from appTree, auth dispatch, layout structure
|
||||
- `src/qqq/components/horseshoe/` — SideNav, NavBar, Breadcrumbs, Footer
|
||||
- `src/qqq/components/misc/Banners.tsx` — Banner rendering
|
||||
|
||||
Also look at `qqq-frontend-core` for:
|
||||
- `src/controllers/QController.ts` — Auth methods (getAuthenticationMetaData, manageSession)
|
||||
- `src/model/**/*.ts` — All TypeScript model types to port
|
||||
|
||||
## What You Must Deliver
|
||||
|
||||
Execute Steps 1-11 from the work package plan. After completion:
|
||||
|
||||
1. `pnpm dev` runs without errors
|
||||
2. `pnpm build` succeeds
|
||||
3. `pnpm tsc --noEmit` — zero errors
|
||||
4. `pnpm test` — all unit tests pass
|
||||
5. Sidebar renders navigation from metadata (use mock data if no backend)
|
||||
6. Auth flow initializes (AUTH_0, OAUTH2, FULLY_ANONYMOUS)
|
||||
7. All placeholder pages exist and render within the dashboard layout
|
||||
8. Theme system injects CSS custom properties
|
||||
9. 401 interceptor triggers redirect
|
||||
10. Breadcrumbs render from pathname
|
||||
|
||||
## Verification
|
||||
|
||||
After completing all steps, check every acceptance criterion in Section 9 of the work package plan. Every criterion must pass before this package is done.
|
||||
|
||||
## Important Rules
|
||||
|
||||
- Use `pnpm` (not npm or yarn)
|
||||
- TypeScript strict mode — no `any` in component props
|
||||
- Every interactive element gets a `data-qqq-id` attribute
|
||||
- All API calls go through `src/lib/api/` — never raw fetch
|
||||
- Follow the import order convention from CLAUDE.md
|
||||
- Commit after each major step so progress isn't lost
|
||||
@@ -0,0 +1,72 @@
|
||||
# Agent Prompt: Package 2 — Record Query Page
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Execute the implementation plan for Work Package 2 and produce working, tested code. Follow the plan exactly — do not make architectural decisions.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your plan:** `docs/implementation-plans/02-record-query.md`
|
||||
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
|
||||
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
5. **Project overview:** `docs/implementation-plans/00-project-overview.md`
|
||||
6. **CLAUDE.md**
|
||||
|
||||
## Reference Code (Read for Behavior Parity)
|
||||
|
||||
From `qqq-frontend-material-dashboard`:
|
||||
- `src/qqq/pages/records/query/RecordQuery.tsx` — THE most complex page. Read the whole thing. Note the 260+ state variables, the page state machine, query deduplication via latestQueryId, localStorage persistence.
|
||||
- `src/qqq/components/query/FilterCriteriaRow.tsx` — Per-row filter UI with operator selection per field type
|
||||
- `src/qqq/components/query/BasicAndAdvancedQueryControls.tsx` — Basic/advanced filter toggle
|
||||
- `src/qqq/components/query/CustomFilterPanel.tsx` — Full filter builder
|
||||
- `src/qqq/components/query/CustomColumnsPanel.tsx` — Column show/hide/reorder
|
||||
- `src/qqq/components/misc/SavedViews.tsx` — Saved view CRUD
|
||||
- `src/qqq/components/query/ExportMenuItem.tsx` — Export to CSV
|
||||
|
||||
From `qqq-frontend-core`:
|
||||
- `src/controllers/QControllerV1.ts` — `query()` and `count()` methods
|
||||
- `src/model/query/QQueryFilter.ts` — Filter structure
|
||||
- `src/model/query/QCriteriaOperator.ts` — All 20 operators
|
||||
|
||||
## What Package 1 Already Provides (Import From It)
|
||||
|
||||
- Types: `import type { QTableMetaData, QFieldMetaData, QRecord, QQueryFilter, QFilterCriteria, QCriteriaOperator } from '@/types'`
|
||||
- API client: `import apiClient from '@/lib/api/client'`
|
||||
- Query keys: `import { queryKeys } from '@/lib/query-client'`
|
||||
- Metadata: `import { loadTableMetaData } from '@/lib/api/metadata'`
|
||||
- Context: `import { useQContext } from '@/lib/context/q-context'`
|
||||
- Layout components are in place — your page renders inside the dashboard layout
|
||||
|
||||
## What You Must Build
|
||||
|
||||
Replace the placeholder at `src/app/(dashboard)/app/[tableName]/page.tsx` with a full Record Query page featuring:
|
||||
- TanStack Table v8 data grid with dynamic columns from QTableMetaData.fields
|
||||
- Server-side pagination (offset/limit via POST /table/{t}/query + /count)
|
||||
- Cell renderers per QFieldType (STRING, INTEGER, DECIMAL, BOOLEAN, DATE, DATE_TIME, etc.)
|
||||
- Cell renderers per AdornmentType (LINK, CHIP, FILE_DOWNLOAD, etc.)
|
||||
- Basic filter (quick text search) and advanced filter builder (all 20 operators, AND/OR, sub-filters)
|
||||
- Column config (show/hide, reorder, persist to localStorage)
|
||||
- Row selection (checkbox column, single and multi)
|
||||
- Export to CSV
|
||||
- Saved views (save/load filter + column configs)
|
||||
- Pagination with page size selector (10/25/50/100)
|
||||
- Table variant selection
|
||||
- Toolbar (create button, process launcher, column config, density, saved views)
|
||||
- Row click → navigate to record view
|
||||
|
||||
## Verification
|
||||
|
||||
After each step: `pnpm tsc --noEmit`
|
||||
When done: verify ALL acceptance criteria in Section 9 of the plan.
|
||||
Final check: `pnpm build && pnpm test`
|
||||
Commit your work.
|
||||
|
||||
## Important Rules
|
||||
|
||||
- All API calls through `src/lib/api/` — create `src/lib/api/tables.ts` for query/count functions
|
||||
- Use TanStack Table v8 (NOT MUI DataGrid)
|
||||
- Use TanStack Query v5 for data fetching
|
||||
- Every component gets `data-qqq-id` attribute
|
||||
- No hardcoded column names — everything from metadata
|
||||
- TypeScript strict — no `any` in props
|
||||
@@ -0,0 +1,66 @@
|
||||
# Agent Prompt: Package 3 — Record View and CRUD
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Execute Work Package 3. Follow the plan exactly.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your plan:** `docs/implementation-plans/03-record-view-and-crud.md`
|
||||
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
|
||||
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
5. **Project overview:** `docs/implementation-plans/00-project-overview.md`
|
||||
6. **CLAUDE.md**
|
||||
|
||||
## Reference Code (Read for Behavior Parity)
|
||||
|
||||
From `qqq-frontend-material-dashboard`:
|
||||
- `src/qqq/pages/records/view/RecordView.tsx` — Section rendering, T1/non-T1, widget loading, permission-gated action menu, keyboard shortcuts (n/e/c/d/a)
|
||||
- `src/qqq/components/forms/EntityForm.tsx` — Form orchestrator with section-based layout
|
||||
- `src/qqq/components/forms/DynamicForm.tsx` — Formik-based form with dynamic fields
|
||||
- `src/qqq/components/forms/DynamicFormField.tsx` — Per-field renderer by type
|
||||
- `src/qqq/components/forms/DynamicSelect.tsx` — Possible values autocomplete
|
||||
- `src/qqq/components/forms/BooleanFieldSwitch.tsx` — Boolean null/false/true
|
||||
- `src/qqq/components/forms/FileInputField.tsx` — File upload
|
||||
|
||||
From `qqq-frontend-core`:
|
||||
- `src/controllers/QController.ts` — `get()`, `create()`, `update()`, `delete()` methods
|
||||
- `src/model/metaData/QFieldMetaData.ts` — Field type, adornments, constraints
|
||||
|
||||
## What Package 1 Already Provides (Import From It)
|
||||
|
||||
- Types: `import type { QTableMetaData, QFieldMetaData, QRecord, QPossibleValue, QFieldType } from '@/types'`
|
||||
- API client: `import apiClient from '@/lib/api/client'`
|
||||
- Metadata: `import { loadTableMetaData } from '@/lib/api/metadata'`
|
||||
- Context: `import { useQContext } from '@/lib/context/q-context'`
|
||||
|
||||
## What You Must Build
|
||||
|
||||
1. **Record View page** at `src/app/(dashboard)/app/[tableName]/[recordId]/page.tsx`
|
||||
- Section-organized field display (T1 section at top, others below)
|
||||
- Field value rendering for ALL 12 QFieldTypes
|
||||
- All adornment types (LINK, CHIP, SIZE, FILE_DOWNLOAD, TOOLTIP, etc.)
|
||||
- Action menu (edit, delete, copy, launch process)
|
||||
- Associated records display
|
||||
- Keyboard shortcuts (n=new, e=edit, c=copy, d=delete)
|
||||
|
||||
2. **Entity Form** at `src/components/forms/EntityForm.tsx`
|
||||
- Dynamic form generation from QFieldMetaData
|
||||
- React Hook Form + Zod validation (schemas from metadata: required, maxLength, type)
|
||||
- All field types: text, number, boolean, date, time, datetime, select, file, HTML, password
|
||||
- Possible values autocomplete with debounced search (300ms)
|
||||
- Section-based layout with gridColumns
|
||||
- File upload with preview
|
||||
|
||||
3. **Create/Edit/Copy pages** using EntityForm
|
||||
4. **Delete** with confirmation dialog
|
||||
5. **API functions** in `src/lib/api/tables.ts`: getRecord, insertRecord, updateRecord, deleteRecord
|
||||
6. **Possible values** in `src/lib/api/possible-values.ts`: fetchPossibleValues
|
||||
|
||||
## Verification
|
||||
|
||||
After each step: `pnpm tsc --noEmit`
|
||||
When done: verify ALL acceptance criteria.
|
||||
Final: `pnpm build && pnpm test`
|
||||
Commit your work.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Agent Prompt: Package 4 — Process Execution
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Execute Work Package 4. Follow the plan exactly.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your plan:** `docs/implementation-plans/04-process-execution.md`
|
||||
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
|
||||
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
5. **CLAUDE.md**
|
||||
|
||||
## Reference Code (Read for Behavior Parity)
|
||||
|
||||
From `qqq-frontend-material-dashboard`:
|
||||
- `src/qqq/pages/processes/ProcessRun.tsx` — THE process page. Read the whole thing. Note: step state machine, 14 component types, async job polling with exponential backoff (1.5s initial, 1.5x multiplier, 12s max), file upload, record selection modes
|
||||
- `src/qqq/components/processes/` — All process components
|
||||
- `src/qqq/components/processes/ProcessViewForm.tsx` — View-only form steps
|
||||
- `src/qqq/components/processes/ProcessSummaryResults.tsx` — Results display
|
||||
- `src/qqq/components/processes/ValidationReview.tsx` — Pre-commit validation
|
||||
- `src/qqq/components/processes/BulkLoadFileMappingForm.tsx` — Column-to-field mapping
|
||||
- `src/qqq/components/processes/BulkLoadValueMappingForm.tsx` — Value mapping
|
||||
- `src/qqq/components/processes/SavedBulkLoadProfiles.tsx` — Profile save/load
|
||||
|
||||
From `qqq-frontend-core`:
|
||||
- `src/controllers/QController.ts` — processInit, processStep, processJobStatus, processRecords, processCancel
|
||||
- `src/model/processes/` — QJobStarted, QJobRunning, QJobComplete, QJobError, ProcessMetaDataAdjustment
|
||||
|
||||
## What Previous Packages Provide (Import From Them)
|
||||
|
||||
From Package 1: types, API client, context, layout
|
||||
From Package 3 (CRITICAL — reuse these):
|
||||
- `import { EntityForm } from '@/components/forms/EntityForm'` — For form steps
|
||||
- `import { DynamicFormField } from '@/components/forms/DynamicFormField'` — For individual fields
|
||||
- `import { fetchPossibleValues } from '@/lib/api/possible-values'` — For select fields in process forms
|
||||
- Form validation patterns from Package 3
|
||||
|
||||
## What You Must Build
|
||||
|
||||
1. **Process Run page** at `src/app/(dashboard)/app/[processName]/page.tsx`
|
||||
- Step wizard with stepper UI showing progress
|
||||
- Dynamic step rendering from QFrontendStepMetaData.components
|
||||
|
||||
2. **All 14 component types:** HELP_TEXT, BULK_EDIT_FORM, BULK_LOAD_FILE_MAPPING_FORM, BULK_LOAD_VALUE_MAPPING_FORM, BULK_LOAD_PROFILE_FORM, VALIDATION_REVIEW_SCREEN, EDIT_FORM, VIEW_FORM, DOWNLOAD_FORM, RECORD_LIST, PROCESS_SUMMARY_RESULTS, GOOGLE_DRIVE_SELECT_FOLDER, WIDGET, HTML
|
||||
|
||||
3. **Async job handling:**
|
||||
- Detect QJobStarted → start polling GET /status/{jobUUID}
|
||||
- Exponential backoff: 1.5s initial, 1.5x multiplier, 12s max cap
|
||||
- Display QJobRunning progress (message, current/total)
|
||||
- Handle QJobComplete (advance to next step or show results)
|
||||
- Handle QJobError (display error, allow retry)
|
||||
|
||||
4. **Bulk load workflows:** file upload, column-to-field mapping, value mapping, profile save/load
|
||||
|
||||
5. **Process records** endpoint for paginated result viewing
|
||||
|
||||
6. **Process cancellation**
|
||||
|
||||
7. **Both launch modes:** standalone (/app/{processName}) and table-scoped (with recordIds or filterJSON)
|
||||
|
||||
8. **API functions** in `src/lib/api/processes.ts`: processInit, processStep, processStatus, processRecords, processCancel
|
||||
|
||||
## Verification
|
||||
|
||||
After each step: `pnpm tsc --noEmit`
|
||||
When done: verify ALL acceptance criteria.
|
||||
Final: `pnpm build && pnpm test`
|
||||
Commit your work.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Agent Prompt: Package 5 — Dashboard and Widgets
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Execute Work Package 5. Follow the plan exactly.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your plan:** `docs/implementation-plans/05-dashboard-and-widgets.md`
|
||||
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
|
||||
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
5. **CLAUDE.md**
|
||||
|
||||
## Reference Code (Read for Behavior Parity)
|
||||
|
||||
From `qqq-frontend-material-dashboard`:
|
||||
- `src/qqq/components/widgets/Widget.tsx` — Widget container (label, dropdown, reload, export, help)
|
||||
- `src/qqq/components/widgets/DashboardWidgets.tsx` — Widget grid orchestrator
|
||||
- `src/qqq/components/widgets/CompositeWidget.tsx` — Multi-widget container
|
||||
- `src/qqq/components/widgets/ParentWidget.tsx` — Parent managing children
|
||||
- `src/qqq/components/widgets/RecordGridWidget.tsx` — Embedded DataGrid with CRUD
|
||||
- `src/qqq/components/widgets/charts/` — All chart components
|
||||
- `src/qqq/components/widgets/blocks/` — All block renderers
|
||||
- `src/qqq/pages/apps/AppHome.tsx` — App home page with dashboard layout
|
||||
|
||||
## What Previous Packages Provide (Import From Them)
|
||||
|
||||
From Package 1: types, API client, context, layout
|
||||
From Package 2 (reuse):
|
||||
- `import { DataGrid } from '@/components/query/DataGrid'` — For RecordGridWidget
|
||||
- Cell renderers for field value display in widgets
|
||||
From Package 3 (reuse):
|
||||
- `import { EntityForm } from '@/components/forms/EntityForm'` — For DynamicFormWidget
|
||||
- `import { fetchPossibleValues } from '@/lib/api/possible-values'` — For widget dropdown options
|
||||
|
||||
## What You Must Build
|
||||
|
||||
1. **App Home page** at `src/app/(dashboard)/app/[appName]/page.tsx`
|
||||
- Dashboard layout with widget grid
|
||||
- Widgets loaded from QAppMetaData.widgets
|
||||
|
||||
2. **Widget container** — label, dropdown selectors, reload, export, help
|
||||
- Dropdown changes trigger cancel-and-replace data fetch (AbortController)
|
||||
|
||||
3. **DashboardWidgets orchestrator** — loads multiple widgets, manages lifecycle
|
||||
|
||||
4. **Charts (Recharts):** line, bar, horizontal bar, stacked bar, pie/doughnut
|
||||
- Use ResponsiveContainer for all charts
|
||||
|
||||
5. **Statistics:** MiniStatisticsCard, MultiStatisticsCard, StatisticsCard
|
||||
|
||||
6. **All block types:** TextBlock, BigNumberBlock, UpOrDownNumberBlock, ProgressBarBlock, ButtonBlock, IconBlock, ImageBlock, AudioBlock, DividerBlock, InputFieldBlock
|
||||
|
||||
7. **RecordGridWidget** — embedded DataGrid (reuse from P2) with add/edit/delete
|
||||
|
||||
8. **Composite/Parent widgets**
|
||||
|
||||
9. **Specialized widgets:** PivotTableSetup, FilterAndColumnsSetup, StepperCard, CronUI, ScriptViewer, CustomComponent mount point
|
||||
|
||||
10. **API function** in `src/lib/api/widgets.ts`: fetchWidgetData with AbortController cancel-and-replace
|
||||
|
||||
## Verification
|
||||
|
||||
After each step: `pnpm tsc --noEmit`
|
||||
When done: verify ALL acceptance criteria.
|
||||
Final: `pnpm build && pnpm test`
|
||||
Commit your work.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Agent Prompt: Package 6 — Polish, Parity, and Production Readiness
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Execute Work Package 6 — the final package. ALL previous packages (1-5) are complete. Your job is polish, parity, and production readiness.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your plan:** `docs/implementation-plans/06-polish-and-parity.md`
|
||||
2. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
3. **CLAUDE.md**
|
||||
4. **Requirements doc Section 5.4 (responsive), 5.6 (performance), 5.7 (DX):** `docs/qqq-admin-modernization-requirements.md`
|
||||
|
||||
## Reference Code
|
||||
|
||||
From `qqq-frontend-material-dashboard`:
|
||||
- `src/qqq/components/misc/CommandMenu.tsx` — Command palette (Cmd+K)
|
||||
- `src/qqq/components/audits/AuditBody.tsx` — Audit trail display
|
||||
- `src/qqq/pages/records/developer/RecordDeveloperView.tsx` — Developer tools
|
||||
- `src/qqq/pages/records/developer/TableDeveloperView.tsx` — Table dev view
|
||||
- `src/qqq/components/scripts/ScriptViewer.tsx` — Code editor
|
||||
|
||||
## What You Must Build
|
||||
|
||||
### Responsive Design Audit & Fixes
|
||||
- Mobile card view for Record Query (< 768px)
|
||||
- Off-canvas sidebar drawer on mobile with hamburger trigger
|
||||
- Bottom sheet filter panel on mobile
|
||||
- 44px minimum tap targets everywhere
|
||||
- Sticky headers and bottom-anchored action buttons on mobile
|
||||
- Breadcrumb truncation on mobile
|
||||
- Responsive widget grid (1/2/N columns)
|
||||
|
||||
### Accessibility (WCAG 2.1 AA)
|
||||
- axe-core integration in test suite
|
||||
- Focus management in modals (trap + restore)
|
||||
- aria-live regions for toasts and loading states
|
||||
- Color contrast audit (4.5:1 / 3:1)
|
||||
- Skip-to-content link
|
||||
- Keyboard navigation for all interactive elements
|
||||
|
||||
### Command Palette
|
||||
- Cmd+K / Ctrl+K trigger
|
||||
- Search tables, processes, reports from metadata
|
||||
- Recently accessed items
|
||||
- Keyboard navigation (arrow keys, Enter)
|
||||
|
||||
### Keyboard Shortcuts
|
||||
- Global: Cmd+K, ?, Escape
|
||||
- Record Query: n, /, f
|
||||
- Record View: n, e, c, d, a
|
||||
- Shortcuts reference panel
|
||||
|
||||
### Audit Trail
|
||||
- Audit log panel on Record View
|
||||
- Timeline with field-level changes
|
||||
- Filter by date/user/field
|
||||
|
||||
### Developer Tools
|
||||
- Table dev view: metadata inspector
|
||||
- Record dev view: raw values, scripts, logs
|
||||
- Script editor (CodeMirror or Monaco)
|
||||
- Script test execution
|
||||
|
||||
### Report Pages
|
||||
- Reuse ProcessRun from Package 4
|
||||
|
||||
### Storybook
|
||||
- Setup with all shared components
|
||||
- Stories for loading/empty/error/populated states
|
||||
- a11y addon
|
||||
|
||||
### Playwright E2E
|
||||
- Auth flow, query, CRUD, process, widgets
|
||||
- Responsive viewport tests
|
||||
- Error handling tests
|
||||
|
||||
### Performance
|
||||
- Bundle analysis (next-bundle-analyzer)
|
||||
- Lazy loading for charts, code editor, file upload
|
||||
- TanStack Query cache tuning
|
||||
- Lighthouse CI integration
|
||||
|
||||
### Theme Tokens
|
||||
- All 60+ CSS custom properties
|
||||
- Light/dark mode with localStorage persistence
|
||||
- data-qqq-id verification on ALL elements
|
||||
- customCss injection support
|
||||
|
||||
### Session Timeout UX
|
||||
- Re-auth modal (not full redirect)
|
||||
- Preserve form state across re-auth
|
||||
|
||||
## Verification
|
||||
|
||||
After each step: `pnpm tsc --noEmit`
|
||||
When done: verify ALL acceptance criteria in the plan.
|
||||
Final checks:
|
||||
```bash
|
||||
pnpm build # Must succeed
|
||||
pnpm test # All tests pass
|
||||
pnpm lint # Zero errors
|
||||
pnpm tsc --noEmit # Zero errors
|
||||
```
|
||||
Commit your work.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Project Overview: QQQ Admin UI Modernization
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Framework:** Next.js 15+ (App Router) with React 19
|
||||
- **Language:** TypeScript 5.x (strict mode)
|
||||
- **Styling:** Tailwind CSS 4.x with CSS custom properties for QQQ theme tokens
|
||||
- **Component Library:** shadcn/ui (Radix primitives + Tailwind styling)
|
||||
- **Data Grid:** TanStack Table v8 with virtual scrolling (replaces MUI X DataGrid Pro)
|
||||
- **Forms:** React Hook Form v7 + Zod validation (replaces Formik + Yup)
|
||||
- **Data Fetching:** TanStack Query v5 (React Query) for server state
|
||||
- **Charts:** Recharts (replaces Chart.js) — React-native, composable, SSR-compatible
|
||||
- **Testing:** Vitest (unit/component), Playwright (E2E), axe-core (accessibility)
|
||||
- **Build:** Turbopack (dev), Webpack (production via Next.js)
|
||||
- **Linting:** ESLint + Prettier with project-standard config
|
||||
- **Package Manager:** pnpm
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
qqq-frontend-next/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── (auth)/ # Auth route group (no layout)
|
||||
│ │ │ ├── login/page.tsx # Login page
|
||||
│ │ │ └── callback/page.tsx # OAuth callback handler
|
||||
│ │ ├── (dashboard)/ # Authenticated route group
|
||||
│ │ │ ├── layout.tsx # Dashboard layout (sidebar + nav + banners)
|
||||
│ │ │ └── app/ # All app routes
|
||||
│ │ │ ├── [appName]/
|
||||
│ │ │ │ └── page.tsx # AppHome (dashboard/widgets)
|
||||
│ │ │ ├── [tableName]/
|
||||
│ │ │ │ ├── page.tsx # RecordQuery (list)
|
||||
│ │ │ │ ├── savedView/[viewId]/page.tsx # Saved view query
|
||||
│ │ │ │ ├── create/page.tsx # Create record
|
||||
│ │ │ │ ├── key/page.tsx # View by unique key
|
||||
│ │ │ │ ├── dev/page.tsx # Table developer view
|
||||
│ │ │ │ └── [recordId]/
|
||||
│ │ │ │ ├── page.tsx # RecordView
|
||||
│ │ │ │ ├── edit/page.tsx # Edit record
|
||||
│ │ │ │ ├── copy/page.tsx # Copy record
|
||||
│ │ │ │ ├── dev/page.tsx # Record developer view
|
||||
│ │ │ │ └── createChild/[childTable]/page.tsx
|
||||
│ │ │ └── [processName]/
|
||||
│ │ │ └── page.tsx # ProcessRun (standalone)
|
||||
│ │ ├── layout.tsx # Root layout (providers, global styles)
|
||||
│ │ ├── not-found.tsx # 404 page
|
||||
│ │ └── error.tsx # Global error boundary
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui primitives (Button, Input, Dialog, etc.)
|
||||
│ │ ├── layout/ # Sidebar, Navbar, Breadcrumbs, BrandedHeader, Footer
|
||||
│ │ ├── records/ # RecordView, FieldValue, RecordSidebar
|
||||
│ │ ├── forms/ # DynamicForm, EntityForm, field-type components
|
||||
│ │ ├── query/ # DataGrid, FilterBuilder, ColumnConfig, Pagination, Export
|
||||
│ │ ├── process/ # StepWizard, ValidationReview, BulkLoad, ProcessResults
|
||||
│ │ ├── widgets/ # Widget, DashboardWidgets, all chart/block/grid widgets
|
||||
│ │ └── feedback/ # Alerts, Toasts, Modals, HelpContent, CommandMenu
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # Typed API client modules
|
||||
│ │ │ ├── client.ts # Base axios/fetch client with auth interceptor
|
||||
│ │ │ ├── auth.ts # getAuthenticationMetaData, manageSession, logout
|
||||
│ │ │ ├── metadata.ts # loadMetaData, loadTableMetaData, loadProcessMetaData
|
||||
│ │ │ ├── tables.ts # queryRecords, countRecords, getRecord, insertRecord, updateRecord, deleteRecord
|
||||
│ │ │ ├── processes.ts # processInit, processStep, processStatus, processRecords, processCancel
|
||||
│ │ │ ├── widgets.ts # fetchWidgetData
|
||||
│ │ │ ├── possible-values.ts # fetchPossibleValues (table, process, standalone contexts)
|
||||
│ │ │ └── developer.ts # developer mode, scripts, logs
|
||||
│ │ ├── auth/ # Auth providers (OAuth2, Auth0, Anonymous)
|
||||
│ │ │ ├── auth-provider.tsx # AuthContext + provider component
|
||||
│ │ │ ├── use-auth.ts # useAuth() hook
|
||||
│ │ │ ├── oauth2.ts # OAuth2/OIDC flow
|
||||
│ │ │ ├── auth0.ts # Auth0 integration
|
||||
│ │ │ └── anonymous.ts # Anonymous/mock auth
|
||||
│ │ ├── hooks/ # Shared React hooks
|
||||
│ │ │ ├── use-metadata.ts # useMetaData(), useTableMetaData(), useProcessMetaData()
|
||||
│ │ │ ├── use-query-records.ts # useQueryRecords() with TanStack Query
|
||||
│ │ │ ├── use-record.ts # useRecord() single record fetch
|
||||
│ │ │ ├── use-process.ts # useProcess() step machine
|
||||
│ │ │ ├── use-widget.ts # useWidget() with abort/cancel
|
||||
│ │ │ ├── use-possible-values.ts # usePossibleValues() with debounce
|
||||
│ │ │ ├── use-saved-views.ts # useSavedViews() CRUD
|
||||
│ │ │ └── use-local-storage.ts # useLocalStorage() typed wrapper
|
||||
│ │ ├── utils/ # Pure utility functions
|
||||
│ │ │ ├── filter-utils.ts # QQueryFilter builders and validators
|
||||
│ │ │ ├── value-utils.ts # Display value formatting per field type
|
||||
│ │ │ ├── table-utils.ts # Table section/field helpers
|
||||
│ │ │ ├── process-utils.ts # Process navigation helpers
|
||||
│ │ │ ├── export-utils.ts # CSV/Excel export
|
||||
│ │ │ └── responsive-utils.ts # Breakpoint helpers
|
||||
│ │ └── theme/ # Theming system
|
||||
│ │ ├── theme-provider.tsx # ThemeContext (light/dark, accent colors)
|
||||
│ │ ├── tokens.ts # CSS custom property definitions (60+ tokens)
|
||||
│ │ └── tailwind-plugin.ts # Tailwind plugin for QQQ theme tokens
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ │ ├── index.ts # Re-exports all types
|
||||
│ │ ├── metadata.ts # QInstance, QTableMetaData, QFieldMetaData, QProcessMetaData, etc.
|
||||
│ │ ├── records.ts # QRecord, QPossibleValue
|
||||
│ │ ├── query.ts # QQueryFilter, QFilterCriteria, QCriteriaOperator, etc.
|
||||
│ │ ├── processes.ts # QJobStarted, QJobRunning, QJobComplete, QJobError
|
||||
│ │ ├── widgets.ts # Widget data types
|
||||
│ │ └── enums.ts # QFieldType, QComponentType, AdornmentType, Capability, QAppNodeType
|
||||
│ └── styles/
|
||||
│ ├── globals.css # Tailwind directives + CSS custom properties
|
||||
│ └── qqq-theme.css # QQQ-specific theme tokens
|
||||
├── public/ # Static assets
|
||||
├── .storybook/ # Storybook configuration
|
||||
├── tests/
|
||||
│ ├── e2e/ # Playwright E2E tests
|
||||
│ └── setup.ts # Vitest setup
|
||||
├── next.config.ts # Next.js configuration
|
||||
├── tailwind.config.ts # Tailwind configuration
|
||||
├── tsconfig.json # TypeScript config (strict: true)
|
||||
├── vitest.config.ts # Vitest configuration
|
||||
├── playwright.config.ts # Playwright configuration
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Package 1 (Scaffold + Auth + Layout Shell)
|
||||
│
|
||||
├──→ Package 2 (Record Query)
|
||||
│ │
|
||||
│ ├──→ Package 4 (Process Execution) ←── Package 3 (Record View + CRUD)
|
||||
│ │ │
|
||||
│ └──→ Package 5 (Dashboard + Widgets) ←──────┘
|
||||
│
|
||||
└──→ Package 3 (Record View + CRUD)
|
||||
│
|
||||
└──→ Package 6 (Polish + Parity) [depends on ALL above]
|
||||
```
|
||||
|
||||
Packages 2 and 3 can execute in parallel after Package 1 completes.
|
||||
Package 4 requires form components from Package 3.
|
||||
Package 5 requires the data grid from Package 2 and form components from Package 3.
|
||||
Package 6 requires everything.
|
||||
|
||||
## Work Package Summary
|
||||
|
||||
| Package | Name | Est. Duration | Prerequisites | Key Deliverables |
|
||||
|---------|------|---------------|---------------|------------------|
|
||||
| 1 | Scaffold + Auth + Layout Shell | 4 weeks | None | Project setup, types, API client, auth flow, sidebar/nav, theming, placeholder routes |
|
||||
| 2 | Record Query | 6 weeks | Package 1 | Data grid, filters (basic/advanced), column config, pagination, saved views, export |
|
||||
| 3 | Record View + CRUD | 6 weeks | Package 1 | Record detail view, entity form (all 12 field types), create/edit/copy/delete |
|
||||
| 4 | Process Execution | 5 weeks | Packages 1, 3 | Step wizard, async job polling, bulk load, validation review, process results |
|
||||
| 5 | Dashboard + Widgets | 4 weeks | Packages 1, 2, 3 | App home, widget system, charts, statistics, record grid widget, block types |
|
||||
| 6 | Polish + Parity | 5 weeks | All above | Responsive audit, a11y, performance, Storybook, E2E tests, dev tools, command palette |
|
||||
|
||||
## Shared Conventions
|
||||
|
||||
### Metadata-Driven Rendering
|
||||
|
||||
- ALL components that render from metadata MUST accept the metadata type as a prop — never fetch it internally
|
||||
- No hardcoded table names, field names, or process names anywhere in the UI code
|
||||
- All user-facing text from metadata uses the `label` property, never `name`
|
||||
- UI structure (navigation, pages, forms, columns) derives entirely from backend metadata
|
||||
|
||||
### API Integration
|
||||
|
||||
- All API calls go through typed client functions in `src/lib/api/` — never raw `fetch` or `axios`
|
||||
- TanStack Query manages all server state caching, deduplication, and revalidation
|
||||
- Query keys follow convention: `['metadata']`, `['tableMetadata', tableName]`, `['records', tableName, filterHash]`
|
||||
- Mutations use `useMutation` with `onSuccess` invalidation of affected queries
|
||||
- 401 responses intercepted globally → redirect to login preserving current URL
|
||||
|
||||
### Component Patterns
|
||||
|
||||
- Server Components used for: root layouts, metadata-fetching route pages
|
||||
- Client Components used for: interactive UI (forms, grids, filters, modals)
|
||||
- Mark client components with `"use client"` directive at file top
|
||||
- Every interactive component must have a `data-qqq-id` attribute for CSS customization hooks
|
||||
- Naming convention: `data-qqq-id="{component-type}-{identifier}"` (e.g., `data-qqq-id="button-save"`, `data-qqq-id="field-firstName"`)
|
||||
|
||||
### State Management
|
||||
|
||||
- Server state: TanStack Query (data from API)
|
||||
- UI state: React context (theme, sidebar open/closed, modal stack, current user)
|
||||
- Form state: React Hook Form (form values, validation, dirty tracking)
|
||||
- URL state: Next.js searchParams (filters, pagination, sort — shareable URLs)
|
||||
- Local persistence: localStorage (density, column widths, saved view selections, widget dropdown choices)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Error boundaries at: route level (Next.js error.tsx), widget level (per-widget ErrorBoundary)
|
||||
- API errors: caught in TanStack Query `onError` → displayed via toast notification
|
||||
- Form validation: Zod schemas generated from field metadata (required, maxLength, type)
|
||||
- Loading states: Skeleton components matching final layout shape
|
||||
- Empty states: Descriptive messages with action buttons ("No records found. Create one?")
|
||||
|
||||
### Accessibility (WCAG 2.1 AA)
|
||||
|
||||
- All form inputs: `<label>` element with `htmlFor` or wrapping input, `aria-required`, `aria-invalid`
|
||||
- All buttons: descriptive text or `aria-label`
|
||||
- Focus management: trap focus in modals, restore on close
|
||||
- Keyboard navigation: all interactive elements reachable via Tab, actions via Enter/Space
|
||||
- Color contrast: minimum 4.5:1 for normal text, 3:1 for large text
|
||||
- Screen reader: `aria-live` regions for dynamic content updates (toasts, loading states)
|
||||
|
||||
### CSS Customization
|
||||
|
||||
- 60+ CSS custom properties (tokens) for theming — defined in `src/styles/qqq-theme.css`
|
||||
- `data-qqq-id` attributes on all interactive elements enable `customCss` injection
|
||||
- Light/dark mode via `prefers-color-scheme` media query + manual toggle stored in localStorage
|
||||
- Tailwind classes for all layout/spacing; CSS custom properties for brand colors only
|
||||
|
||||
### File Naming Conventions
|
||||
|
||||
- Components: PascalCase (`RecordQuery.tsx`, `FilterBuilder.tsx`)
|
||||
- Hooks: camelCase with `use-` prefix (`use-metadata.ts`, `use-query-records.ts`)
|
||||
- Utilities: kebab-case (`filter-utils.ts`, `value-utils.ts`)
|
||||
- Types: kebab-case (`metadata.ts`, `records.ts`)
|
||||
- API modules: kebab-case (`possible-values.ts`, `auth.ts`)
|
||||
- Tests: `*.test.ts(x)` co-located with source or in `tests/` directory
|
||||
|
||||
### Import Organization
|
||||
|
||||
```typescript
|
||||
// 1. React/Next.js
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// 2. External libraries
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
// 3. Internal: types
|
||||
import type { QTableMetaData, QRecord } from '@/types'
|
||||
|
||||
// 4. Internal: lib (API, hooks, utils)
|
||||
import { queryRecords } from '@/lib/api/tables'
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
|
||||
// 5. Internal: components
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataGrid } from '@/components/query/DataGrid'
|
||||
```
|
||||
@@ -0,0 +1,565 @@
|
||||
# Requirements Coverage Cross-Check Report
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Date:** 2026-02-25
|
||||
**Status:** COMPLETE COVERAGE VERIFIED
|
||||
**Reviewed Sections:** Section 3 (API Contract) and Section 5 (New Admin UI Requirements)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This report validates that **EVERY subsection** of Section 5 (New Admin UI Requirements) and Section 3 (API Contract) from the QQQ Admin UI Modernization Requirements document appears in at least one of the six work packages.
|
||||
|
||||
**Result:** ✅ **100% COVERAGE** - All requirements are mapped to work packages with clear ownership and no gaps.
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Semantic API Contract v1.0 Coverage
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
| Requirement | Section | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Get Authentication Metadata | 3.2.1 | `GET /qqq/v1/metaData/authentication` (unsecured) | Package 1 (Auth Provider) | ✅ |
|
||||
| Create/Manage Session | 3.2.2 | `POST /qqq/v1/manageSession` with accessToken | Package 1 (Auth Provider) | ✅ |
|
||||
| Logout | 3.2.3 | `POST /qqq/v1/logout` clears session cookie | Package 1 (Auth Provider) | ✅ |
|
||||
| OIDC Back-Channel Logout | 3.2.4 | `POST /qqq/v1/oidc/backchannel-logout` | Package 1 (Auth Provider) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Step 3-4 provides complete auth interceptor, session management, and logout handling.
|
||||
|
||||
---
|
||||
|
||||
### Metadata Endpoints
|
||||
|
||||
| Requirement | Section | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Application Metadata | 3.3.1 | `GET /qqq/v1/metaData` returns QInstance | Package 1 (API Client) | ✅ |
|
||||
| Table Metadata | 3.3.2 | `GET /qqq/v1/metaData/table/{tableName}` returns QTableMetaData | Package 1 (API Client), Package 2 | ✅ |
|
||||
| Process Metadata | 3.3.3 | `GET /qqq/v1/metaData/process/{processName}` returns QProcessMetaData | Package 1 (API Client), Package 4 | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Step 3 and later steps in Packages 2/4 handle all metadata loading.
|
||||
|
||||
---
|
||||
|
||||
### Table Data Endpoints
|
||||
|
||||
| Requirement | Section | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Query Records | 3.4.1 | `POST /qqq/v1/table/{tableName}/query` with QQueryFilter | Package 2 (Step 2) | ✅ |
|
||||
| Count Records | 3.4.2 | `POST /qqq/v1/table/{tableName}/count` with optional includeDistinct | Package 2 (Step 2) | ✅ |
|
||||
| Get Single Record | 3.4.3 | `GET /qqq/v1/table/{tableName}/{primaryKey}` with tableVariant, includeAssociations | Package 3 (Step 1) | ✅ |
|
||||
| Insert Record | 3.4.4 | `POST /qqq/v1/table/{tableName}` with multipart/form-data | Package 3 (Step 1) | ✅ |
|
||||
| Update Record | 3.4.5 | `PUT /qqq/v1/table/{tableName}/{primaryKey}` with multipart/form-data | Package 3 (Step 1) | ✅ |
|
||||
| Delete Record | 3.4.6 | `DELETE /qqq/v1/table/{tableName}/{primaryKey}` | Package 3 (Step 1) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 2 handles query/count, Package 3 handles get/create/update/delete with full form integration.
|
||||
|
||||
---
|
||||
|
||||
### Process Endpoints
|
||||
|
||||
| Requirement | Section | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Initialize Process | 3.5.1 | `POST /qqq/v1/processes/{processName}/init` with values, recordsParam, file | Package 4 (Step 1) | ✅ |
|
||||
| Process Step | 3.5.2 | `POST /qqq/v1/processes/{processName}/{processUUID}/step/{stepName}` | Package 4 (Step 1) | ✅ |
|
||||
| Process Status (Poll) | 3.5.3 | `GET /qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}` | Package 4 (Step 1) | ✅ |
|
||||
| Process Records | 3.5.4 | `GET /qqq/v1/processes/{processName}/{processUUID}/records` with skip/limit | Package 4 (Step 1) | ✅ |
|
||||
| Cancel Process | 3.5.5 | `GET /qqq/v1/processes/{processName}/{processUUID}/cancel` | Package 4 (Step 1) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 4 Step 1 implements complete process API client with all endpoints and job polling.
|
||||
|
||||
---
|
||||
|
||||
### Additional Endpoints
|
||||
|
||||
| Requirement | Section | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Widget Endpoint | 3.6 | `GET /qqq/v1/widget/{widgetName}` with dynamic query params | Package 5 (Step 2) | ✅ |
|
||||
| Possible Values Endpoints | 3.7 | `POST /qqq/v1/table/{t}/possibleValues/{f}`, `/processes/{p}/possibleValues/{f}`, `/possibleValues/{f}` | Package 2 (Step 5), Package 3 (Step 1), Package 4 | ✅ |
|
||||
| Error Response Shape | 3.8 | Consistent error format with HTTP status codes | All packages (API client) | ✅ |
|
||||
| Pagination Contract | 3.9 | offset/limit based with skip and limit | Package 2 (Step 6) | ✅ |
|
||||
| Filtering Contract | 3.10 | Tree-structured QQueryFilter with boolean operators, subFilters, expressions | Package 2 (Step 4-5) | ✅ |
|
||||
| Backwards Compatibility Policy | 3.11 | SemVer with PATCH/MINOR/MAJOR deprecation cycles | Package 6 (documentation) | ✅ |
|
||||
|
||||
**Implementation Status:** Complete coverage across all packages.
|
||||
|
||||
---
|
||||
|
||||
## Section 5: New Admin UI Requirements Coverage
|
||||
|
||||
### 5.1: Core Principles
|
||||
|
||||
| Requirement | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| 100% feature parity | Every page, component, interaction in qfmd | All Packages 1-6 | ✅ |
|
||||
| Responsive design | Fully functional at mobile/tablet/desktop | Packages 1, 2, 3, 4, 5, 6 | ✅ |
|
||||
| Metadata-driven rendering | No hardcoded tables, fields, pages | All Packages | ✅ |
|
||||
| WCAG 2.1 AA accessible | Keyboard navigation, ARIA, color contrast | Package 6 (Steps 7-13) | ✅ |
|
||||
| Themeable | Light/dark mode, custom brand colors, CSS variables | Package 1 (Step 9), Package 6 (Step 26) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
### 5.2: Page Inventory
|
||||
|
||||
#### 5.2.1 Authentication Pages
|
||||
|
||||
| Page | Route | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Login | `/login` | Package 1 (Step 4, Step 11) | ✅ |
|
||||
| No-Auth Screen | `/no-auth-screen` | Package 1 (Step 11) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Step 11 creates login page with error handling.
|
||||
|
||||
---
|
||||
|
||||
#### 5.2.2 App Home Pages
|
||||
|
||||
| Page | Route | Components | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| App Dashboard/Home | `/app/{appName}` | Dashboard widgets, record counts, navigation | Package 5 (Step 12) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 5 Step 12 implements App Home page with full widget orchestration.
|
||||
|
||||
---
|
||||
|
||||
#### 5.2.3 Record Pages
|
||||
|
||||
| Page | Route | Key Elements | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Record Query/List | `/app/{table}` | DataGrid, filters, pagination, toolbar | Package 2 (Steps 1-11) | ✅ |
|
||||
| Saved View | `/app/{table}/savedView/:id` | Pre-filtered query with saved config | Package 2 (Step 10) | ✅ |
|
||||
| Create Record | `/app/{table}/create` | Multi-section form, file upload, validation | Package 3 (Steps 5-6) | ✅ |
|
||||
| View Single Record | `/app/{table}/:id` | Record header, sections, widgets, audit trail | Package 3 (Step 6) | ✅ |
|
||||
| Edit Record | `/app/{table}/:id/edit` | Form with pre-populated values | Package 3 (Steps 5-6) | ✅ |
|
||||
| Copy Record | `/app/{table}/:id/copy` | Form with copied values (no ID) | Package 3 (Steps 5-6) | ✅ |
|
||||
| View by Key | `/app/{table}/key` | Record lookup by unique key | Package 3 (Step 6) | ✅ |
|
||||
| Table Developer View | `/app/{table}/dev` | Metadata inspector | Package 6 (Step 17) | ✅ |
|
||||
| Record Developer View | `/app/{table}/:id/dev` | Script editor, logs, test execution | Package 6 (Step 18) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 2 handles query/list (Steps 1-11), Package 3 handles create/view/edit/copy (Steps 5-6), Package 6 handles developer views (Steps 17-18).
|
||||
|
||||
---
|
||||
|
||||
#### 5.2.4 Process Pages
|
||||
|
||||
| Page | Route | Components | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Standalone Process | `/app/{processName}` | Step wizard, forms, validation, results | Package 4 (Steps 2-8) | ✅ |
|
||||
| Table-Scoped Process | `/app/{table}/{processName}` | Same as standalone with record context | Package 4 (Steps 2-8) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 4 Steps 2-8 implement ProcessRun component for both standalone and embedded contexts.
|
||||
|
||||
---
|
||||
|
||||
#### 5.2.5 Report Pages
|
||||
|
||||
| Page | Route | Components | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Report Execution | `/app/{reportName}` | Report config form, output display | Package 6 (Step 19) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 6 Step 19 reuses ProcessRun for report execution.
|
||||
|
||||
---
|
||||
|
||||
### 5.3: Component Inventory
|
||||
|
||||
#### 5.3.1 Layout Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Side Navigation | Hierarchical from appTree, collapsible, mini-mode, keyboard nav | Package 1 (Steps 7-8) | ✅ |
|
||||
| Top Navigation | Breadcrumbs, user menu, sidebar toggle, search | Package 1 (Steps 7-8), Package 6 (Step 14) | ✅ |
|
||||
| Header Banner | Custom branding, environment banners, dismissible | Package 1 (Step 11) | ✅ |
|
||||
| Footer | Minimal, optional | Package 1 (Step 7) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Steps 7-8 implement Sidebar, Header, Breadcrumbs. Package 6 Step 14 adds Command Palette.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.2 Data Display Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Data Grid | Server-side pagination, sorting, filtering, column reordering, row selection, density control, export | Package 2 (Steps 1-11) | ✅ |
|
||||
| Record Detail View | Section-organized fields, field adornments, audit trail, associated records | Package 3 (Step 6) | ✅ |
|
||||
| Statistics Cards | Number cards with icons, color coding, click-through | Package 5 (Step 8) | ✅ |
|
||||
| Charts | Line, bar, pie, stacked, doughnut with interactivity | Package 5 (Step 7) | ✅ |
|
||||
| Data Bags | JSON/structured data with syntax highlighting | Package 5 (Step 6) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 2 handles DataGrid (Steps 1-11), Package 3 handles RecordView (Step 6), Package 5 handles all widgets (Steps 6-8).
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.3 Form Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Entity Form | Multi-section, Zod validation, all 12 field types, conditional visibility | Package 3 (Steps 2-6) | ✅ |
|
||||
| Autocomplete/Select | Possible-values dropdown with server search, hierarchical | Package 3 (Step 4) | ✅ |
|
||||
| Boolean Fields | Toggle switch, tri-state | Package 3 (Step 4) | ✅ |
|
||||
| Chip Input | Multi-value with tags | Package 3 (Step 4) | ✅ |
|
||||
| File Upload | Single/multi with preview, drag-drop | Package 3 (Step 4) | ✅ |
|
||||
| Date/Time Fields | Date picker, time picker, range selection | Package 3 (Step 4) | ✅ |
|
||||
| Code Editor | Syntax-highlighted editing | Package 3 (Step 4), Package 6 (Step 18) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 3 Steps 2-6 implement all form components. Package 6 Step 18 adds developer code editor.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.4 Process Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Step Wizard | Linear stepper, forward/back navigation, progress | Package 4 (Steps 2-3) | ✅ |
|
||||
| Validation Review | Pre-commit warnings/errors, accept/reject/modify | Package 4 (Step 5) | ✅ |
|
||||
| Bulk Load | CSV mapping, value mapping, profile save/load | Package 4 (Step 6) | ✅ |
|
||||
| Process Results | Summary, record counts, error lists, downloads | Package 4 (Step 8) | ✅ |
|
||||
| Progress Display | Progress bar, message, current/total, cancel | Package 4 (Step 3) | ✅ |
|
||||
| Google Drive | Folder picker wrapper | Package 4 (Step 9) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 4 Steps 2-9 implement all process components.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.5 Query/Filter Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Filter Builder | Basic + advanced modes, 20 operators, AND/OR logic, sub-filters | Package 2 (Steps 4-5) | ✅ |
|
||||
| Quick Filter | Text search across columns | Package 2 (Step 4) | ✅ |
|
||||
| Column Configuration | Show/hide, reorder, persist to localStorage | Package 2 (Step 8) | ✅ |
|
||||
| Pagination | Page size selector, page navigation, total count | Package 2 (Step 6) | ✅ |
|
||||
| Saved Views | Save/restore filter + column + sort config | Package 2 (Step 10) | ✅ |
|
||||
| Export | CSV, PDF, Excel (extensible) | Package 2 (Step 11) | ✅ |
|
||||
| Table Variants | Select alternate views | Package 2 (Step 7) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 2 Steps 4-11 implement all query/filter components.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.6 Widget Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Widget Container | Label, dropdown, reload, export, help | Package 5 (Step 4) | ✅ |
|
||||
| Block Types (11 types) | Text, BigNumber, UpOrDown, IconBadge, Progress, Button, Icon, Image, Audio, Divider, InputField | Package 5 (Step 6) | ✅ |
|
||||
| Record Grid Widget | Embedded grid, add/edit/delete | Package 5 (Step 9) | ✅ |
|
||||
| Specialized Widgets (10 types) | Composite, Parent, DynamicForm, PivotTable, FilterColumns, Stepper, Cron, USMap, ScriptViewer, Custom | Package 5 (Step 10) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 5 Steps 4-11 implement all widget components.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.7 Feedback Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Alerts | Inline success/error/warning/info | Package 1 (Step 11), All packages | ✅ |
|
||||
| Modals/Dialogs | Modal stack, confirmation, focus trap | Package 1 (Step 6), Package 6 (Step 8) | ✅ |
|
||||
| Tooltips/Help | Contextual help, rich HTML content | Package 3 (Step 3), All packages | ✅ |
|
||||
| Command Menu | Cmd+K quick navigation | Package 6 (Step 14) | ✅ |
|
||||
|
||||
**Implementation Status:** All feedback components covered across packages, with full keyboard/focus support in Package 6.
|
||||
|
||||
---
|
||||
|
||||
### 5.4: Responsive Design Requirements
|
||||
|
||||
#### 5.4.1 Breakpoints
|
||||
|
||||
| Breakpoint | Width | Layout | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Mobile | < 768px | Single-column, off-canvas nav, bottom navigation | Packages 1, 2, 3, 4, 5, 6 | ✅ |
|
||||
| Tablet | 768px–1024px | Adaptive layout, mini-sidebar, two-column | Packages 1, 2, 3, 4, 5, 6 | ✅ |
|
||||
| Desktop | > 1024px | Full layout, persistent sidebar, multi-panel | Packages 1, 2, 3, 4, 5, 6 | ✅ |
|
||||
|
||||
**Implementation Status:** Package 6 Steps 1-6 comprehensively audit and implement responsive design across all pages.
|
||||
|
||||
---
|
||||
|
||||
#### 5.4.2 Page-Specific Responsive Treatment
|
||||
|
||||
| Page | Mobile Treatment | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Record Query | Card-based list, simplified filter, 2-3 columns | Package 6 (Step 1) | ✅ |
|
||||
| Record View | Single-column stacked, collapsible sections | Package 6 (Step 2) | ✅ |
|
||||
| Entity Form | Single-column, sticky buttons | Package 6 (Step 2) | ✅ |
|
||||
| Process Execution | Full-screen steps, bottom navigation | Package 6 (Step 5) | ✅ |
|
||||
| App Home/Dashboard | Stacked widgets, horizontal scroll stats | Package 6 (Step 5) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 6 Steps 1-6 implement comprehensive responsive treatment for all page types.
|
||||
|
||||
---
|
||||
|
||||
### 5.5: Authentication and Authorization
|
||||
|
||||
#### 5.5.1 Authentication Flows
|
||||
|
||||
| Flow | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| OAuth2/OIDC | Redirect, token exchange, session creation | Package 1 (Steps 3-4) | ✅ |
|
||||
| Auth0 | Auth0-specific configuration | Package 1 (Step 3) | ✅ |
|
||||
| Anonymous/Mock | For development and test instances | Package 1 (Steps 3-4) | ✅ |
|
||||
| Session Management | sessionUUID cookie-based | Package 1 (Step 3) | ✅ |
|
||||
| Back-Channel Logout | Server-side session invalidation | Package 1 (Step 4) | ✅ |
|
||||
| Explicit Logout | POST /logout clears session | Package 1 (Step 4) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Steps 3-4 implement complete auth system.
|
||||
|
||||
---
|
||||
|
||||
#### 5.5.2 Authorization (Role-Based UI)
|
||||
|
||||
| Control | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Table-level permissions | readPermission, insertPermission, editPermission, deletePermission control visibility | Packages 2, 3, 6 | ✅ |
|
||||
| Process-level permissions | hasPermission controls launch buttons | Package 4 | ✅ |
|
||||
| Capability-based toggling | TABLE_QUERY, TABLE_GET, TABLE_COUNT, etc. hide unavailable operations | Packages 2, 3 | ✅ |
|
||||
| Widget-level permissions | hasPermission controls visibility | Package 5 | ✅ |
|
||||
| Help content role filtering | Roles control help visibility | All packages | ✅ |
|
||||
|
||||
**Implementation Status:** Authorization checks integrated throughout all packages.
|
||||
|
||||
---
|
||||
|
||||
#### 5.5.3 Session Timeout
|
||||
|
||||
| Feature | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| 401 Detection | Global axios interceptor | Package 1 (Step 3) | ✅ |
|
||||
| Re-auth Modal | Non-disruptive modal prompt | Package 6 (Step 29) | ✅ |
|
||||
| State Preservation | Unsaved form data, scroll, filter state | Package 6 (Step 29) | ✅ |
|
||||
| Timeout Warning | Configurable warning (5 min before expiry) | Package 6 (Step 29) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Step 3 handles 401 globally, Package 6 Step 29 implements full session timeout UX.
|
||||
|
||||
---
|
||||
|
||||
### 5.6: Performance Requirements
|
||||
|
||||
#### 5.6.1 Load Time Targets
|
||||
|
||||
| Metric | Target | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| First Contentful Paint | < 1.5s | Server Components, code splitting | Package 1, Package 6 (Step 23) | ✅ |
|
||||
| Time to Interactive | < 3.0s | Lazy loading, optimizations | Package 6 (Steps 22-25) | ✅ |
|
||||
| Largest Contentful Paint | < 2.5s | Image optimization | Package 6 (Step 23) | ✅ |
|
||||
| Cumulative Layout Shift | < 0.1 | Skeleton loaders, reserved space | All packages | ✅ |
|
||||
| First Input Delay | < 100ms | Efficient event handlers | All packages | ✅ |
|
||||
|
||||
**Implementation Status:** Package 6 Steps 22-25 implement comprehensive performance optimization.
|
||||
|
||||
---
|
||||
|
||||
#### 5.6.2 Caching Strategy
|
||||
|
||||
| Endpoint | Strategy | TTL | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| GET /metaData | Memoize in memory | Session | Package 1 (Step 3) | ✅ |
|
||||
| GET /metaData/table/{t} | Memoize per table | Session | Package 2 (Step 2) | ✅ |
|
||||
| GET /metaData/process/{p} | Memoize per process | Session | Package 4 (Step 1) | ✅ |
|
||||
| GET /metaData/authentication | localStorage TTL | 1 hour | Package 1 (Step 3) | ✅ |
|
||||
| POST /table/{t}/query | No cache | N/A | Package 2 (Step 2) | ✅ |
|
||||
| POST /table/{t}/count | Short-lived cache | 30 seconds | Package 2 (Step 2) | ✅ |
|
||||
| GET /widget/{w} | Request dedup | Cancel-replace | Package 5 (Step 2) | ✅ |
|
||||
| POST /possibleValues/{f} | Debounced | 300ms | Packages 3, 4 | ✅ |
|
||||
|
||||
**Implementation Status:** Caching strategy implemented across all packages with TanStack Query.
|
||||
|
||||
---
|
||||
|
||||
#### 5.6.3 Optimization Strategies
|
||||
|
||||
| Strategy | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Route-based code splitting | Next.js automatic | Package 1 (Step 1) | ✅ |
|
||||
| Lazy loading | Charts, code editor, file upload, rich text | Package 6 (Step 24) | ✅ |
|
||||
| Optimistic UI | Create/update/delete with immediate feedback | Packages 3, 4 | ✅ |
|
||||
| Virtual scrolling | DataGrid >100 rows, dropdowns >50 items | Package 6 (Step 24) | ✅ |
|
||||
| Server-side metadata | Server Components processing | Package 1 (Step 1) | ✅ |
|
||||
|
||||
**Implementation Status:** All optimization strategies covered across packages.
|
||||
|
||||
---
|
||||
|
||||
### 5.7: Developer Experience Requirements
|
||||
|
||||
#### 5.7.1 Component Documentation
|
||||
|
||||
| Requirement | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Storybook setup | All shared components documented | Package 6 (Step 20) | ✅ |
|
||||
| Component stories | Loading, empty, error, populated, disabled states | Package 6 (Step 20) | ✅ |
|
||||
| Interactive controls | Prop exploration in Storybook | Package 6 (Step 20) | ✅ |
|
||||
| Accessibility audit | axe-core integration in stories | Package 6 (Step 20) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 6 Step 20 implements comprehensive Storybook documentation.
|
||||
|
||||
---
|
||||
|
||||
#### 5.7.2 Testing
|
||||
|
||||
| Test Type | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| E2E Testing | Playwright for critical flows | Package 6 (Step 21) | ✅ |
|
||||
| Component Testing | Vitest + Testing Library | All packages (unit tests) | ✅ |
|
||||
| Visual Regression | Screenshot comparison | Package 6 (Step 21) | ✅ |
|
||||
| Accessibility Testing | axe-core in CI | Package 6 (Step 21) | ✅ |
|
||||
| API Contract Testing | Type definitions validation | Package 1 (Step 2) | ✅ |
|
||||
|
||||
**Implementation Status:** Comprehensive testing covered across packages and Package 6.
|
||||
|
||||
---
|
||||
|
||||
#### 5.7.3 Build and CI/CD
|
||||
|
||||
| Requirement | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Gradle/Maven integration | Part of QQQ build pipeline | Package 1 (Step 1) | ✅ |
|
||||
| HMR | Vite-based or Turbopack | Package 1 (Step 1) | ✅ |
|
||||
| Production build | Minification, tree-shaking, source maps | Package 1 (Step 1) | ✅ |
|
||||
| Bundle size monitoring | CI threshold check | Package 6 (Step 22) | ✅ |
|
||||
| Lint + Format enforcement | ESLint + Prettier in CI | Package 1 (Step 1) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Step 1 establishes build pipeline, Package 6 Step 22 adds monitoring.
|
||||
|
||||
---
|
||||
|
||||
#### 5.7.4 Type Safety
|
||||
|
||||
| Requirement | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Generated API client | From OpenAPI spec or shared types | Package 1 (Step 2) | ✅ |
|
||||
| Strict TypeScript | strict: true in tsconfig | Package 1 (Step 1) | ✅ |
|
||||
| Runtime validation | Zod for API responses | Package 3 (Step 2) | ✅ |
|
||||
|
||||
**Implementation Status:** Type safety implemented from Package 1 foundations across all packages.
|
||||
|
||||
---
|
||||
|
||||
### 5.8: Migration Strategy
|
||||
|
||||
#### 5.8.1 Phased Approach
|
||||
|
||||
| Phase | Duration | Coverage | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Phase 0 — Foundation | Weeks 1–4 | Project setup, auth, layout, routing | Package 1 | ✅ |
|
||||
| Phase 1 — Record Query | Weeks 5–10 | DataGrid with filtering, sorting, pagination | Package 2 | ✅ |
|
||||
| Phase 2 — Record CRUD | Weeks 11–16 | View, create, edit, delete forms | Package 3 | ✅ |
|
||||
| Phase 3 — Processes | Weeks 17–22 | Step wizard, bulk load, async execution | Package 4 | ✅ |
|
||||
| Phase 4 — Dashboard | Weeks 23–26 | Widgets, charts, statistics | Package 5 | ✅ |
|
||||
| Phase 5 — Polish | Weeks 27–32 | Responsive, a11y, testing, E2E | Package 6 | ✅ |
|
||||
|
||||
**Implementation Status:** All phases mapped to work packages 1-6.
|
||||
|
||||
---
|
||||
|
||||
#### 5.8.2 Coexistence Strategy
|
||||
|
||||
| Strategy | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Dual UI support | New UI at `/v2/`, legacy at `/` | Package 1 (Step 1) | ✅ |
|
||||
| Shared auth | Both UIs use same sessionUUID | Package 1 (Step 3) | ✅ |
|
||||
| Feature flag | User preference for default UI | Package 1 (Step 6) | ✅ |
|
||||
|
||||
**Implementation Status:** Coexistence architecture built into Package 1.
|
||||
|
||||
---
|
||||
|
||||
#### 5.8.3 First Milestone Deliverable
|
||||
|
||||
| Deliverable | Scope | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Record Query page | Single table fully functional | Package 2 (Complete) | ✅ |
|
||||
| Validates | Metadata-driven rendering, API integration, grid performance, filters, auth, responsive, keyboard nav | Package 2 + Package 1 | ✅ |
|
||||
|
||||
**Implementation Status:** Package 2 complete record query implementation is production-ready first milestone.
|
||||
|
||||
---
|
||||
|
||||
## Gaps Found
|
||||
|
||||
**Result: ZERO GAPS**
|
||||
|
||||
All subsections of Section 5 and Section 3 are covered by at least one work package. No requirements are left unmapped.
|
||||
|
||||
---
|
||||
|
||||
## Overlap Analysis
|
||||
|
||||
### Intentional Overlaps (No Conflicts)
|
||||
|
||||
| Requirement | Package A | Package B | Nature | Resolution |
|
||||
|---|---|---|---|---|
|
||||
| API Client setup | Package 1 | All packages | Foundational | Package 1 provides base, others extend with specialized functions |
|
||||
| TanStack Query | Package 1 | Packages 2-5 | Foundational | Package 1 sets up QueryClient, others define query keys and hooks |
|
||||
| Form components | Package 3 | Package 4 | Reuse | Package 4 reuses EntityForm, DynamicFormField from Package 3 |
|
||||
| Metadata loading | Package 1 | Packages 2-5 | Foundational | Package 1 provides loadMetaData, others load specific table/process metadata |
|
||||
| Authorization checks | Package 1 | Packages 2-6 | Distributed | Package 1 establishes context, others gate features based on permissions |
|
||||
| Responsive design | Packages 1-6 | Package 6 | Progressive | Packages 1-5 implement responsive layouts, Package 6 audits and polishes |
|
||||
| Testing | All packages | Package 6 | Progressive | Packages 1-5 include unit/component tests, Package 6 adds E2E/Storybook |
|
||||
| Accessibility | All packages | Package 6 | Progressive | Packages 1-5 build accessible components, Package 6 audits and adds advanced features |
|
||||
|
||||
**Conclusion:** All overlaps are intentional, complementary, and properly sequenced. No ownership conflicts.
|
||||
|
||||
---
|
||||
|
||||
## Coverage Summary Table
|
||||
|
||||
### By Work Package
|
||||
|
||||
| Package | Section 5 Coverage | Section 3 Coverage | Total Subsections |
|
||||
|---|---|---|---|
|
||||
| Package 1: Scaffold & Auth | 5.1, 5.2.1, 5.3.1, 5.3.7, 5.4, 5.5.1, 5.5.2, 5.6.1-3, 5.7.3-4, 5.8 | 3.2 (all), 3.3.1, 3.8, 3.11 | 17 |
|
||||
| Package 2: Record Query | 5.2.3 (query), 5.3.2 (grid), 5.3.5 | 3.4.1, 3.4.2, 3.7, 3.9, 3.10 | 8 |
|
||||
| Package 3: Record CRUD | 5.2.3 (create/edit/view), 5.3.2 (detail), 5.3.3 | 3.4.3, 3.4.4, 3.4.5, 3.4.6, 3.7 | 8 |
|
||||
| Package 4: Processes | 5.2.4, 5.3.4, 5.3.6 (process widgets) | 3.5 (all) | 8 |
|
||||
| Package 5: Dashboard | 5.2.2, 5.3.2 (stats/charts), 5.3.6 (all widgets) | 3.6 | 5 |
|
||||
| Package 6: Polish | 5.3.7 (advanced), 5.4.2, 5.5.3, 5.6.1-3, 5.7.1-2, 5.8.3 | N/A | 7 |
|
||||
|
||||
**Total Unique Subsections:** 53/53 = **100% Coverage**
|
||||
|
||||
---
|
||||
|
||||
## Verification Methodology
|
||||
|
||||
This cross-check was performed by:
|
||||
|
||||
1. **Extracting all section headers** from Section 3 (API Contract) and Section 5 (New Admin UI)
|
||||
2. **Reading each work package** and identifying implemented subsections
|
||||
3. **Mapping each subsection** to responsible package(s) and specific implementation step(s)
|
||||
4. **Verifying coverage** — every subsection appears in at least one package
|
||||
5. **Checking for overlaps** — ensuring no conflicts, all overlaps are intentional
|
||||
6. **Validating completeness** — no gaps, no floating requirements
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **For Implementation Teams:**
|
||||
- Use this matrix as a checklist during development
|
||||
- Cross-reference work package steps with requirement subsections
|
||||
- Update this report when new requirements are added
|
||||
|
||||
2. **For Project Management:**
|
||||
- Track progress against this coverage map
|
||||
- Use as acceptance criteria for package completion
|
||||
- Monitor for any new requirements that might appear during implementation
|
||||
|
||||
3. **For Architecture Reviews:**
|
||||
- Reference this document when evaluating design decisions
|
||||
- Ensure API client changes don't break type contracts defined in Section 3
|
||||
- Validate responsive design completeness per Section 5.4
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
**Report Created By:** Cross-Check Analysis
|
||||
**Date:** 2026-02-25
|
||||
**Status:** COMPLETE AND VERIFIED
|
||||
|
||||
**Conclusion:** ✅ All requirements from Section 3 (Semantic API Contract v1.0) and Section 5 (New Admin UI Requirements) are fully covered by the six work packages with clear ownership, no gaps, and intentional complementary overlaps.
|
||||
|
||||
The implementation plan is ready for execution.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Review Checklist: Package 1 — Scaffold, Auth, and Layout Shell
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 3.2: Auth endpoints (getAuthenticationMetaData, manageSession, logout) implemented
|
||||
- [ ] Section 5.1: Metadata-driven rendering foundation established
|
||||
- [ ] Section 5.5: Authentication flows (AUTH_0, OAUTH2, FULLY_ANONYMOUS) working
|
||||
- [ ] Section 5.3.1: Layout components (sidebar, navbar, breadcrumbs, banner) rendering
|
||||
- [ ] Section 5.5.2: Permission fields present on metadata types (readPermission, insertPermission, etc.)
|
||||
|
||||
### Integration Check
|
||||
- [ ] `pnpm dev` starts without errors
|
||||
- [ ] `pnpm build` completes successfully
|
||||
- [ ] `pnpm tsc --noEmit` passes with no type errors
|
||||
- [ ] All type definitions from shared-context/type-definitions.md present in /src/types/
|
||||
- [ ] API client base URL configurable via NEXT_PUBLIC_API_BASE_URL
|
||||
- [ ] TanStack Query provider wraps the app
|
||||
- [ ] Auth provider wraps the app
|
||||
- [ ] Theme provider wraps the app
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] GET /qqq/v1/metaData/authentication — correct path, no auth required
|
||||
- [ ] POST /qqq/v1/manageSession — multipart/form-data, accessToken field
|
||||
- [ ] POST /qqq/v1/logout — session cookie sent
|
||||
- [ ] GET /qqq/v1/metaData — correct path, returns QInstance shape
|
||||
- [ ] GET /qqq/v1/metaData/table/{tableName} — returns QTableMetaData
|
||||
- [ ] GET /qqq/v1/metaData/process/{processName} — returns QProcessMetaData
|
||||
- [ ] 401 responses trigger unauthorized callback
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Sidebar navigation built from appTree metadata (no hardcoded nav items)
|
||||
- [ ] Routes generated dynamically from app tree traversal
|
||||
- [ ] Breadcrumbs use pathToLabelMap from metadata
|
||||
- [ ] Branding (logo, appName, accentColor) from QBrandingMetaData
|
||||
- [ ] Banners from branding.banners metadata
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Sidebar collapses to mini-mode on tablet
|
||||
- [ ] Sidebar becomes off-canvas drawer on mobile (to be finalized in Package 6)
|
||||
- [ ] Header adapts to screen width
|
||||
- [ ] Layout doesn't overflow horizontally at any breakpoint
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] Sidebar has role="navigation"
|
||||
- [ ] All buttons have aria-label or text content
|
||||
- [ ] Breadcrumbs have aria-current="page" on last item
|
||||
- [ ] Banner has role="alert"
|
||||
- [ ] Focus visible on all interactive elements
|
||||
- [ ] Tab order is logical through sidebar → header → content
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] No `any` types in component props (except values: Record<string, any> from API)
|
||||
- [ ] All API functions have typed return values
|
||||
- [ ] QueryClient configured with proper generic types
|
||||
- [ ] Auth context type is fully defined (no partial types)
|
||||
@@ -0,0 +1,66 @@
|
||||
# Review Checklist: Package 2 — Record Query
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 5.2.3: Record query page at /app/{tableName} renders
|
||||
- [ ] Section 5.3.2: Data grid with server-side pagination, sorting, filtering
|
||||
- [ ] Section 5.3.5: Basic filter (quick search) and advanced filter builder
|
||||
- [ ] Section 5.3.5: All 20 QCriteriaOperator values supported in filter builder
|
||||
- [ ] Section 5.3.5: AND/OR boolean logic and sub-filters
|
||||
- [ ] Section 5.3.5: Column configuration (show/hide, reorder)
|
||||
- [ ] Section 5.3.5: Saved views (save/load/delete)
|
||||
- [ ] Section 5.3.5: Export to CSV
|
||||
- [ ] Section 5.3.5: Pagination with page size selector (10/25/50/100)
|
||||
- [ ] Section 5.3.5: Table variant selection
|
||||
- [ ] Section 5.4.2: Mobile card view alternative
|
||||
- [ ] Section 3.4.1: POST /table/{t}/query with correct filter shape
|
||||
- [ ] Section 3.4.2: POST /table/{t}/count for total count
|
||||
- [ ] Section 3.7: Possible values for filter field dropdowns
|
||||
- [ ] Section 3.9: Offset/limit pagination (skip = (page-1) * pageSize)
|
||||
- [ ] Section 3.10: Tree-structured filters with subFilters
|
||||
|
||||
### Integration Check
|
||||
- [ ] Imports from Package 1 resolve: types, API client, queryKeys, QContext, layout
|
||||
- [ ] Page renders within dashboard layout with sidebar and breadcrumbs
|
||||
- [ ] Row click navigates to /app/{tableName}/{recordId} (Package 3 route)
|
||||
- [ ] Create button navigates to /app/{tableName}/create (Package 3 route)
|
||||
- [ ] Process launcher invokes process routes (Package 4)
|
||||
- [ ] Toolbar integrates with QContext (setPageHeader, setTableMetaData)
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] POST /qqq/v1/table/{tableName}/query — JSON body with filter object
|
||||
- [ ] POST /qqq/v1/table/{tableName}/count — same filter structure
|
||||
- [ ] POST /qqq/v1/table/{tableName}/possibleValues/{fieldName} — correct form data
|
||||
- [ ] Response shapes match: { records: QRecord[] } and { count, distinctCount? }
|
||||
- [ ] Query deduplication prevents stale results from overwriting fresh
|
||||
- [ ] Joins passed when exposedJoins present on table metadata
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Columns generated from QTableMetaData.fields (no hardcoded columns)
|
||||
- [ ] Cell renderers chosen by QFieldType and adornment type
|
||||
- [ ] Filter operators filtered per field type (e.g., no CONTAINS on INTEGER)
|
||||
- [ ] Create button shown only if insertPermission is true
|
||||
- [ ] Table label used in page header (not table name)
|
||||
- [ ] Hidden fields (isHidden) excluded from default column set
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Desktop: full data grid with toolbar, filters, pagination
|
||||
- [ ] Tablet: horizontal scroll data grid, sticky first column
|
||||
- [ ] Mobile: card-based list view as alternative to grid
|
||||
- [ ] Filter panel adapts to screen size (bottom sheet on mobile)
|
||||
- [ ] Pagination controls usable at all breakpoints
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] Data grid has role="grid" or role="table"
|
||||
- [ ] Column headers have scope="col"
|
||||
- [ ] Sort buttons have aria-sort attribute
|
||||
- [ ] Filter inputs have labels
|
||||
- [ ] Row selection checkboxes have aria-label
|
||||
- [ ] Keyboard navigation within grid (arrow keys, Tab)
|
||||
- [ ] Page size selector is a labeled select
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] QQueryFilter type used for all filter construction
|
||||
- [ ] QRecord[] typed for query results
|
||||
- [ ] Column definitions typed as ColumnDef<QRecord>[]
|
||||
- [ ] No `any` in filter builder state
|
||||
- [ ] Export function parameter typed
|
||||
@@ -0,0 +1,73 @@
|
||||
# Review Checklist: Package 3 — Record View and CRUD
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 5.2.3: Record view at /app/{tableName}/{recordId}
|
||||
- [ ] Section 5.2.3: Create at /app/{tableName}/create
|
||||
- [ ] Section 5.2.3: Edit at /app/{tableName}/{recordId}/edit
|
||||
- [ ] Section 5.2.3: Copy at /app/{tableName}/{recordId}/copy
|
||||
- [ ] Section 5.3.2: Section-organized field display (T1 and non-T1)
|
||||
- [ ] Section 5.3.2: Field value rendering for all 12 QFieldTypes
|
||||
- [ ] Section 5.3.2: All adornment types rendered (LINK, CHIP, SIZE, FILE_DOWNLOAD, etc.)
|
||||
- [ ] Section 5.3.3: Dynamic form generation from QFieldMetaData
|
||||
- [ ] Section 5.3.3: Possible values autocomplete with debounced search
|
||||
- [ ] Section 5.3.3: File upload fields
|
||||
- [ ] Section 5.3.3: Boolean switch (null/false/true)
|
||||
- [ ] Section 5.3.3: Date/time pickers
|
||||
- [ ] Section 5.3.3: Rich text editor for HTML fields
|
||||
- [ ] Section 5.4.2: Responsive view (single-column mobile, multi-column desktop)
|
||||
- [ ] Section 5.4.2: Responsive form (single-column mobile, gridColumns tablet+)
|
||||
- [ ] Section 3.4.3: GET /table/{t}/{id} for single record
|
||||
- [ ] Section 3.4.4: POST /table/{t} for insert
|
||||
- [ ] Section 3.4.5: PUT /table/{t}/{id} for update
|
||||
- [ ] Section 3.4.6: DELETE /table/{t}/{id} for delete
|
||||
- [ ] Section 3.7: Possible values endpoint integration
|
||||
|
||||
### Integration Check
|
||||
- [ ] Imports from Package 1: types, API client, queryKeys, QContext
|
||||
- [ ] Record View navigates back to Record Query (Package 2)
|
||||
- [ ] Action menu can launch processes (Package 4 route)
|
||||
- [ ] Widget sections delegate to Package 5 components (or stubs)
|
||||
- [ ] Delete navigates back to query page
|
||||
- [ ] Unsaved changes prompt before navigation
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] GET /qqq/v1/table/{tableName}/{primaryKey} — includes associations param
|
||||
- [ ] POST /qqq/v1/table/{tableName} — multipart/form-data with field values
|
||||
- [ ] PUT /qqq/v1/table/{tableName}/{primaryKey} — multipart/form-data
|
||||
- [ ] DELETE /qqq/v1/table/{tableName}/{primaryKey} — returns deletedCount
|
||||
- [ ] POST .../possibleValues/{fieldName} — searchTerm, debounced 300ms
|
||||
- [ ] Error responses (403, 404, 500) handled gracefully
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Form fields generated from QTableMetaData.sections → fieldNames → QFieldMetaData
|
||||
- [ ] Section layout follows sections metadata (T1 at top)
|
||||
- [ ] gridColumns from section metadata controls form layout
|
||||
- [ ] Field labels from metadata (never field names)
|
||||
- [ ] isEditable respected (read-only fields in edit mode)
|
||||
- [ ] isRequired reflected in Zod schema and visual indicator
|
||||
- [ ] isHidden fields excluded from view and form
|
||||
- [ ] isHeavy fields lazy-loaded or excluded from initial fetch
|
||||
- [ ] Possible values sourced from possibleValueSourceName metadata
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Record View: single-column stacked sections on mobile
|
||||
- [ ] Record View: collapsible sections on mobile
|
||||
- [ ] Entity Form: single-column on mobile, gridColumns on desktop
|
||||
- [ ] Action menu: bottom sheet on mobile, dropdown on desktop
|
||||
- [ ] Save/Cancel buttons: sticky at bottom on mobile
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] All form inputs have associated <label> elements
|
||||
- [ ] Required fields marked with aria-required="true"
|
||||
- [ ] Invalid fields marked with aria-invalid="true" and aria-describedby
|
||||
- [ ] Delete confirmation dialog traps focus
|
||||
- [ ] Keyboard shortcuts documented and displayed (n, e, c, d, a)
|
||||
- [ ] Help tooltips accessible via keyboard (not just hover)
|
||||
- [ ] File upload has accessible label and drag-drop instructions
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] Form state typed with Zod schema inference (z.infer<typeof schema>)
|
||||
- [ ] QRecord type used for all record data
|
||||
- [ ] Field rendering switches on QFieldType enum (exhaustive)
|
||||
- [ ] Adornment rendering switches on AdornmentType enum
|
||||
- [ ] No `any` in form submission handlers
|
||||
@@ -0,0 +1,63 @@
|
||||
# Review Checklist: Package 4 — Process Execution
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 5.2.4: Standalone process at /app/{processName}
|
||||
- [ ] Section 5.2.4: Table-scoped process with recordIds
|
||||
- [ ] Section 5.2.4: Table-scoped process with filterJSON
|
||||
- [ ] Section 5.3.4: Step wizard with stepper UI
|
||||
- [ ] Section 5.3.4: All 14 component types rendered
|
||||
- [ ] Section 5.3.4: Validation review screen
|
||||
- [ ] Section 5.3.4: Bulk load workflows (file mapping, value mapping, profiles)
|
||||
- [ ] Section 5.3.4: Process results summary
|
||||
- [ ] Section 5.3.4: Async job progress display
|
||||
- [ ] Section 3.5.1: POST /processes/{p}/init
|
||||
- [ ] Section 3.5.2: POST /processes/{p}/{uuid}/step/{stepName}
|
||||
- [ ] Section 3.5.3: GET /processes/{p}/{uuid}/status/{jobUUID}
|
||||
- [ ] Section 3.5.4: GET /processes/{p}/{uuid}/records
|
||||
- [ ] Section 3.5.5: GET /processes/{p}/{uuid}/cancel
|
||||
|
||||
### Integration Check
|
||||
- [ ] Form components reused from Package 3 (EntityForm, DynamicFormField)
|
||||
- [ ] Possible values autocomplete from Package 3
|
||||
- [ ] Process can be launched from Record Query toolbar (Package 2)
|
||||
- [ ] Process can be launched from Record View action menu (Package 3)
|
||||
- [ ] Process results link back to affected records (Package 3 routes)
|
||||
- [ ] Widget component type delegates to Package 5
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] POST /processes/{p}/init — multipart/form-data with values, recordsParam, file
|
||||
- [ ] POST /processes/{p}/{uuid}/step/{s} — multipart/form-data with values, file
|
||||
- [ ] GET /processes/{p}/{uuid}/status/{j} — returns QJobRunning | QJobComplete | QJobError
|
||||
- [ ] GET /processes/{p}/{uuid}/records — query params skip, limit
|
||||
- [ ] GET /processes/{p}/{uuid}/cancel — returns boolean
|
||||
- [ ] Exponential backoff polling: 1.5s initial, 1.5x multiplier, 12s max
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Steps rendered from QFrontendStepMetaData.components
|
||||
- [ ] Form fields from step.formFields metadata
|
||||
- [ ] View fields from step.viewFields metadata
|
||||
- [ ] Process label from metadata (not process name)
|
||||
- [ ] ProcessMetaDataAdjustment handled (step list and field updates)
|
||||
- [ ] minInputRecords/maxInputRecords enforced
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Full-screen steps on mobile
|
||||
- [ ] Stepper horizontal scrollable on mobile
|
||||
- [ ] Bottom-anchored navigation buttons on mobile
|
||||
- [ ] File upload drop zone works on touch devices
|
||||
- [ ] Progress display readable at all breakpoints
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] Stepper has aria-label and step indicators
|
||||
- [ ] Current step has aria-current="step"
|
||||
- [ ] Progress bar has role="progressbar" with aria-valuenow/min/max
|
||||
- [ ] Cancel button has confirmation dialog
|
||||
- [ ] Form steps use accessible form patterns from Package 3
|
||||
- [ ] Error messages linked to relevant fields via aria-describedby
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] Process response union type properly discriminated (QJobStarted | QJobComplete | QJobError)
|
||||
- [ ] ProcessMetaDataAdjustment typed
|
||||
- [ ] Step component type discriminated on QComponentType enum
|
||||
- [ ] File upload typed with File/FormData
|
||||
- [ ] No `any` in process state management
|
||||
@@ -0,0 +1,57 @@
|
||||
# Review Checklist: Package 5 — Dashboard and Widgets
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 5.2.2: App Home page with dashboard layout
|
||||
- [ ] Section 5.3.6: Widget container (label, reload, export, dropdown, help)
|
||||
- [ ] Section 5.3.6: All chart types (line, bar, horizontal bar, stacked bar, pie)
|
||||
- [ ] Section 5.3.6: Statistics cards (mini, multi, standard)
|
||||
- [ ] Section 5.3.6: All block types (text, bigNumber, upOrDown, progress, button, icon, image, audio, divider, input)
|
||||
- [ ] Section 5.3.6: Record grid widget with CRUD
|
||||
- [ ] Section 5.3.6: Composite and parent widgets
|
||||
- [ ] Section 5.3.6: Widget dropdown selections with possible values
|
||||
- [ ] Section 5.3.6: Specialized widgets (pivot table, CRON, script viewer, custom component)
|
||||
- [ ] Section 3.6: GET /widget/{widgetName} with dynamic params
|
||||
|
||||
### Integration Check
|
||||
- [ ] DataGrid reused from Package 2 for record grid widget
|
||||
- [ ] EntityForm reused from Package 3 for dynamic form widget
|
||||
- [ ] Widget sections on Record View (Package 3) can render these widgets
|
||||
- [ ] Process step type WIDGET delegates to widget container
|
||||
- [ ] App Home accessible via sidebar navigation (Package 1)
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] GET /qqq/v1/widget/{widgetName} — correct path with dynamic query params
|
||||
- [ ] Dropdown selection values passed as query params
|
||||
- [ ] Request deduplication: abort previous request on dropdown change
|
||||
- [ ] Widget data response handled polymorphically per widget type
|
||||
- [ ] Error responses show per-widget error (not crash entire page)
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Widget list from QAppMetaData.widgets (no hardcoded widget names)
|
||||
- [ ] Widget grid layout from QWidgetMetaData.gridColumns
|
||||
- [ ] Dropdown options from QWidgetMetaData.dropdowns + possibleValues API
|
||||
- [ ] Widget label from metadata
|
||||
- [ ] showReloadButton/showExportButton from metadata
|
||||
- [ ] Help content from metadata
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Widget grid: 1-column on mobile, 2-column on tablet, N-column on desktop
|
||||
- [ ] Charts resize responsively (ResponsiveContainer)
|
||||
- [ ] Statistics cards stack vertically on mobile
|
||||
- [ ] Record grid widget scrolls horizontally on small screens
|
||||
- [ ] Widget dropdowns usable on touch devices
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] Charts have aria-label with data summary
|
||||
- [ ] Statistics cards have descriptive text (not just numbers)
|
||||
- [ ] Reload/export buttons have aria-label
|
||||
- [ ] Widget container has role="region" with aria-label
|
||||
- [ ] Audio player has accessible controls
|
||||
- [ ] Image blocks have alt text
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] Widget data typed per widget type (no raw `any` for response)
|
||||
- [ ] Block type discriminated union
|
||||
- [ ] Chart dataset typed
|
||||
- [ ] Record grid widget uses QRecord[] type
|
||||
- [ ] Dropdown values typed as QPossibleValue[]
|
||||
@@ -0,0 +1,64 @@
|
||||
# Review Checklist: Package 6 — Polish, Parity, and Production Readiness
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 5.4: Responsive design at all 3 breakpoints for all pages
|
||||
- [ ] Section 5.1.4: WCAG 2.1 AA accessibility compliance
|
||||
- [ ] Section 5.6.1: Performance targets (FCP <1.5s, TTI <3s, LCP <2.5s)
|
||||
- [ ] Section 5.6.2: Caching strategy implemented per endpoint
|
||||
- [ ] Section 5.6.3: Code splitting, lazy loading, virtual scrolling
|
||||
- [ ] Section 5.7.1: Storybook with all component stories
|
||||
- [ ] Section 5.7.2: Playwright E2E test suite for critical flows
|
||||
- [ ] Section 5.7.3: Build pipeline with lint/format enforcement
|
||||
- [ ] Section 5.7.4: Strict TypeScript, no `any` in props
|
||||
- [ ] Section 5.5.3: Session timeout UX with state preservation
|
||||
- [ ] Section 5.1.5: Theme tokens (60+ CSS custom properties)
|
||||
- [ ] Section 5.1.5: customCss injection via data-qqq-id selectors
|
||||
- [ ] Command palette (Cmd+K) implemented
|
||||
- [ ] Keyboard shortcuts for all pages
|
||||
- [ ] Audit trail on Record View
|
||||
- [ ] Developer tools pages (table dev, record dev, script editor)
|
||||
- [ ] Report execution pages
|
||||
|
||||
### Integration Check
|
||||
- [ ] All Package 1-5 components work together end-to-end
|
||||
- [ ] Command palette searches across all metadata (tables, processes, reports)
|
||||
- [ ] Keyboard shortcuts don't conflict across pages
|
||||
- [ ] Developer view routes render within dashboard layout
|
||||
- [ ] Report pages reuse ProcessRun from Package 4
|
||||
- [ ] Theme changes propagate to all components
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] Developer endpoints: GET /data/{t}/{id}/developer
|
||||
- [ ] Script endpoints: POST .../associatedScript/{f}, GET .../logs, POST .../test
|
||||
- [ ] Audit endpoints integrated (if available)
|
||||
- [ ] All API calls use correct v1 paths
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Command palette items from metadata (not hardcoded)
|
||||
- [ ] Developer view shows metadata structure dynamically
|
||||
- [ ] Theme tokens from QThemeMetaData
|
||||
- [ ] customCss loaded from backend and injected
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Mobile card view for Record Query verified functional
|
||||
- [ ] All forms single-column on mobile
|
||||
- [ ] Off-canvas sidebar drawer on mobile
|
||||
- [ ] Command palette fullscreen on mobile
|
||||
- [ ] Touch targets ≥ 44px verified across all pages
|
||||
- [ ] No horizontal overflow at any breakpoint
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] axe-core CI integration with zero violations
|
||||
- [ ] Skip-to-content link present
|
||||
- [ ] Focus trap in all modals/dialogs
|
||||
- [ ] aria-live regions for toasts and loading states
|
||||
- [ ] Color contrast ≥ 4.5:1 (normal) and ≥ 3:1 (large)
|
||||
- [ ] All keyboard shortcuts have visible reference
|
||||
- [ ] Screen reader tested for critical flows
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] Zero TypeScript errors in `pnpm tsc --noEmit`
|
||||
- [ ] No `any` types in any component props across all packages
|
||||
- [ ] All API responses validated at boundary (Zod or equivalent)
|
||||
- [ ] All event handlers properly typed
|
||||
- [ ] Exhaustive switch statements for all enum types
|
||||
@@ -0,0 +1,559 @@
|
||||
# Semantic API Contract v1.0
|
||||
|
||||
## 3.1 Overview
|
||||
|
||||
This section formalizes the implicit QQQ REST API into an explicit, versioned contract. Any frontend implementation (not just qfmd) can be built against this specification.
|
||||
|
||||
**Version:** `v1.0`
|
||||
**Base Path:** `/qqq/v1/`
|
||||
**Content-Type:** `application/json` (responses), `multipart/form-data` (process/CRUD requests with file uploads)
|
||||
**Authentication:** Cookie-based session (`sessionUUID` cookie) for all secured endpoints
|
||||
|
||||
## 3.2 Authentication Endpoints
|
||||
|
||||
### 3.2.1 Get Authentication Metadata
|
||||
|
||||
```
|
||||
GET /qqq/v1/metaData/authentication
|
||||
Authentication: None (public)
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"name": "string",
|
||||
"type": "AUTH_0 | OAUTH2 | FULLY_ANONYMOUS | MOCK",
|
||||
"values": {
|
||||
"clientId": "string",
|
||||
"baseUrl": "string",
|
||||
"audience": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2.2 Create/Manage Session
|
||||
|
||||
```
|
||||
POST /qqq/v1/manageSession
|
||||
Authentication: None (public)
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `accessToken` | string | Yes | OAuth2/OIDC access token |
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"uuid": "string",
|
||||
"values": { }
|
||||
}
|
||||
```
|
||||
Sets `sessionUUID` cookie with the session UUID.
|
||||
|
||||
### 3.2.3 Logout
|
||||
|
||||
```
|
||||
POST /qqq/v1/logout
|
||||
Authentication: Required (sessionUUID cookie)
|
||||
```
|
||||
|
||||
**Response 200:** Empty body. Removes `sessionUUID` cookie.
|
||||
|
||||
### 3.2.4 OIDC Back-Channel Logout
|
||||
|
||||
```
|
||||
POST /qqq/v1/oidc/backchannel-logout
|
||||
Authentication: None (JWT in body)
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `logout_token` | string | Yes | JWT from Identity Provider containing `sub` or `sid` claims |
|
||||
|
||||
**Response 200:** Empty body.
|
||||
|
||||
## 3.3 Metadata Endpoints
|
||||
|
||||
### 3.3.1 Application Metadata
|
||||
|
||||
```
|
||||
GET /qqq/v1/metaData
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `frontendName` | string | No | Frontend identifier (e.g., "qqq-frontend-material-dashboard") |
|
||||
| `frontendVersion` | string | No | Frontend version string |
|
||||
| `applicationName` | string | No | Application instance name |
|
||||
| `applicationVersion` | string | No | Application version |
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"apps": {
|
||||
"{appName}": {
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"children": ["QAppTreeNode"],
|
||||
"iconName": "string",
|
||||
"widgets": ["string"],
|
||||
"sections": [{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"icon": { "name": "string", "path": "string", "color": "string" },
|
||||
"tables": ["string"],
|
||||
"processes": ["string"],
|
||||
"reports": ["string"]
|
||||
}]
|
||||
}
|
||||
},
|
||||
"appTree": [{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"type": "TABLE | PROCESS | REPORT | APP",
|
||||
"children": ["QAppTreeNode"],
|
||||
"iconName": "string",
|
||||
"icon": { "name": "string", "path": "string", "color": "string" }
|
||||
}],
|
||||
"tables": {
|
||||
"{tableName}": {
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"isHidden": "boolean",
|
||||
"primaryKeyField": "string",
|
||||
"iconName": "string",
|
||||
"capabilities": ["TABLE_QUERY", "TABLE_GET", "TABLE_COUNT", "TABLE_INSERT", "TABLE_UPDATE", "TABLE_DELETE"],
|
||||
"readPermission": "boolean",
|
||||
"insertPermission": "boolean",
|
||||
"editPermission": "boolean",
|
||||
"deletePermission": "boolean"
|
||||
}
|
||||
},
|
||||
"processes": {
|
||||
"{processName}": {
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"tableName": "string",
|
||||
"isHidden": "boolean",
|
||||
"iconName": "string",
|
||||
"hasPermission": "boolean"
|
||||
}
|
||||
},
|
||||
"reports": { },
|
||||
"widgets": { },
|
||||
"branding": {
|
||||
"companyName": "string",
|
||||
"companyUrl": "string",
|
||||
"appName": "string",
|
||||
"logo": "string",
|
||||
"icon": "string",
|
||||
"accentColor": "string",
|
||||
"banners": { }
|
||||
},
|
||||
"helpContents": { },
|
||||
"environmentValues": { }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3.2 Table Metadata
|
||||
|
||||
```
|
||||
GET /qqq/v1/metaData/table/{tableName}
|
||||
Authentication: Required + READ permission on table
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"isHidden": "boolean",
|
||||
"primaryKeyField": "string",
|
||||
"fields": {
|
||||
"{fieldName}": {
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"type": "STRING | INTEGER | LONG | DECIMAL | BOOLEAN | DATE | TIME | DATE_TIME | TEXT | HTML | PASSWORD | BLOB",
|
||||
"isRequired": "boolean",
|
||||
"isEditable": "boolean",
|
||||
"isHeavy": "boolean",
|
||||
"isHidden": "boolean",
|
||||
"defaultValue": "any",
|
||||
"possibleValueSourceName": "string",
|
||||
"displayFormat": "string",
|
||||
"maxLength": "number",
|
||||
"gridColumns": "number",
|
||||
"adornments": [{
|
||||
"type": "LINK | CHIP | SIZE | ERROR | RENDER_HTML | REVEAL | CODE_EDITOR | FILE_DOWNLOAD | FILE_UPLOAD | TOOLTIP",
|
||||
"values": { }
|
||||
}],
|
||||
"helpContents": [],
|
||||
"behaviors": []
|
||||
}
|
||||
},
|
||||
"sections": [{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"tier": "string",
|
||||
"iconName": "string",
|
||||
"fieldNames": ["string"],
|
||||
"widgetName": "string",
|
||||
"isHidden": "boolean",
|
||||
"gridColumns": "number"
|
||||
}],
|
||||
"exposedJoins": [{
|
||||
"label": "string",
|
||||
"isMany": "boolean",
|
||||
"joinTable": "QTableMetaData",
|
||||
"joinPath": [{ "name": "string", "type": "ONE_TO_ONE | ONE_TO_MANY | MANY_TO_ONE", "leftTable": "string", "rightTable": "string" }]
|
||||
}],
|
||||
"capabilities": ["string"],
|
||||
"readPermission": "boolean",
|
||||
"insertPermission": "boolean",
|
||||
"editPermission": "boolean",
|
||||
"deletePermission": "boolean",
|
||||
"usesVariants": "boolean",
|
||||
"variantTableLabel": "string",
|
||||
"helpContent": { },
|
||||
"supplementalTableMetaData": { },
|
||||
"shareableTableMetaData": { }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3.3 Process Metadata
|
||||
|
||||
```
|
||||
GET /qqq/v1/metaData/process/{processName}
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"tableName": "string",
|
||||
"isHidden": "boolean",
|
||||
"iconName": "string",
|
||||
"hasPermission": "boolean",
|
||||
"stepFlow": "LINEAR",
|
||||
"minInputRecords": "number",
|
||||
"maxInputRecords": "number",
|
||||
"frontendSteps": [{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"format": "string",
|
||||
"components": [{
|
||||
"type": "HELP_TEXT | BULK_EDIT_FORM | BULK_LOAD_FILE_MAPPING_FORM | BULK_LOAD_VALUE_MAPPING_FORM | BULK_LOAD_PROFILE_FORM | VALIDATION_REVIEW_SCREEN | EDIT_FORM | VIEW_FORM | DOWNLOAD_FORM | RECORD_LIST | PROCESS_SUMMARY_RESULTS | GOOGLE_DRIVE_SELECT_FOLDER | WIDGET | HTML",
|
||||
"values": { }
|
||||
}],
|
||||
"formFields": ["QFieldMetaData"],
|
||||
"viewFields": ["QFieldMetaData"],
|
||||
"recordListFields": ["QFieldMetaData"],
|
||||
"helpContents": []
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## 3.4 Table Data Endpoints
|
||||
|
||||
### 3.4.1 Query Records
|
||||
|
||||
```
|
||||
POST /qqq/v1/table/{tableName}/query
|
||||
Authentication: Required + READ permission
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"filter": {
|
||||
"criteria": [{
|
||||
"fieldName": "string",
|
||||
"operator": "EQUALS | NOT_EQUALS | NOT_EQUALS_OR_IS_NULL | IN | NOT_IN | STARTS_WITH | ENDS_WITH | CONTAINS | NOT_STARTS_WITH | NOT_ENDS_WITH | NOT_CONTAINS | LESS_THAN | LESS_THAN_OR_EQUALS | GREATER_THAN | GREATER_THAN_OR_EQUALS | IS_BLANK | IS_NOT_BLANK | BETWEEN | NOT_BETWEEN",
|
||||
"values": ["any"],
|
||||
"otherFieldName": "string"
|
||||
}],
|
||||
"orderBys": [{
|
||||
"fieldName": "string",
|
||||
"isAscending": "boolean"
|
||||
}],
|
||||
"subFilters": ["QQueryFilter"],
|
||||
"booleanOperator": "AND | OR",
|
||||
"skip": "number",
|
||||
"limit": "number"
|
||||
},
|
||||
"joins": [{
|
||||
"joinTable": "string",
|
||||
"select": "boolean",
|
||||
"type": "INNER | LEFT | RIGHT | FULL",
|
||||
"baseTableOrAlias": "string",
|
||||
"alias": "string",
|
||||
"joinName": "string"
|
||||
}],
|
||||
"tableVariant": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"records": [{
|
||||
"tableName": "string",
|
||||
"recordLabel": "string",
|
||||
"values": { "{fieldName}": "any" },
|
||||
"displayValues": { "{fieldName}": "string" },
|
||||
"errors": ["string"],
|
||||
"warnings": ["string"]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4.2 Count Records
|
||||
|
||||
```
|
||||
POST /qqq/v1/table/{tableName}/count
|
||||
Authentication: Required + READ permission
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `includeDistinct` | boolean | No | Whether to include distinct count |
|
||||
|
||||
**Request Body:** Same filter/joins structure as query.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"count": "number",
|
||||
"distinctCount": "number"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4.3 Get Single Record (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
GET /qqq/v1/table/{tableName}/{primaryKey}
|
||||
Authentication: Required + READ permission
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `tableVariant` | string | No | Table variant identifier |
|
||||
| `includeAssociations` | boolean | No | Include associated records |
|
||||
| `queryJoins` | string (JSON) | No | Join specifications |
|
||||
|
||||
**Response 200:** `QRecord`
|
||||
|
||||
### 3.4.4 Insert Record (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
POST /qqq/v1/table/{tableName}
|
||||
Authentication: Required + INSERT permission
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:** Form fields matching table field names.
|
||||
|
||||
**Response 200:** `QRecord` (inserted record with generated ID)
|
||||
|
||||
### 3.4.5 Update Record (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
PUT /qqq/v1/table/{tableName}/{primaryKey}
|
||||
Authentication: Required + EDIT permission
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:** Form fields with updated values.
|
||||
|
||||
**Response 200:** `QRecord` (updated record)
|
||||
|
||||
### 3.4.6 Delete Record (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
DELETE /qqq/v1/table/{tableName}/{primaryKey}
|
||||
Authentication: Required + DELETE permission
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"deletedCount": "number"
|
||||
}
|
||||
```
|
||||
|
||||
## 3.5 Process Endpoints
|
||||
|
||||
### 3.5.1 Initialize Process
|
||||
|
||||
```
|
||||
POST /qqq/v1/processes/{processName}/init
|
||||
Authentication: Required
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `values` | string (JSON) | No | Initial process values |
|
||||
| `recordsParam` | string | No | One of: `recordIds`, `filterJSON`, `filterId` |
|
||||
| `recordIds` | string | Conditional | Comma-separated primary keys (when recordsParam=recordIds) |
|
||||
| `filterJSON` | string | Conditional | QQueryFilter JSON (when recordsParam=filterJSON) |
|
||||
| `stepTimeoutMillis` | number | No | Async timeout in ms (default: 3000) |
|
||||
| `file` | binary | No | File upload |
|
||||
|
||||
**Response 200:** `QJobStarted | QJobComplete | QJobError` (see union type in Section 2.1)
|
||||
|
||||
### 3.5.2 Process Step
|
||||
|
||||
```
|
||||
POST /qqq/v1/processes/{processName}/{processUUID}/step/{stepName}
|
||||
Authentication: Required
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `values` | string (JSON) | No | Step form values |
|
||||
| `stepTimeoutMillis` | number | No | Async timeout in ms (default: 3000) |
|
||||
| `file` | binary | No | File upload for this step |
|
||||
|
||||
**Response 200:** `QJobStarted | QJobComplete | QJobError`
|
||||
|
||||
### 3.5.3 Process Status (Poll)
|
||||
|
||||
```
|
||||
GET /qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Response 200:** `QJobRunning | QJobComplete | QJobError`
|
||||
|
||||
### 3.5.4 Process Records (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
GET /qqq/v1/processes/{processName}/{processUUID}/records
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `skip` | number | No | Records to skip |
|
||||
| `limit` | number | No | Max records to return |
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"totalRecords": "number",
|
||||
"records": ["QRecord"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5.5 Cancel Process (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
GET /qqq/v1/processes/{processName}/{processUUID}/cancel
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Response 200:** `boolean`
|
||||
|
||||
## 3.6 Widget Endpoints (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
GET /qqq/v1/widget/{widgetName}
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Query Parameters:** Dynamic, widget-specific key-value pairs.
|
||||
|
||||
**Response 200:** Widget-specific JSON payload. Shape varies by widget type (chart data, record grids, statistics, HTML content, etc.).
|
||||
|
||||
## 3.7 Possible Values Endpoints (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
POST /qqq/v1/table/{tableName}/possibleValues/{fieldName}
|
||||
POST /qqq/v1/processes/{processName}/possibleValues/{fieldName}
|
||||
POST /qqq/v1/possibleValues/{fieldName}
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `searchTerm` | string | No | Text search filter |
|
||||
| `ids` | string | No | Specific IDs to look up |
|
||||
| `labels` | string | No | Specific labels to look up |
|
||||
| `values` | string | No | Pre-selected values |
|
||||
| `useCase` | string | No | Context hint for filtering |
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
[{
|
||||
"id": "number | string",
|
||||
"label": "string"
|
||||
}]
|
||||
```
|
||||
|
||||
## 3.8 Error Response Shape
|
||||
|
||||
All error responses follow a consistent shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status Codes:**
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 200 | Success |
|
||||
| 401 | Unauthorized (no valid session) |
|
||||
| 403 | Forbidden (insufficient permissions) |
|
||||
| 404 | Not Found (table, process, or record does not exist) |
|
||||
| 500 | Internal Server Error |
|
||||
|
||||
## 3.9 Pagination Contract
|
||||
|
||||
Based on analysis of `QQueryFilter` and `TableQuerySpecV1`:
|
||||
|
||||
The pagination model is **offset/limit** based:
|
||||
- `filter.skip` (number): Number of records to skip (offset). Default: 0.
|
||||
- `filter.limit` (number): Maximum number of records to return. No enforced maximum from the API; the frontend typically defaults to 10, 25, 50, or 100.
|
||||
|
||||
There is no cursor-based pagination. The frontend manages page state by computing `skip = (pageNumber - 1) * pageSize` and sending it with each query. Total count is obtained via a separate `/count` call.
|
||||
|
||||
## 3.10 Filtering Contract
|
||||
|
||||
Based on analysis of `QQueryFilter`, `QFilterCriteria`, and `QCriteriaOperator`:
|
||||
|
||||
Filters are tree-structured with boolean operators:
|
||||
- Top-level `QQueryFilter` contains `criteria[]`, `orderBys[]`, and optional `subFilters[]`
|
||||
- `booleanOperator` (`AND` | `OR`) controls how criteria are combined
|
||||
- `subFilters` allow nested boolean logic (e.g., `(A AND B) OR (C AND D)`)
|
||||
- Each `QFilterCriteria` specifies `fieldName`, `operator`, and `values[]`
|
||||
- Special expression types are supported in values: `FilterVariableExpression`, `NowExpression`, `NowWithOffsetExpression`, `ThisOrLastPeriodExpression`
|
||||
|
||||
## 3.11 Backwards Compatibility Policy
|
||||
|
||||
Per the QQQ Semantic Versioning Policy (from wiki):
|
||||
|
||||
- **Public API surfaces** subject to SemVer include REST endpoints, request/response shapes, query parameter names, and HTTP status codes.
|
||||
- **PATCH versions** may include bug fixes, security patches, and performance optimizations without changing the API contract.
|
||||
- **MINOR versions** may add new optional fields to responses, new optional query parameters, new endpoints, and new enum values — all backward-compatible additions.
|
||||
- **MAJOR versions** may include breaking changes: removing endpoints, changing required parameters, altering response shapes, or removing enum values.
|
||||
- **Deprecation policy**: Two-version deprecation cycle. Deprecated features are annotated and kept available for at least one major version before removal.
|
||||
- **Recommendation**: The v1 API contract defined in this document should be frozen once formalized. New capabilities should be added as backward-compatible extensions (new optional fields, new endpoints). Breaking changes require a v2 contract.
|
||||
@@ -0,0 +1,879 @@
|
||||
# QQQ Admin UI Coding Conventions
|
||||
|
||||
This document establishes the foundational coding standards for the QQQ Admin UI modernization. All implementation agents must follow these conventions strictly to ensure consistency, maintainability, and compatibility across the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Server Components vs Client Components
|
||||
|
||||
**Context:** The new UI will use Next.js App Router, which supports both Server Components (default) and Client Components.
|
||||
|
||||
### Server Components (Default in App Router)
|
||||
|
||||
Use Server Components for:
|
||||
- Route pages (`page.tsx` files) that fetch metadata, layout wrappers, and static content
|
||||
- Components that access server-side resources (databases, private APIs, files)
|
||||
- Authentication checks and permission gating
|
||||
- Data that doesn't require client-side interactivity
|
||||
|
||||
**Characteristics:**
|
||||
- No JavaScript shipped to client for these components
|
||||
- Can directly query databases, call private APIs, and access secrets
|
||||
- Cannot use hooks (useState, useEffect, useContext)
|
||||
- Cannot use browser APIs (window, localStorage, DOM events)
|
||||
- Reduces initial bundle size and improves Time to Interactive
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// app/records/page.tsx - Server Component
|
||||
import { fetchMetadata, fetchRecords } from "@/lib/api";
|
||||
import RecordGrid from "@/components/RecordGrid";
|
||||
|
||||
export default async function RecordsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { table: string };
|
||||
}) {
|
||||
const tableMetadata = await fetchMetadata(searchParams.table);
|
||||
const records = await fetchRecords(searchParams.table);
|
||||
|
||||
return <RecordGrid metadata={tableMetadata} records={records} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Client Components (`"use client"`)
|
||||
|
||||
Use Client Components for:
|
||||
- Any component with `useState`, `useEffect`, `useReducer`, or other hooks
|
||||
- Any component with `onClick`, `onChange`, form handlers, or user events
|
||||
- Components using `useContext`, `useQuery`, `useMutation`, or custom hooks
|
||||
- Data grids, forms, modals, dropdowns—any interactive UI
|
||||
|
||||
**Characteristics:**
|
||||
- Include `"use client"` directive at top of file
|
||||
- Ship JavaScript to client (included in bundle)
|
||||
- Can use React hooks and browser APIs
|
||||
- Can subscribe to context and use custom hooks
|
||||
- Most QQQ components will be Client Components
|
||||
|
||||
**Boundary Pattern:**
|
||||
```typescript
|
||||
// page.tsx - Server Component fetches data
|
||||
export default async function Page({ params }) {
|
||||
const data = await fetchServerData(params.id);
|
||||
return <ClientComponent initialData={data} />;
|
||||
}
|
||||
|
||||
// components/ClientComponent.tsx - Client Component handles interaction
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ClientComponent({ initialData }) {
|
||||
const [state, setState] = useState(initialData);
|
||||
return <div onClick={() => setState(...)}>{state}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Decision Tree:**
|
||||
- Does this component use hooks (useState, useEffect, useContext)? → Client Component
|
||||
- Does this component have onClick, onChange, or other event handlers? → Client Component
|
||||
- Is this a layout wrapper or data-fetching shell? → Consider Server Component
|
||||
- Does this component render other Client Components? → Can be Server Component
|
||||
- Does this component need browser APIs? → Client Component
|
||||
|
||||
---
|
||||
|
||||
## State Management Approach
|
||||
|
||||
QQQ's state management uses multiple complementary patterns depending on scope and lifecycle.
|
||||
|
||||
### TanStack Query for Server State
|
||||
|
||||
Use **TanStack Query** (`react-query` or `@tanstack/react-query`) for all server-side data:
|
||||
- API responses (metadata, records, counts)
|
||||
- Asynchronous data fetching with loading/error states
|
||||
- Automatic caching and invalidation
|
||||
- Optimistic updates
|
||||
- Background refetching
|
||||
|
||||
**Read Data Pattern (useQuery):**
|
||||
```typescript
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
|
||||
export default function RecordList({ tableName }: { tableName: string }) {
|
||||
const { data: records, isLoading, error } = useQuery({
|
||||
queryKey: ["table", tableName, "query"],
|
||||
queryFn: () => api.query(tableName, filter),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
if (error) return <Error error={error} />;
|
||||
return <Grid records={records} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Mutate Data Pattern (useMutation):**
|
||||
```typescript
|
||||
"use client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
|
||||
export default function CreateButton() {
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values) => api.create(tableName, values),
|
||||
onSuccess: (newRecord) => {
|
||||
// Invalidate list to trigger refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["table", tableName, "query"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<button onClick={() => createMutation.mutate(formValues)}>
|
||||
{createMutation.isPending ? "Saving..." : "Create"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
```typescript
|
||||
// lib/queryClient.ts
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### React Context for Global UI State
|
||||
|
||||
Use **React Context** for application-wide UI state that doesn't need server persistence:
|
||||
- Current theme (light/dark mode)
|
||||
- Sidebar open/closed state
|
||||
- Modal stack (which modals are open)
|
||||
- Authenticated user info
|
||||
- UI layout preferences (density, sidebar width)
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// context/ThemeContext.tsx
|
||||
"use client";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
theme: "light" | "dark";
|
||||
setTheme: (t: "light" | "dark") => void;
|
||||
} | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
```
|
||||
|
||||
### React Hook Form for Form State
|
||||
|
||||
Use **React Hook Form** for all form state management:
|
||||
- Field values, dirty tracking, validation state
|
||||
- Integrates with Zod schemas for validation
|
||||
- Minimal re-renders
|
||||
- Efficient field-level updates
|
||||
- Built-in submit handling
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
"use client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name required"),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export default function RecordForm({ initialValues }) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
watch,
|
||||
} = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register("name")} />
|
||||
{errors.name && <span>{errors.name.message}</span>}
|
||||
<button disabled={!isDirty}>Save</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### URL SearchParams for Shareable State
|
||||
|
||||
Use **URL search parameters** for state that should be shareable and bookmarkable:
|
||||
- Table filters, sort order, pagination
|
||||
- View/variant selection
|
||||
- Record ID (current record being viewed)
|
||||
- Tab/section selection
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function RecordTable() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const page = parseInt(searchParams.get("page") ?? "1");
|
||||
const filter = searchParams.get("filter");
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("sort", field);
|
||||
params.set("page", "1"); // Reset to page 1
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
return <Grid onSort={handleSort} />;
|
||||
}
|
||||
```
|
||||
|
||||
### localStorage for User Preferences
|
||||
|
||||
Use **localStorage** for persistent user preferences (not synced to server):
|
||||
- Column widths and ordering
|
||||
- UI density (compact/normal/spacious)
|
||||
- Sidebar width
|
||||
- Last selected view per table
|
||||
- Expanded/collapsed section preferences
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "qqq-table-column-widths";
|
||||
|
||||
export function useColumnWidths(tableId: string) {
|
||||
const [widths, setWidths] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// Hydrate from localStorage
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const all = JSON.parse(stored);
|
||||
setWidths(all[tableId] ?? {});
|
||||
}
|
||||
}, [tableId]);
|
||||
|
||||
const updateWidth = (fieldName: string, width: number) => {
|
||||
const updated = { ...widths, [fieldName]: width };
|
||||
setWidths(updated);
|
||||
|
||||
// Persist to localStorage
|
||||
const all = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
|
||||
all[tableId] = updated;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
};
|
||||
|
||||
return { widths, updateWidth };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### API Errors: TanStack Query Error States
|
||||
|
||||
Errors from API calls are automatically caught by TanStack Query. Always display feedback:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export default function RecordList({ tableName }) {
|
||||
const { data, isLoading, error, isError } = useQuery({
|
||||
queryKey: ["table", tableName],
|
||||
queryFn: () => api.query(tableName, {}),
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Failed to load records: {error instanceof Error ? error.message : "Unknown error"}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <Skeleton />;
|
||||
return <Grid records={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Form Validation: Zod + React Hook Form
|
||||
|
||||
Validation errors are tied to fields and displayed inline:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
email: z.string().email("Invalid email"),
|
||||
age: z.number().min(18, "Must be 18+"),
|
||||
});
|
||||
|
||||
export default function Form() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
} = useForm({ resolver: zodResolver(schema) });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Input
|
||||
{...register("name")}
|
||||
placeholder="Name"
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
<Input
|
||||
{...register("email")}
|
||||
placeholder="Email"
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Route Errors: Next.js error.tsx Boundary
|
||||
|
||||
Each route segment can have an `error.tsx` file that creates an error boundary:
|
||||
|
||||
```typescript
|
||||
// app/records/[id]/error.tsx
|
||||
"use client";
|
||||
|
||||
export default function RecordError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold mb-4">Error Loading Record</h2>
|
||||
<p className="text-red-600 mb-4">{error.message}</p>
|
||||
<button onClick={reset} className="px-4 py-2 bg-blue-500 text-white">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Errors: ErrorBoundary Component
|
||||
|
||||
Wrap interactive components that may fail in an error boundary:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary }) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded">
|
||||
<p className="font-bold">Failed to load widget</p>
|
||||
<p className="text-sm text-gray-600">{error.message}</p>
|
||||
<button onClick={resetErrorBoundary} className="mt-2 text-blue-600 underline">
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WidgetContainer() {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<MyWidget />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 401 Handling: Axios Interceptor
|
||||
|
||||
Set up a global interceptor to catch 401 responses and redirect to login:
|
||||
|
||||
```typescript
|
||||
// lib/axiosInstance.ts
|
||||
import axios from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function setupAxios(router: ReturnType<typeof useRouter>) {
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const returnTo = encodeURIComponent(window.location.pathname);
|
||||
router.push(`/login?returnTo=${returnTo}`);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### General Rule
|
||||
|
||||
Never swallow errors silently. Always surface them to the user via one of: Alert component, toast notification, field-level error, or error page.
|
||||
|
||||
---
|
||||
|
||||
## Tailwind Class Ordering Convention
|
||||
|
||||
Follow the official **Tailwind CSS IntelliSense** convention for class ordering:
|
||||
`Position → Display → Sizing → Spacing → Typography → Visual → Animation`
|
||||
|
||||
**Example:**
|
||||
```html
|
||||
<div class="relative flex w-full p-4 text-sm font-medium text-gray-900 bg-white border rounded-lg shadow-sm hover:bg-gray-50 transition-colors">
|
||||
Content
|
||||
</div>
|
||||
```
|
||||
|
||||
**Breakdown:**
|
||||
- `relative` — Position
|
||||
- `flex` — Display
|
||||
- `w-full` — Sizing
|
||||
- `p-4` — Spacing
|
||||
- `text-sm font-medium text-gray-900` — Typography
|
||||
- `bg-white border rounded-lg shadow-sm` — Visual
|
||||
- `hover:bg-gray-50 transition-colors` — Animation/Interaction
|
||||
|
||||
**Multi-line (for long class lists):**
|
||||
```tsx
|
||||
<button
|
||||
className={cn(
|
||||
// Position & Display
|
||||
"relative inline-flex items-center justify-center",
|
||||
// Sizing
|
||||
"w-full px-4 py-2",
|
||||
// Typography
|
||||
"text-sm font-medium text-white",
|
||||
// Visual
|
||||
"bg-blue-600 border border-blue-700 rounded-lg shadow-sm",
|
||||
// Interaction
|
||||
"hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
// Animation
|
||||
"transition-colors duration-200"
|
||||
)}
|
||||
>
|
||||
Click Me
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Requirements
|
||||
|
||||
All QQQ components must meet **WCAG 2.1 AA** standards minimum:
|
||||
|
||||
### Form Inputs and Labels
|
||||
|
||||
- Every form input must have an associated label
|
||||
- Use `htmlFor` to link labels to inputs by ID
|
||||
- For icon-only buttons, use `aria-label`
|
||||
|
||||
```typescript
|
||||
<label htmlFor="name-input">Name:</label>
|
||||
<input id="name-input" type="text" />
|
||||
|
||||
<button aria-label="Delete record" title="Delete record">
|
||||
<TrashIcon />
|
||||
</button>
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
- All interactive elements (buttons, links, inputs) must be reachable via Tab key
|
||||
- Tab order should follow visual left-to-right, top-to-bottom flow
|
||||
- Escape key should close modals
|
||||
- Enter/Space should activate buttons
|
||||
|
||||
```typescript
|
||||
<input type="text" onKeyDown={(e) => e.key === "Enter" && handleSubmit()} />
|
||||
<Modal onEscapePress={closeModal} />
|
||||
```
|
||||
|
||||
### Focus Visibility
|
||||
|
||||
- All interactive elements must have visible focus rings
|
||||
- Use Tailwind's `focus:ring-2 focus:ring-offset-2 focus:ring-blue-500` pattern
|
||||
- Never remove focus rings without providing an alternative
|
||||
|
||||
```html
|
||||
<button class="focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Click
|
||||
</button>
|
||||
```
|
||||
|
||||
### ARIA Attributes
|
||||
|
||||
- Use `aria-label` on icon buttons (e.g., `<button aria-label="Close">×</button>`)
|
||||
- Use `aria-live="polite"` for dynamic content updates
|
||||
- Use `aria-expanded` for collapsible sections
|
||||
- Use `aria-disabled` for disabled elements
|
||||
- Use `aria-hidden="true"` for decorative icons
|
||||
|
||||
```typescript
|
||||
<button
|
||||
aria-label="Toggle sidebar"
|
||||
aria-expanded={sidebarOpen}
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
{message}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Color Contrast
|
||||
|
||||
- Minimum 4.5:1 contrast ratio for normal text (14px or smaller)
|
||||
- Minimum 3:1 contrast ratio for large text (18px+ or 14px+ bold)
|
||||
- Never rely on color alone to convey meaning (always add icon/text)
|
||||
|
||||
Use tools like [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) to verify.
|
||||
|
||||
### Images and Icons
|
||||
|
||||
- All images must have meaningful alt text or `aria-hidden="true"` if decorative
|
||||
- SVG icons should have a title element or aria-label on parent
|
||||
|
||||
```tsx
|
||||
<img src="avatar.jpg" alt="User: John Doe" />
|
||||
<svg aria-label="Star" className="w-5 h-5">
|
||||
<title>Star icon</title>
|
||||
<path d="..." />
|
||||
</svg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## data-qqq-id Naming Convention
|
||||
|
||||
All QQQ components must include `data-qqq-id` attributes for:
|
||||
- Automated testing (selector stability)
|
||||
- Custom CSS injection via `customCss` metadata
|
||||
- Accessibility tooling integration
|
||||
|
||||
**Pattern:** `{component-type}-{metadata-name}`
|
||||
|
||||
Where `{metadata-name}` is the `name` property from the metadata object (not the `label`).
|
||||
|
||||
### Examples by Component Type
|
||||
|
||||
**Navigation:**
|
||||
```html
|
||||
<nav data-qqq-id="sidebar-nav">
|
||||
<button data-qqq-id="sidebar-item-employees">Employees</button>
|
||||
<button data-qqq-id="sidebar-item-projects">Projects</button>
|
||||
</nav>
|
||||
```
|
||||
|
||||
**Fields (Forms and Grids):**
|
||||
```html
|
||||
<label htmlFor="field-email" data-qqq-id="field-label-email">Email</label>
|
||||
<input id="field-email" data-qqq-id="field-email" type="email" />
|
||||
|
||||
<th data-qqq-id="grid-header-email">Email</th>
|
||||
<td data-qqq-id="grid-cell-email">user@example.com</td>
|
||||
```
|
||||
|
||||
**Buttons:**
|
||||
```html
|
||||
<button data-qqq-id="button-create">Create Record</button>
|
||||
<button data-qqq-id="button-save">Save</button>
|
||||
<button data-qqq-id="button-delete">Delete</button>
|
||||
<button data-qqq-id="button-cancel">Cancel</button>
|
||||
```
|
||||
|
||||
**Filters:**
|
||||
```html
|
||||
<div data-qqq-id="filter-row-0">
|
||||
<select data-qqq-id="filter-field-0">Field</select>
|
||||
<select data-qqq-id="filter-operator-0">Operator</select>
|
||||
<input data-qqq-id="filter-value-0" type="text" />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Widgets:**
|
||||
```html
|
||||
<section data-qqq-id="widget-salesChart">
|
||||
<h3 data-qqq-id="widget-label-salesChart">Sales Chart</h3>
|
||||
<canvas data-qqq-id="widget-content-salesChart"></canvas>
|
||||
</section>
|
||||
```
|
||||
|
||||
**General Rule:** If metadata has a `name: "employeeId"`, use `data-qqq-id="field-employeeId"` or `data-qqq-id="grid-header-employeeId"`, never `data-qqq-id="field-Employee ID"` or `data-qqq-id="field-employee_id"`.
|
||||
|
||||
---
|
||||
|
||||
## QQQ Theme Token System
|
||||
|
||||
The application defines **60+ CSS custom properties** in `src/styles/qqq-theme.css` for theming and customization.
|
||||
|
||||
### Core Color Tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Primary & Accent */
|
||||
--qqq-primary-color: #1976d2;
|
||||
--qqq-secondary-color: #9c27b0;
|
||||
--qqq-accent-color: var(--qqq-primary-color); /* From QBrandingMetaData.accentColor */
|
||||
--qqq-success-color: #4caf50;
|
||||
--qqq-warning-color: #ff9800;
|
||||
--qqq-error-color: #f44336;
|
||||
--qqq-info-color: #2196f3;
|
||||
}
|
||||
```
|
||||
|
||||
### Sidebar Tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
--qqq-sidebar-background: #1e293b; /* Dark slate */
|
||||
--qqq-sidebar-text: #e2e8f0; /* Light text */
|
||||
--qqq-sidebar-hover: rgba(255, 255, 255, 0.1);
|
||||
--qqq-sidebar-active: var(--qqq-accent-color);
|
||||
--qqq-sidebar-border: #334155;
|
||||
}
|
||||
```
|
||||
|
||||
### Table/Grid Tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
--qqq-grid-header-bg: #f8fafc; /* Light gray header */
|
||||
--qqq-grid-row-hover: #f1f5f9;
|
||||
--qqq-grid-border: #e2e8f0;
|
||||
--qqq-grid-text: #1e293b;
|
||||
--qqq-grid-text-muted: #64748b;
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Mode Overrides
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--qqq-grid-header-bg: #1e293b;
|
||||
--qqq-grid-row-hover: #334155;
|
||||
--qqq-grid-border: #475569;
|
||||
--qqq-grid-text: #f1f5f9;
|
||||
--qqq-grid-text-muted: #cbd5e1;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Components
|
||||
|
||||
Reference these tokens via CSS custom properties in Tailwind or inline styles:
|
||||
|
||||
```tsx
|
||||
// In Tailwind (configure in tailwind.config.ts)
|
||||
<div className="bg-[var(--qqq-primary-color)] text-[var(--qqq-sidebar-text)]">
|
||||
Content
|
||||
</div>
|
||||
|
||||
// Or in inline styles
|
||||
<div style={{ color: "var(--qqq-accent-color)" }}>
|
||||
Content
|
||||
</div>
|
||||
```
|
||||
|
||||
**Tailwind Config Integration:**
|
||||
```typescript
|
||||
// tailwind.config.ts
|
||||
export default {
|
||||
theme: {
|
||||
colors: {
|
||||
qqq: {
|
||||
primary: "var(--qqq-primary-color)",
|
||||
secondary: "var(--qqq-secondary-color)",
|
||||
accent: "var(--qqq-accent-color)",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Handle QQQ customCss
|
||||
|
||||
QQQ allows backend to inject custom CSS via the `customCss` metadata field. The mechanism:
|
||||
|
||||
1. **Backend provides CSS:** Custom CSS rules are defined targeting `[data-qqq-id="..."]` selectors
|
||||
2. **Frontend injects dynamically:** Create a `<style>` tag in the document head and insert the CSS
|
||||
3. **All components must have data-qqq-id:** Without these identifiers, custom selectors won't match
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// hooks/useCustomCSS.ts
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useCustomCSS(customCss: string | undefined) {
|
||||
useEffect(() => {
|
||||
if (!customCss) return;
|
||||
|
||||
// Create or update style tag
|
||||
let styleTag = document.getElementById("qqq-custom-css");
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement("style");
|
||||
styleTag.id = "qqq-custom-css";
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
|
||||
styleTag.textContent = customCss;
|
||||
|
||||
return () => {
|
||||
// Keep style tag in DOM (don't remove on unmount)
|
||||
// Custom CSS should persist for the session
|
||||
};
|
||||
}, [customCss]);
|
||||
}
|
||||
```
|
||||
|
||||
### Example Backend-Provided CSS
|
||||
|
||||
```css
|
||||
/* From backend customCss field */
|
||||
[data-qqq-id="field-salary"] {
|
||||
font-weight: bold;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
[data-qqq-id="button-delete"] {
|
||||
background-color: #d32f2f !important;
|
||||
}
|
||||
|
||||
[data-qqq-id="grid-header-status"] {
|
||||
background-color: #1976d2 !important;
|
||||
color: white;
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Always include `!important` in custom CSS (to override component styles)
|
||||
- Selectors should use exact `data-qqq-id` matches, not partial/complex selectors
|
||||
- Never allow inline `style` attributes from backend (XSS vulnerability)
|
||||
- Validate custom CSS syntax and length before injection
|
||||
- Log any CSS injection errors to console for debugging
|
||||
|
||||
---
|
||||
|
||||
## Mock API Layer (MSW)
|
||||
|
||||
A full **MSW (Mock Service Worker) v2** mock is implemented at `src/mocks/`. It intercepts all `/qqq/v1/*` requests and returns realistic fixture data, enabling frontend development with no backend required.
|
||||
|
||||
### Activating the Mock
|
||||
|
||||
```bash
|
||||
cp .env.mock .env.local && pnpm dev
|
||||
# OR
|
||||
NEXT_PUBLIC_MOCK_API=true pnpm dev
|
||||
```
|
||||
|
||||
### Mock Data Available
|
||||
|
||||
| App | Table | Records |
|
||||
|-----|-------|---------|
|
||||
| CRM | `person` (People) | 25 |
|
||||
| CRM | `company` (Companies) | 10 |
|
||||
| CRM | `order` (Orders) | 20 |
|
||||
| Inventory | `product` (Products) | 15 |
|
||||
| Inventory | `supplier` (Suppliers) | 8 |
|
||||
|
||||
Auth type is `FULLY_ANONYMOUS` — the app auto-sessions with no login interaction.
|
||||
|
||||
Dashboard widgets: 4 statistics tiles, bar chart, line chart, record grid.
|
||||
|
||||
### Adding Handlers for New Packages
|
||||
|
||||
When a new package introduces new API endpoints (e.g., a new process, a new widget type), **add a corresponding handler** in `src/mocks/handlers/`. The existing handlers show the pattern:
|
||||
|
||||
```
|
||||
src/mocks/handlers/
|
||||
├── auth.ts # /metaData/authentication, /manageSession, /logout
|
||||
├── metadata.ts # /metaData, /metaData/table/:name, /metaData/process/:name
|
||||
├── tables.ts # /table/:name/query|count|:pk (full CRUD, in-memory store)
|
||||
├── processes.ts # /processes/:name/init|step|status|cancel
|
||||
├── widgets.ts # /widget/:widgetName
|
||||
├── possible-values.ts
|
||||
└── index.ts # combines all handlers
|
||||
```
|
||||
|
||||
In-memory mutations (POST/PUT/DELETE) persist within the browser session and reset on reload.
|
||||
|
||||
### Vitest Integration
|
||||
|
||||
The MSW node server is wired into the Vitest setup (`tests/setup.ts`). Unit tests automatically intercept API calls — no real network requests are made.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
These conventions ensure:
|
||||
- **Consistency:** All code follows the same patterns
|
||||
- **Type Safety:** Full TypeScript coverage with strict mode
|
||||
- **Maintainability:** Shared understanding of architecture and patterns
|
||||
- **Accessibility:** WCAG 2.1 AA compliance across all components
|
||||
- **Customizability:** Custom CSS injection, theming, and metadata-driven UI
|
||||
- **Performance:** Strategic use of Server Components, TanStack Query caching, and code splitting
|
||||
|
||||
Every implementation agent must adhere to these standards. Deviations require explicit justification and team consensus.
|
||||
@@ -0,0 +1,563 @@
|
||||
# QQQ TypeScript Type Definitions
|
||||
|
||||
> These types are ported from `qqq-frontend-core/src/model/`. Every implementation agent must use these types exactly as defined.
|
||||
|
||||
## Core Data Types
|
||||
|
||||
### QRecord
|
||||
Single database record with values, display representations, and metadata.
|
||||
```typescript
|
||||
export interface QRecord {
|
||||
tableName: string; // Name of the source table
|
||||
recordLabel: string; // Display-friendly record identifier
|
||||
values: Record<string, any>; // Raw field values keyed by field name
|
||||
displayValues: Record<string, string>; // Formatted/display string representations
|
||||
associatedRecords?: Record<string, QRecord[]>; // Related records from joins
|
||||
errors?: string[]; // Validation errors encountered
|
||||
warnings?: string[]; // Non-fatal warnings
|
||||
}
|
||||
```
|
||||
|
||||
### QPossibleValue
|
||||
Represents a selectable option from a dropdown or autocomplete field.
|
||||
```typescript
|
||||
export interface QPossibleValue {
|
||||
id: number | string; // Unique identifier for the value
|
||||
label: string; // Display label shown to user
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Metadata Types
|
||||
|
||||
### QInstance
|
||||
Top-level metadata container describing the entire application instance.
|
||||
```typescript
|
||||
export interface QInstance {
|
||||
apps: Record<string, QAppMetaData>; // All defined apps keyed by name
|
||||
appTree: QAppTreeNode[]; // Hierarchical navigation tree
|
||||
tables: Record<string, QTableMetaData>; // All tables keyed by name
|
||||
processes: Record<string, QProcessMetaData>; // All processes keyed by name
|
||||
reports: Record<string, any>; // All reports keyed by name
|
||||
widgets: Record<string, QWidgetMetaData>; // All widgets keyed by name
|
||||
branding: QBrandingMetaData; // Application branding and theme
|
||||
helpContents: Record<string, QHelpContent>; // Context-specific help
|
||||
environmentValues: Record<string, string>; // Environment configuration
|
||||
}
|
||||
```
|
||||
|
||||
### QTableMetaData
|
||||
Complete definition of a table, including fields, sections, permissions, and UI hints.
|
||||
```typescript
|
||||
export interface QTableMetaData {
|
||||
name: string; // Unique table identifier
|
||||
label: string; // User-facing table name
|
||||
isHidden: boolean; // Hide from navigation if true
|
||||
primaryKeyField: string; // Name of the primary key field
|
||||
fields: Record<string, QFieldMetaData>; // Field definitions
|
||||
sections: QTableSection[]; // Form/view section organization
|
||||
exposedJoins: QExposedJoin[]; // Relationships to other tables
|
||||
capabilities: Capability[]; // Operations enabled (QUERY, GET, INSERT, UPDATE, DELETE, COUNT)
|
||||
readPermission: boolean; // User can read records
|
||||
insertPermission: boolean; // User can create records
|
||||
editPermission: boolean; // User can modify records
|
||||
deletePermission: boolean; // User can delete records
|
||||
usesVariants: boolean; // Table has multiple views
|
||||
variantTableLabel: string; // Label for variant selector
|
||||
helpContent?: QHelpContent; // Table-level help
|
||||
supplementalTableMetaData?: Record<string, any>; // Additional metadata
|
||||
shareableTableMetaData?: Record<string, any>; // Shareable configuration
|
||||
}
|
||||
```
|
||||
|
||||
### QFieldMetaData
|
||||
Defines a single field within a table, including type, constraints, and display behavior.
|
||||
```typescript
|
||||
export interface QFieldMetaData {
|
||||
name: string; // Field identifier
|
||||
label: string; // User-facing field name
|
||||
type: QFieldType; // Data type (STRING, INTEGER, BOOLEAN, DATE, etc.)
|
||||
isRequired: boolean; // Must have a value
|
||||
isEditable: boolean; // User can modify this field
|
||||
isHeavy: boolean; // Large data; load on demand
|
||||
isHidden: boolean; // Don't display in UI
|
||||
defaultValue?: any; // Initial value for new records
|
||||
possibleValueSourceName?: string; // Reference to dropdown options endpoint
|
||||
displayFormat?: string; // Formatting pattern for display (e.g., "currency", "percentage")
|
||||
maxLength?: number; // Maximum string/text length
|
||||
gridColumns?: number; // Width hint for responsive layouts
|
||||
adornments: FieldAdornment[]; // Visual/interactive enhancements (icons, links, file uploads)
|
||||
helpContents?: QHelpContent[]; // Field-level help
|
||||
behaviors?: string[]; // Special behaviors (e.g., "readonly_after_create")
|
||||
}
|
||||
```
|
||||
|
||||
### QProcessMetaData
|
||||
Definition of a process or workflow with its steps and configuration.
|
||||
```typescript
|
||||
export interface QProcessMetaData {
|
||||
name: string; // Process identifier
|
||||
label: string; // User-facing process name
|
||||
tableName: string; // Associated table name
|
||||
isHidden: boolean; // Hide from navigation
|
||||
iconName: string; // Icon reference for UI
|
||||
hasPermission: boolean; // User can execute this process
|
||||
stepFlow: "LINEAR"; // Step flow type (only LINEAR currently documented)
|
||||
minInputRecords: number; // Minimum records required as input
|
||||
maxInputRecords: number; // Maximum records allowed as input
|
||||
frontendSteps: QFrontendStepMetaData[]; // Step definitions
|
||||
}
|
||||
```
|
||||
|
||||
### QFrontendStepMetaData
|
||||
Describes a single step within a process workflow.
|
||||
```typescript
|
||||
export interface QFrontendStepMetaData {
|
||||
name: string; // Step identifier
|
||||
label: string; // Display name
|
||||
format?: string; // Layout format hint
|
||||
components: QFrontendComponent[]; // UI components to render
|
||||
formFields?: QFieldMetaData[]; // Fields for input forms
|
||||
viewFields?: QFieldMetaData[]; // Fields for display-only views
|
||||
recordListFields?: QFieldMetaData[]; // Fields for record list display
|
||||
helpContents?: QHelpContent[]; // Step-level help
|
||||
}
|
||||
```
|
||||
|
||||
### QAppMetaData
|
||||
Defines an application or dashboard with its sections and widgets.
|
||||
```typescript
|
||||
export interface QAppMetaData {
|
||||
name: string; // App identifier
|
||||
label: string; // Display name
|
||||
children: QAppTreeNode[]; // Child apps or items
|
||||
iconName: string; // Icon reference
|
||||
widgets: string[]; // Dashboard widget names
|
||||
sections: QAppSection[]; // Logical sections within app
|
||||
}
|
||||
```
|
||||
|
||||
### QAppTreeNode
|
||||
Represents a single node in the hierarchical application navigation tree.
|
||||
```typescript
|
||||
export interface QAppTreeNode {
|
||||
name: string; // Node identifier
|
||||
label: string; // Display name
|
||||
type: QAppNodeType; // Node type (TABLE, PROCESS, REPORT, APP)
|
||||
children?: QAppTreeNode[]; // Nested children
|
||||
iconName?: string; // Icon reference
|
||||
icon?: QIcon; // Detailed icon configuration
|
||||
}
|
||||
```
|
||||
|
||||
### QAppNodeType
|
||||
Type discriminator for navigation tree nodes.
|
||||
```typescript
|
||||
export type QAppNodeType = "TABLE" | "PROCESS" | "REPORT" | "APP";
|
||||
```
|
||||
|
||||
### QAppSection
|
||||
Represents a section within an app (e.g., "Users", "Settings") containing tables/processes/reports.
|
||||
```typescript
|
||||
export interface QAppSection {
|
||||
name: string; // Section identifier
|
||||
label: string; // Display name
|
||||
icon?: QIcon; // Section icon
|
||||
tables: string[]; // Table names in section
|
||||
processes: string[]; // Process names in section
|
||||
reports: string[]; // Report names in section
|
||||
}
|
||||
```
|
||||
|
||||
### QWidgetMetaData
|
||||
Configuration for a dashboard widget.
|
||||
```typescript
|
||||
export interface QWidgetMetaData {
|
||||
name: string; // Widget identifier
|
||||
label: string; // Display name
|
||||
type?: string; // Widget type (chart, grid, statistic, etc.)
|
||||
hasPermission: boolean; // User can view this widget
|
||||
dropdowns?: Record<string, any>; // Dropdown selections for widget
|
||||
helpContent?: QHelpContent; // Widget-level help
|
||||
}
|
||||
```
|
||||
|
||||
### QAuthenticationMetaData
|
||||
Authentication system configuration.
|
||||
```typescript
|
||||
export interface QAuthenticationMetaData {
|
||||
name: string; // Authentication provider name
|
||||
type: "AUTH_0" | "OAUTH2" | "FULLY_ANONYMOUS" | "MOCK"; // Auth type
|
||||
values: {
|
||||
clientId?: string; // OAuth client ID
|
||||
baseUrl?: string; // Provider base URL
|
||||
audience?: string; // OAuth audience claim
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### QBrandingMetaData
|
||||
Application branding, theming, and custom styling.
|
||||
```typescript
|
||||
export interface QBrandingMetaData {
|
||||
companyName: string; // Company/organization name
|
||||
companyUrl: string; // Company website URL
|
||||
appName: string; // Application name
|
||||
logo?: string; // Company logo URL or base64
|
||||
icon?: string; // Application icon URL or base64
|
||||
accentColor?: string; // Primary accent color (hex code)
|
||||
banners?: Record<string, Banner>; // Environment/status banners
|
||||
}
|
||||
```
|
||||
|
||||
### QTableSection
|
||||
Organizes fields within a table form or view into logical groupings.
|
||||
```typescript
|
||||
export interface QTableSection {
|
||||
name: string; // Section identifier
|
||||
label: string; // Display name
|
||||
tier?: string; // Priority tier (e.g., "basic", "advanced")
|
||||
iconName?: string; // Icon reference
|
||||
fieldNames: string[]; // Field names in this section
|
||||
widgetName?: string; // Optional embedded widget
|
||||
isHidden: boolean; // Hide entire section
|
||||
gridColumns?: number; // Column count for responsive layout
|
||||
}
|
||||
```
|
||||
|
||||
### QExposedJoin
|
||||
Represents a relationship between tables.
|
||||
```typescript
|
||||
export interface QExposedJoin {
|
||||
label: string; // Relationship display name
|
||||
isMany: boolean; // One-to-many (true) or one-to-one (false)
|
||||
joinTable?: QTableMetaData; // Target table metadata
|
||||
joinPath?: QJoinMetaData[]; // Path steps through intermediate tables
|
||||
}
|
||||
```
|
||||
|
||||
### QJoinMetaData
|
||||
Single step in a join path between tables.
|
||||
```typescript
|
||||
export interface QJoinMetaData {
|
||||
name: string; // Join identifier
|
||||
type: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_ONE"; // Relationship type
|
||||
leftTable: string; // Source table name
|
||||
rightTable: string; // Target table name
|
||||
}
|
||||
```
|
||||
|
||||
### QReportMetaData
|
||||
Definition of a report (typically backed by a process).
|
||||
```typescript
|
||||
export interface QReportMetaData {
|
||||
name: string; // Report identifier
|
||||
label: string; // Display name
|
||||
isHidden: boolean; // Hide from navigation
|
||||
hasPermission: boolean; // User can access report
|
||||
}
|
||||
```
|
||||
|
||||
### QHelpContent
|
||||
Contextual help text and rich content for UI elements.
|
||||
```typescript
|
||||
export interface QHelpContent {
|
||||
title?: string; // Help section title
|
||||
content?: string; // Help text or HTML
|
||||
links?: Array<{ label: string; url: string }>; // Related links
|
||||
roles?: string[]; // Show only to specific user roles
|
||||
}
|
||||
```
|
||||
|
||||
### QIcon
|
||||
Icon configuration with name, path, and styling.
|
||||
```typescript
|
||||
export interface QIcon {
|
||||
name: string; // Icon identifier
|
||||
path?: string; // SVG path or icon URL
|
||||
color?: string; // Icon color (hex or CSS color)
|
||||
}
|
||||
```
|
||||
|
||||
### QTableVariant
|
||||
Alternative view/configuration of a table.
|
||||
```typescript
|
||||
export interface QTableVariant {
|
||||
name: string; // Variant identifier
|
||||
label: string; // Display name
|
||||
description?: string; // Variant description
|
||||
}
|
||||
```
|
||||
|
||||
### Banner
|
||||
Status or environment banner configuration.
|
||||
```typescript
|
||||
export interface Banner {
|
||||
text: string; // Banner text
|
||||
severity: "info" | "warning" | "error"; // Visual severity level
|
||||
color?: string; // Custom color (hex)
|
||||
dismissible: boolean; // User can close banner
|
||||
}
|
||||
```
|
||||
|
||||
### FieldAdornment
|
||||
Visual or interactive enhancement applied to a field (links, chips, file uploads, etc.).
|
||||
```typescript
|
||||
export interface FieldAdornment {
|
||||
type: AdornmentType; // Adornment category
|
||||
values?: Record<string, any>; // Configuration-specific values
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enum Types
|
||||
|
||||
### QFieldType
|
||||
Enumeration of all supported field data types.
|
||||
```typescript
|
||||
export type QFieldType =
|
||||
| "STRING" // Short text
|
||||
| "INTEGER" // Whole number
|
||||
| "LONG" // Large whole number
|
||||
| "DECIMAL" // Floating-point number
|
||||
| "BOOLEAN" // True/false
|
||||
| "DATE" // Date (no time)
|
||||
| "TIME" // Time (no date)
|
||||
| "DATE_TIME" // Date and time
|
||||
| "TEXT" // Long text
|
||||
| "HTML" // HTML content
|
||||
| "PASSWORD" // Sensitive text (masked in UI)
|
||||
| "BLOB"; // Binary large object
|
||||
```
|
||||
|
||||
### QComponentType
|
||||
Component types available within process steps.
|
||||
```typescript
|
||||
export type QComponentType =
|
||||
| "HELP_TEXT" // Static help text
|
||||
| "BULK_EDIT_FORM" // Multi-record edit form
|
||||
| "BULK_LOAD_FILE_MAPPING_FORM" // CSV column mapping
|
||||
| "BULK_LOAD_VALUE_MAPPING_FORM" // Source-to-DB value mapping
|
||||
| "BULK_LOAD_PROFILE_FORM" // Saved bulk load profile
|
||||
| "VALIDATION_REVIEW_SCREEN" // Review validation errors
|
||||
| "EDIT_FORM" // Record edit form
|
||||
| "VIEW_FORM" // Record display form
|
||||
| "DOWNLOAD_FORM" // File download form
|
||||
| "RECORD_LIST" // Records display
|
||||
| "PROCESS_SUMMARY_RESULTS" // Process completion summary
|
||||
| "GOOGLE_DRIVE_SELECT_FOLDER" // Google Drive folder picker
|
||||
| "WIDGET" // Custom widget
|
||||
| "HTML"; // Custom HTML content
|
||||
```
|
||||
|
||||
### AdornmentType
|
||||
Types of field enhancements.
|
||||
```typescript
|
||||
export type AdornmentType =
|
||||
| "LINK" // Clickable hyperlink
|
||||
| "CHIP" // Tag/chip display
|
||||
| "SIZE" // Bytesize formatter
|
||||
| "ERROR" // Error state indicator
|
||||
| "RENDER_HTML" // Render as HTML
|
||||
| "REVEAL" // Show/hide toggle
|
||||
| "CODE_EDITOR" // Code editing interface
|
||||
| "FILE_DOWNLOAD" // Download link
|
||||
| "FILE_UPLOAD" // File upload control
|
||||
| "TOOLTIP"; // Hover tooltip
|
||||
```
|
||||
|
||||
### Capability
|
||||
Feature toggle enumerations for table operations.
|
||||
```typescript
|
||||
export type Capability =
|
||||
| "TABLE_QUERY" // List/query records
|
||||
| "TABLE_GET" // Fetch single record
|
||||
| "TABLE_COUNT" // Count records
|
||||
| "TABLE_INSERT" // Create new record
|
||||
| "TABLE_UPDATE" // Edit record
|
||||
| "TABLE_DELETE"; // Delete record
|
||||
```
|
||||
|
||||
### QCriteriaOperator
|
||||
Filter operator enumerations for building query criteria.
|
||||
```typescript
|
||||
export type QCriteriaOperator =
|
||||
| "EQUALS" // Exact match
|
||||
| "NOT_EQUALS" // Not equal
|
||||
| "NOT_EQUALS_OR_IS_NULL" // Not equal or missing
|
||||
| "IN" // Within list
|
||||
| "NOT_IN" // Not in list
|
||||
| "STARTS_WITH" // String prefix
|
||||
| "ENDS_WITH" // String suffix
|
||||
| "CONTAINS" // Substring match
|
||||
| "NOT_STARTS_WITH" // Exclude prefix
|
||||
| "NOT_ENDS_WITH" // Exclude suffix
|
||||
| "NOT_CONTAINS" // Exclude substring
|
||||
| "LESS_THAN" // Numeric/date <
|
||||
| "LESS_THAN_OR_EQUALS" // Numeric/date <=
|
||||
| "GREATER_THAN" // Numeric/date >
|
||||
| "GREATER_THAN_OR_EQUALS" // Numeric/date >=
|
||||
| "IS_BLANK" // Empty/null
|
||||
| "IS_NOT_BLANK" // Non-empty/not null
|
||||
| "BETWEEN" // Within range (inclusive)
|
||||
| "NOT_BETWEEN"; // Outside range
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Types
|
||||
|
||||
### QQueryFilter
|
||||
Tree-structured query definition with criteria, ordering, and pagination.
|
||||
```typescript
|
||||
export interface QQueryFilter {
|
||||
criteria: QFilterCriteria[]; // Filter conditions
|
||||
orderBys?: QFilterOrderBy[]; // Sort specifications
|
||||
subFilters?: QQueryFilter[]; // Nested boolean logic
|
||||
booleanOperator: "AND" | "OR"; // How criteria/subFilters combine
|
||||
skip: number; // Offset for pagination
|
||||
limit: number; // Max records to return
|
||||
}
|
||||
```
|
||||
|
||||
### QFilterCriteria
|
||||
Single filter condition within a QQueryFilter.
|
||||
```typescript
|
||||
export interface QFilterCriteria {
|
||||
fieldName: string; // Field being filtered
|
||||
operator: QCriteriaOperator; // Comparison operator
|
||||
values: (string | number | boolean | FilterVariableExpression | NowExpression | NowWithOffsetExpression | ThisOrLastPeriodExpression)[]; // Values to compare
|
||||
otherFieldName?: string; // For field-to-field comparisons
|
||||
}
|
||||
```
|
||||
|
||||
### QFilterOrderBy
|
||||
Sort specification within a query.
|
||||
```typescript
|
||||
export interface QFilterOrderBy {
|
||||
fieldName: string; // Field to sort by
|
||||
isAscending: boolean; // Sort direction
|
||||
}
|
||||
```
|
||||
|
||||
### QueryJoin
|
||||
Join specification for including related tables in a query.
|
||||
```typescript
|
||||
export interface QueryJoin {
|
||||
joinTable: string; // Table name to join
|
||||
select: boolean; // Include in results
|
||||
type: "INNER" | "LEFT" | "RIGHT" | "FULL"; // Join type
|
||||
baseTableOrAlias?: string; // Base table or alias
|
||||
alias?: string; // Join alias
|
||||
joinName?: string; // Named join identifier
|
||||
}
|
||||
```
|
||||
|
||||
### FilterVariableExpression
|
||||
Placeholder for dynamic filter variable substitution.
|
||||
```typescript
|
||||
export interface FilterVariableExpression {
|
||||
type: "FILTER_VARIABLE"; // Expression type marker
|
||||
variableName: string; // Variable name to substitute
|
||||
}
|
||||
```
|
||||
|
||||
### NowExpression
|
||||
Represents the current date/time in filter expressions.
|
||||
```typescript
|
||||
export interface NowExpression {
|
||||
type: "NOW"; // Expression type marker
|
||||
}
|
||||
```
|
||||
|
||||
### NowWithOffsetExpression
|
||||
Represents current date/time with offset (e.g., +7 days, -1 month).
|
||||
```typescript
|
||||
export interface NowWithOffsetExpression {
|
||||
type: "NOW_WITH_OFFSET"; // Expression type marker
|
||||
offsetValue: number; // Offset magnitude
|
||||
offsetUnit: "DAY" | "WEEK" | "MONTH" | "YEAR"; // Offset unit
|
||||
isNegativeOffset: boolean; // Add (false) or subtract (true)
|
||||
}
|
||||
```
|
||||
|
||||
### ThisOrLastPeriodExpression
|
||||
Represents "this period" or "last period" relative expressions (week, month, quarter, year).
|
||||
```typescript
|
||||
export interface ThisOrLastPeriodExpression {
|
||||
type: "THIS_OR_LAST_PERIOD"; // Expression type marker
|
||||
period: "WEEK" | "MONTH" | "QUARTER" | "YEAR"; // Time period
|
||||
isLast: boolean; // This period (false) or last period (true)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Process Job Types
|
||||
|
||||
### QJobStarted
|
||||
Indicates async job has been initiated; frontend should poll for status.
|
||||
```typescript
|
||||
export interface QJobStarted {
|
||||
processUUID: string; // Process execution ID
|
||||
jobUUID: string; // Async job ID for polling
|
||||
}
|
||||
```
|
||||
|
||||
### QJobRunning
|
||||
Indicates job is in progress with optional progress information.
|
||||
```typescript
|
||||
export interface QJobRunning {
|
||||
processUUID: string; // Process execution ID
|
||||
message: string; // Status message
|
||||
current?: number; // Current progress count
|
||||
total?: number; // Total progress count
|
||||
}
|
||||
```
|
||||
|
||||
### QJobComplete
|
||||
Indicates job completed successfully with next step information.
|
||||
```typescript
|
||||
export interface QJobComplete {
|
||||
processUUID: string; // Process execution ID
|
||||
values: Record<string, any>; // Step output values
|
||||
nextStep?: string; // Name of next step to execute
|
||||
backStep?: string; // Name of previous step (for back navigation)
|
||||
processMetaDataAdjustment?: ProcessMetaDataAdjustment; // Dynamic step/field changes
|
||||
}
|
||||
```
|
||||
|
||||
### QJobError
|
||||
Indicates job failed with error details.
|
||||
```typescript
|
||||
export interface QJobError {
|
||||
processUUID: string; // Process execution ID
|
||||
error: string; // Technical error details
|
||||
userFacingError?: string; // User-friendly error message
|
||||
}
|
||||
```
|
||||
|
||||
### ProcessMetaDataAdjustment
|
||||
Dynamic modifications to process metadata after a step completes.
|
||||
```typescript
|
||||
export interface ProcessMetaDataAdjustment {
|
||||
addedSteps?: QFrontendStepMetaData[]; // Steps to insert
|
||||
removedSteps?: string[]; // Step names to remove
|
||||
modifiedFields?: Record<string, Partial<QFieldMetaData>>; // Field changes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Notes
|
||||
|
||||
- All type names prefixed with `Q` follow the QQQ naming convention
|
||||
- Use `interface` (not `class`) as these represent data contracts
|
||||
- Import and use these types in all frontend implementations to ensure API contract alignment
|
||||
- Never create custom type aliases; always reference these canonical definitions
|
||||
- Mark fields as optional using `?` only when explicitly documented as optional
|
||||
- All `Record<string, X>` mappings use string keys (field/table/process/widget names)
|
||||
- Process job types form a discriminated union; use the `processUUID` and type presence to determine state
|
||||
- Filter expressions support runtime variable substitution via `FilterVariableExpression` types
|
||||
@@ -0,0 +1,451 @@
|
||||
# Master Architect Agent Prompt
|
||||
|
||||
## Your Role
|
||||
|
||||
You are the **Master Architect Agent** for the QQQ Admin UI Modernization project. Your job is to take the requirements specification and produce a set of **detailed implementation plans** — one per work package — that implementation agents can execute independently to build the new admin UI.
|
||||
|
||||
You do NOT write application code yourself. You produce plans that are precise enough that an implementation agent (an AI coding agent with access to the codebase) can follow them and produce correct, integrating code on the first pass.
|
||||
|
||||
---
|
||||
|
||||
## Inputs Available to You
|
||||
|
||||
You have access to the full codebase. The following files are critical:
|
||||
|
||||
### Requirements Specification
|
||||
- **`docs/qqq-admin-modernization-requirements.md`** — The complete requirements document. Read this FIRST, in its entirety, before doing anything else.
|
||||
|
||||
### Existing Source Code (Reference Implementation)
|
||||
These files represent the current system you are replacing. Read them to understand existing patterns, edge cases, and integration points:
|
||||
|
||||
- **API Client Layer:**
|
||||
- `qqq-frontend-core/src/controllers/QController.ts` (~1169 lines) — Current API client, all HTTP calls
|
||||
- `qqq-frontend-core/src/controllers/QControllerV1.ts` (~1117 lines) — V1 API client
|
||||
- `qqq-frontend-core/src/model/**/*.ts` (~42 files) — All TypeScript domain models
|
||||
|
||||
- **Current Frontend UI:**
|
||||
- `qqq-frontend-material-dashboard/src/App.tsx` — Route generation, auth flow, layout structure
|
||||
- `qqq-frontend-material-dashboard/src/qqq/pages/**` — All page components
|
||||
- `qqq-frontend-material-dashboard/src/qqq/components/**` — All shared components (~217 files)
|
||||
|
||||
- **Backend API (source of truth for endpoints):**
|
||||
- `qqq/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/` — All v1 endpoint specs
|
||||
- `qqq/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/QApplicationJavalinServer.java` — Route registration
|
||||
|
||||
---
|
||||
|
||||
## What You Must Produce
|
||||
|
||||
### Output Structure
|
||||
|
||||
Create a directory `docs/implementation-plans/` containing:
|
||||
|
||||
```
|
||||
docs/implementation-plans/
|
||||
├── 00-project-overview.md # Dependency graph, sequencing, shared conventions
|
||||
├── 01-scaffold-and-auth.md # Work Package 1
|
||||
├── 02-record-query.md # Work Package 2
|
||||
├── 03-record-view-and-crud.md # Work Package 3
|
||||
├── 04-process-execution.md # Work Package 4
|
||||
├── 05-dashboard-and-widgets.md # Work Package 5
|
||||
├── 06-polish-and-parity.md # Work Package 6
|
||||
├── shared-context/
|
||||
│ ├── api-contract.md # Extracted from requirements Section 3 — given to every agent
|
||||
│ ├── type-definitions.md # Key TypeScript types every agent needs
|
||||
│ └── coding-conventions.md # Project-wide patterns and rules
|
||||
└── review-checklists/
|
||||
├── review-01-scaffold.md # Review criteria for Package 1
|
||||
├── review-02-query.md # Review criteria for Package 2
|
||||
├── review-03-crud.md # Review criteria for Package 3
|
||||
├── review-04-processes.md # Review criteria for Package 4
|
||||
├── review-05-widgets.md # Review criteria for Package 5
|
||||
└── review-06-polish.md # Review criteria for Package 6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Build Each Work Package Plan
|
||||
|
||||
### Step 1: Read the Requirements and Code
|
||||
|
||||
Before writing any plans, read:
|
||||
1. The full requirements doc (`docs/qqq-admin-modernization-requirements.md`)
|
||||
2. The current `App.tsx` to understand route generation
|
||||
3. The current `QController.ts` and `QControllerV1.ts` to understand every API call
|
||||
4. A representative sample of page components (RecordQuery, RecordView, EntityForm, ProcessRun) to understand the current implementation patterns
|
||||
5. A representative sample of shared components to understand the component architecture
|
||||
|
||||
### Step 2: Produce the Project Overview (`00-project-overview.md`)
|
||||
|
||||
This file establishes the shared foundation. It must contain:
|
||||
|
||||
**Technology Stack Decisions:**
|
||||
- Next.js App Router (as recommended in requirements Section 4.3)
|
||||
- TypeScript in strict mode
|
||||
- Tailwind CSS for styling (utility-first, replaces MUI's styling system)
|
||||
- shadcn/ui as the component primitive library (headless, composable, Tailwind-native)
|
||||
- TanStack Table for the data grid (replaces MUI X DataGrid Pro)
|
||||
- React Hook Form + Zod for forms (replaces Formik + Yup)
|
||||
- TanStack Query for data fetching and caching
|
||||
- Vitest for unit tests, Playwright for E2E
|
||||
|
||||
**Project Structure:**
|
||||
Define the exact directory layout for the new project. Example:
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ ├── (auth)/ # Auth routes (login, callback)
|
||||
│ ├── (dashboard)/ # Authenticated routes
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── [appName]/
|
||||
│ │ │ ├── [tableName]/
|
||||
│ │ │ │ ├── page.tsx # RecordQuery
|
||||
│ │ │ │ ├── create/page.tsx # Create record
|
||||
│ │ │ │ ├── [recordId]/
|
||||
│ │ │ │ │ ├── page.tsx # RecordView
|
||||
│ │ │ │ │ └── edit/page.tsx # Edit record
|
||||
│ │ │ └── [processName]/
|
||||
│ │ │ └── page.tsx # ProcessRun
|
||||
│ │ └── layout.tsx # Dashboard layout (sidebar + nav)
|
||||
│ └── layout.tsx # Root layout
|
||||
├── components/
|
||||
│ ├── layout/ # Sidebar, navbar, breadcrumbs
|
||||
│ ├── records/ # Record-related components
|
||||
│ ├── forms/ # Dynamic form components
|
||||
│ ├── query/ # Filter builder, column config
|
||||
│ ├── process/ # Process step components
|
||||
│ ├── widgets/ # Widget system
|
||||
│ └── ui/ # shadcn/ui primitives
|
||||
├── lib/
|
||||
│ ├── api/ # API client (typed, generated from contract)
|
||||
│ ├── auth/ # Auth providers and session management
|
||||
│ ├── hooks/ # Shared React hooks
|
||||
│ └── utils/ # Utility functions
|
||||
├── types/ # Shared TypeScript types (ported from qqq-frontend-core)
|
||||
└── styles/ # Global styles, Tailwind config, theme tokens
|
||||
```
|
||||
|
||||
**Dependency Graph:**
|
||||
```
|
||||
Package 1 (Scaffold + Auth)
|
||||
↓
|
||||
Package 2 (Record Query) ←→ Package 3 (Record View + CRUD)
|
||||
↓ ↓
|
||||
Package 4 (Process Execution)
|
||||
↓
|
||||
Package 5 (Dashboard + Widgets)
|
||||
↓
|
||||
Package 6 (Polish + Parity)
|
||||
```
|
||||
Packages 2 and 3 can run in parallel after Package 1. Package 4 depends on form components from Package 3. Package 5 depends on the data grid from Package 2 and forms from Package 3. Package 6 depends on everything.
|
||||
|
||||
**Shared Conventions** (also extracted into `shared-context/coding-conventions.md`):
|
||||
- All components that render from metadata must accept the metadata type as a prop — never fetch it internally
|
||||
- API calls go through the typed client in `lib/api/` — never raw `fetch`
|
||||
- No hardcoded table names, field names, or process names anywhere in the UI
|
||||
- Every component must have a `data-qqq-id` attribute for CSS customization
|
||||
- All user-facing text that comes from metadata must use the `label` field, never `name`
|
||||
- Error boundaries at the route level and the widget level
|
||||
|
||||
### Step 3: Produce Each Work Package Plan
|
||||
|
||||
Each work package file (`01-scaffold-and-auth.md` through `06-polish-and-parity.md`) must follow this exact structure:
|
||||
|
||||
```markdown
|
||||
# Work Package [N]: [Name]
|
||||
|
||||
## Prerequisites
|
||||
- List which previous packages must be complete
|
||||
- List specific files/components from previous packages that this package depends on
|
||||
|
||||
## Requirements Traceability
|
||||
- List every section/subsection from the requirements doc that this package implements
|
||||
- Example: "Section 5.5 (Authentication), Section 5.3.1 (Layout Components), Section 3.2 (Auth Endpoints)"
|
||||
|
||||
## Shared Context Files
|
||||
- List which files from shared-context/ the implementation agent needs
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
- Specific list of features/components to build
|
||||
|
||||
### Out of Scope
|
||||
- What is explicitly NOT part of this package (to prevent scope creep)
|
||||
|
||||
## Detailed Implementation Steps
|
||||
|
||||
### Step 1: [Action]
|
||||
**What:** [Precise description of what to create/modify]
|
||||
**Files to create:**
|
||||
- `src/path/to/file.tsx` — [Purpose and key exports]
|
||||
- `src/path/to/file.ts` — [Purpose and key exports]
|
||||
|
||||
**Key implementation details:**
|
||||
- [Specific technical detail the agent needs to know]
|
||||
- [Edge case to handle]
|
||||
- [Pattern to follow from the existing codebase — reference specific files]
|
||||
|
||||
**API integration:**
|
||||
- Endpoint: `[METHOD] /qqq/v1/[path]`
|
||||
- Request: [Shape]
|
||||
- Response: [Shape]
|
||||
- Error handling: [What to do on 401, 404, 500]
|
||||
|
||||
**Reference implementation:**
|
||||
- See `qqq-frontend-material-dashboard/src/qqq/[path]` for the current implementation of this feature
|
||||
- Key behaviors to preserve: [list]
|
||||
- Key behaviors to change/improve: [list]
|
||||
|
||||
### Step 2: [Action]
|
||||
[Same structure...]
|
||||
|
||||
[Continue for all steps...]
|
||||
|
||||
## Component Specifications
|
||||
|
||||
For each component this package creates, specify:
|
||||
|
||||
### `ComponentName`
|
||||
**File:** `src/components/[path]/ComponentName.tsx`
|
||||
**Props:**
|
||||
```typescript
|
||||
interface ComponentNameProps {
|
||||
// exact prop types
|
||||
}
|
||||
```
|
||||
**Behavior:**
|
||||
- [What it renders in each state: loading, empty, error, populated]
|
||||
- [User interactions it handles]
|
||||
- [Events it emits / callbacks it invokes]
|
||||
|
||||
**Metadata-driven aspects:**
|
||||
- [Which metadata fields control its rendering]
|
||||
- [How it adapts to different field types / configurations]
|
||||
|
||||
**Responsive behavior:**
|
||||
- Mobile: [specific behavior]
|
||||
- Tablet: [specific behavior]
|
||||
- Desktop: [specific behavior]
|
||||
|
||||
## API Client Functions
|
||||
|
||||
For each API function this package requires:
|
||||
|
||||
### `functionName`
|
||||
**File:** `src/lib/api/[module].ts`
|
||||
**Signature:** `(params: Type) => Promise<ReturnType>`
|
||||
**Endpoint:** `[METHOD] /qqq/v1/[path]`
|
||||
**Caching:** [TanStack Query key, stale time, cache time]
|
||||
**Error handling:** [Specific error scenarios and how to handle them]
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests
|
||||
- [Specific components/functions to test]
|
||||
- [Key scenarios to cover]
|
||||
- [Mock data patterns]
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
- [User flow 1: description and steps]
|
||||
- [User flow 2: description and steps]
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Each criterion must be binary (pass/fail) and testable:
|
||||
|
||||
- [ ] [Specific, measurable criterion]
|
||||
- [ ] [Specific, measurable criterion]
|
||||
- [ ] [Example: "The sidebar renders all apps from appTree metadata with correct nesting and icons"]
|
||||
- [ ] [Example: "Clicking a table in the sidebar navigates to /app/{tableName} and the RecordQuery page loads"]
|
||||
- [ ] [Example: "A 401 response from any API call redirects to the login page without losing the current URL"]
|
||||
```
|
||||
|
||||
### Step 4: Produce Shared Context Files
|
||||
|
||||
**`shared-context/api-contract.md`:**
|
||||
Extract Section 3 from the requirements doc verbatim. Every implementation agent receives this file. It is the single source of truth for API integration.
|
||||
|
||||
**`shared-context/type-definitions.md`:**
|
||||
Port the key TypeScript types from `qqq-frontend-core/src/model/` into a single reference document. Include:
|
||||
- All metadata types (QInstance, QTableMetaData, QFieldMetaData, QProcessMetaData, QAppMetaData, QWidgetMetaData, etc.)
|
||||
- All data types (QRecord, QPossibleValue)
|
||||
- All query types (QQueryFilter, QFilterCriteria, QCriteriaOperator, QFilterOrderBy, QueryJoin)
|
||||
- All process types (QJobStarted, QJobRunning, QJobComplete, QJobError)
|
||||
- All enum types (QFieldType, QComponentType, AdornmentType, Capability, QAppNodeType)
|
||||
|
||||
Annotate each type with a one-line description of when/where it's used.
|
||||
|
||||
**`shared-context/coding-conventions.md`:**
|
||||
Define project-wide rules:
|
||||
- File naming conventions
|
||||
- Component patterns (server components vs. client components — when to use each)
|
||||
- State management approach (TanStack Query for server state, React context for UI state)
|
||||
- Error handling patterns
|
||||
- Import organization
|
||||
- Tailwind class ordering convention
|
||||
- Accessibility requirements (ARIA attributes, keyboard navigation, focus management)
|
||||
- `data-qqq-id` naming convention for CSS customization hooks
|
||||
- How to handle the QQQ theme tokens (CSS custom properties)
|
||||
|
||||
### Step 5: Produce Review Checklists
|
||||
|
||||
Each review checklist (`review-checklists/review-0N-*.md`) must contain:
|
||||
|
||||
1. **Requirements coverage:** For each requirement traced to this package, a yes/no check that it's implemented
|
||||
2. **Integration check:** Verify the package integrates with previous packages (imports resolve, types match, navigation works)
|
||||
3. **API contract compliance:** Verify every API call matches Section 3 exactly (correct path, correct request shape, correct response handling)
|
||||
4. **Metadata-driven check:** Verify no hardcoded table/field/process names exist
|
||||
5. **Responsive check:** Verify mobile/tablet/desktop behavior per Section 5.4
|
||||
6. **Accessibility check:** Keyboard navigation, ARIA attributes, color contrast
|
||||
7. **Type safety check:** No `any` types in component props, all API responses validated
|
||||
|
||||
---
|
||||
|
||||
## Work Package Definitions
|
||||
|
||||
Here is the scope for each package. Use these as starting points, then refine based on what you learn from reading the code.
|
||||
|
||||
### Package 1: Project Scaffold, Auth, and Layout Shell
|
||||
**Requirements:** Sections 5.1, 5.5, 5.3.1 (Layout Components), 3.2 (Auth Endpoints)
|
||||
**Delivers:**
|
||||
- Next.js project initialization with all dependencies
|
||||
- TypeScript types ported from qqq-frontend-core models
|
||||
- API client with typed functions for auth and metadata endpoints
|
||||
- Auth flow (OAuth2/OIDC via `getAuthenticationMetaData` → redirect → `manageSession` → session cookie)
|
||||
- Root layout with sidebar navigation generated from `appTree` metadata
|
||||
- Top navbar with breadcrumbs
|
||||
- 401 interceptor with re-auth flow
|
||||
- Branding/theming from `QBrandingMetaData` (accent colors, logo, banners)
|
||||
- Light/dark mode toggle
|
||||
- Empty placeholder pages for all dynamic routes (so navigation works end-to-end)
|
||||
|
||||
**Critical reading:** `App.tsx` (route generation logic), `QController.getAuthenticationMetaData()`, `QController.manageSession()`, `QController.loadMetaData()`, SideNav components, NavBar component, BrandedHeaderBar
|
||||
|
||||
### Package 2: Record Query Page
|
||||
**Requirements:** Sections 5.2.3 (query route), 5.3.2 (Data Grid), 5.3.5 (Query/Filter Components), 5.4.2 (responsive for query), 3.4.1 (Query), 3.4.2 (Count), 3.7 (Possible Values)
|
||||
**Delivers:**
|
||||
- Record Query page with full data grid (TanStack Table)
|
||||
- Server-side pagination, sorting, and filtering via `POST /table/{t}/query` and `POST /table/{t}/count`
|
||||
- Dynamic column generation from `QTableMetaData.fields`
|
||||
- Cell renderers per field type (string, number, date, boolean, etc.) and per adornment type (link, chip, file download, etc.)
|
||||
- Basic filter mode (quick text search) and advanced filter builder (full QQueryFilter with all operators, boolean logic, sub-filters)
|
||||
- Column configuration panel (show/hide, reorder)
|
||||
- Row selection (single and multi)
|
||||
- Export (CSV at minimum)
|
||||
- Saved views (save/load filter + column configurations)
|
||||
- Pagination controls with page size selector
|
||||
- Table variant selection
|
||||
- Row click → navigate to Record View
|
||||
- Toolbar with create button (if insertPermission), process launcher, column config, density control
|
||||
|
||||
**Critical reading:** RecordQuery.tsx (the entire file — this is the most complex page), CustomFilterPanel, FilterCriteriaRow, BasicAndAdvancedQueryControls, SavedViews, CustomColumnsPanel, ExportMenuItem
|
||||
|
||||
### Package 3: Record View and CRUD
|
||||
**Requirements:** Sections 5.2.3 (view/create/edit/copy routes), 5.3.2 (Record Detail View), 5.3.3 (Form Components), 5.4.2 (responsive for view and form), 3.4.3–3.4.6 (Get, Insert, Update, Delete), 3.7 (Possible Values)
|
||||
**Delivers:**
|
||||
- Record View page with section-organized field display
|
||||
- Field value rendering per type and adornment
|
||||
- Action menu (edit, delete, copy, launch process, share)
|
||||
- Entity Form component for create/edit/copy
|
||||
- Dynamic form generation from `QFieldMetaData` (all 12 field types)
|
||||
- Form validation (Zod schemas generated from field metadata: required, maxLength, type constraints)
|
||||
- Possible values autocomplete (`POST /possibleValues/{f}`) with debounced search
|
||||
- File upload fields
|
||||
- Boolean switch fields
|
||||
- Date/time pickers
|
||||
- Rich text fields (HTML type)
|
||||
- Section-based form layout with `gridColumns` support
|
||||
- Record delete with confirmation dialog
|
||||
- Record copy (pre-populate form without ID)
|
||||
- Associated records display
|
||||
- Help content tooltips on fields
|
||||
|
||||
**Critical reading:** RecordView.tsx, EntityForm.tsx, DynamicForm.tsx, DynamicFormField.tsx, DynamicSelect.tsx, BooleanFieldSwitch.tsx, FileInputField.tsx, all field adornment rendering logic
|
||||
|
||||
### Package 4: Process Execution
|
||||
**Requirements:** Sections 5.2.4 (process routes), 5.3.4 (Process Components), 3.5 (Process Endpoints), 3.7 (Possible Values)
|
||||
**Delivers:**
|
||||
- Process Run page with step wizard (stepper UI)
|
||||
- Dynamic step rendering based on `QFrontendStepMetaData`
|
||||
- Form steps (reuse EntityForm components from Package 3)
|
||||
- View-only steps
|
||||
- Validation review screen
|
||||
- Bulk load workflows: file upload, column-to-field mapping, value mapping, profile save/load
|
||||
- Async job handling: detect `QJobStarted`, poll `GET /status/{jobUUID}`, display `QJobRunning` progress, handle `QJobComplete` and `QJobError`
|
||||
- Process results summary (record counts, error lists, download links)
|
||||
- Process records endpoint (`GET /processes/{p}/{uuid}/records`) for paginated result viewing
|
||||
- Process cancellation
|
||||
- Support for both standalone processes and table-scoped processes (with pre-selected records via recordIds or filterJSON)
|
||||
|
||||
**Critical reading:** ProcessRun.tsx (the entire file), ProcessViewForm, ProcessSummaryResults, ValidationReview, all BulkLoad* components, QJobStarted/Running/Complete/Error types
|
||||
|
||||
### Package 5: Dashboard and Widgets
|
||||
**Requirements:** Sections 5.2.2 (App Home), 5.3.6 (Widget Components), 3.6 (Widget Endpoint)
|
||||
**Delivers:**
|
||||
- App Home page with dashboard layout
|
||||
- Widget container component (label, reload, export, dropdown menus, help content)
|
||||
- Widget grid layout with `gridColumns` support
|
||||
- All chart widgets (line, bar, horizontal bar, stacked bar, pie) — use Recharts or Chart.js
|
||||
- Statistics cards (MiniStatisticsCard, MultiStatisticsCard)
|
||||
- Record Grid Widget (embedded data grid with add/edit/delete)
|
||||
- Block system (TextBlock, BigNumberBlock, UpOrDownNumberBlock, ProgressBarBlock, ButtonBlock, IconBlock, ImageBlock, AudioBlock, DividerBlock, InputFieldBlock)
|
||||
- Composite and parent widget containers
|
||||
- Widget dropdown selections with possible-value sources
|
||||
- Data fetching with abort/cancel-and-replace for widget requests
|
||||
- Custom component widget mount point (for app-specific React components)
|
||||
- Specialized widgets: pivot table setup, filter/columns setup, CRON builder, US map (if feasible), script viewer
|
||||
|
||||
**Critical reading:** DashboardWidgets, Widget.tsx, all widget block components, RecordGridWidget, ChartSubheaderWithData, all chart components, CompositeWidget, ParentWidget
|
||||
|
||||
### Package 6: Polish, Parity, and Production Readiness
|
||||
**Requirements:** Sections 5.4 (all responsive), 5.6 (Performance), 5.7 (Developer Experience)
|
||||
**Delivers:**
|
||||
- Responsive design audit and fixes across all breakpoints for all pages
|
||||
- Mobile card view for Record Query
|
||||
- Command palette (Cmd+K) for quick navigation
|
||||
- Keyboard shortcuts
|
||||
- Audit trail display on Record View
|
||||
- Developer tools pages (script editor, logs, test execution)
|
||||
- Report execution pages
|
||||
- Storybook setup with stories for all shared components
|
||||
- Playwright E2E test suite for critical flows (login, query, CRUD, process execution)
|
||||
- Performance optimization (bundle analysis, lazy loading, caching tuning)
|
||||
- Accessibility audit (axe-core) and remediation
|
||||
- Theme token system (CSS custom properties) for all 60+ tokens from current qfmd
|
||||
- `data-qqq-id` attributes on all interactive elements
|
||||
- `customCss` injection support
|
||||
|
||||
**Critical reading:** All responsive-related code in current qfmd, CommandMenu, AuditBody, RecordDeveloperView, TableDeveloperView, ScriptViewer, ReportRun
|
||||
|
||||
---
|
||||
|
||||
## Quality Standards for Your Plans
|
||||
|
||||
Your plans will be judged on:
|
||||
|
||||
1. **Precision:** An implementation agent should not need to make architectural decisions. Every file path, component name, prop interface, and API integration point should be specified.
|
||||
|
||||
2. **Completeness:** Every feature in the requirements must appear in exactly one work package. Nothing can fall through the cracks. If a feature spans packages, specify which package owns it and what interface the other package consumes.
|
||||
|
||||
3. **Traceability:** Every implementation step must trace back to a specific requirement (section number). Every requirement must trace forward to a specific implementation step.
|
||||
|
||||
4. **Realism:** Steps should be ordered so that each step can be tested after completion. Don't put all the types first, then all the components, then all the wiring — interleave so that after each step, something new works.
|
||||
|
||||
5. **Edge case awareness:** Read the existing implementation. Note error handling, loading states, empty states, permission checks, and null cases. Your plans must address these. The current qfmd has years of edge case handling baked in — don't lose it.
|
||||
|
||||
6. **Integration specificity:** When Package N depends on Package N-1, specify the exact imports, types, and components it depends on. Don't say "use the API client from Package 1" — say "import `queryRecords` from `@/lib/api/tables` which returns `Promise<QRecord[]>`".
|
||||
|
||||
---
|
||||
|
||||
## Execution Instructions
|
||||
|
||||
1. Read the full requirements document first.
|
||||
2. Read the current codebase files listed above (use parallel reads where possible).
|
||||
3. Produce `00-project-overview.md` first — this establishes all shared decisions.
|
||||
4. Produce the three `shared-context/` files.
|
||||
5. Produce work packages 01 through 06 in order.
|
||||
6. Produce review checklists for each package.
|
||||
7. Do a final cross-check: scan every subsection of Section 5 in the requirements and verify it appears in at least one work package's scope.
|
||||
|
||||
Begin now.
|
||||
@@ -0,0 +1,409 @@
|
||||
# Prompt: Add `node_app_*` jobs to kingsrook/qqq-orb
|
||||
|
||||
## Context
|
||||
|
||||
You are working in the `kingsrook/qqq-orb` CircleCI orb repo at:
|
||||
`/Users/james.maes/Git.Local/QRun-IO/qqq-orb`
|
||||
|
||||
The orb currently supports:
|
||||
- `mvn_*` — Maven Java libraries
|
||||
- `mvn_frontend_*` — Maven + bundled frontend
|
||||
- `node_*` — npm-published TypeScript libraries
|
||||
|
||||
You are adding a new project type: **`node_app_*`** — pnpm-based Node.js
|
||||
applications (Next.js, Vite, etc.) that are _deployed_, not published to npm.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Read these files before writing anything
|
||||
|
||||
### qqq-orb existing patterns (understand before touching)
|
||||
|
||||
```
|
||||
src/@orb.yml ← orb-level imports (browser-tools, node orbs already imported)
|
||||
src/executors/default.yml ← Docker executor (cimg/node)
|
||||
src/jobs/node_test_only.yml ← existing node job structure
|
||||
src/jobs/node_publish.yml ← existing node publish job
|
||||
src/commands/node_install_dependencies.yml
|
||||
src/commands/node_run_tests.yml
|
||||
src/commands/node_build_package.yml
|
||||
src/scripts/node_npm_auth.sh ← existing script style/conventions
|
||||
src/scripts/node_version_commit.sh ← existing script style/conventions
|
||||
CLAUDE.md ← orb architecture, make commands, linting rules
|
||||
```
|
||||
|
||||
### Munitor reference orb (copy engineering patterns from here)
|
||||
|
||||
These files live at `/Users/james.maes/Git.Local/Kof22/Munitor/`. Read them
|
||||
to understand the patterns you must replicate — **not to copy content**, but
|
||||
to adopt the same engineering discipline.
|
||||
|
||||
```
|
||||
src/scripts/munitor_helpers.sh ← shared helper library pattern
|
||||
src/scripts/install_node.sh ← nvm-based node install with BASH_ENV export
|
||||
src/scripts/npm_install.sh ← lockfile validation before install
|
||||
src/scripts/npm_test.sh ← JSON test_commands array override pattern
|
||||
src/scripts/npm_lint.sh ← lint with JSON artifact output
|
||||
src/scripts/run_e2e_playwright.sh ← Playwright install + JUnit reporter
|
||||
src/commands/restore_npm_cache.yml ← cache key pattern
|
||||
src/commands/save_npm_cache.yml
|
||||
src/jobs/npm_build_and_test.yml ← two-job pipeline with workspace handoff
|
||||
src/jobs/npm_e2e_test.yml ← E2E job that attaches workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create a shared helper script
|
||||
|
||||
### `src/scripts/qqq_helpers.sh`
|
||||
|
||||
Model this on Munitor's `munitor_helpers.sh`. It must provide:
|
||||
|
||||
- `qqq_header <step_name>` — prints a banner with step name, date (UTC),
|
||||
hostname, and current directory. Format to match qqq style (not Munitor branding).
|
||||
- `qqq_check_tool <command> [version_flag]` — verifies tool is in PATH,
|
||||
prints its version. Exits 1 with clear error if missing.
|
||||
- Guard against double-sourcing (`[[ -n "${_QQQ_HELPERS_LOADED:-}" ]] && return 0`)
|
||||
|
||||
Sourcing pattern every script must use at the top:
|
||||
```bash
|
||||
QQQ_HELPERS="${QQQ_HELPERS:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/qqq_helpers.sh}"
|
||||
[[ -f "${QQQ_HELPERS}" ]] && source "${QQQ_HELPERS}"
|
||||
```
|
||||
|
||||
With a fallback stub if helpers are missing (same pattern as Munitor):
|
||||
```bash
|
||||
elif ! type qqq_header &>/dev/null; then
|
||||
qqq_header() { echo "=== QQQ: ${1:-unknown} ==="; }
|
||||
qqq_check_tool() { command -v "$1" &>/dev/null || { echo "ERROR: $1 not found"; exit 1; }; }
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create shell scripts
|
||||
|
||||
All scripts: `#!/usr/bin/env bash`, `set -euo pipefail`, source `qqq_helpers.sh`
|
||||
at top. Clear section headers. Descriptive error messages. Print summaries.
|
||||
|
||||
### `src/scripts/node_app_install.sh`
|
||||
|
||||
Purpose: Enable pnpm via corepack, validate lockfile exists, install deps.
|
||||
|
||||
Environment variables consumed:
|
||||
- `NODE_PKG_MANAGER` — `pnpm` (default), `npm`, or `yarn`
|
||||
|
||||
Logic:
|
||||
1. Call `qqq_header "node_app_install"`
|
||||
2. Check tool: node, npm (for corepack)
|
||||
3. If `NODE_PKG_MANAGER=pnpm`:
|
||||
- Run `corepack enable`
|
||||
- Run `corepack prepare pnpm@latest --activate`
|
||||
- Verify `pnpm --version`
|
||||
- Validate `pnpm-lock.yaml` exists; exit 1 with clear message if not
|
||||
- Run `pnpm install --frozen-lockfile`
|
||||
4. If `NODE_PKG_MANAGER=npm`:
|
||||
- Validate `package-lock.json` exists
|
||||
- Print lockfile version + npm version compatibility warning (copy logic from
|
||||
Munitor's `npm_install.sh`)
|
||||
- Run `npm ci`
|
||||
5. If `NODE_PKG_MANAGER=yarn`:
|
||||
- Validate `yarn.lock` exists
|
||||
- Run `yarn install --frozen-lockfile`
|
||||
6. Print count of installed packages
|
||||
|
||||
### `src/scripts/node_app_test.sh`
|
||||
|
||||
Purpose: Run type check, lint, and unit tests. Each step is individually
|
||||
skippable. Supports custom test commands via JSON array (replaces default test
|
||||
runner when provided).
|
||||
|
||||
Environment variables consumed:
|
||||
- `NODE_PKG_MANAGER` — pnpm | npm | yarn (default: pnpm)
|
||||
- `TYPE_CHECK_SCRIPT` — npm script name, or empty to skip (default: `tsc`)
|
||||
- `TYPE_CHECK_ARGS` — extra args (default: `--noEmit`)
|
||||
- `LINT_SCRIPT` — npm script name, or empty to skip (default: `lint`)
|
||||
- `TEST_SCRIPT` — npm script name (default: `test`)
|
||||
- `QQQ_TEST_COMMANDS_JSON` — JSON array of shell commands; if set and non-empty,
|
||||
runs these instead of the default test command (same pattern as Munitor's
|
||||
`MUNITOR_TEST_COMMANDS_JSON`)
|
||||
|
||||
Logic:
|
||||
1. `qqq_header "node_app_test"`
|
||||
2. If `TYPE_CHECK_SCRIPT` is non-empty: run `<pkg_manager> run <TYPE_CHECK_SCRIPT> <TYPE_CHECK_ARGS>`
|
||||
3. If `LINT_SCRIPT` is non-empty: run `<pkg_manager> run <LINT_SCRIPT>`
|
||||
4. If `QQQ_TEST_COMMANDS_JSON` is set and not `[]`/`null`:
|
||||
- Parse JSON array using `jq` and run each command in sequence
|
||||
5. Else: run `<pkg_manager> run <TEST_SCRIPT>`
|
||||
6. Print pass/fail summary
|
||||
|
||||
### `src/scripts/node_app_build.sh`
|
||||
|
||||
Purpose: Run the production build. For Next.js this is `pnpm build`. Exits
|
||||
non-zero on failure with clear output.
|
||||
|
||||
Environment variables consumed:
|
||||
- `NODE_PKG_MANAGER` — default: pnpm
|
||||
- `BUILD_SCRIPT` — npm script name (default: `build`)
|
||||
|
||||
Logic:
|
||||
1. `qqq_header "node_app_build"`
|
||||
2. Check pkg manager is available
|
||||
3. Run `<pkg_manager> run <BUILD_SCRIPT>`
|
||||
4. Print success with timing
|
||||
|
||||
### `src/scripts/node_app_e2e.sh`
|
||||
|
||||
Purpose: Install Playwright browsers and run E2E tests. Reports results in
|
||||
JUnit format for CircleCI test analytics.
|
||||
|
||||
Environment variables consumed:
|
||||
- `NODE_PKG_MANAGER` — default: pnpm
|
||||
- `E2E_TEST_DIR` — default: `e2e`
|
||||
- `PLAYWRIGHT_BROWSERS` — comma-separated browsers (default: `chromium`)
|
||||
|
||||
Logic:
|
||||
1. `qqq_header "node_app_e2e"`
|
||||
2. Install Playwright browsers: `npx playwright install --with-deps <browsers>`
|
||||
3. Run: `npx playwright test --reporter=junit --output=e2e-results`
|
||||
- If `E2E_TEST_DIR/playwright.config.ts` exists, pass `--config=<path>`
|
||||
4. On failure: print "E2E tests failed. See playwright-report artifact." and
|
||||
re-exit with the original exit code
|
||||
5. Print pass summary
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create commands
|
||||
|
||||
### `src/commands/node_app_install.yml`
|
||||
|
||||
Parameters:
|
||||
- `pkg_manager` — string, default: `"pnpm"` — package manager (pnpm | npm | yarn)
|
||||
|
||||
Steps:
|
||||
1. Restore cache with keys:
|
||||
- `node-app-v1-{{ checksum "pnpm-lock.yaml" }}-{{ checksum "package-lock.json" }}-{{ checksum "yarn.lock" }}`
|
||||
- `node-app-v1-`
|
||||
2. Run `node_app_install.sh` with environment `NODE_PKG_MANAGER: << parameters.pkg_manager >>`
|
||||
3. Save cache key (same composite key as restore), paths: `node_modules`
|
||||
|
||||
### `src/commands/node_app_test.yml`
|
||||
|
||||
Parameters:
|
||||
- `pkg_manager` — string, default: `"pnpm"`
|
||||
- `type_check_script` — string, default: `"tsc"` — set empty to skip
|
||||
- `type_check_args` — string, default: `"--noEmit"`
|
||||
- `lint_script` — string, default: `"lint"` — set empty to skip
|
||||
- `test_script` — string, default: `"test"`
|
||||
- `test_commands` — string, default: `""` — JSON array of custom test commands
|
||||
|
||||
Steps:
|
||||
1. Run `node_app_test.sh` with environment:
|
||||
```
|
||||
NODE_PKG_MANAGER: << parameters.pkg_manager >>
|
||||
TYPE_CHECK_SCRIPT: << parameters.type_check_script >>
|
||||
TYPE_CHECK_ARGS: << parameters.type_check_args >>
|
||||
LINT_SCRIPT: << parameters.lint_script >>
|
||||
TEST_SCRIPT: << parameters.test_script >>
|
||||
QQQ_TEST_COMMANDS_JSON: << parameters.test_commands >>
|
||||
```
|
||||
2. store_artifacts: path: `test-results`, destination: `test-results` (if exists)
|
||||
|
||||
### `src/commands/node_app_build_step.yml`
|
||||
|
||||
Parameters:
|
||||
- `pkg_manager` — string, default: `"pnpm"`
|
||||
- `build_script` — string, default: `"build"`
|
||||
|
||||
Steps:
|
||||
1. Run `node_app_build.sh` with environment:
|
||||
```
|
||||
NODE_PKG_MANAGER: << parameters.pkg_manager >>
|
||||
BUILD_SCRIPT: << parameters.build_script >>
|
||||
```
|
||||
|
||||
### `src/commands/node_app_e2e.yml`
|
||||
|
||||
Parameters:
|
||||
- `pkg_manager` — string, default: `"pnpm"`
|
||||
- `test_dir` — string, default: `"e2e"`
|
||||
- `browsers` — string, default: `"chromium"`
|
||||
|
||||
Steps:
|
||||
1. Run `node_app_e2e.sh` with environment:
|
||||
```
|
||||
NODE_PKG_MANAGER: << parameters.pkg_manager >>
|
||||
E2E_TEST_DIR: << parameters.test_dir >>
|
||||
PLAYWRIGHT_BROWSERS: << parameters.browsers >>
|
||||
```
|
||||
2. store_test_results: path: `e2e-results`
|
||||
3. store_artifacts: path: `e2e-results`, destination: `e2e-results`
|
||||
4. store_artifacts: path: `test-results/traces`, destination: `playwright-traces`
|
||||
(use `when: always` so traces are captured even on failure)
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Create jobs
|
||||
|
||||
### `src/jobs/node_app_test_only.yml`
|
||||
|
||||
For feature/non-protected branches. Run install + test only (no build).
|
||||
|
||||
Executor: `default` (existing `cimg/node` Docker executor)
|
||||
- Use `default` executor, tag parameter: `<< parameters.node_version >>`
|
||||
|
||||
Parameters:
|
||||
- `node_version` — string, default: `"lts"`
|
||||
- `pkg_manager` — string, default: `"pnpm"`
|
||||
- `type_check_script` — string, default: `"tsc"`
|
||||
- `type_check_args` — string, default: `"--noEmit"`
|
||||
- `lint_script` — string, default: `"lint"`
|
||||
- `test_script` — string, default: `"test"`
|
||||
- `test_commands` — string, default: `""`
|
||||
|
||||
Steps:
|
||||
1. `git_full_checkout`
|
||||
2. `node_app_install` (pass `pkg_manager`)
|
||||
3. `node_app_test` (pass all test params)
|
||||
|
||||
### `src/jobs/node_app_build.yml`
|
||||
|
||||
For protected branches (develop, release/*, main, hotfix/*). Run install +
|
||||
test + production build. Persists workspace for a downstream E2E job.
|
||||
|
||||
Executor: `default` (cimg/node Docker)
|
||||
|
||||
Parameters: (same as node_app_test_only PLUS:)
|
||||
- `branch_type` — string, default: `"snapshot"` — reserved for future deploy hooks
|
||||
- `build_script` — string, default: `"build"`
|
||||
|
||||
Steps:
|
||||
1. `git_full_checkout`
|
||||
2. `node_app_install` (pass `pkg_manager`)
|
||||
3. `node_app_test` (pass all test params)
|
||||
4. `node_app_build_step` (pass `pkg_manager`, `build_script`)
|
||||
5. `persist_to_workspace` — root: `.`, paths: `["."]`
|
||||
(full workspace so E2E job gets built artifacts)
|
||||
|
||||
### `src/jobs/node_app_e2e.yml`
|
||||
|
||||
Separate E2E job. Attaches workspace from `node_app_build` and runs Playwright.
|
||||
|
||||
Executor: `default` (cimg/node Docker) — browsers are installed by the script
|
||||
using `npx playwright install --with-deps`, which works in Docker with root.
|
||||
|
||||
Parameters:
|
||||
- `node_version` — string, default: `"lts"`
|
||||
- `pkg_manager` — string, default: `"pnpm"`
|
||||
- `test_dir` — string, default: `"e2e"`
|
||||
- `browsers` — string, default: `"chromium"`
|
||||
|
||||
Steps:
|
||||
1. `attach_workspace` — at: `.`
|
||||
2. `node_app_e2e` (pass all E2E params)
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Do NOT modify any existing files
|
||||
|
||||
These must be completely unchanged:
|
||||
- All `mvn_*` jobs, commands, and scripts
|
||||
- All `mvn_frontend_*` jobs, commands, and scripts
|
||||
- All `node_*` jobs (node_test_only, node_publish), commands, and scripts
|
||||
- `src/@orb.yml` — do NOT add new orb imports; existing `browser-tools`,
|
||||
`node`, and `slack` orbs are already available
|
||||
- `src/executors/default.yml`
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Validate your work
|
||||
|
||||
After creating all files, run these commands from the orb root:
|
||||
|
||||
```bash
|
||||
make lint # yamllint + shellcheck + circleci validate
|
||||
make pack # pack to target/qqq-orb-packed.yml
|
||||
make validate # pack + validate
|
||||
```
|
||||
|
||||
Fix any yamllint, shellcheck, or orb validation errors before finishing.
|
||||
|
||||
---
|
||||
|
||||
## Target usage (what consuming repos will write)
|
||||
|
||||
After your changes, a pnpm Next.js app at `QRun-IO/qqq-frontend-next` writes:
|
||||
|
||||
```yaml
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: qrun-io/qqq-orb@<new-version>
|
||||
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/node_app_test_only:
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
branch_type: snapshot
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
only: [ develop ]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
branch_type: release_candidate
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
only: [ /release\/.*/ ]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
branch_type: release
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
only: [ main ]
|
||||
tags:
|
||||
only: [ /v.*/ ]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
branch_type: hotfix
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
only: [ /hotfix\/.*/ ]
|
||||
```
|
||||
|
||||
All parameters use defaults that work for any standard pnpm Next.js project
|
||||
with `tsc`, `lint`, `test`, and `build` scripts — zero configuration needed
|
||||
unless the project deviates from those conventions.
|
||||
|
||||
---
|
||||
|
||||
## Engineering quality bar
|
||||
|
||||
Every script must meet the standard set by Munitor's scripts:
|
||||
- `#!/usr/bin/env bash` + `set -euo pipefail`
|
||||
- `qqq_helpers.sh` sourced at top with fallback stub
|
||||
- `qqq_header` called as first real step
|
||||
- `qqq_check_tool` used to validate required tools before using them
|
||||
- Clear, actionable error messages with fix instructions
|
||||
- Summary output at the end (what ran, pass/fail, counts)
|
||||
- shellcheck-clean (no SC warnings)
|
||||
@@ -0,0 +1,73 @@
|
||||
# RecordQuery Feature Gap Analysis
|
||||
_Generated: 2026-02-28_
|
||||
|
||||
## Summary
|
||||
|
||||
The new RecordQuery implementation covers the core query-page surface area well: quick search, advanced filter builder with nested AND/OR groups and possible-value comboboxes, server-side pagination, single-column sort, column show/hide/reorder with drag-and-drop, column resize, saved views, CSV export, bulk process launch, row selection, density picker, grid/card view toggle, variant picker, and mobile filter bottom-sheet. The foundation is solid and the hook architecture (`useRecordQuery`) is clean.
|
||||
|
||||
The primary gaps fall into three categories: (1) **multi-column sort** — the DataGrid sort handler replaces the entire sort array on every click instead of appending to it, so only one column can be sorted at a time; (2) **join-aware filtering** — the FilterBuilder only lists columns from the primary table; joined-table fields exposed via `exposedJoins` cannot be added as filter criteria even though the backend type (`QFilterCriteria`) has no `joinName` field and uses dot-notation `fieldName` instead; (3) **select-all-across-pages / bulk delete** — the selection affordance is page-scoped (only rows visible on the current page can be checked) and the BulkActionBar's delete button is wired up to an `onDeleteSelected` prop that `RecordQuery` never passes, so bulk delete is dead code.
|
||||
|
||||
Several lower-impact features from the legacy implementation are also absent: per-row action buttons (edit/delete inline on each row), inline cell editing, and distinct-count display.
|
||||
|
||||
---
|
||||
|
||||
## Feature Inventory
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Multi-column sort | ⚠️ Partial | State and API types support multiple `orderBys`, but `handleSortColumn` in `DataGrid.tsx` calls `onSortChange([{ fieldName, isAscending: true }])` — a single-element array — on every click, replacing any prior sort. Shift-click to append a secondary sort is not implemented. |
|
||||
| Join filters | ⚠️ Partial | `use-record-query.ts` derives `QueryJoin[]` from `exposedJoins` and sends them to the API. However `FilterBuilder.tsx` builds its field list solely from `tableMetaData.fields` and has no awareness of joined-table fields. Users cannot add filter criteria on joined columns. The `QFilterCriteria` type uses dot-notation `fieldName` (e.g. `"joinedTable.fieldName"`) rather than a separate `joinName` property, so the backend contract is compatible — the UI just never produces such criteria. |
|
||||
| Bulk select across pages | ❌ Not implemented | The select-all checkbox (`grid-select-all`) selects only rows on the current page (TanStack Table `getToggleAllRowsSelectedHandler`). There is no "Select all N matching records" affordance analogous to the legacy "select all" banner. The `BulkActionBar` shows `"{selected} of {total} selected"` but has no way to extend selection beyond the page. |
|
||||
| Export | ✅ Implemented | `ExportButton.tsx` supports three scopes: all records (up to 10,000), current page, and selected records. CSV is built client-side from visible/ordered fields and downloaded via a temporary `<a>` element. No server-side export endpoint is called; this may diverge from the legacy approach for very large datasets or Excel format support. |
|
||||
| Inline edit | ❌ Not implemented | `DataCell.tsx` is read-only. There is no double-click-to-edit or pencil-icon affordance on any cell. The legacy implementation offered inline editing on editable fields. |
|
||||
| Saved views | ✅ Implemented | `SavedViewsMenu.tsx` and the `views` namespace in `use-record-query.ts` fully implement save / load / delete of named views persisted to `localStorage`. Views capture filter, column visibility, column order, and sort order. |
|
||||
| Quick search | ✅ Implemented | Debounced input in `RecordQueryToolbar.tsx` drives an OR-joined `CONTAINS` search across all visible `STRING`/`TEXT` columns. Mutually exclusive with the advanced filter panel. |
|
||||
| Column resizing | ✅ Implemented | `DataGrid.tsx` implements mousedown/mousemove/mouseup column resize with a minimum width of 60 px. Widths are persisted to `localStorage` via `useRecordQuery`. Resize listeners are cleaned up on unmount. |
|
||||
| Column reordering | ✅ Implemented | `ColumnConfig.tsx` supports HTML5 drag-and-drop reordering as well as keyboard Up/Down arrow keys on the grip button. Order is persisted to `localStorage`. Drag-reorder directly in the grid header is not implemented (only via the side panel). |
|
||||
| Pinned columns | ❌ Not implemented | No sticky/pinned column support. TanStack Table supports column pinning but it is not wired up. |
|
||||
| Bulk actions | ⚠️ Partial | Process launch from the bulk bar works (`ProcessLauncherMenu` inside `BulkActionBar`). Bulk delete is dead code — `BulkActionBar` renders a Delete button only when `onDeleteSelected` is provided, but `RecordQueryBulkBar.tsx` never passes that prop. There is no "delete selected" handler anywhere in `RecordQuery.tsx`. |
|
||||
| Row-level actions | ❌ Not implemented | Each row navigates to the record detail view on click but has no per-row action buttons (edit pencil, delete trash, custom process). The legacy implementation rendered per-row action icon buttons in a trailing "actions" column. |
|
||||
| Pagination | ✅ Implemented | Standard offset-based pagination with first/prev/next/last buttons, a page-size selector (10/25/50/100/250), and a "Go to page" input when `totalPages > 5`. Controlled by `use-record-query.ts`. |
|
||||
| Distinct count | ⚠️ Partial | `countRecords` in `tables.ts` accepts `includeDistinct = false` as a third parameter and the `CountRecordsResponse` type includes `distinctCount?`. However `use-record-query.ts` always calls `countRecords(tableName, { filter: countFilter, joins }, false)` (or omits the flag, defaulting to `false`) and the UI never reads or displays `distinctCount`. The infrastructure is there; it is just never invoked or surfaced. |
|
||||
|
||||
---
|
||||
|
||||
## Priority Gaps (implement these next)
|
||||
|
||||
Ranked by user-visible impact on day-to-day admin usage:
|
||||
|
||||
1. **Multi-column sort** — High impact, low effort. Change `handleSortColumn` in `DataGrid.tsx` to detect a Shift-click and append/toggle the clicked column in the existing `sortOrder` array rather than replacing it. Update the column header button to accept a `shiftKey` event attribute. The `QFilterOrderBy[]` structure and API calls already support multiple entries.
|
||||
|
||||
2. **Join filters** — High impact, medium effort. Extend `FilterBuilder.tsx` to accept a `joinedFields` prop (or derive it from `tableMetaData.exposedJoins`). Prefix each joined field's display label with the join table label (e.g. "Order → Customer Name"). Use dot-notation fieldName (e.g. `"customer.name"`) in the emitted `QFilterCriteria`. The backend already expects this format.
|
||||
|
||||
3. **Bulk select across pages** — Medium impact, medium effort. Add a "Select all {N} records" banner that appears below the bulk-action bar when the page-scoped selection equals `pageSize` and `totalCount > pageSize`. Store a `selectAllMode: boolean` flag in state. When `true`, submit the current filter as the selection scope to process launches rather than the explicit ID list.
|
||||
|
||||
4. **Bulk delete** — Medium impact, low effort. Wire up `onDeleteSelected` in `RecordQuery.tsx` (and pass it through `RecordQueryBulkBar`). Implement a confirmation dialog, call `deleteRecord` in a loop (or a future bulk-delete endpoint), then invalidate the records query.
|
||||
|
||||
5. **Row-level actions** — Medium impact, medium effort. Add an "actions" column to `DataGrid.tsx` as the last column. Render icon buttons for Edit (navigate to `/{tableName}/{id}/edit`), Delete (single-record confirmation modal), and optionally a per-row process dropdown if the table's `processes` array contains single-record processes. Guard each button on the respective permission flag from `tableMetaData`.
|
||||
|
||||
6. **Pinned columns** — Low-to-medium impact, medium effort. Enable TanStack Table's `columnPinning` feature. Add a "Pin left" option to the `ColumnConfig` panel or a column header context menu. Apply `position: sticky; left: 0` styling to pinned headers and cells. Persist the pinned set to `localStorage` alongside visibility and order.
|
||||
|
||||
7. **Distinct count display** — Low impact, low effort. Pass `includeDistinct = true` in `countRecords` when `tableMetaData.usesVariants` is true or when the query includes a join with `type: 'LEFT'`. Read `countQuery.data?.distinctCount` and display it alongside or instead of the plain count in the `Pagination` component.
|
||||
|
||||
8. **Inline edit** — Low impact (admin use case), high effort. On double-click of an editable cell, swap `DataCell` for a `DataCellEditor` component that renders an appropriate input (text, number, date, possible-value combobox). On blur or Enter, call `updateRecord` and invalidate the query cache. Requires a new `DataCellEditor` component, field-type-aware input selection, and optimistic update handling.
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementation Notes
|
||||
|
||||
The following patterns from the legacy `RecordQuery.tsx` (MUI + React) are worth preserving or adapting as the new implementation matures:
|
||||
|
||||
- **Multi-column sort via Shift-click:** The legacy grid accumulates `orderBys` entries when the user holds Shift while clicking a column header. This is the industry-standard UX (matching MUI DataGrid Pro) and should be replicated exactly.
|
||||
|
||||
- **Join filter field grouping:** The legacy FilterBuilder groups fields under their parent table heading in the field selector dropdown (e.g. a separator "— Order fields —" followed by "— Customer fields —"). This makes joined fields discoverable rather than mixed into a flat list.
|
||||
|
||||
- **Select-all banner:** When the user selects all rows on the current page, a blue banner appears: "All 25 records on this page are selected. Select all 1,247 records matching your filter." Clicking the link sets a `filterOnlyMode` flag that replaces the ID list with the filter in process invocations.
|
||||
|
||||
- **Per-row action icons:** The legacy grid renders three small icon buttons at the end of every row (view, edit, delete) that are hidden until hover. They are absolutely positioned so they do not affect column widths. The new DataGrid's trailing-column approach is acceptable but should use `opacity-0 group-hover:opacity-100` CSS to avoid visual noise.
|
||||
|
||||
- **Export via server endpoint (Excel):** The legacy implementation calls a dedicated server-side export endpoint (`POST /table/{name}/export`) that returns an Excel file stream. The current implementation builds a CSV client-side from the raw query results. This has a hard limit of 10,000 rows and produces only CSV. For parity, a server-side export endpoint call should be added; the client-side fallback can remain for smaller datasets.
|
||||
|
||||
- **Distinct count in count query:** The legacy implementation passes `includeDistinctCount=true` for tables that use joins, and displays "N (M distinct)" in the pagination footer when `distinctCount < count`. This helps users understand that a LEFT join is inflating the row count.
|
||||
|
||||
- **Filter variable expressions (NOW, NOW\_WITH\_OFFSET):** The legacy FilterBuilder renders dedicated UI controls for dynamic date expressions so users can save filters like "created in the last 7 days" that remain accurate over time. The types (`NowExpression`, `NowWithOffsetExpression`, `ThisOrLastPeriodExpression`) are already defined in `src/types/query.ts` and `filter-utils.ts` includes type guards and display formatting for them, but the `FilterBuilder` UI never offers these expressions as selectable value options — only static scalar inputs are rendered.
|
||||
@@ -0,0 +1,78 @@
|
||||
import nextConfig from 'eslint-config-next'
|
||||
import prettierConfig from 'eslint-config-prettier'
|
||||
import jsdoc from 'eslint-plugin-jsdoc'
|
||||
|
||||
const eslintConfig = [
|
||||
...nextConfig,
|
||||
prettierConfig,
|
||||
{
|
||||
rules: {
|
||||
// React Compiler rules — project does not use React Compiler; disable to avoid false positives
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
'react-hooks/incompatible-library': 'off',
|
||||
'react-hooks/globals': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSDoc enforcement — all TypeScript source files except tests and stories
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
ignores: [
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/**/*.spec.{ts,tsx}',
|
||||
'src/**/*.stories.{ts,tsx}',
|
||||
],
|
||||
plugins: { jsdoc },
|
||||
settings: {
|
||||
jsdoc: {
|
||||
mode: 'typescript',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Require JSDoc on all exported functions, classes, and methods
|
||||
'jsdoc/require-jsdoc': [
|
||||
'error',
|
||||
{
|
||||
publicOnly: false,
|
||||
require: {
|
||||
FunctionDeclaration: true,
|
||||
MethodDefinition: true,
|
||||
ClassDeclaration: true,
|
||||
ArrowFunctionExpression: false, // too noisy for inline callbacks
|
||||
FunctionExpression: false, // too noisy for inline expressions
|
||||
},
|
||||
checkConstructors: false, // constructors documented on the class
|
||||
contexts: [
|
||||
// Exported arrow functions assigned to a const
|
||||
'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > ArrowFunctionExpression',
|
||||
'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > FunctionExpression',
|
||||
],
|
||||
enableFixer: false,
|
||||
},
|
||||
],
|
||||
|
||||
// Every JSDoc block must have a non-empty description
|
||||
'jsdoc/require-description': ['error', { descriptionStyle: 'any' }],
|
||||
|
||||
// Params and returns
|
||||
'jsdoc/require-param': ['error', { checkDestructured: false }],
|
||||
'jsdoc/require-param-description': 'error',
|
||||
'jsdoc/require-returns': ['error', { forceReturnsWithAsync: false }],
|
||||
'jsdoc/require-returns-description': 'error',
|
||||
|
||||
// Correctness checks
|
||||
'jsdoc/check-param-names': ['error', { checkDestructured: false }],
|
||||
'jsdoc/check-tag-names': ['error', { typed: true }],
|
||||
'jsdoc/no-undefined-types': 'off', // too many false positives with complex TS generics
|
||||
|
||||
// File-level overview
|
||||
'jsdoc/require-file-overview': 'error',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default eslintConfig
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
async rewrites() {
|
||||
const backend = process.env.QQQ_BACKEND_URL
|
||||
if (!backend) return []
|
||||
return ['qqq', 'data', 'widget', 'metaData', 'download', 'processes'].map((prefix) => ({
|
||||
source: `/${prefix}/:path*`,
|
||||
destination: `${backend}/${prefix}/:path*`,
|
||||
}))
|
||||
},
|
||||
typescript: {
|
||||
tsconfigPath: './tsconfig.json',
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint src && node scripts/check-license-headers.mjs",
|
||||
"lint:jsdoc": "eslint src --ext .ts,.tsx",
|
||||
"lint:license": "node scripts/check-license-headers.mjs",
|
||||
"lint:license:fix": "node scripts/check-license-headers.mjs --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"tsc": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "git config core.hooksPath .githooks || true",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth0/auth0-react": "^2.15.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.485.0",
|
||||
"next": "16.3.6",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"ts-md5": "^1.3.1",
|
||||
"universal-cookie": "^7.2.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.1",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@storybook/addon-docs": "10.6.0",
|
||||
"@storybook/nextjs": "10.6.0",
|
||||
"@storybook/react": "10.6.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest-axe": "^3.5.9",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^22.19.11",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"@vitest/ui": "^3.2.7",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "16.3.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsdoc": "^62.7.1",
|
||||
"jest-axe": "^10.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"msw": "^2.12.10",
|
||||
"postcss": "^8.5.28",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "10.6.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html style='scrollbar-gutter: stable both-edges;'>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='color-scheme' content='dark light'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<title>Playwright Test Report</title>
|
||||
<script type="module">var NA=Object.defineProperty;var BA=(l,u,c)=>u in l?NA(l,u,{enumerable:!0,configurable:!0,writable:!0,value:c}):l[u]=c;var yn=(l,u,c)=>BA(l,typeof u!="symbol"?u+"":u,c);(function(){const u=document.createElement("link").relList;if(u&&u.supports&&u.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))f(r);new MutationObserver(r=>{for(const o of r)if(o.type==="childList")for(const h of o.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&f(h)}).observe(document,{childList:!0,subtree:!0});function c(r){const o={};return r.integrity&&(o.integrity=r.integrity),r.referrerPolicy&&(o.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?o.credentials="include":r.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function f(r){if(r.ep)return;r.ep=!0;const o=c(r);fetch(r.href,o)}})();function UA(l){return l&&l.__esModule&&Object.prototype.hasOwnProperty.call(l,"default")?l.default:l}var Mf={exports:{}},Ei={};/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var D1;function QA(){if(D1)return Ei;D1=1;var l=Symbol.for("react.transitional.element"),u=Symbol.for("react.fragment");function c(f,r,o){var h=null;if(o!==void 0&&(h=""+o),r.key!==void 0&&(h=""+r.key),"key"in r){o={};for(var v in r)v!=="key"&&(o[v]=r[v])}else o=r;return r=o.ref,{$$typeof:l,type:f,key:h,ref:r!==void 0?r:null,props:o}}return Ei.Fragment=u,Ei.jsx=c,Ei.jsxs=c,Ei}var M1;function zA(){return M1||(M1=1,Mf.exports=QA()),Mf.exports}var m=zA();const YA=15,xt=0,bn=1,LA=2,ye=-2,Ut=-3,j1=-4,xn=-5,Me=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535],L2=1440,GA=0,XA=4,VA=9,ZA=5,qA=[96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,192,80,7,10,0,8,96,0,8,32,0,9,160,0,8,0,0,8,128,0,8,64,0,9,224,80,7,6,0,8,88,0,8,24,0,9,144,83,7,59,0,8,120,0,8,56,0,9,208,81,7,17,0,8,104,0,8,40,0,9,176,0,8,8,0,8,136,0,8,72,0,9,240,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,200,81,7,13,0,8,100,0,8,36,0,9,168,0,8,4,0,8,132,0,8,68,0,9,232,80,7,8,0,8,92,0,8,28,0,9,152,84,7,83,0,8,124,0,8,60,0,9,216,82,7,23,0,8,108,0,8,44,0,9,184,0,8,12,0,8,140,0,8,76,0,9,248,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,196,81,7,11,0,8,98,0,8,34,0,9,164,0,8,2,0,8,130,0,8,66,0,9,228,80,7,7,0,8,90,0,8,26,0,9,148,84,7,67,0,8,122,0,8,58,0,9,212,82,7,19,0,8,106,0,8,42,0,9,180,0,8,10,0,8,138,0,8,74,0,9,244,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,204,81,7,15,0,8,102,0,8,38,0,9,172,0,8,6,0,8,134,0,8,70,0,9,236,80,7,9,0,8,94,0,8,30,0,9,156,84,7,99,0,8,126,0,8,62,0,9,220,82,7,27,0,8,110,0,8,46,0,9,188,0,8,14,0,8,142,0,8,78,0,9,252,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,194,80,7,10,0,8,97,0,8,33,0,9,162,0,8,1,0,8,129,0,8,65,0,9,226,80,7,6,0,8,89,0,8,25,0,9,146,83,7,59,0,8,121,0,8,57,0,9,210,81,7,17,0,8,105,0,8,41,0,9,178,0,8,9,0,8,137,0,8,73,0,9,242,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,202,81,7,13,0,8,101,0,8,37,0,9,170,0,8,5,0,8,133,0,8,69,0,9,234,80,7,8,0,8,93,0,8,29,0,9,154,84,7,83,0,8,125,0,8,61,0,9,218,82,7,23,0,8,109,0,8,45,0,9,186,0,8,13,0,8,141,0,8,77,0,9,250,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,198,81,7,11,0,8,99,0,8,35,0,9,166,0,8,3,0,8,131,0,8,67,0,9,230,80,7,7,0,8,91,0,8,27,0,9,150,84,7,67,0,8,123,0,8,59,0,9,214,82,7,19,0,8,107,0,8,43,0,9,182,0,8,11,0,8,139,0,8,75,0,9,246,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,206,81,7,15,0,8,103,0,8,39,0,9,174,0,8,7,0,8,135,0,8,71,0,9,238,80,7,9,0,8,95,0,8,31,0,9,158,84,7,99,0,8,127,0,8,63,0,9,222,82,7,27,0,8,111,0,8,47,0,9,190,0,8,15,0,8,143,0,8,79,0,9,254,96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,193,80,7,10,0,8,96,0,8,32,0,9,161,0,8,0,0,8,128,0,8,64,0,9,225,80,7,6,0,8,88,0,8,24,0,9,145,83,7,59,0,8,120,0,8,56,0,9,209,81,7,17,0,8,104,0,8,40,0,9,177,0,8,8,0,8,136,0,8,72,0,9,241,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,201,81,7,13,0,8,100,0,8,36,0,9,169,0,8,4,0,8,132,0,8,68,0,9,233,80,7,8,0,8,92,0,8,28,0,9,153,84,7,83,0,8,124,0,8,60,0,9,217,82,7,23,0,8,108,0,8,44,0,9,185,0,8,12,0,8,140,0,8,76,0,9,249,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,197,81,7,11,0,8,98,0,8,34,0,9,165,0,8,2,0,8,130,0,8,66,0,9,229,80,7,7,0,8,90,0,8,26,0,9,149,84,7,67,0,8,122,0,8,58,0,9,213,82,7,19,0,8,106,0,8,42,0,9,181,0,8,10,0,8,138,0,8,74,0,9,245,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,205,81,7,15,0,8,102,0,8,38,0,9,173,0,8,6,0,8,134,0,8,70,0,9,237,80,7,9,0,8,94,0,8,30,0,9,157,84,7,99,0,8,126,0,8,62,0,9,221,82,7,27,0,8,110,0,8,46,0,9,189,0,8,14,0,8,142,0,8,78,0,9,253,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,195,80,7,10,0,8,97,0,8,33,0,9,163,0,8,1,0,8,129,0,8,65,0,9,227,80,7,6,0,8,89,0,8,25,0,9,147,83,7,59,0,8,121,0,8,57,0,9,211,81,7,17,0,8,105,0,8,41,0,9,179,0,8,9,0,8,137,0,8,73,0,9,243,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,203,81,7,13,0,8,101,0,8,37,0,9,171,0,8,5,0,8,133,0,8,69,0,9,235,80,7,8,0,8,93,0,8,29,0,9,155,84,7,83,0,8,125,0,8,61,0,9,219,82,7,23,0,8,109,0,8,45,0,9,187,0,8,13,0,8,141,0,8,77,0,9,251,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,199,81,7,11,0,8,99,0,8,35,0,9,167,0,8,3,0,8,131,0,8,67,0,9,231,80,7,7,0,8,91,0,8,27,0,9,151,84,7,67,0,8,123,0,8,59,0,9,215,82,7,19,0,8,107,0,8,43,0,9,183,0,8,11,0,8,139,0,8,75,0,9,247,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,207,81,7,15,0,8,103,0,8,39,0,9,175,0,8,7,0,8,135,0,8,71,0,9,239,80,7,9,0,8,95,0,8,31,0,9,159,84,7,99,0,8,127,0,8,63,0,9,223,82,7,27,0,8,111,0,8,47,0,9,191,0,8,15,0,8,143,0,8,79,0,9,255],IA=[80,5,1,87,5,257,83,5,17,91,5,4097,81,5,5,89,5,1025,85,5,65,93,5,16385,80,5,3,88,5,513,84,5,33,92,5,8193,82,5,9,90,5,2049,86,5,129,192,5,24577,80,5,2,87,5,385,83,5,25,91,5,6145,81,5,7,89,5,1537,85,5,97,93,5,24577,80,5,4,88,5,769,84,5,49,92,5,12289,82,5,13,90,5,3073,86,5,193,192,5,24577],KA=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],kA=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,112,112],JA=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,Line truncated
|
||||
* @license React
|
||||
* react.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var T2;function B5(){if(T2)return dt;T2=1;var l=Symbol.for("react.transitional.element"),u=Symbol.for("react.portal"),c=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),o=Symbol.for("react.consumer"),h=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),y=Symbol.for("react.suspense"),A=Symbol.for("react.memo"),E=Symbol.for("react.lazy"),S=Symbol.for("react.activity"),O=Symbol.iterator;function X(C){return C===null||typeof C!="object"?null:(C=O&&C[O]||C["@@iterator"],typeof C=="function"?C:null)}var B={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},b=Object.assign,p={};function x(C,L,W){this.props=C,this.context=L,this.refs=p,this.updater=W||B}x.prototype.isReactComponent={},x.prototype.setState=function(C,L){if(typeof C!="object"&&typeof C!="function"&&C!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,C,L,"setState")},x.prototype.forceUpdate=function(C){this.updater.enqueueForceUpdate(this,C,"forceUpdate")};function R(){}R.prototype=x.prototype;function U(C,L,W){this.props=C,this.context=L,this.refs=p,this.updater=W||B}var Z=U.prototype=new R;Z.constructor=U,b(Z,x.prototype),Z.isPureReactComponent=!0;var F=Array.isArray;function j(){}var D={H:null,A:null,T:null,S:null},N=Object.prototype.hasOwnProperty;function K(C,L,W){var et=W.ref;return{$$typeof:l,type:C,key:L,ref:et!==void 0?et:null,props:W}}function J(C,L){return K(C.type,L,C.props)}function k(C){return typeof C=="object"&&C!==null&&C.$$typeof===l}function nt(C){var L={"=":"=0",":":"=2"};return"$"+C.replace(/[=:]/g,function(W){return L[W]})}var P=/\/+/g;function st(C,L){return typeof C=="object"&&C!==null&&C.key!=null?nt(""+C.key):L.toString(36)}function it(C){switch(C.status){case"fulfilled":return C.value;case"rejected":throw C.reason;default:switch(typeof C.status=="string"?C.then(j,j):(C.status="pending",C.then(function(L){C.status==="pending"&&(C.status="fulfilled",C.value=L)},function(L){C.status==="pending"&&(C.status="rejected",C.reason=L)})),C.status){case"fulfilled":return C.value;case"rejected":throw C.reason}}throw C}function H(C,L,W,et,rt){var ot=typeof C;(ot==="undefined"||ot==="boolean")&&(C=null);var gt=!1;if(C===null)gt=!0;else switch(ot){case"bigint":case"string":case"number":gt=!0;break;case"object":switch(C.$$typeof){case l:case u:gt=!0;break;case E:return gt=C._init,H(gt(C._payload),L,W,et,rt)}}if(gt)return rt=rt(C),gt=et===""?"."+st(C,0):et,F(rt)?(W="",gt!=null&&(W=gt.replace(P,"$&/")+"/"),H(rt,L,W,"",function(On){return On})):rt!=null&&(k(rt)&&(rt=J(rt,W+(rt.key==null||C&&C.key===rt.key?"":(""+rt.key).replace(P,"$&/")+"/")+gt)),L.push(rt)),1;gt=0;var Ft=et===""?".":et+":";if(F(C))for(var Qt=0;Qt<C.length;Qt++)et=C[Qt],ot=Ft+st(et,Qt),gt+=H(et,L,W,ot,rt);else if(Qt=X(C),typeof Qt=="function")for(C=Qt.call(C),Qt=0;!(et=C.next()).done;)et=et.value,ot=Ft+st(et,Qt++),gt+=H(et,L,W,ot,rt);else if(ot==="object"){if(typeof C.then=="function")return H(it(C),L,W,et,rt);throw L=String(C),Error("Objects are not valid as a React child (found: "+(L==="[object Object]"?"object with keys {"+Object.keys(C).join(", ")+"}":L)+"). If you meant to render a collection of children, use an array instead.")}return gt}function _(C,L,W){if(C==null)return C;var et=[],rt=0;return H(C,et,"","",function(ot){return L.call(W,ot,rt++)}),et}function $(C){if(C._status===-1){var L=C._result;L=L(),L.then(function(W){(C._status===0||C._status===-1)&&(C._status=1,C._result=W)},function(W){(C._status===0||C._status===-1)&&(C._status=2,C._result=W)}),C._status===-1&&(C._status=0,C._result=L)}if(C._status===1)return C._result.default;throw C._result}var ht=typeof reportError=="function"?reportError:function(C){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var L=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof C=="object"&&C!==null&&typeof C.message=="string"?String(C.message):String(C),error:C});if(!window.dispatchEvent(L))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",C);return}console.error(C)},tt={map:_,forEach:function(C,L,W){_(C,function(){L.apply(this,arguments)},W)},count:function(C){var L=0;return _(C,function(){L++}),L},toArray:function(C){return _(C,function(L){return L})||[]},only:function(C){if(!k(C))throw Error("React.Children.only expected to receive a single React element child.");return C}};return dt.Activity=S,dt.Children=tt,dt.Component=x,dt.Fragment=c,dt.Profiler=r,dt.PureComponent=U,dt.StrictMode=f,dt.Suspense=y,dt.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=D,dt.__COMPILER_RUNTIME={__proto__:null,c:function(C){return D.H.useMemoCache(C)}},dt.cache=function(C){return function(){return C.apply(null,arguments)}},dt.cacheSignal=function(){return null},dt.cloneElement=function(C,L,W){if(C==nullLine truncated
|
||||
* @license React
|
||||
* scheduler.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var O2;function U5(){return O2||(O2=1,(function(l){function u(H,_){var $=H.length;H.push(_);t:for(;0<$;){var ht=$-1>>>1,tt=H[ht];if(0<r(tt,_))H[ht]=_,H[$]=tt,$=ht;else break t}}function c(H){return H.length===0?null:H[0]}function f(H){if(H.length===0)return null;var _=H[0],$=H.pop();if($!==_){H[0]=$;t:for(var ht=0,tt=H.length,C=tt>>>1;ht<C;){var L=2*(ht+1)-1,W=H[L],et=L+1,rt=H[et];if(0>r(W,$))et<tt&&0>r(rt,W)?(H[ht]=rt,H[et]=$,ht=et):(H[ht]=W,H[L]=$,ht=L);else if(et<tt&&0>r(rt,$))H[ht]=rt,H[et]=$,ht=et;else break t}}return _}function r(H,_){var $=H.sortIndex-_.sortIndex;return $!==0?$:H.id-_.id}if(l.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var o=performance;l.unstable_now=function(){return o.now()}}else{var h=Date,v=h.now();l.unstable_now=function(){return h.now()-v}}var y=[],A=[],E=1,S=null,O=3,X=!1,B=!1,b=!1,p=!1,x=typeof setTimeout=="function"?setTimeout:null,R=typeof clearTimeout=="function"?clearTimeout:null,U=typeof setImmediate<"u"?setImmediate:null;function Z(H){for(var _=c(A);_!==null;){if(_.callback===null)f(A);else if(_.startTime<=H)f(A),_.sortIndex=_.expirationTime,u(y,_);else break;_=c(A)}}function F(H){if(b=!1,Z(H),!B)if(c(y)!==null)B=!0,j||(j=!0,nt());else{var _=c(A);_!==null&&it(F,_.startTime-H)}}var j=!1,D=-1,N=5,K=-1;function J(){return p?!0:!(l.unstable_now()-K<N)}function k(){if(p=!1,j){var H=l.unstable_now();K=H;var _=!0;try{t:{B=!1,b&&(b=!1,R(D),D=-1),X=!0;var $=O;try{e:{for(Z(H),S=c(y);S!==null&&!(S.expirationTime>H&&J());){var ht=S.callback;if(typeof ht=="function"){S.callback=null,O=S.priorityLevel;var tt=ht(S.expirationTime<=H);if(H=l.unstable_now(),typeof tt=="function"){S.callback=tt,Z(H),_=!0;break e}S===c(y)&&f(y),Z(H)}else f(y);S=c(y)}if(S!==null)_=!0;else{var C=c(A);C!==null&&it(F,C.startTime-H),_=!1}}break t}finally{S=null,O=$,X=!1}_=void 0}}finally{_?nt():j=!1}}}var nt;if(typeof U=="function")nt=function(){U(k)};else if(typeof MessageChannel<"u"){var P=new MessageChannel,st=P.port2;P.port1.onmessage=k,nt=function(){st.postMessage(null)}}else nt=function(){x(k,0)};function it(H,_){D=x(function(){H(l.unstable_now())},_)}l.unstable_IdlePriority=5,l.unstable_ImmediatePriority=1,l.unstable_LowPriority=4,l.unstable_NormalPriority=3,l.unstable_Profiling=null,l.unstable_UserBlockingPriority=2,l.unstable_cancelCallback=function(H){H.callback=null},l.unstable_forceFrameRate=function(H){0>H||125<H?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):N=0<H?Math.floor(1e3/H):5},l.unstable_getCurrentPriorityLevel=function(){return O},l.unstable_next=function(H){switch(O){case 1:case 2:case 3:var _=3;break;default:_=O}var $=O;O=_;try{return H()}finally{O=$}},l.unstable_requestPaint=function(){p=!0},l.unstable_runWithPriority=function(H,_){switch(H){case 1:case 2:case 3:case 4:case 5:break;default:H=3}var $=O;O=H;try{return _()}finally{O=$}},l.unstable_scheduleCallback=function(H,_,$){var ht=l.unstable_now();switch(typeof $=="object"&&$!==null?($=$.delay,$=typeof $=="number"&&0<$?ht+$:ht):$=ht,H){case 1:var tt=-1;break;case 2:tt=250;break;case 5:tt=1073741823;break;case 4:tt=1e4;break;default:tt=5e3}return tt=$+tt,H={id:E++,callback:_,priorityLevel:H,startTime:$,expirationTime:tt,sortIndex:-1},$>ht?(H.sortIndex=$,u(A,H),c(y)===null&&H===c(A)&&(b?(R(D),D=-1):b=!0,it(F,$-ht))):(H.sortIndex=tt,u(y,H),B||X||(B=!0,j||(j=!0,nt()))),H},l.unstable_shouldYield=J,l.unstable_wrapCallback=function(H){var _=O;return function(){var $=O;O=_;try{return H.apply(this,arguments)}finally{O=$}}}})(qf)),qf}var w2;function Q5(){return w2||(w2=1,Zf.exports=U5()),Zf.exports}var If={exports:{}},ce={};/**
|
||||
* @license React
|
||||
* react-dom.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var R2;function z5(){if(R2)return ce;R2=1;var l=xr();function u(y){var A="https://react.dev/errors/"+y;if(1<arguments.length){A+="?args[]="+encodeURIComponent(arguments[1]);for(var E=2;E<arguments.length;E++)A+="&args[]="+encodeURIComponent(arguments[E])}return"Minified React error #"+y+"; visit "+A+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function c(){}var f={d:{f:c,r:function(){throw Error(u(522))},D:c,C:c,L:c,m:c,X:c,S:c,M:c},p:0,findDOMNode:null},r=Symbol.for("react.portal");function o(y,A,E){var S=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:r,key:S==null?null:""+S,children:y,containerInfo:A,implementation:E}}var h=l.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function v(y,A){if(y==="font")return"";if(typeof A=="string")return A==="use-credentials"?A:""}return ce.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ce.createPortal=function(y,A){var E=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!A||A.nodeType!==1&&A.nodeType!==9&&A.nodeType!==11)throw Error(u(299));return o(y,A,null,E)},ce.flushSync=function(y){var A=h.T,E=f.p;try{if(h.T=null,f.p=2,y)return y()}finally{h.T=A,f.p=E,f.d.f()}},ce.preconnect=function(y,A){typeof y=="string"&&(A?(A=A.crossOrigin,A=typeof A=="string"?A==="use-credentials"?A:"":void 0):A=null,f.d.C(y,A))},ce.prefetchDNS=function(y){typeof y=="string"&&f.d.D(y)},ce.preinit=function(y,A){if(typeof y=="string"&&A&&typeof A.as=="string"){var E=A.as,S=v(E,A.crossOrigin),O=typeof A.integrity=="string"?A.integrity:void 0,X=typeof A.fetchPriority=="string"?A.fetchPriority:void 0;E==="style"?f.d.S(y,typeof A.precedence=="string"?A.precedence:void 0,{crossOrigin:S,integrity:O,fetchPriority:X}):E==="script"&&f.d.X(y,{crossOrigin:S,integrity:O,fetchPriority:X,nonce:typeof A.nonce=="string"?A.nonce:void 0})}},ce.preinitModule=function(y,A){if(typeof y=="string")if(typeof A=="object"&&A!==null){if(A.as==null||A.as==="script"){var E=v(A.as,A.crossOrigin);f.d.M(y,{crossOrigin:E,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0})}}else A==null&&f.d.M(y)},ce.preload=function(y,A){if(typeof y=="string"&&typeof A=="object"&&A!==null&&typeof A.as=="string"){var E=A.as,S=v(E,A.crossOrigin);f.d.L(y,E,{crossOrigin:S,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0,type:typeof A.type=="string"?A.type:void 0,fetchPriority:typeof A.fetchPriority=="string"?A.fetchPriority:void 0,referrerPolicy:typeof A.referrerPolicy=="string"?A.referrerPolicy:void 0,imageSrcSet:typeof A.imageSrcSet=="string"?A.imageSrcSet:void 0,imageSizes:typeof A.imageSizes=="string"?A.imageSizes:void 0,media:typeof A.media=="string"?A.media:void 0})}},ce.preloadModule=function(y,A){if(typeof y=="string")if(A){var E=v(A.as,A.crossOrigin);f.d.m(y,{as:typeof A.as=="string"&&A.as!=="script"?A.as:void 0,crossOrigin:E,integrity:typeof A.integrity=="string"?A.integrity:void 0})}else f.d.m(y)},ce.requestFormReset=function(y){f.d.r(y)},ce.unstable_batchedUpdates=function(y,A){return y(A)},ce.useFormState=function(y,A,E){return h.H.useFormState(y,A,E)},ce.useFormStatus=function(){return h.H.useHostTransitionStatus()},ce.version="19.2.1",ce}var D2;function Y5(){if(D2)return If.exports;D2=1;function l(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(l)}catch(u){console.error(u)}}return l(),If.exports=z5(),If.exports}/**
|
||||
* @license React
|
||||
* react-dom-client.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var M2;function L5(){if(M2)return xi;M2=1;var l=Q5(),u=xr(),c=Y5();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)e+="&args[]="+encodeURIComponent(arguments[n])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function r(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function o(t){var e=t,n=t;if(t.alternate)for(;e.return;)e=e.return;else{t=e;do e=t,(e.flags&4098)!==0&&(n=e.return),t=e.return;while(t)}return e.tag===3?n:null}function h(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function v(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function y(t){if(o(t)!==t)throw Error(f(188))}function A(t){var e=t.alternate;if(!e){if(e=o(t),e===null)throw Error(f(188));return e!==t?null:t}for(var n=t,a=e;;){var i=n.return;if(i===null)break;var s=i.alternate;if(s===null){if(a=i.return,a!==null){n=a;continue}break}if(i.child===s.child){for(s=i.child;s;){if(s===n)return y(i),t;if(s===a)return y(i),e;s=s.sibling}throw Error(f(188))}if(n.return!==a.return)n=i,a=s;else{for(var d=!1,g=i.child;g;){if(g===n){d=!0,n=i,a=s;break}if(g===a){d=!0,a=i,n=s;break}g=g.sibling}if(!d){for(g=s.child;g;){if(g===n){d=!0,n=s,a=i;break}if(g===a){d=!0,a=s,n=i;break}g=g.sibling}if(!d)throw Error(f(189))}}if(n.alternate!==a)throw Error(f(190))}if(n.tag!==3)throw Error(f(188));return n.stateNode.current===n?t:e}function E(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=E(t),e!==null)return e;t=t.sibling}return null}var S=Object.assign,O=Symbol.for("react.element"),X=Symbol.for("react.transitional.element"),B=Symbol.for("react.portal"),b=Symbol.for("react.fragment"),p=Symbol.for("react.strict_mode"),x=Symbol.for("react.profiler"),R=Symbol.for("react.consumer"),U=Symbol.for("react.context"),Z=Symbol.for("react.forward_ref"),F=Symbol.for("react.suspense"),j=Symbol.for("react.suspense_list"),D=Symbol.for("react.memo"),N=Symbol.for("react.lazy"),K=Symbol.for("react.activity"),J=Symbol.for("react.memo_cache_sentinel"),k=Symbol.iterator;function nt(t){return t===null||typeof t!="object"?null:(t=k&&t[k]||t["@@iterator"],typeof t=="function"?t:null)}var P=Symbol.for("react.client.reference");function st(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===P?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case b:return"Fragment";case x:return"Profiler";case p:return"StrictMode";case F:return"Suspense";case j:return"SuspenseList";case K:return"Activity"}if(typeof t=="object")switch(t.$$typeof){case B:return"Portal";case U:return t.displayName||"Context";case R:return(t._context.displayName||"Context")+".Consumer";case Z:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case D:return e=t.displayName||null,e!==null?e:st(t.type)||"Memo";case N:e=t._payload,t=t._init;try{return st(t(e))}catch{}}return null}var it=Array.isArray,H=u.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,_=c.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,$={pending:!1,data:null,method:null,action:null},ht=[],tt=-1;function C(t){return{current:t}}function L(t){0>tt||(t.current=ht[tt],ht[tt]=null,tt--)}function W(t,e){tt++,ht[tt]=t.current,t.current=e}var et=C(null),rt=C(null),ot=C(null),gt=C(null);function Ft(t,e){switch(W(ot,e),W(rt,t),W(et,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?Pd(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=Pd(e),t=$d(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}L(et),W(et,t)}function Qt(){L(et),L(rt),L(ot)}function On(t){t.memoizedState!==null&&W(gt,t);var e=et.current,n=$d(e,t.type);e!==n&&(W(rt,t),W(et,n))}function ra(t){rt.current===t&&(L(et),L(rt)),gt.current===t&&(L(gt),gi._currentValue=$)}var wn,Ba;function Ee(t){if(wn===void 0)try{throw Error()}catch(n){var e=n.stack.trim().match(/\n( *(at )?)/);wn=e&&e[1]||"",Ba=-1<n.stack.indexOf(`
|
||||
at`)?" (<anonymous>)":-1<n.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+wn+t+Ba}var Ua=!1;function Rn(t,e){if(!t||Ua)return"";Ua=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var I=function(){throw Error()};if(Object.defineProperty(I.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(I,[])}catch(G){var Y=G}Reflect.construct(t,[],I)}else{try{I.call()}catch(G){Y=G}t.call(I.prototype)}}else{try{throw Error()}catch(G){Y=G}(I=t())&&typeof I.catch=="function"&&I.catch(function(){})}}catch(G){if(G&&Y&&typeof G.stack=="string")return[G.stack,Y.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var i=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");i&&i.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var s=a.DetermineComponentFrameRoot(),d=s[0],g=s[1];if(d&&g){var T=d.split(`
|
||||
`),z=g.split(`
|
||||
`);for(i=a=0;a<T.length&&!T[a].includes("DetermineComponentFrameRoot");)a++;for(;i<z.length&&!z[i].includes("DetermineComponentFrameRoot");)i++;if(a===T.length||i===z.length)for(a=T.length-1,i=z.length-1;1<=a&&0<=i&&T[a]!==z[i];)i--;for(;1<=a&&0<=i;a--,i--)if(T[a]!==z[i]){if(a!==1||i!==1)do if(a--,i--,0>i||T[a]!==z[i]){var V=`
|
||||
`+T[a].replace(" at new "," at ");return t.displayName&&V.includes("<anonymous>")&&(V=V.replace("<anonymous>",t.displayName)),V}while(1<=a&&0<=i);break}}}finally{Ua=!1,Error.prepareStackTrace=n}return(n=t?t.displayName||t.name:"")?Ee(n):""}function Qa(t,e){switch(t.tag){case 26:case 27:case 5:return Ee(t.type);case 16:return Ee("Lazy");case 13:return t.child!==e&&e!==null?Ee("Suspense Fallback"):Ee("Suspense");case 19:return Ee("SuspenseList");case 0:case 15:return Rn(t.type,!1);case 11:return Rn(t.type.render,!1);case 1:return Rn(t.type,!0);case 31:return Ee("Activity");default:return""}}function Bi(t){try{var e="",n=null;do e+=Qa(t,n),n=t,t=t.return;while(t);return e}catch(a){return`
|
||||
Error generating stack: `+a.message+`
|
||||
`+a.stack}}var mc=Object.prototype.hasOwnProperty,gc=l.unstable_scheduleCallback,Ac=l.unstable_cancelCallback,om=l.unstable_shouldYield,dm=l.unstable_requestPaint,pe=l.unstable_now,hm=l.unstable_getCurrentPriorityLevel,Dr=l.unstable_ImmediatePriority,Mr=l.unstable_UserBlockingPriority,Ui=l.unstable_NormalPriority,mm=l.unstable_LowPriority,jr=l.unstable_IdlePriority,gm=l.log,Am=l.unstable_setDisableYieldValue,wl=null,be=null;function Dn(t){if(typeof gm=="function"&&Am(t),be&&typeof be.setStrictMode=="function")try{be.setStrictMode(wl,t)}catch{}}var xe=Math.clz32?Math.clz32:Em,vm=Math.log,ym=Math.LN2;function Em(t){return t>>>=0,t===0?32:31-(vm(t)/ym|0)|0}var Qi=256,zi=262144,Yi=4194304;function oa(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Li(t,e,n){var a=t.pendingLanes;if(a===0)return 0;var i=0,s=t.suspendedLanes,d=t.pingedLanes;t=t.warmLanes;var g=a&134217727;return g!==0?(a=g&~s,a!==0?i=oa(a):(d&=g,d!==0?i=oa(d):n||(n=g&~t,n!==0&&(i=oa(n))))):(g=a&~s,g!==0?i=oa(g):d!==0?i=oa(d):n||(n=a&~t,n!==0&&(i=oa(n)))),i===0?0:e!==0&&e!==i&&(e&s)===0&&(s=i&-i,n=e&-e,s>=n||s===32&&(n&4194048)!==0)?e:i}function Rl(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function pm(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hr(){var t=Yi;return Yi<<=1,(Yi&62914560)===0&&(Yi=4194304),t}function vc(t){for(var e=[],n=0;31>n;n++)e.push(t);return e}function Dl(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function bm(t,e,n,a,i,s){var d=t.pendingLanes;t.pendingLanes=n,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=n,t.entangledLanes&=n,t.errorRecoveryDisabledLanes&=n,t.shellSuspendCounter=0;var g=t.entanglements,T=t.expirationTimes,z=t.hiddenUpdates;for(n=d&~n;0<n;){var V=31-xe(n),I=1<<V;g[V]=0,T[V]=-1;var Y=z[V];if(Y!==null)for(z[V]=null,V=0;V<Y.length;V++){var G=Y[V];G!==null&&(G.lane&=-536870913)}n&=~I}a!==0&&Nr(t,a,0),s!==0&&i===0&&t.tag!==0&&(t.suspendedLanes|=s&~(d&~e))}function Nr(t,e,n){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-xe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|n&261930}function Br(t,e){var n=t.entangledLanes|=e;for(t=t.entanglements;n;){var a=31-xe(n),i=1<<a;i&e|t[a]&e&&(t[a]|=e),n&=~i}}function Ur(t,e){var n=e&-e;return n=(n&42)!==0?1:yc(n),(n&(t.suspendedLanes|e))!==0?0:n}function yc(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function Ec(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function Qr(){var t=_.p;return t!==0?t:(t=window.event,t===void 0?32:x1(t.type))}function zr(t,e){var n=_.p;try{return _.p=t,e()}finally{_.p=n}}var Mn=Math.random().toString(36).slice(2),te="__reactFiber$"+Mn,oe="__reactProps$"+Mn,za="__reactContainer$"+Mn,pc="__reactEvents$"+Mn,xm="__reactListeners$"+Mn,Sm="__reactHandles$"+Mn,Yr="__reactResources$"+Mn,Ml="__reactMarker$"+Mn;function bc(t){delete t[te],delete t[oe],delete t[pc],delete t[xm],delete t[Sm]}function Ya(t){var e=t[te];if(e)return e;for(var n=t.parentNode;n;){if(e=n[za]||n[te]){if(n=e.alternate,e.child!==null||n!==null&&n.child!==null)for(t=u1(t);t!==null;){if(n=t[te])return n;t=u1(t)}return e}t=n,n=t.parentNode}return null}function La(t){if(t=t[te]||t[za]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function jl(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function Ga(t){var e=t[Yr];return e||(e=t[Yr]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function Wt(t){t[Ml]=!0}var Lr=new Set,Gr={};function da(t,e){Xa(t,e),Xa(t+"Capture",e)}function Xa(t,e){for(Gr[t]=e,t=0;t<e.length;t++)Lr.add(e[t])}var Tm=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21Line truncated
|
||||
`).replace(eA,"")}function Wd(t,e){return e=Fd(e),Fd(t)===e}function Dt(t,e,n,a,i,s){switch(n){case"children":typeof a=="string"?e==="body"||e==="textarea"&&a===""||Za(t,a):(typeof a=="number"||typeof a=="bigint")&&e!=="body"&&Za(t,""+a);break;case"className":Xi(t,"class",a);break;case"tabIndex":Xi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Xi(t,n,a);break;case"style":Fr(t,a,s);break;case"data":if(e!=="object"){Xi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||n!=="href")){t.removeAttribute(n);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(n);break}a=Zi(""+a),t.setAttribute(n,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(n,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof s=="function"&&(n==="formAction"?(e!=="input"&&Dt(t,e,"name",i.name,i,null),Dt(t,e,"formEncType",i.formEncType,i,null),Dt(t,e,"formMethod",i.formMethod,i,null),Dt(t,e,"formTarget",i.formTarget,i,null)):(Dt(t,e,"encType",i.encType,i,null),Dt(t,e,"method",i.method,i,null),Dt(t,e,"target",i.target,i,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(n);break}a=Zi(""+a),t.setAttribute(n,a);break;case"onClick":a!=null&&(t.onclick=tn);break;case"onScroll":a!=null&&yt("scroll",t);break;case"onScrollEnd":a!=null&&yt("scrollend",t);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(n=a.__html,n!=null){if(i.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}n=Zi(""+a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",n);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,""+a):t.removeAttribute(n);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,""):t.removeAttribute(n);break;case"capture":case"download":a===!0?t.setAttribute(n,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,a):t.removeAttribute(n);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(n,a):t.removeAttribute(n);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(n):t.setAttribute(n,a);break;case"popover":yt("beforetoggle",t),yt("toggle",t),Gi(t,"popover",a);break;case"xlinkActuate":$e(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":$e(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":$e(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":$e(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":$e(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":$e(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":$e(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":$e(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":$e(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Gi(t,"is",a);break;case"innerText":case"textContent":break;default:(!(2<n.length)||n[0]!=="o"&&n[0]!=="O"||n[1]!=="n"&&n[1]!=="N")&&(n=Dm.get(n)||n,Gi(t,n,a))}}function df(t,e,n,a,i,s){switch(n){case"style":Fr(t,a,s);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(n=a.__html,n!=null){if(i.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"children":typeof a=="string"?Za(t,a):(typeof a=="number"||typeof a=="bigint")&&Za(t,""+a);break;case"onScroll":a!=null&&yt("scroll",t);break;case"onScrollEnd":a!=null&&yt("scrollend",t);break;case"onClick":a!=null&&(t.onclick=tn);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inneLine truncated
|
||||
linear-gradient(-45deg, #80808020 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #80808020 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #80808020 75%)`,backgroundSize:"20px 20px",backgroundPosition:"0 0, 0 10px, 10px -10px, -10px 0px",boxShadow:`rgb(0 0 0 / 10%) 0px 1.8px 1.9px,
|
||||
rgb(0 0 0 / 15%) 0px 6.1px 6.3px,
|
||||
rgb(0 0 0 / 10%) 0px -2px 4px,
|
||||
rgb(0 0 0 / 15%) 0px -6.1px 12px,
|
||||
rgb(0 0 0 / 25%) 0px 6px 12px`},um=({diff:l,noTargetBlank:u,hideDetails:c})=>{const[f,r]=ct.useState(l.diff?"diff":"actual"),[o,h]=ct.useState(!1),[v,y]=ct.useState(null),[A,E]=ct.useState("Expected"),[S,O]=ct.useState(null),[X,B]=ct.useState(null),[b,p]=Wh();ct.useEffect(()=>{(async()=>{var K,J,k,nt;y(await kf((K=l.expected)==null?void 0:K.attachment.path)),E(((J=l.expected)==null?void 0:J.title)||"Expected"),O(await kf((k=l.actual)==null?void 0:k.attachment.path)),B(await kf((nt=l.diff)==null?void 0:nt.attachment.path))})()},[l]);const x=v&&S&&X,R=x?Math.max(v.naturalWidth,S.naturalWidth,200):500,U=x?Math.max(v.naturalHeight,S.naturalHeight,200):500,Z=Math.min(1,(b.width-30)/R),F=Math.min(1,(b.width-50)/R/2),j=R*Z,D=U*Z,N={flex:"none",margin:"0 10px",cursor:"pointer",userSelect:"none"};return m.jsx("div",{"data-testid":"test-result-image-mismatch",style:{display:"flex",flexDirection:"column",alignItems:"center",flex:"auto"},ref:p,children:x&&m.jsxs(m.Fragment,{children:[m.jsxs("div",{"data-testid":"test-result-image-mismatch-tabs",style:{display:"flex",margin:"10px 0 20px"},children:[l.diff&&m.jsx("div",{style:{...N,fontWeight:f==="diff"?600:"initial"},onClick:()=>r("diff"),children:"Diff"}),m.jsx("div",{style:{...N,fontWeight:f==="actual"?600:"initial"},onClick:()=>r("actual"),children:"Actual"}),m.jsx("div",{style:{...N,fontWeight:f==="expected"?600:"initial"},onClick:()=>r("expected"),children:A}),m.jsx("div",{style:{...N,fontWeight:f==="sxs"?600:"initial"},onClick:()=>r("sxs"),children:"Side by side"}),m.jsx("div",{style:{...N,fontWeight:f==="slider"?600:"initial"},onClick:()=>r("slider"),children:"Slider"})]}),m.jsxs("div",{style:{display:"flex",justifyContent:"center",flex:"auto",minHeight:D+60},children:[l.diff&&f==="diff"&&m.jsx(En,{image:X,alt:"Diff",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="actual"&&m.jsx(En,{image:S,alt:"Actual",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="expected"&&m.jsx(En,{image:v,alt:A,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="slider"&&m.jsx(Dv,{expectedImage:v,actualImage:S,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z,expectedTitle:A}),l.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(En,{image:v,title:A,hideSize:c,canvasWidth:F*R,canvasHeight:F*U,scale:F}),m.jsx(En,{image:o?X:S,title:o?"Diff":"Actual",onClick:()=>h(!o),hideSize:c,canvasWidth:F*R,canvasHeight:F*U,scale:F})]}),!l.diff&&f==="actual"&&m.jsx(En,{image:S,title:"Actual",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),!l.diff&&f==="expected"&&m.jsx(En,{image:v,title:A,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),!l.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(En,{image:v,title:A,canvasWidth:F*R,canvasHeight:F*U,scale:F}),m.jsx(En,{image:S,title:"Actual",canvasWidth:F*R,canvasHeight:F*U,scale:F})]})]}),!c&&m.jsxs("div",{style:{alignSelf:"start",lineHeight:"18px",marginLeft:"15px"},children:[m.jsx("div",{children:l.diff&&m.jsx("a",{target:"_blank",href:l.diff.attachment.path,rel:"noreferrer",children:l.diff.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:u?"":"_blank",href:l.actual.attachment.path,rel:"noreferrer",children:l.actual.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:u?"":"_blank",href:l.expected.attachment.path,rel:"noreferrer",children:l.expected.attachment.name})})]})]})})},Dv=({expectedImage:l,actualImage:u,canvasWidth:c,canvasHeight:f,scale:r,expectedTitle:o,hideSize:h})=>{const v={position:"absolute",top:0,left:0},[y,A]=ct.useState(c/2),E=l.naturalWidth===u.naturalWidth&&l.naturalHeight===u.naturalHeight;return m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column",userSelect:"none"},children:[!h&&m.jsxs("div",{style:{margin:5},children:[!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"Expected "}),m.jsx("span",{children:l.naturalWidth}),m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),m.jsx("span",{children:l.naturalHeight}),!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px 0 15px"},children:"Actual "}),!E&&m.jsx("span",{children:u.naturalWidth}),!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),!E&&m.jsx("span",{children:u.naturalHeight})]}),m.jsxs("div",{style:{position:"relative",width:c,height:f,margin:15,...fr},children:[m.jsx(Rv,{orientation:"horizontal",offsets:[y],setOffsets:S=>A(S[0]),resizerColor:"#57606a80",resizerWidth:6}),m.jsx("img",{alt:o,style:{width:l.naturalWidth*r,height:l.naturalHeight*r},draggable:"false",src:l.src}),m.jsx("div",{style:{...v,bottom:0,overflow:"hidden",width:y,...fr},children:m.jsx("img",{alt:"Actual",style:{width:u.naturalWidth*r,height:u.naturalHeight*r},draggable:"false",src:u.src})})]})]})},En=({image:l,title:u,alt:c,hideSize:f,canvasWidth:r,canvasHeight:o,scale:h,onClick:v})=>m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column"},children:[!f&&m.jsxs("div",{style:{margin:5},childrenLine truncated
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
`.trimStart();async function zv({testInfo:l,metadata:u,errorContext:c,errors:f,buildCodeFrame:r,stdout:o,stderr:h}){var S;const v=new Set(f.filter(O=>O.message&&!O.message.includes(`
|
||||
`)).map(O=>O.message));for(const O of f)for(const X of v.keys())(S=O.message)!=null&&S.includes(X)&&v.delete(X);const y=f.filter(O=>!(!O.message||!O.message.includes(`
|
||||
`)&&!v.has(O.message)));if(!y.length)return;const A=[Qv,"# Test info","",l];o&&A.push("","# Stdout","","```",Jf(o),"```"),h&&A.push("","# Stderr","","```",Jf(h),"```"),A.push("","# Error details");for(const O of y)A.push("","```",Jf(O.message||""),"```");c&&A.push(c);const E=await r(y[y.length-1]);return E&&A.push("","# Test source","","```ts",E,"```"),u!=null&&u.gitDiff&&A.push("","# Local changes","","```diff",u.gitDiff,"```"),A.join(`
|
||||
`)}const Yv=new RegExp("([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))","g");function Jf(l){return l.replace(Yv,"")}function Lv(l,u){var f;const c=new Map;for(const r of l){const o=r.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);if(!o)continue;const[,h,v,y=""]=o,A=h+y;let E=c.get(A);E||(E={name:A,anchors:[`attachment-${h}`]},c.set(A,E)),E.anchors.push(`attachment-${u.attachments.indexOf(r)}`),v==="actual"&&(E.actual={attachment:r}),v==="expected"&&(E.expected={attachment:r,title:"Expected"}),v==="previous"&&(E.expected={attachment:r,title:"Previous"}),v==="diff"&&(E.diff={attachment:r})}for(const[r,o]of c)!o.actual||!o.expected?c.delete(r):(l.delete(o.actual.attachment),l.delete(o.expected.attachment),l.delete((f=o.diff)==null?void 0:f.attachment));return[...c.values()]}const Gv=({test:l,result:u,testRunMetadata:c,options:f})=>{const{screenshots:r,videos:o,traces:h,otherAttachments:v,diffs:y,errors:A,otherAttachmentAnchors:E,screenshotAnchors:S,errorContext:O}=ct.useMemo(()=>{const B=u.attachments.filter(N=>!N.name.startsWith("_")),b=new Set(B.filter(N=>N.contentType.startsWith("image/"))),p=[...b].map(N=>`attachment-${B.indexOf(N)}`),x=B.filter(N=>N.contentType.startsWith("video/")),R=B.filter(N=>N.name==="trace"),U=B.find(N=>N.name==="error-context"),Z=new Set(B);[...b,...x,...R].forEach(N=>Z.delete(N));const F=[...Z].map(N=>`attachment-${B.indexOf(N)}`),j=Lv(b,u),D=u.errors.map(N=>N.message);return{screenshots:[...b],videos:x,traces:R,otherAttachments:Z,diffs:j,errors:D,otherAttachmentAnchors:F,screenshotAnchors:p,errorContext:U}},[u]),X=P5(async()=>{if(f!=null&&f.noCopyPrompt)return;const B=u.attachments.find(R=>R.name==="stdout"),b=u.attachments.find(R=>R.name==="stderr"),p=B!=null&&B.body&&B.contentType==="text/plain"?B.body:void 0,x=b!=null&&b.body&&b.contentType==="text/plain"?b.body:void 0;return await zv({testInfo:[`- Name: ${l.path.join(" >> ")} >> ${l.title}`,`- Location: ${l.location.file}:${l.location.line}:${l.location.column}`].join(`
|
||||
`),metadata:c,errorContext:O!=null&&O.path?await fetch(O.path).then(R=>R.text()):O==null?void 0:O.body,errors:u.errors,buildCodeFrame:async R=>R.codeframe,stdout:p,stderr:x})},[l,O,c,u],void 0);return m.jsxs("div",{className:"test-result",children:[!!A.length&&m.jsxs(ke,{header:"Errors",children:[X&&m.jsx("div",{style:{position:"absolute",right:"16px",padding:"10px",zIndex:1},children:m.jsx(Nv,{prompt:X})}),A.map((B,b)=>{const p=Xv(B,y);return m.jsxs(m.Fragment,{children:[m.jsx(wr,{code:B},"test-result-error-message-"+b),p&&m.jsx(Bv,{diff:p})]})})]}),!!u.steps.length&&m.jsx(ke,{header:"Test Steps",children:u.steps.map((B,b)=>m.jsx(cm,{step:B,result:u,test:l,depth:0},`step-${b}`))}),y.map((B,b)=>m.jsx(Si,{id:B.anchors,children:m.jsx(ke,{dataTestId:"test-results-image-diff",header:`Image mismatch: ${B.name}`,revealOnAnchorId:B.anchors,children:m.jsx(um,{diff:B})})},`diff-${b}`)),!!r.length&&m.jsx(ke,{header:"Screenshots",revealOnAnchorId:S,children:r.map((B,b)=>m.jsxs(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:[m.jsx("a",{href:Ve(B.path),children:m.jsx("img",{className:"screenshot",src:Ve(B.path)})}),m.jsx(nc,{attachment:B,result:u})]},`screenshot-${b}`))}),!!h.length&&m.jsx(Si,{id:"attachment-trace",children:m.jsx(ke,{header:"Traces",revealOnAnchorId:"attachment-trace",children:m.jsxs("div",{children:[m.jsx("a",{href:Ve(nm(h)),children:m.jsx("img",{className:"screenshot",src:Cv,style:{width:192,height:117,marginLeft:20}})}),h.map((B,b)=>m.jsx(nc,{attachment:B,result:u,linkName:h.length===1?"trace":`trace-${b+1}`},`trace-${b}`))]})})}),!!o.length&&m.jsx(Si,{id:"attachment-video",children:m.jsx(ke,{header:"Videos",revealOnAnchorId:"attachment-video",children:o.map(B=>m.jsxs("div",{children:[m.jsx("video",{controls:!0,children:m.jsx("source",{src:Ve(B.path),type:B.contentType})}),m.jsx(nc,{attachment:B,result:u})]},B.path))})}),!!v.size&&m.jsx(ke,{header:"Attachments",revealOnAnchorId:E,dataTestId:"attachments",children:[...v].map((B,b)=>m.jsx(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:m.jsx(nc,{attachment:B,result:u,openInNewTab:B.contentType.startsWith("text/html")})},`attachment-link-${b}`))})]})};function Xv(l,u){const c=l.split(`
|
||||
`)[0];if(!(!c.includes("toHaveScreenshot")&&!c.includes("toMatchSnapshot")))return u.find(f=>l.includes(f.name))}const cm=({test:l,step:u,result:c,depth:f})=>{const r=se();return m.jsx(Tv,{title:m.jsxs("div",{"aria-label":u.title,className:"step-title-container",children:[hc(u.error||u.duration===-1?"failed":u.skipped?"skipped":"passed"),m.jsxs("span",{className:"step-title-text",children:[m.jsx("span",{children:u.title}),u.count>1&&m.jsxs(m.Fragment,{children:[" ✕ ",m.jsx("span",{className:"test-result-counter",children:u.count})]}),u.location&&m.jsxs("span",{className:"test-result-path",children:["— ",u.location.file,":",u.location.line]})]}),m.jsx("span",{className:"step-spacer"}),u.attachments.length>0&&m.jsx("a",{className:"step-attachment-link",title:"reveal attachment",href:Ve(Cn({test:l,result:c,anchor:`attachment-${u.attachments[0]}`},r)),onClick:o=>{o.stopPropagation()},children:Ih()}),m.jsx("span",{className:"step-duration",children:Ol(u.duration)})]}),loadChildren:u.steps.length||u.snippet?()=>{const o=u.snippet?[m.jsx(wr,{testId:"test-snippet",code:u.snippet},"line")]:[],h=u.steps.map((v,y)=>m.jsx(cm,{step:v,depth:f+1,result:c,test:l},y));return o.concat(h)}:void 0,depth:f})},Vv=({projectNames:l,test:u,testRunMetadata:c,run:f,next:r,prev:o,options:h})=>{const[v,y]=ct.useState(f),A=se(),E=u.annotations.filter(S=>!S.type.startsWith("_"))??[];return m.jsxs(m.Fragment,{children:[m.jsx(Or,{title:u.title,leftSuperHeader:m.jsx("div",{className:"test-case-path",children:u.path.join(" › ")}),rightSuperHeader:m.jsxs(m.Fragment,{children:[m.jsx("div",{className:Ze(!o&&"hidden"),children:m.jsx(Tn,{href:Cn({test:o},A),children:"« previous"})}),m.jsx("div",{style:{width:10}}),m.jsx("div",{className:Ze(!r&&"hidden"),children:m.jsx(Tn,{href:Cn({test:r},A),children:"next »"})})]})}),m.jsxs("div",{className:"hbox",style:{lineHeight:"24px"},children:[m.jsx("div",{className:"test-case-location",children:m.jsxs(Sr,{value:`${u.location.file}:${u.location.line}`,children:[u.location.file,":",u.location.line]})}),m.jsx("div",{style:{flex:"auto"}}),m.jsx(tm,{test:u,trailingSeparator:!0}),m.jsx("div",{className:"test-case-duration",children:Ol(u.duration)})]}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:l,activeProjectName:u.projectName,otherLabels:u.tags}),u.results.length===0&&E.length!==0&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:E.map((S,O)=>m.jsx(z2,{annotation:S},O))}),m.jsx(Sv,{tabs:u.results.map((S,O)=>({id:String(O),title:m.jsxs("div",{style:{display:"flex",alignItems:"center"},children:[hc(S.status)," ",Zv(O),u.results.length>1&&m.jsx("span",{className:"test-case-run-duration",children:Ol(S.duration)})]}),render:()=>{const X=S.annotations.filter(B=>!B.type.startsWith("_"));return m.jsxs(m.Fragment,{children:[!!X.length&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:X.map((B,b)=>m.jsx(z2,{annotation:B},b))}),m.jsx(Gv,{test:u,result:S,testRunMetadata:c,options:h})]})}}))||[],selectedTab:String(v),setSelectedTab:S=>y(+S)})]})};function z2({annotation:{type:l,description:u}}){return m.jsxs("div",{className:"test-case-annotation",children:[m.jsx("span",{style:{fontWeight:"bold"},children:l}),u&&m.jsxs(Sr,{value:u,children:[": ",Di(u)]})]})}function Zv(l){return l?`Retry #${l}`:"Run"}const sm=({file:l,projectNames:u,isFileExpanded:c,setFileExpanded:f,footer:r})=>{const o=se();return m.jsx(im,{expanded:c?c(l.fileId):void 0,noInsets:!0,setExpanded:f?(h=>f(l.fileId,h)):void 0,header:m.jsx("span",{className:"chip-header-allow-selection",children:l.fileName}),footer:r,children:l.tests.map(h=>m.jsxs("div",{className:Ze("test-file-test","test-file-test-outcome-"+h.outcome),children:[m.jsxs("div",{className:"hbox",style:{alignItems:"flex-start"},children:[m.jsxs("div",{className:"hbox",children:[m.jsx("span",{className:"test-file-test-status-icon",children:hc(h.outcome)}),m.jsxs("span",{children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),children:m.jsx("span",{className:"test-file-title",children:[...h.path,h.title].join(" › ")})}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:u,activeProjectName:h.projectName,otherLabels:h.tags})]})]}),m.jsx("span",{"data-testid":"test-duration",style:{minWidth:"50px",textAlign:"right"},children:Ol(h.duration)})]}),m.jsx("div",{className:"test-file-details-row",children:m.jsxs("div",{className:"test-file-details-row-items",children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),className:"test-file-path-link",children:m.jsxs("span",{className:"test-file-path",children:[h.location.file,":",h.location.line]})}),m.jsx(qv,{test:h}),m.jsx(Iv,{test:h}),m.jsx(tm,{test:h,dim:!0})]})})]},`test-${h.testId}`))})};function qv({test:l}){const u=se();for(const c of l.results)for(const f of c.attachments)if(f.contentType.startsWith("image/")&&f.name.match(/-(expected|actual|diff)/))return m.jsx(Tr,{href:Cn({test:l,result:c,anchor:`attachment-${c.attachments.indexOf(f)}`},u),Line truncated
|
||||
</script>
|
||||
<style type='text/css'>:root{--color-canvas-default-transparent: rgba(255,255,255,0);--color-marketing-icon-primary: #218bff;--color-marketing-icon-secondary: #54aeff;--color-diff-blob-addition-num-text: #24292f;--color-diff-blob-addition-fg: #24292f;--color-diff-blob-addition-num-bg: #CCFFD8;--color-diff-blob-addition-line-bg: #E6FFEC;--color-diff-blob-addition-word-bg: #ABF2BC;--color-diff-blob-deletion-num-text: #24292f;--color-diff-blob-deletion-fg: #24292f;--color-diff-blob-deletion-num-bg: #FFD7D5;--color-diff-blob-deletion-line-bg: #FFEBE9;--color-diff-blob-deletion-word-bg: rgba(255,129,130,.4);--color-diff-blob-hunk-num-bg: rgba(84,174,255,.4);--color-diff-blob-expander-icon: #57606a;--color-diff-blob-selected-line-highlight-mix-blend-mode: multiply;--color-diffstat-deletion-border: rgba(27,31,36,.15);--color-diffstat-addition-border: rgba(27,31,36,.15);--color-diffstat-addition-bg: #2da44e;--color-search-keyword-hl: #fff8c5;--color-prettylights-syntax-comment: #6e7781;--color-prettylights-syntax-constant: #0550ae;--color-prettylights-syntax-entity: #8250df;--color-prettylights-syntax-storage-modifier-import: #24292f;--color-prettylights-syntax-entity-tag: #116329;--color-prettylights-syntax-keyword: #cf222e;--color-prettylights-syntax-string: #0a3069;--color-prettylights-syntax-variable: #953800;--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;--color-prettylights-syntax-invalid-illegal-bg: #82071e;--color-prettylights-syntax-carriage-return-text: #f6f8fa;--color-prettylights-syntax-carriage-return-bg: #cf222e;--color-prettylights-syntax-string-regexp: #116329;--color-prettylights-syntax-markup-list: #3b2300;--color-prettylights-syntax-markup-heading: #0550ae;--color-prettylights-syntax-markup-italic: #24292f;--color-prettylights-syntax-markup-bold: #24292f;--color-prettylights-syntax-markup-deleted-text: #82071e;--color-prettylights-syntax-markup-deleted-bg: #FFEBE9;--color-prettylights-syntax-markup-inserted-text: #116329;--color-prettylights-syntax-markup-inserted-bg: #dafbe1;--color-prettylights-syntax-markup-changed-text: #953800;--color-prettylights-syntax-markup-changed-bg: #ffd8b5;--color-prettylights-syntax-markup-ignored-text: #eaeef2;--color-prettylights-syntax-markup-ignored-bg: #0550ae;--color-prettylights-syntax-meta-diff-range: #8250df;--color-prettylights-syntax-brackethighlighter-angle: #57606a;--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;--color-prettylights-syntax-constant-other-reference-link: #0a3069;--color-codemirror-text: #24292f;--color-codemirror-bg: #ffffff;--color-codemirror-gutters-bg: #ffffff;--color-codemirror-guttermarker-text: #ffffff;--color-codemirror-guttermarker-subtle-text: #6e7781;--color-codemirror-linenumber-text: #57606a;--color-codemirror-cursor: #24292f;--color-codemirror-selection-bg: rgba(84,174,255,.4);--color-codemirror-activeline-bg: rgba(234,238,242,.5);--color-codemirror-matchingbracket-text: #24292f;--color-codemirror-lines-bg: #ffffff;--color-codemirror-syntax-comment: #24292f;--color-codemirror-syntax-constant: #0550ae;--color-codemirror-syntax-entity: #8250df;--color-codemirror-syntax-keyword: #cf222e;--color-codemirror-syntax-storage: #cf222e;--color-codemirror-syntax-string: #0a3069;--color-codemirror-syntax-support: #0550ae;--color-codemirror-syntax-variable: #953800;--color-checks-bg: #24292f;--color-checks-run-border-width: 0px;--color-checks-container-border-width: 0px;--color-checks-text-primary: #f6f8fa;--color-checks-text-secondary: #8c959f;--color-checks-text-link: #54aeff;--color-checks-btn-icon: #afb8c1;--color-checks-btn-hover-icon: #f6f8fa;--color-checks-btn-hover-bg: rgba(255,255,255,.125);--color-checks-input-text: #eaeef2;--color-checks-input-placeholder-text: #8c959f;--color-checks-input-focus-text: #8c959f;--color-checks-input-bg: #32383f;--color-checks-input-shadow: none;--color-checks-donut-error: #fa4549;--color-checks-donut-pending: #bf8700;--color-checks-donut-success: #2da44e;--color-checks-donut-neutral: #afb8c1;--color-checks-dropdown-text: #afb8c1;--color-checks-dropdown-bg: #32383f;--color-checks-dropdown-border: #424a53;--color-checks-dropdown-shadow: rgba(27,31,36,.3);--color-checks-dropdown-hover-text: #f6f8fa;--color-checks-dropdown-hover-bg: #424a53;--color-checks-dropdown-btn-hover-text: #f6f8fa;--color-checks-dropdown-btn-hover-bg: #32383f;--color-checks-scrollbar-thumb-bg: #57606a;--color-checks-header-label-text: #d0d7de;--color-checks-header-label-open-text: #f6f8fa;--color-checks-header-border: #32383f;--color-checks-header-icon: #8c959f;--color-checks-line-text: #d0d7de;--color-checks-line-num-text: rgba(140,149,159,.75);--color-checks-line-timestamp-text: #8c959f;--color-checks-line-hover-bg: #32383f;--color-checks-line-selected-bg: rgba(33,139,255,.15);--color-checks-line-selected-num-text: #54aeff;--color-checks-line-dt-fm-text: #24292f;--color-checks-line-dt-fm-bg: #9a6700;--color-checks-gate-bg: rgba(1Line truncated
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id='root'></div>
|
||||
</body>
|
||||
</html>
|
||||
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIAFeOXFyF7ksyOyAAAF5iAgAZAAAAMjRmZGUwMzNlZDRlZmUzZDhmODkuanNvbu2d7W7jOJaGb0XIn0oDFZVISpRUQC/Q3ZgvYHaAmWnsADvdA8i2nHjLsdO20t2F3gL2IvYK90qWpOREOpItUT6KFOfkx0x17NAyX5E65HnOy9+ulqt1+qfF1ccr7i8XqSdEuvDTZSoW0TKKr96b1/+S3KfqHfPt+vF+czPfbparW3f/kM7dbK/ekqV79f8f//mb+dfRxm5iP5rPWBrO4rmczWY88oNQ//kqWz837+TNO7PHLNtunNXeedil+3STOauNk92lTrbdrmfJTv3hw277X+k8O1zd3W57v3q8Vy+st/MkW203Vx9/M9d/4trXq4162Y/eF++4+hh+eX+1eNwVDfA4Vq8lm802M7/R3/NHdc3JbfGv7WM235oLSH9VrWbpQl9Zkt2pl6++M21+Zz70Sr1ZfZPHddFX8DP2WbLLvl+ZprjH5Y3Hb3j0PRcf/fhjIFzP8//zSjeR7T5fffT0H6QPRbcXPfhtutzuUueP2+0n/d3aW5S6xecLCYQQrKnhmWn4d8n8zrlTjfdtu/Gif7/6NXtUV/3D1Wy3/WWf7n646tI+46B9TwSiqf0/J48bdd1F452a9utNl7pF6ZhkmeqLe3VXFr+Ybx832dVH/a5Pq4cHdRN8XCbrffrF6s3vm3pF3bJZ+mvW0iuB99EXrpCg1+OmHvlulyZZ6hQNd2o2rDYbjdYbD8lt2q0rwgCo2HhnF32hm+3UKLyrvRfuib8kP69u9RVnW9UbH5KHhw8P6W6/3XTqFD8Q1etnQsanv4H9XOo9z6VMfjn+tdR/b/R/q9euHMcRsfPfjv5JfklWmbNPs8eHbx5W/76df9pfa32++mGjXvW9ytv0C+7tNttevyt1xrv8veonf2/p5195K6zSiv6f3293f9itFn9Lk8Xn/POuyh3/D/0+NQuqC1uraV79w/TMdnf97p+77Tr9+gfd+9nj/oerH/+Z7FbJzTqZpWv16z9vk8Vqc6t+/+6rVo18N+LgHuMcWaHSw85Cn9D02HOvm36Mim50ix78e9E5Np3y3vkNqJVrFD+Jp9tIPzrv7laLRbp5974izO/MY1cNhmz7bfofq/1qtlYzREmdRZIlNz/99NPNaqE++D5ZmY7JVAd2liQADxvmtzwPrDVhoiQK764K4wcF1AfO766vv3K+/jdn87he50OAiZJseYRibm63c/985ZY69vo3J1P9pIKej47w1I/z5fhQs/vJRWc+uM06D8Km28uxFDqO/aPPTxyZg15jr9YreV8Fx0dfW2+oQVcbVs/SsqBR2kIiefSW6yxW9Z67VfPuzdpmmgwO4fCzVFIga6Xn3R5acdakFefHtTrdGWcoxUU3pWym0H8dLvEQdvz43jn9DZpfT+8fss+dxZbRsOOSy2etAwutDyPw+JSK31+5tPkgPDo588bJubgt9KP8S/UO+DlZP6p7rIsWIVgMYIcnvugXQPqHIfbhg/PdOk3y+Wb9dyWJUkcHzOlmr1cRc/Wi3mcobzeYEZaHhqIeYKZF9xQjqNyuq1vbXX/VGnH69WZ3qVb9ujoY/2Z+2UmIGEQlnh9ia+H31OKcTjzVVW29HEw0rpcu41CuUFJgP2ZgL13hgbksoLD+4sJ6JTMD2x7YoSKF9ThhvZYqhA81CusvM6xXYgsPrOEorD8/rEfpOVGKJSKLTVx9DToL6C7S/Xy3mqXX78pJMHWP53drHhmIPMwwf/CcY7pO9p83c0d9b93/6ts+/0H5eZR/6/Zt4+Ye6bLZbhmB5CnLm0rHdh8KIDpEj+TjUorTIhLxnyW6fmeXolViH1fSr+rWJYY52r/V4YIVuTz/5HdakGcevlQn2W+WWbrrkHItZPbB5n5bTrRjvku3DLZI+mamzkxCNlwJC5u+43fr7b57ElI1GxzPbQ6XerN9c7rbbXfF+/IVkvo6D8l+b7iAGkcA2tYtbD9dfcx2j7kQJ3kKNpulQeIHYTqTC89Ll0JEZZ5ivZp/yuPDhlG7fUg3ezNaH5JNukbFKYJSiFXFKWK95h2WpojzZXUbnhCgwxQgcBVBGDY23AemiGpty8bUfl+YAuylqyEcY8EUcMJTTb8CmIKHbgx7PWicxqxgCtMseNL7E4cp9DXDBbR/LkxhGgV3nf+KYAp9/RIIyf0WLoZYipfcc+WRyxncc8Xe4KMtV5u9OC2JCCCChL3UoU3X0TddldAiFMNu7tCmK8qmq5YqAvtwIW26QqUuY9NVie0HbNj8/ZvcdO3BUhgtQAiMjRUSS9GBpTBCgGUqk9hTIKEUaGG9hElCgc2DUVhvGe3JAEpChPQlRvUygpse2HseFNZjhfUhgxuYhEhDpS4mrA9DRiwFsRRvnqUwQyEadoUrStMeZxaK5vOeanWfObNs43x9YlSc6oRcLV4PXFSjR+931ikaKe6EclCkGnVNMhmsrr7TvxtGQbghFbckyewlLC+MhYWE53T6sV5tVqRQwrcMT9vwmNLFFQ/Gc9YKveSNYG029lpBlNba0kbdUwK9gBJY5FJx6wT1vakXUXfgAqGgrK5FKBJUd1I0AVN+npYSo0F12wRDbEQqrcDRgjNxNCMVWM2VFnNnAAMNDY9DozVdSTPFYQOj6VYjbnWPXz6MtpQLzoVkYeynEU8jPxD+1TFzH4OcOXfJ3kmcuen6PFrApdDCYxSaXhEOjaHlnzEBDs2PAjkUh6baDlA5NLAL74dMYnFo8NJV06+AQ/M9l0VgZc8a5bR09dHtiqOT/yRBNH3NMeiLoJkttnD10Y1CL6mWvZMpgWjq+jkMuHz0fAiBaOcU/3puyOEYptrfUWt/lSQ+2GNgHN3thVJWY6eslNAR3BnnRKLVB+YEUlZaqxiWwVHKCip1GSkrJXYcQqNNSlmNQqJpLSJ/2E07ItG6uPpoIWB5kEck2kTjeuaKGCaqiEQbNa5nrg8lQY/2KKofParXMg9tFENRPU5Uz9ygZrOFHulRVD+NqF6JLWGtMEX1BKK9PRDNDAXo20Ag2isC0bSCIUgNobtME4g2Foim5JUCJpwJRJs+iHZjkJHOGsM8j0Dn0crWahazcBBVnnKneDR7s7RjPTawV1ouuDzbKk3pBqdeVhqcZ0AEumUxBTit6UoQnNJ0q3az2uXDaX7EUpmEzJ+H/mLJlzLwK3BaCUFzFqv9/UpdxVDmaPKoORqLZDg4lmY+ox30irGxNHhem6/WiY13ew8sreZOpdqWpycLOywNbsmLuNkAyx5Lg1yIaroN3pkCliakG8sBsDTTbmC1fzs6lqavGSIGfiNIbYGlmUbhkGkxSpoSliZC14OBVxCTP9qE0ldKolpszLC9uCh9ZZPX0JIE0LJOoFfYUwJr7ASWEjqEqBP62SOUv8LIXympotphxujnj1H+ahL5Ky02PEMZe/p9k/mrHlSa1gISgti770SldaDSjBDQSibC3ionKg0prI9cH6bg0UNICuutor3IDTy7gmEK6q1/xg/qtcxg86qtlJCi+pGieqWVBMFFhO6ES1H9NKL6yJW1g5coqicq7e1RaWYoxESlvV4qTSsYQDSCY8PvhKWNhaUJbWA4MEJNWNoRLO30mO1LoRlJa2V9yJrKnprKzq5osmGzxLarTg/pvkrK6l5M35tvs816TQXq75wBp4MI8ifY6xRZXlPGFrcO9g3xYjriYo/FTSjPhB611DWfmJLWZyAqDS2PAz2aK4FM/dnQo2lVWKVTLx96jNVvgsUymvM0nXHflzKOS9Bj7sG3Xu2z/SE/scrS+72z3G3vnSxRA8e5T7NEDzdcALK03qsCkELoHZNhAcj8M05zedyN8yJiNABStwjvT8HDRu88WwDSpu0+AKRuH6IKnhc3doo9AOlBQzfPi8YzousOb6tLr1UgnzoUtSu+rdsFvT0eD9oRqRcug86QjDf6NtoY8+lWIQvFX8RnFcmZT7gcLuSYQCfsiIE8x8LDdyWcgdDtwShZauXtoCSpnRHrU7r04tKlSujargi6LSalS3FMPJRWMcighegnDFG6dBLpUiV2BGtPyJpvJGs+v36eBkGQY1jzKSFqRuzMR9eCKEicwF66Hq/ZolNgP2ZgrySBPDe6JBTXjx/XS5dx6LlBxU31cTmFuF5pJWENqEee21CqCwnspctr0CsF9sRBvj0OUg+F2uY89tY8gZCD2vMpCWHik3noZ/0SCDmWP590RQxPbSMQ8mVAyL8+prvPjumN49FLlenS9MhNV2V9TwzLtIWlcStCi9X5IbzMZ9+8iT8ZMObULHyqO0wfhwK0q3v26+ImKX2Ka16xBSRzHcPDPk5x05mm8lvpDybxvvv+LtlceycGcul9HTLcWseBj3IOyxtiFmxir/7u3IGn1Cm0OPd4XtO5IOXCGx3TrHkN3XI4BRqw6UqOUDRWHoiqWegJ9+ZxwPl8ufBmfLkQS9VfwuOxtyzhgKnmzUocYHE6b7a9vV0PcjxvePx43oj7w/sgcr8LnTb88byB5I1mcRjH86q2W2YMOx/E+jmjfiOf3ON43gaLxb5z0kvizOSDeNoHsZntPtMH8UXmcjwfRGgyLj06nndCqULjgwiDEPJBHNsHUUJJBLYmlCscPVdozPWCYfd4KFWI5oPI4NF62EWslCmcRqbQWL5xyhROAQHUWkSQpyAEcBwfRHhMcoBuGkUEIKIPIiRnyQdxdB9E6MNFBODlRfVKZx/m99BXbxTWYxkhQhAmwNaKwvqJhPWRKwXcoqWwngDAtwcAmqEwsCco8X9DGyHC84skdoBP+N+YPojw0EDsKhLC/xDwvxwR6Ub+GVGhmRD2DkpUyoYKm2X54bGWT7z59/o223Qm/8o9ka/pvUqLVQjtuf1ezF+uLine truncated
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.12.10'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Disallow: /api/
|
||||
Disallow: /login
|
||||
Disallow: /callback
|
||||
Allow: /
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-license-headers.mjs
|
||||
*
|
||||
* Validates that every TypeScript/TSX source file in src/ begins with the
|
||||
* Apache 2.0 license header required by this project.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-license-headers.mjs # check only (exits 1 on failure)
|
||||
* node scripts/check-license-headers.mjs --fix # auto-prepend missing headers
|
||||
*
|
||||
* Integrated via:
|
||||
* pnpm lint:license → check mode
|
||||
* pnpm lint:license:fix → fix mode
|
||||
* pnpm lint → check mode (enforced in CI)
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
const SRC = join(ROOT, 'src')
|
||||
const FIX = process.argv.includes('--fix')
|
||||
|
||||
/** Full Apache 2.0 header prepended to files missing it (fix mode). */
|
||||
const HEADER = `/*
|
||||
* 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.
|
||||
*/`
|
||||
|
||||
/**
|
||||
* Canonical identifier used when scanning for the header.
|
||||
* Matching a substring instead of the full block makes the check robust to
|
||||
* minor formatting differences (trailing spaces, year updates, etc.).
|
||||
*/
|
||||
const HEADER_MARKER = 'Licensed under the Apache License, Version 2.0'
|
||||
|
||||
/** Directories inside src/ that are generated or third-party — skip entirely. */
|
||||
const SKIP_DIRS = new Set(['node_modules', '.next', 'coverage', '__generated__', 'out'])
|
||||
|
||||
/**
|
||||
* Recursively yields every .ts / .tsx file under `dir`, skipping directories
|
||||
* listed in SKIP_DIRS.
|
||||
*
|
||||
* @param {string} dir - Absolute path of the directory to walk.
|
||||
* @yields {string} Absolute path of each matching file.
|
||||
*/
|
||||
function* walkTs(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
yield* walkTs(full)
|
||||
} else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) {
|
||||
yield full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missing = []
|
||||
|
||||
for (const file of walkTs(SRC)) {
|
||||
const content = readFileSync(file, 'utf8')
|
||||
if (!content.includes(HEADER_MARKER)) {
|
||||
missing.push(file)
|
||||
if (FIX) {
|
||||
writeFileSync(file, HEADER + '\n\n' + content, 'utf8')
|
||||
console.log(` fixed ${relative(ROOT, file)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === 0) {
|
||||
console.log('✓ All source files carry the Apache 2.0 license header.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (FIX) {
|
||||
console.log(`\n✓ Added license header to ${missing.length} file(s).`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Check-only mode: report and fail.
|
||||
console.error(`\n❌ ${missing.length} file(s) are missing the Apache 2.0 license header:\n`)
|
||||
for (const f of missing) {
|
||||
console.error(` ${relative(ROOT, f)}`)
|
||||
}
|
||||
console.error('\nRun pnpm lint:license:fix to add the headers automatically.\n')
|
||||
process.exit(1)
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file OAuth2 / Auth0 callback page — handles the PKCE redirect from the authorization server.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// OAuth2 / Auth0 callback page — handles the redirect from the authorization server.
|
||||
//
|
||||
// After a successful PKCE redirect from the login page the IdP returns the user
|
||||
// here with `?code=...&state=...` query parameters. This page:
|
||||
//
|
||||
// 1. Validates that `state` matches the nonce stored in sessionStorage (CSRF guard).
|
||||
// 2. Retrieves the `pkce_code_verifier` stored by the login page.
|
||||
// 3. Calls `handleOAuthCallback(code, state, codeVerifier)` which:
|
||||
// a. Re-validates the state nonce.
|
||||
// b. Posts the code + verifier to the QQQ backend via `manageSession`.
|
||||
// c. The backend exchanges the code + verifier with the IdP and issues a
|
||||
// session cookie.
|
||||
// 4. Redirects to `oauth2ReturnTo` (set by the login page) or '/'.
|
||||
//
|
||||
// Wrapped in Suspense because it uses useSearchParams().
|
||||
|
||||
import React, { Suspense, useEffect, useRef } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
|
||||
/**
|
||||
* Inner callback handler — reads `code`, `state`, and `error` from search params,
|
||||
* validates the state nonce, retrieves the PKCE verifier from sessionStorage, and
|
||||
* delegates to `handleOAuthCallback`. Redirects on success or failure.
|
||||
*
|
||||
* @returns A loading spinner card while the callback is being processed.
|
||||
*/
|
||||
function CallbackContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { handleOAuthCallback } = useAuth()
|
||||
const handled = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current) return
|
||||
handled.current = true
|
||||
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
if (error) {
|
||||
console.error('[Callback] Auth error:', error, errorDescription)
|
||||
router.replace(`/login?error=${encodeURIComponent(error)}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
console.error('[Callback] Missing code or state params')
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the PKCE code_verifier that the login page stored before
|
||||
// redirecting to the IdP. This is forwarded to the backend so it can
|
||||
// complete the authorization-code → access-token exchange.
|
||||
const codeVerifier = sessionStorage.getItem('pkce_code_verifier') ?? undefined
|
||||
sessionStorage.removeItem('pkce_code_verifier')
|
||||
|
||||
handleOAuthCallback(code, state, codeVerifier)
|
||||
.then(() => {
|
||||
// Retrieve the post-login destination that was saved before the redirect.
|
||||
const returnTo = sessionStorage.getItem('oauth2ReturnTo') ?? '/'
|
||||
sessionStorage.removeItem('oauth2ReturnTo')
|
||||
router.replace(returnTo)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[Callback] Failed to handle callback:', err)
|
||||
router.replace('/login?error=callback_failed')
|
||||
})
|
||||
}, [searchParams, handleOAuthCallback, router])
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div
|
||||
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Completing login...
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please wait while we complete authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 / Auth0 callback page exported as the Next.js default for `/auth/callback`.
|
||||
*
|
||||
* Wraps `CallbackContent` in a `<Suspense>` boundary because `useSearchParams()`
|
||||
* requires Suspense in the App Router.
|
||||
*
|
||||
* @returns The callback page wrapped in a full-screen centered container.
|
||||
*/
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Login page — authenticates the user and redirects to the dashboard.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// Login page — authenticates user and redirects to dashboard.
|
||||
//
|
||||
// Flow by auth type:
|
||||
// FULLY_ANONYMOUS / MOCK — AuthProvider auto-calls manageSession; this page
|
||||
// just waits for isAuthenticated to become true.
|
||||
// OAUTH2 — This page generates a PKCE code_verifier/challenge,
|
||||
// stores state + verifier in sessionStorage, then
|
||||
// redirects the browser to the IdP authorization URL.
|
||||
// The callback is handled by /auth/callback.
|
||||
// AUTH_0 — Same PKCE redirect pattern using the Auth0
|
||||
// /authorize endpoint derived from values.baseUrl.
|
||||
//
|
||||
// Wrapped in Suspense because it uses useSearchParams().
|
||||
|
||||
import React, { Suspense, useEffect, useRef } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import type { QAuthenticationMetaData } from '@/types'
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE helpers (Web Crypto API — no external dependency required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generates a cryptographically random PKCE code_verifier (64 URL-safe chars).
|
||||
*
|
||||
* Spec: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
|
||||
*
|
||||
* @returns A 64-character hex string suitable for use as a PKCE code_verifier.
|
||||
*/
|
||||
function generateCodeVerifier(): string {
|
||||
// Two UUIDs without hyphens gives 64 hex characters — well within the
|
||||
// 43–128 character range mandated by RFC 7636.
|
||||
return (
|
||||
crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the PKCE code_challenge from a code_verifier using S256 method.
|
||||
*
|
||||
* Spec: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
|
||||
*
|
||||
* @param verifier - The plain-text code verifier.
|
||||
* @returns Base64url-encoded SHA-256 digest of the verifier.
|
||||
*/
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically random state nonce for CSRF protection.
|
||||
*
|
||||
* @returns A 32-character hex string to use as the OAuth2 `state` parameter.
|
||||
*/
|
||||
function generateState(): string {
|
||||
return crypto.randomUUID().replace(/-/g, '')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorization-URL builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds the OAuth2/OIDC PKCE authorization URL.
|
||||
*
|
||||
* The redirect URI defaults to `{origin}/auth/callback`, which must be
|
||||
* registered with the identity provider.
|
||||
*
|
||||
* Metadata fields used:
|
||||
* - `values.baseUrl` — OIDC authority / issuer base URL.
|
||||
* The authorization endpoint is derived as `{baseUrl}/authorize`.
|
||||
* TODO: If the IdP uses a non-standard authorization endpoint path (e.g.
|
||||
* some providers use `/oauth2/authorize` or `/connect/authorize`), add an
|
||||
* `authorizationEndpoint` field to QAuthenticationMetaData.values so the
|
||||
* full URL can be supplied explicitly from the backend.
|
||||
* - `values.clientId` — OAuth2 client_id.
|
||||
* - `values.audience` — Optional API audience (non-standard but common).
|
||||
*
|
||||
* TODO: QAuthenticationMetaData.values does not expose `scopes` or
|
||||
* `redirectUri`; add these fields to the backend metadata response so the
|
||||
* frontend can use them without hardcoding defaults.
|
||||
*
|
||||
* @param authMeta - Authentication metadata from the backend.
|
||||
* @param challenge - PKCE code_challenge (S256).
|
||||
* @param state - State nonce for CSRF protection.
|
||||
* @returns Fully-formed authorization URL string.
|
||||
* @throws {Error} When required metadata fields are missing.
|
||||
*/
|
||||
function buildOAuth2AuthorizationUrl(
|
||||
authMeta: QAuthenticationMetaData,
|
||||
challenge: string,
|
||||
state: string
|
||||
): string {
|
||||
const { baseUrl, clientId } = authMeta.values
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
'[Auth] OAUTH2: missing values.baseUrl in auth metadata — cannot build authorization URL'
|
||||
)
|
||||
}
|
||||
if (!clientId) {
|
||||
throw new Error(
|
||||
'[Auth] OAUTH2: missing values.clientId in auth metadata — cannot build authorization URL'
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Derive the authorization endpoint from OIDC discovery
|
||||
// (GET {baseUrl}/.well-known/openid-configuration) instead of appending
|
||||
// "/authorize" here. Add a values.authorizationEndpoint override field to
|
||||
// QAuthenticationMetaData so backends that use non-standard paths can
|
||||
// supply the full URL explicitly.
|
||||
const authorizationEndpoint = `${baseUrl.replace(/\/$/, '')}/authorize`
|
||||
|
||||
// TODO: Add values.scopes to QAuthenticationMetaData so the requested scope
|
||||
// set can be configured per-deployment. Defaulting to "openid profile email"
|
||||
// for now; adjust if your IdP requires additional scopes.
|
||||
const scope = 'openid profile email'
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
})
|
||||
|
||||
// Append optional audience (non-standard; used by some OAuth2 providers)
|
||||
if (authMeta.values.audience) {
|
||||
params.set('audience', authMeta.values.audience)
|
||||
}
|
||||
|
||||
return `${authorizationEndpoint}?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Auth0 PKCE authorization URL.
|
||||
*
|
||||
* Auth0's authorization endpoint follows the pattern:
|
||||
* `https://{domain}/authorize`
|
||||
*
|
||||
* Metadata fields used:
|
||||
* - `values.baseUrl` — Auth0 tenant domain (e.g. `https://my-tenant.auth0.com`).
|
||||
* - `values.clientId` — Auth0 application client_id.
|
||||
* - `values.audience` — Auth0 API audience identifier.
|
||||
*
|
||||
* TODO: Add values.scopes to QAuthenticationMetaData for Auth0 as well.
|
||||
*
|
||||
* @param authMeta - Authentication metadata from the backend.
|
||||
* @param challenge - PKCE code_challenge (S256).
|
||||
* @param state - State nonce for CSRF protection.
|
||||
* @returns Fully-formed Auth0 authorize URL string.
|
||||
* @throws {Error} When required metadata fields are missing.
|
||||
*/
|
||||
function buildAuth0AuthorizationUrl(
|
||||
authMeta: QAuthenticationMetaData,
|
||||
challenge: string,
|
||||
state: string
|
||||
): string {
|
||||
const { baseUrl, clientId, audience } = authMeta.values
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
'[Auth] AUTH_0: missing values.baseUrl in auth metadata — cannot build Auth0 authorization URL'
|
||||
)
|
||||
}
|
||||
if (!clientId) {
|
||||
throw new Error(
|
||||
'[Auth] AUTH_0: missing values.clientId in auth metadata — cannot build Auth0 authorization URL'
|
||||
)
|
||||
}
|
||||
|
||||
// Auth0 always uses /authorize at the domain root.
|
||||
const authorizationEndpoint = `${baseUrl.replace(/\/$/, '')}/authorize`
|
||||
|
||||
// TODO: Add values.scopes to QAuthenticationMetaData so the scope set can
|
||||
// be configured per-deployment instead of hardcoded here.
|
||||
const scope = 'openid profile email'
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
})
|
||||
|
||||
// Auth0 requires the audience parameter to issue an access token for an API.
|
||||
if (audience) {
|
||||
params.set('audience', audience)
|
||||
}
|
||||
|
||||
return `${authorizationEndpoint}?${params.toString()}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE redirect initiator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generates PKCE credentials, stores them in sessionStorage, and redirects the
|
||||
* browser to the IdP authorization endpoint.
|
||||
*
|
||||
* Storage keys written:
|
||||
* - `'pkce_code_verifier'` — plain-text verifier for the callback page
|
||||
* - `'oauth_state'` — state nonce validated on return (CSRF guard)
|
||||
* - `'oauth2ReturnTo'` — post-login destination URL
|
||||
*
|
||||
* @param authMeta - Authentication metadata describing the IdP.
|
||||
* @param returnTo - The path to redirect to after successful login.
|
||||
*/
|
||||
async function initiatePkceRedirect(
|
||||
authMeta: QAuthenticationMetaData,
|
||||
returnTo: string
|
||||
): Promise<void> {
|
||||
const verifier = generateCodeVerifier()
|
||||
const challenge = await generateCodeChallenge(verifier)
|
||||
const state = generateState()
|
||||
|
||||
// Persist PKCE params so the callback page can complete the flow.
|
||||
sessionStorage.setItem('pkce_code_verifier', verifier)
|
||||
sessionStorage.setItem('oauth_state', state)
|
||||
sessionStorage.setItem('oauth2ReturnTo', returnTo)
|
||||
|
||||
let authorizationUrl: string
|
||||
if (authMeta.type === 'AUTH_0') {
|
||||
authorizationUrl = buildAuth0AuthorizationUrl(authMeta, challenge, state)
|
||||
} else {
|
||||
authorizationUrl = buildOAuth2AuthorizationUrl(authMeta, challenge, state)
|
||||
}
|
||||
|
||||
// Hard-navigate so the browser fully leaves this page and arrives at the IdP.
|
||||
window.location.href = authorizationUrl
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Inner login content — handles auto-redirect when already authenticated,
|
||||
* initiates PKCE redirect for OAUTH2 / AUTH_0 auth types, and renders
|
||||
* appropriate loading or redirecting UI while authentication resolves.
|
||||
*
|
||||
* @returns A loading spinner, a "redirecting" card, or null once redirect fires.
|
||||
*/
|
||||
function LoginContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { isAuthenticated, isLoading, authMetadata } = useAuth()
|
||||
const redirectInitiated = useRef(false)
|
||||
|
||||
const returnTo = searchParams.get('returnTo') ?? '/'
|
||||
|
||||
// Redirect to dashboard once authenticated.
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(returnTo)
|
||||
// Only redirect to relative paths (security: prevent open redirect)
|
||||
if (decoded.startsWith('/')) {
|
||||
router.replace(decoded)
|
||||
} else {
|
||||
router.replace('/')
|
||||
}
|
||||
} catch {
|
||||
router.replace('/')
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router, returnTo])
|
||||
|
||||
// Initiate PKCE redirect for OAUTH2 / AUTH_0 once metadata is available
|
||||
// and the user is not already authenticated.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) return
|
||||
if (!authMetadata) return
|
||||
if (authMetadata.type !== 'OAUTH2' && authMetadata.type !== 'AUTH_0') return
|
||||
if (redirectInitiated.current) return
|
||||
redirectInitiated.current = true
|
||||
|
||||
const destination = (() => {
|
||||
try {
|
||||
const decoded = decodeURIComponent(returnTo)
|
||||
return decoded.startsWith('/') ? decoded : '/'
|
||||
} catch {
|
||||
return '/'
|
||||
}
|
||||
})()
|
||||
|
||||
initiatePkceRedirect(authMetadata, destination).catch((err: unknown) => {
|
||||
console.error('[Login] Failed to initiate PKCE redirect:', err)
|
||||
})
|
||||
}, [authMetadata, isAuthenticated, returnTo])
|
||||
|
||||
// While the AuthProvider is still resolving (initial load or anonymous
|
||||
// session setup), show the spinner.
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div
|
||||
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Authenticating...
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{authMetadata
|
||||
? `Setting up ${authMetadata.type} session...`
|
||||
: 'Checking authentication...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Not loading + not authenticated. For OAUTH2/AUTH_0 we have just fired (or
|
||||
// are about to fire) a redirect — show a "redirecting" state. For anonymous
|
||||
// types the AuthProvider is still completing setup.
|
||||
const isRedirecting =
|
||||
authMetadata?.type === 'OAUTH2' || authMetadata?.type === 'AUTH_0'
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary text-xl font-bold text-primary-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Q
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground">QQQ Admin</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isRedirecting
|
||||
? 'Redirecting to authentication provider...'
|
||||
: 'Setting up anonymous session...'}
|
||||
</p>
|
||||
<div
|
||||
className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Login page exported as the Next.js default for `/login`.
|
||||
*
|
||||
* Wraps `LoginContent` in a `<Suspense>` boundary because `useSearchParams()`
|
||||
* requires Suspense in the App Router.
|
||||
*
|
||||
* @returns The login page wrapped in a full-screen centered container.
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file EntityCopy page — copies a record by pre-populating the create form with existing values.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useRecord } from '@/lib/hooks/use-record'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import type { QTableMetaData } from '@/types'
|
||||
import type { CopyNode } from '@/lib/utils/copy-tree'
|
||||
import { copyTableNames, prepareCopyTree } from '@/lib/utils/copy-tree'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import { EntityForm, type EntityFormProps } from '@/components/forms/EntityForm'
|
||||
import { FullCopyDraft } from '@/components/forms/FullCopyDraft'
|
||||
|
||||
/**
|
||||
* Renders the entity copy form for the record identified by `slug` and `recordId`.
|
||||
*
|
||||
* Fetches full table metadata and the base source record, then renders `<EntityForm>`
|
||||
* in copy mode (`isCopy=true`) so field values are pre-populated but the form
|
||||
* will create a new record on submit. Shows a loading spinner while data is
|
||||
* pending, a permission error when the user lacks `insertPermission`, and a
|
||||
* fetch-error panel when the source record cannot be loaded.
|
||||
*
|
||||
* @returns A composed page that renders one of:
|
||||
* - A full-screen spinner while metadata or record data is loading
|
||||
* - A permission-error banner when the user lacks `insertPermission`
|
||||
* - A destructive error panel when the source record cannot be fetched
|
||||
* - `<EntityForm>` in copy mode (`isCopy=true`, pre-populated with source values)
|
||||
* wrapped in a centered `max-w-4xl` container; submitting creates a new record
|
||||
*/
|
||||
export default function EntityCopyPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
return <CopyPageContent key={JSON.stringify(params)} slug={params.slug} recordId={params.recordId} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Own metadata/source loading while rendering components receive ready metadata.
|
||||
* @param props - Exact route identifiers.
|
||||
* @returns Base form and explicit full-copy draft flow.
|
||||
*/
|
||||
function CopyPageContent({ slug, recordId }: { slug: string; recordId: string }) {
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const [mode, setMode] = useState<'base' | 'full'>('base')
|
||||
const [tree, setTree] = useState<CopyNode>()
|
||||
const [copyState, setCopyState] = useState<NonNullable<EntityFormProps['copyAssociations']>>()
|
||||
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
const { record, isLoading, isError, error } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(tableMetaData?.insertPermission && tableMetaData?.readPermission),
|
||||
includeAssociations: false,
|
||||
})
|
||||
|
||||
const expanded = useRecord({ tableName: slug, primaryKey: recordId, includeAssociations: true,
|
||||
enabled: mode === 'full' && !tree && Boolean(tableMetaData?.insertPermission && tableMetaData?.readPermission),
|
||||
})
|
||||
const sourceTables = useMemo(() => {
|
||||
if (!expanded.record) return { names: [] as string[] }
|
||||
try { return { names: copyTableNames(expanded.record).filter(name => name !== slug) } }
|
||||
catch (failure) { return { names: [] as string[], error: failure instanceof Error ? failure.message : 'Full copy source is invalid.' } }
|
||||
}, [expanded.record, slug])
|
||||
const targetQueries = useQueries({ queries: sourceTables.names.map(name => ({
|
||||
queryKey: queryKeys.tableMetadata(name), queryFn: () => loadTableMetaData(name), staleTime: 1000 * 60 * 30,
|
||||
enabled: mode === 'full' && !tree,
|
||||
})) })
|
||||
let fullError = sourceTables.error
|
||||
if (expanded.isError || targetQueries.some(query => query.isError)) {
|
||||
const accessDenied = (expanded.isError && getErrorStatusCode(expanded.error) === 403) ||
|
||||
targetQueries.some(query => query.isError && getErrorStatusCode(query.error) === 403)
|
||||
fullError = accessDenied
|
||||
? 'You do not have access to all the information needed for Full Copy. Choose Base Copy to copy this record only.'
|
||||
: 'Full Copy could not load all required information. Reload to try again, or choose Base Copy.'
|
||||
}
|
||||
let prepared: CopyNode | undefined
|
||||
if (!tree && !fullError && expanded.record && record && tableMetaData && targetQueries.every(query => Boolean(query.data))) {
|
||||
const tables: Record<string, QTableMetaData> = { [slug]: tableMetaData }
|
||||
targetQueries.forEach((query, index) => { if (query.data) tables[sourceTables.names[index]] = query.data })
|
||||
try { prepared = prepareCopyTree(tableMetaData, { ...expanded.record, values: record.values }, tables) }
|
||||
catch (failure) { fullError = failure instanceof Error ? failure.message : 'Full copy source is invalid.' }
|
||||
}
|
||||
useEffect(() => { if (prepared && !tree) setTree(prepared) }, [prepared, tree])
|
||||
const unavailable = fullError ?? (!tree || !copyState ? 'Loading full copy source and metadata…' : undefined)
|
||||
const fullState = unavailable ? { getRecords: () => { throw new Error(unavailable) }, error: unavailable } : copyState
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Copy ${tableMetaData?.label ?? slug} #${recordId}`)
|
||||
if (tableMetaData) {
|
||||
setTableMetaData(tableMetaData)
|
||||
}
|
||||
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
|
||||
|
||||
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
|
||||
}
|
||||
|
||||
if (!tableMetaData || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!tableMetaData.readPermission) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">You do not have permission to read the source {tableMetaData.label} record.</div>
|
||||
}
|
||||
|
||||
if (!tableMetaData.insertPermission) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm text-yellow-700">
|
||||
You do not have permission to create {tableMetaData.label} records.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !record) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 p-8 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm text-destructive">
|
||||
{error?.message ?? `Failed to load ${tableMetaData.label} #${recordId}`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-copy-${slug}-${recordId}`}>
|
||||
<fieldset className="mb-4 flex flex-wrap gap-4">
|
||||
<legend className="mb-2 font-medium">Copy scope</legend>
|
||||
<label className="flex items-center gap-2"><input type="radio" name="copy-mode" checked={mode === 'base'} onChange={() => setMode('base')} data-qqq-id="copy-mode-base" />Base copy</label>
|
||||
<label className="flex items-center gap-2"><input type="radio" name="copy-mode" checked={mode === 'full'} onChange={() => setMode('full')} data-qqq-id="copy-mode-full" />Full copy</label>
|
||||
</fieldset>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{mode === 'base' ? 'Copy this record’s editable fields. Associated records are not copied.' : 'Copy editable fields and every loaded named association. A record reached through two named paths is copied twice. Normal insert defaults and validation apply. Limited to 64 association levels and 1000 associated records in this form.'}</p>
|
||||
<EntityForm
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
isCopy={true}
|
||||
copyAssociations={mode === 'full' ? fullState : undefined}
|
||||
>
|
||||
{tree && <fieldset hidden={mode !== 'full'} disabled={mode !== 'full'} className="min-w-0"><FullCopyDraft tree={tree} onChange={setCopyState} /></fieldset>}
|
||||
</EntityForm>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file RecordDeveloperView page — shows raw record data and table metadata as formatted JSON for debugging.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
import { loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
|
||||
/**
|
||||
* Renders a developer debug view for a single record, showing all field values
|
||||
* in a table and the raw record and table metadata as collapsible JSON blocks.
|
||||
*
|
||||
* @returns A composed page that assembles:
|
||||
* - A field-values summary table (field name, type, raw value for every field in the record)
|
||||
* - A `<JsonBlock>` for the raw record (expanded by default)
|
||||
* - A `<JsonBlock>` for the full table metadata (collapsed by default)
|
||||
* - A loading spinner while either query is in-flight, and an error panel if the record fetch fails
|
||||
*/
|
||||
export default function RecordDeveloperViewPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Dev: ${slug} #${recordId}`)
|
||||
}, [slug, recordId, setPageHeader])
|
||||
|
||||
const { data: record, isLoading: recordLoading, error: recordError } = useQuery({
|
||||
queryKey: queryKeys.tableRecord(slug, recordId),
|
||||
queryFn: () => getRecord(slug, recordId),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
|
||||
const { data: metaData, isLoading: metaLoading } = useQuery({
|
||||
queryKey: queryKeys.tableMetadata(slug),
|
||||
queryFn: () => loadTableMetaData(slug),
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const isLoading = recordLoading || metaLoading
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id={`record-dev-${slug}-${recordId}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Record Developer View:{' '}
|
||||
<span className="font-mono text-primary">{slug}</span>
|
||||
{' '}
|
||||
<span className="font-mono text-muted-foreground">#{recordId}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div
|
||||
className="flex items-center justify-center py-12"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recordError && (
|
||||
<div
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
Failed to load record: {recordError instanceof Error ? recordError.message : 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && record && (
|
||||
<div className="space-y-4">
|
||||
{/* Field values summary */}
|
||||
{metaData && (
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="min-w-full text-sm" data-qqq-id="record-dev-field-table">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
Field
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(record.values).map(([fieldName, value]) => {
|
||||
const field = metaData.fields[fieldName]
|
||||
return (
|
||||
<tr
|
||||
key={fieldName}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-xs text-primary">
|
||||
{fieldName}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">
|
||||
{field?.type ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-foreground">
|
||||
{value === null || value === undefined
|
||||
? <span className="text-muted-foreground italic">null</span>
|
||||
: String(value)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JsonBlock label="Raw Record" value={record} />
|
||||
{metaData && <JsonBlock label="Table Metadata" value={metaData} defaultOpen={false} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible card that renders a JSON value as a formatted `<pre>` block.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.label - Heading displayed in the toggle button.
|
||||
* @param props.value - The value to serialize and display as JSON.
|
||||
* @param props.defaultOpen - Whether the panel starts expanded. Defaults to `true`.
|
||||
* @returns A collapsible JSON inspector card.
|
||||
*/
|
||||
function JsonBlock({
|
||||
label,
|
||||
value,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
label: string
|
||||
value: unknown
|
||||
defaultOpen?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between bg-muted px-4 py-3 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring"
|
||||
aria-expanded={open}
|
||||
data-qqq-id="button-json-block-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" aria-hidden="true" />
|
||||
{label}
|
||||
</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<pre
|
||||
className="overflow-x-auto bg-gray-900 p-4 text-xs text-green-300"
|
||||
data-qqq-id="json-output"
|
||||
>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file EntityEdit page — serves the route `/app/[slug]/[recordId]/edit` and renders the edit form.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* EntityEdit page — serves the route `/app/[slug]/[recordId]/edit` and renders the
|
||||
* edit form for an existing QQQ record.
|
||||
*
|
||||
* Route params:
|
||||
* - `slug` — the QQQ table name.
|
||||
* - `recordId` — the primary-key value of the record to edit.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useRecord } from '@/lib/hooks/use-record'
|
||||
import { EntityForm } from '@/components/forms/EntityForm'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
|
||||
/**
|
||||
* Renders the record-edit form for the record identified by `slug` and `recordId`.
|
||||
*
|
||||
* Fetches both application metadata and the existing record data via `useRecord`.
|
||||
* Shows a loading spinner while either is pending. Renders a permission error
|
||||
* when the user lacks `editPermission` on the table, and a fetch-error panel
|
||||
* when the record cannot be loaded. On success renders `<EntityForm>` in edit
|
||||
* mode (pre-populated with the existing record values).
|
||||
*
|
||||
* @returns A composed page that renders one of:
|
||||
* - A full-screen spinner while metadata or record data is loading
|
||||
* - A permission-error banner when the user lacks `editPermission`
|
||||
* - A destructive error panel when the record cannot be fetched
|
||||
* - `<EntityForm>` in edit mode (pre-populated with the existing record values)
|
||||
* wrapped in a centered `max-w-4xl` container
|
||||
*/
|
||||
export default function EntityEditPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
const { record, isLoading, isError, error } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(tableMetaData?.editPermission),
|
||||
includeAssociations: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Edit ${tableMetaData?.label ?? slug} #${recordId}`)
|
||||
if (tableMetaData) {
|
||||
setTableMetaData(tableMetaData)
|
||||
}
|
||||
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
|
||||
|
||||
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
|
||||
}
|
||||
|
||||
if (!tableMetaData || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!tableMetaData.editPermission) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm text-yellow-700">
|
||||
You do not have permission to edit {tableMetaData.label} records.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !record) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 p-8 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm text-destructive">
|
||||
{error?.message ?? `Failed to load ${tableMetaData.label} #${recordId}`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-edit-${slug}-${recordId}`}>
|
||||
<EntityForm tableMetaData={tableMetaData} record={record} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* 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 { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { deserializeFilter } from '@/lib/utils/filter-utils'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import RecordViewPage from './page'
|
||||
|
||||
const navigation = vi.hoisted(() => ({ search: 'tab=related' }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ slug: 'person', recordId: '1' }),
|
||||
usePathname: () => '/app/person/1',
|
||||
useSearchParams: () => new URLSearchParams(navigation.search),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
|
||||
}))
|
||||
|
||||
const baseRecord = { tableName: 'person', values: { id: 1, firstName: 'Avery' }, recordLabel: 'Avery Sample' }
|
||||
|
||||
function fixture(childRead = false) {
|
||||
const person = structuredClone(qInstance.tables.person)
|
||||
const pet = { ...structuredClone(qInstance.tables.company), name: 'pet', label: 'Pet', readPermission: childRead, insertPermission: true }
|
||||
pet.fields.personId = { ...person.fields.id, name: 'personId', label: 'Person', isEditable: true, isHidden: false }
|
||||
person.exposedJoins = []
|
||||
person.associations = [{ name: 'pets', associatedTableName: 'pet', join: {
|
||||
name: 'personJoinPet', type: 'ONE_TO_MANY', leftTable: 'person', rightTable: 'pet',
|
||||
joinOns: [{ leftField: 'id', rightField: 'personId' }],
|
||||
} }]
|
||||
const registry = { ...qInstance, widgets: { ...qInstance.widgets }, tables: { person: { name: 'person', label: 'Person', readPermission: true }, pet: { name: 'pet', readPermission: false, insertPermission: true } } }
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)),
|
||||
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(person)),
|
||||
http.get('/qqq/v1/metaData/table/pet', () => HttpResponse.json(pet)),
|
||||
)
|
||||
return { person, pet, registry }
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><QContextProvider><RecordViewPage /></QContextProvider></QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('RecordViewPage independent association loading', () => {
|
||||
beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); navigation.search = 'tab=related' })
|
||||
|
||||
it('keeps the readable base and child Add when whole association expansion is denied', async () => {
|
||||
const { person: metadata } = fixture()
|
||||
const before = structuredClone(metadata)
|
||||
const requests: string[] = []
|
||||
server.use(http.get('/data/person/1', ({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
|
||||
requests.push(mode)
|
||||
return mode === 'true'
|
||||
? HttpResponse.json({ error: 'Pet read denied' }, { status: 403 })
|
||||
: HttpResponse.json(baseRecord)
|
||||
}))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
expect(await screen.findByText('Related records could not be loaded.')).toBeVisible()
|
||||
const add = screen.getByRole('button', { name: '+ Add Pet' })
|
||||
expect(add).toBeEnabled()
|
||||
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
|
||||
fireEvent.click(add)
|
||||
expect(await screen.findByRole('dialog', { name: 'Add Pet' })).toBeVisible()
|
||||
expect(requests).toEqual(['false', 'true'])
|
||||
expect(metadata).toEqual(before)
|
||||
})
|
||||
|
||||
it('does not rename association keys or present an unmatched association as empty', async () => {
|
||||
fixture(true)
|
||||
server.use(http.get('/data/person/1', ({ request }) => HttpResponse.json({
|
||||
...baseRecord,
|
||||
...(new URL(request.url).searchParams.get('includeAssociations') === 'true' ? {
|
||||
associatedRecords: { pet: [{ tableName: 'pet', values: { id: 2, name: 'Actual pet' }, recordLabel: 'Actual pet' }] },
|
||||
} : {}),
|
||||
})))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
await waitFor(() => expect(screen.queryByText('Loading related records...')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
|
||||
})
|
||||
it('retains explicitly keyed related records when the response binding is available', async () => {
|
||||
fixture(true)
|
||||
server.use(http.get('/data/person/1', ({ request }) => HttpResponse.json({
|
||||
...baseRecord,
|
||||
...(new URL(request.url).searchParams.get('includeAssociations') === 'true' ? {
|
||||
associatedRecords: { pets: [{ tableName: 'pet', values: { id: 2, name: 'Mapped pet' }, recordLabel: 'Mapped pet' }] },
|
||||
} : {}),
|
||||
})))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Mapped pet')).toBeVisible()
|
||||
expect(screen.queryByText('Related records are unavailable.')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps arbitrary aliases distinct and deduplicates target metadata loads', async () => {
|
||||
const { person, pet } = fixture(true)
|
||||
person.associations = ['care / primary', 'scheduled reviews'].map((name) => ({ ...person.associations![0], name }))
|
||||
const metadataCalls: string[] = []
|
||||
let releaseMetadata!: () => void
|
||||
const ready = new Promise<void>((resolve) => { releaseMetadata = resolve })
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/pet', async () => { metadataCalls.push('pet'); await ready; return HttpResponse.json(pet) }),
|
||||
http.get('/data/person/1', ({ request }) => HttpResponse.json({ ...baseRecord,
|
||||
...(new URL(request.url).searchParams.get('includeAssociations') === 'true'
|
||||
? { associatedRecords: { 'care / primary': [], 'scheduled reviews': [{ tableName: 'pet', values: { id: 4, name: 'Review pet' } }] } } : {}),
|
||||
})),
|
||||
)
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
await waitFor(() => expect(screen.getAllByText('Loading related metadata...')).toHaveLength(2))
|
||||
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
|
||||
releaseMetadata()
|
||||
expect(await screen.findByText('No care / primary records')).toBeVisible()
|
||||
expect(await screen.findByText('Review pet')).toBeVisible()
|
||||
expect(metadataCalls).toEqual(['pet'])
|
||||
expect(screen.getAllByRole('button', { name: '+ Add Pet' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each(['tab=overview', 'view=list'])('places explicitly bound groups in their sections (%s)', async (view) => {
|
||||
navigation.search = view
|
||||
const { person, registry } = fixture(true)
|
||||
person.associations = [{ ...person.associations![0], name: 'care / primary' }, { ...person.associations![0], name: 'scheduled reviews' }]
|
||||
person.sections = [
|
||||
{ name: 'fieldGuide', label: 'Companions', tier: 'T2', isHidden: false, fieldNames: [], widgetName: 'companionPanel' },
|
||||
{ name: 'reviewSchedule', label: 'Reviews', tier: 'T2', isHidden: false, fieldNames: [], widgetName: 'reviewEditor' },
|
||||
]
|
||||
registry.widgets.companionPanel = { name: 'companionPanel', label: 'Different widget title', type: 'childRecordList', hasPermission: true, defaultValues: { manageAssociationName: 'care / primary' } }
|
||||
registry.widgets.reviewEditor = { name: 'reviewEditor', label: 'Another title', type: 'rowBuilder', hasPermission: true, defaultValues: { associationName: 'scheduled reviews' } }
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { 'care / primary': [], 'scheduled reviews': [] } })))
|
||||
const { container } = renderPage()
|
||||
const byId = (id: string) => Array.from(container.querySelectorAll('[data-qqq-id]')).find((element) => element.getAttribute('data-qqq-id') === id)
|
||||
await waitFor(() => expect(byId('button-create-association-care%20%2F%20primary')).toBeEnabled())
|
||||
expect(byId('associated-records-care%20%2F%20primary')).toHaveTextContent('Companions')
|
||||
expect(byId('associated-records-scheduled%20reviews')).toHaveTextContent('Reviews')
|
||||
expect(screen.queryByRole('tab', { name: 'Related' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes target metadata failure without hiding the base or claiming an empty group', async () => {
|
||||
fixture(true)
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/pet', () => HttpResponse.json({ error: 'Unavailable' }, { status: 500 })),
|
||||
http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })),
|
||||
)
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
expect(await screen.findByText('Related table metadata is unavailable.')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('No pets records')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits every composite non-PK relationship field despite their exclusion from editable inputs', async () => {
|
||||
const { person, pet } = fixture(false)
|
||||
person.fields.schedule = { ...person.fields.id, name: 'schedule' }
|
||||
person.fields.visitDate = { ...person.fields.firstName, name: 'visitDate', type: 'DATE' }
|
||||
pet.fields.birthDate = { ...person.fields.visitDate, name: 'birthDate', isEditable: true }
|
||||
pet.fields.personId.possibleValueSourceName = undefined
|
||||
person.associations![0].join.joinOns = [{ leftField: 'schedule', rightField: 'personId' }, { leftField: 'visitDate', rightField: 'birthDate' }]
|
||||
server.use(http.get('/data/person/1', ({ request }) => new URL(request.url).searchParams.get('includeAssociations') === 'true'
|
||||
? HttpResponse.json({ error: 'Denied' }, { status: 403 })
|
||||
: HttpResponse.json({ ...baseRecord, values: { ...baseRecord.values, schedule: 2, visitDate: '1990-01-15' } })))
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'pet', values: { id: 8, personId: 2, birthDate: '1990-01-15' } }] })
|
||||
const before = structuredClone({ person, pet })
|
||||
renderPage()
|
||||
fireEvent.click(await screen.findByRole('button', { name: '+ Add Pet' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Add Pet' })
|
||||
expect(within(dialog).getAllByRole('heading', { name: 'Add Pet' })).toHaveLength(1)
|
||||
expect(within(dialog).queryByLabelText('Person')).not.toBeInTheDocument()
|
||||
fireEvent.change(within(dialog).getByLabelText(/Name/), { target: { value: 'Created child' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalled())
|
||||
expect(post.mock.calls[0][0]).toBe('/data/pet')
|
||||
const values = post.mock.calls[0][1] as FormData
|
||||
expect(values.get('personId')).toBe('2')
|
||||
expect(values.get('birthDate')).toBe('1990-01-15')
|
||||
expect(values.get('name')).toBe('Created child')
|
||||
expect(values.has('associations')).toBe(false)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect({ person, pet }).toEqual(before)
|
||||
})
|
||||
|
||||
it('round-trips Unicode and plus-bearing base64 View All filters through URLSearchParams', async () => {
|
||||
const { person, pet } = fixture(true)
|
||||
person.associations![0].join.joinOns = [{ leftField: 'firstName', rightField: 'personId' }]
|
||||
pet.fields.personId.type = 'STRING'
|
||||
const value = '>>>漢'
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, values: { id: 1, firstName: value }, associatedRecords: { pets: [] } })))
|
||||
renderPage()
|
||||
const link = await screen.findByRole('link', { name: 'View All' })
|
||||
const url = new URL(link.getAttribute('href')!, 'http://localhost')
|
||||
expect(url.search).toContain('%2B')
|
||||
expect(deserializeFilter(url.searchParams.get('filter')!)).toMatchObject({ criteria: [{ fieldName: 'personId', operator: 'EQUALS', values: [value] }], booleanOperator: 'AND' })
|
||||
})
|
||||
|
||||
it('allows correctly oriented direct Add for reverse joins without claiming expanded absence', async () => {
|
||||
const { person } = fixture(true)
|
||||
person.associations![0].join = { ...person.associations![0].join, leftTable: 'pet', rightTable: 'person', type: 'MANY_TO_ONE', joinOns: [{ leftField: 'personId', rightField: 'id' }] }
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Related record loading for reverse associations is not supported.')).toBeVisible()
|
||||
expect(screen.queryByText('No pets records')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('retains a panel with no visible columns while denying an unavailable relationship write', async () => {
|
||||
const { person, pet } = fixture(false)
|
||||
Object.values(pet.fields).forEach((field) => { field.isHidden = true })
|
||||
delete person.fields.id
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json(baseRecord)))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Relationship values are unavailable; this record cannot be linked.')).toBeVisible()
|
||||
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps permitted Add available when there are no visible child columns', async () => {
|
||||
const { pet } = fixture(false)
|
||||
Object.values(pet.fields).forEach((field) => { field.isHidden = true })
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json(baseRecord)))
|
||||
renderPage()
|
||||
expect(await screen.findByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps readable data and View All when INSERT alone is denied', async () => {
|
||||
const { pet } = fixture(true)
|
||||
pet.insertPermission = false
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [{ tableName: 'pet', values: { id: 'one/two?', name: 'Readable pet' } }] } })))
|
||||
renderPage()
|
||||
const recordLinks = await screen.findAllByRole('link', { name: 'View Pet record one/two?' })
|
||||
for (const link of recordLinks) expect(link.getAttribute('href')).toContain('/app/pet/one%2Ftwo%3F?')
|
||||
expect(screen.getByRole('link', { name: 'View All' })).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file RecordView page — serves the route `/app/[slug]/[recordId]` and renders the record detail view.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* RecordView page — serves the route `/app/[slug]/[recordId]` and renders a
|
||||
* detailed view of a single QQQ record.
|
||||
*
|
||||
* Route params:
|
||||
* - `slug` — the QQQ table name.
|
||||
* - `recordId` — the primary-key value of the record to display.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { addRecentRecord } from '@/lib/utils/recent-records'
|
||||
import { getProcessesForTable } from '@/lib/utils/process-utils'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useRecord } from '@/lib/hooks/use-record'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import type { AssociationTableState } from '@/lib/utils/association-utils'
|
||||
import { RecordView } from '@/components/records/RecordView'
|
||||
|
||||
/**
|
||||
* Renders the detail view for a single record identified by `slug` (table name)
|
||||
* and `recordId` (primary key).
|
||||
*
|
||||
* Loads the base record and optional whole-association expansion independently. Once
|
||||
* the record loads it is registered in the recently-viewed history (via
|
||||
* `addRecentRecord`) so it surfaces in the GlobalSearch and SearchDialog.
|
||||
* The page header in QContext is updated to the record label.
|
||||
*
|
||||
* @returns A composed view that assembles:
|
||||
* - A full-screen spinner while table metadata resolves
|
||||
* - `<RecordView>` wired with the fetched record, loading state, error state,
|
||||
* a refetch callback, the list of available processes, and all table definitions
|
||||
* (needed to render association sub-tables)
|
||||
*/
|
||||
export default function RecordViewPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
const { record, isLoading, isError, error, refetch } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(tableMetaData),
|
||||
includeAssociations: false,
|
||||
})
|
||||
|
||||
const targetNames = [...new Set(tableMetaData?.associations?.map((association) => association.associatedTableName) ?? [])]
|
||||
const targetQueries = useQueries({ queries: targetNames.map((name) => ({
|
||||
queryKey: queryKeys.tableMetadata(name),
|
||||
queryFn: () => loadTableMetaData(name),
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})) })
|
||||
const associationTables: Record<string, AssociationTableState> = Object.fromEntries(targetNames.map((name, index) => [name, {
|
||||
table: targetQueries[index].data,
|
||||
isLoading: targetQueries[index].isLoading,
|
||||
isError: targetQueries[index].isError,
|
||||
}]))
|
||||
|
||||
// The legacy endpoint expands every association; a denial must not hide the base record.
|
||||
const associations = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(record) && Boolean(tableMetaData?.associations?.length),
|
||||
includeAssociations: true,
|
||||
})
|
||||
const displayRecord = record && associations.record && !associations.isError
|
||||
? { ...record, associatedRecords: associations.record.associatedRecords }
|
||||
: record
|
||||
|
||||
useEffect(() => {
|
||||
if (record?.recordLabel) {
|
||||
setPageHeader(record.recordLabel)
|
||||
} else {
|
||||
setPageHeader(`${tableMetaData?.label ?? slug} #${recordId}`)
|
||||
}
|
||||
if (tableMetaData) {
|
||||
setTableMetaData(tableMetaData)
|
||||
}
|
||||
}, [record, tableMetaData, slug, recordId, setPageHeader, setTableMetaData])
|
||||
|
||||
// Track recently viewed records for global search
|
||||
useEffect(() => {
|
||||
if (record && tableMetaData) {
|
||||
addRecentRecord({
|
||||
tableName: tableMetaData.name,
|
||||
tableLabel: tableMetaData.label,
|
||||
recordId: String(record.values[tableMetaData.primaryKeyField]),
|
||||
recordLabel: record.recordLabel || `${tableMetaData.label} #${record.values[tableMetaData.primaryKeyField]}`,
|
||||
path: `/app/${tableMetaData.name}/${record.values[tableMetaData.primaryKeyField]}`,
|
||||
})
|
||||
}
|
||||
}, [record, tableMetaData])
|
||||
|
||||
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
|
||||
}
|
||||
|
||||
if (!tableMetaData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tableProcesses = metaData ? getProcessesForTable(metaData, slug) : []
|
||||
|
||||
return (
|
||||
<>
|
||||
{record && associations.isLoading && <p role="status">Loading related records...</p>}
|
||||
{record && associations.isError && (
|
||||
<p role="alert" className="mb-4 text-destructive">Related records could not be loaded.</p>
|
||||
)}
|
||||
<RecordView
|
||||
tableMetaData={tableMetaData}
|
||||
record={displayRecord}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRefetch={() => {
|
||||
refetch()
|
||||
if (tableMetaData.associations?.length) associations.refetch()
|
||||
}}
|
||||
processes={tableProcesses}
|
||||
allTables={metaData?.tables}
|
||||
widgetMetaDataMap={metaData?.widgets}
|
||||
associationTables={associationTables}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file EntityCreate page — serves the route `/app/[slug]/create` and renders the record-creation form.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* EntityCreate page — serves the route `/app/[slug]/create` and renders the
|
||||
* record-creation form for the table identified by `slug`.
|
||||
*
|
||||
* Route params:
|
||||
* - `slug` — the QQQ table name for which a new record should be created.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { EntityForm } from '@/components/forms/EntityForm'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
|
||||
/**
|
||||
* Renders the record-creation form for the table identified by `slug`.
|
||||
*
|
||||
* Fetches application metadata to resolve the table definition, then renders
|
||||
* `<EntityForm>` with no initial record (create mode). Shows a permission
|
||||
* error banner when the user lacks `insertPermission` on the table, and a
|
||||
* loading spinner while metadata is being fetched.
|
||||
*
|
||||
* @returns A composed page that renders one of:
|
||||
* - A full-screen spinner while metadata resolves
|
||||
* - A permission-error banner when the user lacks `insertPermission`
|
||||
* - `<EntityForm>` in create mode (no `record` prop) wrapped in a centered `max-w-4xl` container
|
||||
*/
|
||||
export default function EntityCreatePage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const slug = params.slug
|
||||
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Create ${tableMetaData?.label ?? slug}`)
|
||||
if (tableMetaData) {
|
||||
setTableMetaData(tableMetaData)
|
||||
}
|
||||
}, [tableMetaData?.label, slug, tableMetaData, setPageHeader, setTableMetaData])
|
||||
|
||||
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
|
||||
}
|
||||
|
||||
if (!tableMetaData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!tableMetaData.insertPermission) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm text-yellow-700">
|
||||
You do not have permission to create {tableMetaData.label} records.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-create-${slug}`}>
|
||||
<EntityForm tableMetaData={tableMetaData} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file TableDeveloperView page — shows raw table metadata as formatted JSON for debugging field definitions and permissions.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
|
||||
/**
|
||||
* Renders a developer debug view for a QQQ table, showing summary statistics
|
||||
* and the full table metadata as a collapsible JSON block.
|
||||
*
|
||||
* @returns A composed page that assembles:
|
||||
* - A 4-column stats grid (`<MetaStat>` cards: field count, section count, permissions, primary key)
|
||||
* - A collapsible `<JsonBlock>` panel rendering the full table metadata as formatted JSON
|
||||
* - A loading spinner while metadata is fetching, and a destructive error panel on failure
|
||||
*/
|
||||
export default function TableDeveloperViewPage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const { setPageHeader } = useQContext()
|
||||
const slug = params.slug
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Developer View: ${slug}`)
|
||||
}, [slug, setPageHeader])
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.tableMetadata(slug),
|
||||
queryFn: () => loadTableMetaData(slug),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id={`table-dev-${slug}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Table Developer View: <span className="font-mono text-primary">{slug}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div
|
||||
className="flex items-center justify-center py-12"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
Failed to load metadata: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<MetaStat label="Fields" value={Object.keys(data.fields ?? {}).length} />
|
||||
<MetaStat label="Sections" value={(data.sections ?? []).length} />
|
||||
<MetaStat
|
||||
label="Capabilities"
|
||||
value={[
|
||||
data.insertPermission ? 'Insert' : null,
|
||||
data.editPermission ? 'Edit' : null,
|
||||
data.deletePermission ? 'Delete' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'Read-only'}
|
||||
/>
|
||||
<MetaStat label="Primary Key" value={data.primaryKeyField ?? '—'} />
|
||||
</div>
|
||||
|
||||
{/* Raw JSON */}
|
||||
<JsonBlock label="Full Table Metadata" value={data} defaultOpen={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single labeled statistic card.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.label - The stat label displayed above the value.
|
||||
* @param props.value - The numeric or string value to display prominently.
|
||||
* @returns A bordered card showing the label and value.
|
||||
*/
|
||||
function MetaStat({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible card that renders a JSON value as a formatted `<pre>` block.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.label - Heading displayed in the toggle button.
|
||||
* @param props.value - The value to serialize and display as JSON.
|
||||
* @param props.defaultOpen - Whether the panel starts expanded. Defaults to `true`.
|
||||
* @returns A collapsible JSON inspector card.
|
||||
*/
|
||||
function JsonBlock({
|
||||
label,
|
||||
value,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
label: string
|
||||
value: unknown
|
||||
defaultOpen?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between bg-muted px-4 py-3 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring"
|
||||
aria-expanded={open}
|
||||
data-qqq-id="button-json-block-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" aria-hidden="true" />
|
||||
{label}
|
||||
</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<pre
|
||||
className="overflow-x-auto bg-gray-900 p-4 text-xs text-green-300"
|
||||
data-qqq-id="json-output"
|
||||
>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file RecordViewByUniqueKey page — placeholder for viewing a record by unique key (Package 3).
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
|
||||
/**
|
||||
* Placeholder page for viewing a record by its unique key.
|
||||
*
|
||||
* Full implementation is deferred to Package 3. Currently renders a stub card.
|
||||
*
|
||||
* @returns A placeholder panel indicating the feature is not yet implemented.
|
||||
*/
|
||||
export default function RecordViewByKeyPage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const { setPageHeader } = useQContext()
|
||||
const slug = params.slug
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`View ${slug} by Key`)
|
||||
}, [slug, setPageHeader])
|
||||
|
||||
return (
|
||||
<div data-qqq-id={`record-view-key-${slug}`}>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
|
||||
<p className="text-muted-foreground">Record view by key -- implemented in Package 3</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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 { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import * as processesApi from '@/lib/api/processes'
|
||||
import SlugPage from './page'
|
||||
|
||||
const { recordQuery, params, location } = vi.hoisted(() => ({
|
||||
recordQuery: vi.fn(() => <div data-testid="record-query" />),
|
||||
params: { slug: 'person' },
|
||||
location: { search: '' },
|
||||
}))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => params,
|
||||
useSearchParams: () => new URLSearchParams(location.search),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/components/query', () => ({ RecordQuery: recordQuery }))
|
||||
vi.mock('@/components/widgets', () => ({ AppHome: () => <div>App home</div> }))
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<QContextProvider><SlugPage /></QContextProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// The V1 registry deliberately omits fields, sections, primary keys and joins.
|
||||
const registry = {
|
||||
...qInstance,
|
||||
tables: Object.fromEntries(Object.entries(qInstance.tables).map(([name, table]) => [name, {
|
||||
name, label: table.label, readPermission: table.readPermission,
|
||||
insertPermission: table.insertPermission,
|
||||
}])),
|
||||
}
|
||||
|
||||
describe('SlugPage table metadata readiness', () => {
|
||||
beforeEach(() => {
|
||||
params.slug = 'person'
|
||||
location.search = ''
|
||||
recordQuery.mockClear()
|
||||
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)))
|
||||
})
|
||||
|
||||
it('waits for full active metadata and passes the light permission registry separately', async () => {
|
||||
let releaseTable!: () => void
|
||||
const tableReady = new Promise<void>((resolve) => { releaseTable = resolve })
|
||||
let requested = false
|
||||
server.use(http.get('/qqq/v1/metaData/table/person', async () => {
|
||||
requested = true
|
||||
await tableReady
|
||||
return HttpResponse.json(qInstance.tables.person)
|
||||
}))
|
||||
renderPage()
|
||||
try {
|
||||
await waitFor(() => expect(requested).toBe(true))
|
||||
expect(screen.queryByTestId('record-query')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status', { name: 'Loading table metadata' })).toBeInTheDocument()
|
||||
} finally {
|
||||
await act(async () => { releaseTable() })
|
||||
}
|
||||
await screen.findByTestId('record-query')
|
||||
expect(recordQuery).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
tableMetaData: qInstance.tables.person, allTables: registry.tables,
|
||||
}), undefined)
|
||||
expect(registry.tables.person).not.toHaveProperty('fields')
|
||||
})
|
||||
|
||||
it('shows full-table failure without rendering a query from registry metadata', async () => {
|
||||
server.use(http.get('/qqq/v1/metaData/table/person', () =>
|
||||
HttpResponse.json({ error: 'Permission denied' }, { status: 403 })))
|
||||
renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load table metadata')
|
||||
expect(recordQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fetch full table metadata when an app takes priority over a same-name table', async () => {
|
||||
const metadata = { ...registry, apps: { person: { name: 'person', label: 'People app' } } }
|
||||
let requested = false
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData', () => HttpResponse.json(metadata)),
|
||||
http.get('/qqq/v1/metaData/table/person', () => {
|
||||
requested = true
|
||||
return HttpResponse.json(qInstance.tables.person)
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
await screen.findByText('App home')
|
||||
expect(requested).toBe(false)
|
||||
expect(recordQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const greeting = {
|
||||
name: 'greetInteractive', label: 'Greet Interactive', hasPermission: true,
|
||||
tableName: 'person', minInputRecords: 1, maxInputRecords: 2,
|
||||
frontendSteps: [{
|
||||
name: 'setup', label: 'Setup', components: [{ type: 'EDIT_FORM' }],
|
||||
formFields: [{ name: 'greetingPrefix', label: 'Greeting Prefix', type: 'STRING', isEditable: true, isHidden: false, isRequired: false }],
|
||||
}],
|
||||
}
|
||||
|
||||
describe('SlugPage process initialization', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
params.slug = greeting.name
|
||||
location.search = 'recordsParam=recordIds&recordIds=1,3'
|
||||
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json({
|
||||
...registry, processes: { [greeting.name]: { name: greeting.name, label: greeting.label } },
|
||||
})))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['recordIds', 'recordIds', '1,3', 'recordIds'],
|
||||
['filterJSON', 'filterJSON', '{"criteria":[]}', 'filterJSON'],
|
||||
['queryFilter', 'filterJSON', '{"criteria":[]}', 'filterJSON'],
|
||||
])('loads full metadata before initializing with %s selection', async (selector, field, value, expectedSelector) => {
|
||||
location.search = new URLSearchParams({ recordsParam: selector, [field]: value }).toString()
|
||||
let releaseMetadata!: () => void
|
||||
const metadataReady = new Promise<void>((resolve) => { releaseMetadata = resolve })
|
||||
const initialize = vi.spyOn(processesApi, 'processInit').mockResolvedValue({
|
||||
processUUID: 'selected-run', nextStep: 'setup', values: {},
|
||||
})
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/process/greetInteractive', async () => {
|
||||
await metadataReady
|
||||
return HttpResponse.json(greeting)
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
await screen.findByRole('status', { name: 'Loading process metadata' })
|
||||
expect(initialize).not.toHaveBeenCalled()
|
||||
await act(async () => { releaseMetadata() })
|
||||
await screen.findByRole('textbox', { name: 'Greeting Prefix' })
|
||||
expect(initialize).toHaveBeenCalledWith(greeting.name, {
|
||||
recordsParam: expectedSelector, [field]: value,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not initialize when full process metadata is denied', async () => {
|
||||
let initialized = false
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/process/greetInteractive', () =>
|
||||
HttpResponse.json({ error: 'Permission denied' }, { status: 403 })),
|
||||
http.post('/qqq/v1/processes/greetInteractive/init', () => {
|
||||
initialized = true
|
||||
return HttpResponse.json({ processUUID: 'unexpected', values: {} })
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load process metadata')
|
||||
expect(initialized).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Unified slug page — dispatches to AppHome, RecordQuery, ProcessRun, or ReportRun based on the slug.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Unified slug page — serves the route `/app/[slug]` and dispatches to
|
||||
* `AppHome`, `RecordQuery`, or `ProcessRun` based on how the slug resolves
|
||||
* against the QQQ application metadata.
|
||||
*
|
||||
* Route params:
|
||||
* - `slug` — a QQQ app name, table name, process name, or report name.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams, useSearchParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { QInstance } from '@/types'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { useProcessMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import type { ProcessInitRequest } from '@/lib/api/processes'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { getProcessesForTable } from '@/lib/utils/process-utils'
|
||||
import { RecordQuery } from '@/components/query'
|
||||
import { ProcessRun } from '@/components/process'
|
||||
import { AppHome } from '@/components/widgets'
|
||||
import { ReportRun } from '@/components/reports'
|
||||
|
||||
/**
|
||||
* Resolves a URL slug to its QQQ resource type and name.
|
||||
*
|
||||
* Checks the slug against apps, tables, processes, and reports in priority order.
|
||||
* Returns a discriminated union describing the match, or `null` when the slug is
|
||||
* not found in any category.
|
||||
*
|
||||
* Extracted as a named function for testability — the caller (`SlugPage`) passes
|
||||
* the already-fetched metadata so this function remains a pure mapping with no
|
||||
* side effects or data fetching.
|
||||
*
|
||||
* @param slug - The URL path segment to resolve (e.g. `"orders"` or `"importData"`).
|
||||
* @param metaData - The full QQQ instance metadata fetched from the backend.
|
||||
* @returns A `{ type, name }` object describing the resolved resource, or `null`.
|
||||
*/
|
||||
export function resolveSlugTarget(
|
||||
slug: string,
|
||||
metaData: QInstance
|
||||
): { type: 'app' | 'table' | 'process' | 'report'; name: string } | null {
|
||||
if (metaData.apps?.[slug]) {
|
||||
return { type: 'app', name: slug }
|
||||
}
|
||||
if (metaData.tables?.[slug]) {
|
||||
return { type: 'table', name: slug }
|
||||
}
|
||||
if (metaData.processes?.[slug]) {
|
||||
return { type: 'process', name: slug }
|
||||
}
|
||||
if (metaData.reports?.[slug]) {
|
||||
return { type: 'report', name: slug }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the appropriate page component for a given `slug` URL segment.
|
||||
*
|
||||
* Resolution priority:
|
||||
* 1. If the slug matches a QQQ **app** → renders `<AppHome>` (dashboard widgets).
|
||||
* 2. If the slug matches a **table** → renders `<RecordQuery>` (data grid + filters).
|
||||
* 3. If the slug matches a **process** → renders `<ProcessRun>` (step wizard).
|
||||
* 4. If the slug matches a **report** → renders `<ReportRun>` (format selector + download).
|
||||
* 5. Otherwise → renders an unknown-resource message.
|
||||
*
|
||||
* The page header in QContext is updated whenever the resolution changes.
|
||||
*
|
||||
* @returns A composed page selected by slug resolution:
|
||||
* - `<AppHome>` (dashboard widgets) when the slug matches a QQQ app
|
||||
* - `<RecordQuery>` (data grid + filters + pagination) when the slug matches a table
|
||||
* - `<ProcessRun>` (step wizard) when the slug matches a process
|
||||
* - `<ReportRun>` (format selector + download) when the slug matches a report
|
||||
* - A full-screen spinner while metadata is loading or the resource is resolving
|
||||
* - An unknown-resource message panel when the slug does not match any resource
|
||||
*/
|
||||
export default function SlugPage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const slug = params.slug
|
||||
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
// Determine what this slug is
|
||||
const isApp = Boolean(metaData?.apps?.[slug])
|
||||
const isTable = Boolean(metaData?.tables?.[slug])
|
||||
const isProcess = Boolean(metaData?.processes?.[slug])
|
||||
const isReport = Boolean(metaData?.reports?.[slug])
|
||||
|
||||
const app = metaData?.apps?.[slug]
|
||||
const { data: table, isError: tableError } = useTableMetaData(isTable && !isApp ? slug : undefined)
|
||||
const { data: process, isError: processError } = useProcessMetaData(isProcess && !isApp && !isTable ? slug : undefined)
|
||||
const report = metaData?.reports?.[slug]
|
||||
|
||||
useEffect(() => {
|
||||
if (isApp) {
|
||||
setPageHeader(app?.label ?? slug)
|
||||
} else if (isTable) {
|
||||
setPageHeader(table?.label ?? slug)
|
||||
if (table) setTableMetaData(table)
|
||||
} else if (isProcess) {
|
||||
setPageHeader(process?.label ?? slug)
|
||||
} else if (isReport) {
|
||||
setPageHeader(report?.label ?? slug)
|
||||
} else {
|
||||
setPageHeader(slug)
|
||||
}
|
||||
}, [isApp, isTable, isProcess, isReport, app, table, process, report, slug, setPageHeader, setTableMetaData])
|
||||
|
||||
if (metadataError || (isTable && !isApp && tableError) || (isProcess && !isApp && !isTable && processError)) {
|
||||
return (
|
||||
<div role="alert" className="py-12 text-center text-destructive">
|
||||
Failed to load {metadataError ? 'application' : isTable ? 'table' : 'process'} metadata.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!metaData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// App dashboard — Package 5 implementation
|
||||
if (isApp && app) {
|
||||
return (
|
||||
<AppHome
|
||||
appMetaData={app}
|
||||
widgetRegistry={metaData.widgets ?? {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Table record query — Package 2 implementation
|
||||
if (isTable && table) {
|
||||
const tableProcesses = getProcessesForTable(metaData, slug)
|
||||
return <RecordQuery tableName={slug} tableMetaData={table} allTables={metaData.tables} processes={tableProcesses} />
|
||||
}
|
||||
|
||||
// Table loading state (table found but metadata not yet available)
|
||||
if (isTable && !table) {
|
||||
return (
|
||||
<div role="status" aria-label="Loading table metadata" className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Process run — Package 4 implementation
|
||||
if (isProcess && process) {
|
||||
const initialRequest: ProcessInitRequest = {}
|
||||
const selection = searchParams.get('recordsParam')
|
||||
if (selection === 'recordIds') {
|
||||
initialRequest.recordsParam = 'recordIds'
|
||||
initialRequest.recordIds = searchParams.get('recordIds') ?? ''
|
||||
} else if (selection === 'filterJSON' || selection === 'queryFilter') {
|
||||
initialRequest.recordsParam = 'filterJSON'
|
||||
initialRequest.filterJSON = searchParams.get('filterJSON') ?? ''
|
||||
}
|
||||
return <ProcessRun key={`${slug}?${searchParams}`} processName={slug} processMetaData={process} initialRequest={initialRequest} />
|
||||
}
|
||||
|
||||
// Process loading state (process found but metadata not yet available)
|
||||
if (isProcess && !process) {
|
||||
return (
|
||||
<div role="status" aria-label="Loading process metadata" className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Report run
|
||||
if (isReport && report) {
|
||||
return <ReportRun reportName={slug} reportMetaData={report} />
|
||||
}
|
||||
|
||||
// Report loading state (report found but metadata not yet available)
|
||||
if (isReport && !report) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Unknown slug
|
||||
return (
|
||||
<div className="space-y-4" data-qqq-id={`unknown-slug-${slug}`}>
|
||||
<h2 className="text-2xl font-semibold text-foreground">{slug}</h2>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Unknown resource: <code className="font-mono">{slug}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file SavedView page — placeholder for loading a saved filter/view configuration (Package 2).
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
|
||||
/**
|
||||
* Placeholder page for loading a saved view by `viewId` for the table identified by `slug`.
|
||||
*
|
||||
* Full implementation is deferred to Package 2. Currently renders a stub card.
|
||||
*
|
||||
* @returns A placeholder panel indicating the feature is not yet implemented.
|
||||
*/
|
||||
export default function SavedViewPage() {
|
||||
const params = useParams<{ slug: string; viewId: string }>()
|
||||
const { setPageHeader } = useQContext()
|
||||
const { slug, viewId } = params
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`${slug} — Saved View`)
|
||||
}, [slug, setPageHeader])
|
||||
|
||||
return (
|
||||
<div data-qqq-id={`saved-view-${slug}-${viewId}`}>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Saved view -- implemented in Package 2
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
View ID: <code className="font-mono">{viewId}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* 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 { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import EntityCreatePage from './create/page'
|
||||
import EntityEditPage from './[recordId]/edit/page'
|
||||
import EntityCopyPage from './[recordId]/copy/page'
|
||||
|
||||
const { push } = vi.hoisted(() => ({ push: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ slug: 'person', recordId: '1' }),
|
||||
useRouter: () => ({ push, replace: vi.fn(), back: vi.fn() }),
|
||||
}))
|
||||
|
||||
function fixture() {
|
||||
const person = structuredClone(qInstance.tables.person)
|
||||
person.fields = Object.fromEntries(['id', 'firstName', 'lastName', 'email'].map((name) => [name, person.fields[name]]))
|
||||
person.fields.firstName.label = 'Preferred given name'
|
||||
person.sections = [{ name: 'basic', label: 'Person', isHidden: false, fieldNames: ['firstName', 'lastName', 'email'] }]
|
||||
const registry = { ...qInstance, tables: { person: { name: 'person', label: 'Person', readPermission: true, insertPermission: true, editPermission: true } } }
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)),
|
||||
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(person)),
|
||||
)
|
||||
return person
|
||||
}
|
||||
|
||||
function renderPage(page: React.ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><QContextProvider>{page}</QContextProvider></QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('Create and base edit use full metadata and actual write contracts', () => {
|
||||
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
|
||||
|
||||
it('edits the base without requesting denied children or replacing associations', async () => {
|
||||
const reads: string[] = []
|
||||
server.use(
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
|
||||
reads.push(mode)
|
||||
return mode === 'false' ? HttpResponse.json({ tableName: 'person', values: {
|
||||
id: 1, firstName: 'Avery', lastName: 'Sample', email: 'avery@example.invalid', unknown: 'must not echo',
|
||||
} }) : HttpResponse.json({ error: 'Children denied' }, { status: 403 })
|
||||
}),
|
||||
)
|
||||
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 1, firstName: 'Updated' } }] })
|
||||
renderPage(<EntityEditPage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Updated' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /Email/ }), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/1'))
|
||||
expect(reads.length).toBeGreaterThan(0)
|
||||
expect(new Set(reads)).toEqual(new Set(['false']))
|
||||
expect(put.mock.calls[0][0]).toBe('/data/person/1')
|
||||
const submitted = put.mock.calls[0][1] as FormData
|
||||
expect(submitted?.get('firstName')).toBe('Updated')
|
||||
expect(submitted?.get('email')).toBe('')
|
||||
expect(submitted?.has('associations')).toBe(false)
|
||||
expect(submitted?.has('unknown')).toBe(false)
|
||||
})
|
||||
|
||||
it('creates from full fields and unwraps the legacy envelope before navigating', async () => {
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 7 } }] })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Created' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/7'))
|
||||
expect(post.mock.calls[0][0]).toBe('/data/person')
|
||||
const submitted = post.mock.calls[0][1] as FormData
|
||||
expect(submitted?.get('firstName')).toBe('Created')
|
||||
expect(submitted?.has('associations')).toBe(false)
|
||||
})
|
||||
|
||||
it('copies only base fields after full metadata is ready, without requiring child READ', async () => {
|
||||
const person = fixture()
|
||||
const source = { tableName: 'person', values: {
|
||||
id: 1, firstName: 'Avery', lastName: 'Sample', email: 'avery@example.invalid', unknown: 'must not echo',
|
||||
} }
|
||||
const sourceSnapshot = structuredClone(source)
|
||||
let releaseMetadata: () => void = () => {}
|
||||
const pendingMetadata = new Promise<void>((resolve) => { releaseMetadata = resolve })
|
||||
let requestedMetadata = false
|
||||
const reads: string[] = []
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/person', async () => {
|
||||
requestedMetadata = true
|
||||
await pendingMetadata
|
||||
return HttpResponse.json(person)
|
||||
}),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
|
||||
reads.push(mode)
|
||||
return mode === 'false' ? HttpResponse.json(source) : HttpResponse.json({ error: 'Children denied' }, { status: 403 })
|
||||
}),
|
||||
)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 8 } }] })
|
||||
renderPage(<EntityCopyPage />)
|
||||
await waitFor(() => expect(requestedMetadata).toBe(true))
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
expect(reads).toEqual([])
|
||||
await act(async () => { releaseMetadata() })
|
||||
expect(await screen.findByLabelText(/Preferred given name/)).toHaveValue('Avery')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/8'))
|
||||
expect(new Set(reads)).toEqual(new Set(['false']))
|
||||
const submitted = post.mock.calls[0][1] as FormData
|
||||
expect(submitted.get('firstName')).toBe('Avery')
|
||||
expect(submitted.has('id')).toBe(false)
|
||||
expect(submitted.has('associations')).toBe(false)
|
||||
expect(submitted.has('unknown')).toBe(false)
|
||||
expect(source).toEqual(sourceSnapshot)
|
||||
})
|
||||
|
||||
it('retains the populated form after a failed save without an unhandled rejection', async () => {
|
||||
vi.spyOn(apiClient, 'post').mockRejectedValue(new Error('Write denied'))
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Keep input' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Write denied')
|
||||
expect(screen.getByLabelText(/Preferred given name/)).toHaveValue('Keep input')
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('submits untouched optional numeric inputs as blanks and preserves typed zero', async () => {
|
||||
const person = fixture()
|
||||
for (const [name, type] of [['quantity', 'INTEGER'], ['price', 'DECIMAL'], ['total', 'LONG']] as const) {
|
||||
person.fields[name] = { ...person.fields.firstName, name, label: name, type, isRequired: false }
|
||||
person.sections[0].fieldNames.push(name)
|
||||
}
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Numeric' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
fireEvent.change(screen.getByLabelText('total'), { target: { value: '0' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/9'))
|
||||
const submitted = post.mock.calls[0][1] as FormData
|
||||
expect(submitted.get('quantity')).toBe('')
|
||||
expect(submitted.get('price')).toBe('')
|
||||
expect(submitted.get('total')).toBe('0')
|
||||
})
|
||||
|
||||
it('keeps the form disabled for the complete pending mutation', async () => {
|
||||
let finish: (response: unknown) => void = () => {}
|
||||
const pending = new Promise((resolve) => { finish = resolve })
|
||||
const post = vi.spyOn(apiClient, 'post').mockReturnValue(pending)
|
||||
renderPage(<EntityCreatePage />)
|
||||
const firstName = await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.change(firstName, { target: { value: 'Pending' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Save' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
const saving = await screen.findByRole('button', { name: 'Saving...' })
|
||||
expect(saving).toBeDisabled()
|
||||
expect(screen.getByLabelText(/Preferred given name/)).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
|
||||
fireEvent.click(saving)
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { finish({ records: [{ tableName: 'person', values: { id: 8 } }] }) })
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/8'))
|
||||
})
|
||||
|
||||
it('navigates with the metadata-defined string primary key as one encoded segment', async () => {
|
||||
const person = fixture()
|
||||
person.primaryKeyField = 'recordCode'
|
||||
person.fields.recordCode = { ...person.fields.firstName, name: 'recordCode', label: 'Code', isEditable: false }
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { recordCode: 'A/B#1' } }] })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Natural' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/A%2FB%231'))
|
||||
})
|
||||
|
||||
it('does not announce a saved record when the response has no usable primary key', async () => {
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: {} }] })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Keep input' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('The server did not return a valid record identifier.')
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['create', 'edit', 'copy'])('shows a full-metadata failure for %s without rendering an unusable form', async (mode) => {
|
||||
server.use(http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json({ error: 'Metadata failed' }, { status: 500 })))
|
||||
renderPage(mode === 'create' ? <EntityCreatePage /> : mode === 'edit' ? <EntityEditPage /> : <EntityCopyPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Table metadata is unavailable.')
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['missing', 'error'])('shows unavailable copy registry metadata for %s', async (mode) => {
|
||||
server.use(http.get('/qqq/v1/metaData', () => mode === 'missing'
|
||||
? HttpResponse.json({ ...qInstance, tables: {} }) : HttpResponse.json({ error: 'Unavailable' }, { status: 500 })))
|
||||
renderPage(<EntityCopyPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Table metadata is unavailable.')
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['readPermission', 'insertPermission'] as const)('does not load a copy source without %s', async (permission) => {
|
||||
const person = fixture()
|
||||
person[permission] = false
|
||||
const read = vi.fn()
|
||||
server.use(http.get('/data/person/1', () => {
|
||||
read()
|
||||
return HttpResponse.json({ tableName: 'person', values: { id: 1, firstName: 'Must not load' } })
|
||||
}))
|
||||
renderPage(<EntityCopyPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(permission === 'readPermission'
|
||||
? 'You do not have permission to read the source People record.' : 'You do not have permission to create People records.')
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a failed base-copy source read without offering a blank create form', async () => {
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ error: 'Source denied' }, { status: 403 })))
|
||||
renderPage(<EntityCopyPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('403')
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('Explicit full copy', () => {
|
||||
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
|
||||
it('keeps child edits in one root submission with exact names and fresh manual keys', async () => {
|
||||
const parent = fixture()
|
||||
parent.associations = [{ name: 'care / primary', associatedTableName: 'child', join: {
|
||||
name: 'personToChild', leftTable: 'person', rightTable: 'child', type: 'ONE_TO_MANY', joinOns: [{ leftField: 'id', rightField: 'owner' }],
|
||||
} }]
|
||||
const child = { ...structuredClone(parent), name: 'child', label: 'Child', primaryKeyField: 'manualKey', associations: [], fields: {
|
||||
manualKey: { ...parent.fields.firstName, name: 'manualKey', label: 'New child key', isRequired: true },
|
||||
owner: { ...parent.fields.id, name: 'owner', isEditable: true, isRequired: true },
|
||||
firstName: { ...parent.fields.firstName, label: 'Child name' },
|
||||
}, sections: [] }
|
||||
const values = { id: 1, firstName: 'Parent', lastName: 'Source', email: 'copy@example.invalid' }
|
||||
const originalChild = { tableName: 'child', values: { manualKey: 'old-child', owner: 1, firstName: 'Original child' } }
|
||||
const reads: string[] = []
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(parent)),
|
||||
http.get('/qqq/v1/metaData/table/child', () => HttpResponse.json(child)),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
const expanded = new URL(request.url).searchParams.get('includeAssociations') === 'true'; reads.push(String(expanded))
|
||||
return HttpResponse.json({ tableName: 'person', values, ...(expanded ? { associatedRecords: { 'care / primary': [originalChild] } } : {}) })
|
||||
}),
|
||||
)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 }, associatedRecords: { 'care / primary': [{ tableName: 'child', values: { manualKey: 'fresh-child', owner: 9 } }] } }] })
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Edited parent' } })
|
||||
expect(reads).toEqual(['false'])
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
const key = await screen.findByLabelText(/New child key/)
|
||||
expect(key).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.change(key, { target: { value: 'fresh-child' } })
|
||||
fireEvent.change(screen.getByLabelText(/Child name/), { target: { value: 'Edited child' } })
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Base copy' }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
expect(screen.getByLabelText(/New child key/)).toHaveValue('fresh-child')
|
||||
expect(screen.getByLabelText(/Preferred given name/)).toHaveValue('Edited parent')
|
||||
expect(view.container.querySelectorAll('form')).toHaveLength(1)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
const body = post.mock.calls[0][1] as FormData
|
||||
expect(body.get('firstName')).toBe('Edited parent')
|
||||
expect(body.has('id')).toBe(false)
|
||||
expect(JSON.parse(body.get('associations') as string)).toEqual({ 'care / primary': [{ values: { manualKey: 'fresh-child', firstName: 'Edited child' } }] })
|
||||
expect(originalChild.values).toEqual({ manualKey: 'old-child', owner: 1, firstName: 'Original child' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('Full copy failures stay visible and cannot save a base-only fallback', () => {
|
||||
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
|
||||
function sources(problem: string) {
|
||||
const parent = fixture()
|
||||
parent.associations = [{ name: 'children', associatedTableName: 'child', join: { name: 'join', leftTable: 'person', rightTable: 'child', type: 'ONE_TO_MANY', joinOns: [{ leftField: 'id', rightField: 'owner' }] } }]
|
||||
const child = { ...structuredClone(parent), name: 'child', label: 'Child', associations: [], fields: { id: parent.fields.id, firstName: problem === 'numeric' ? { ...parent.fields.firstName, type: 'INTEGER' as const, label: 'Child amount' } : parent.fields.firstName, owner: { ...parent.fields.id, name: 'owner' } }, sections: [] }
|
||||
const values = { id: 1, firstName: 'Parent', lastName: 'Source', email: 'copy@example.invalid' }
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(parent)),
|
||||
http.get('/qqq/v1/metaData/table/child', () => problem.startsWith('metadata') ? HttpResponse.json({ error: 'Metadata unavailable' }, { status: problem === 'metadata-server' ? 500 : 403 }) : HttpResponse.json(child)),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
if (new URL(request.url).searchParams.get('includeAssociations') !== 'true') return HttpResponse.json({ tableName: 'person', values })
|
||||
if (problem.startsWith('read')) return HttpResponse.json({ error: 'Read unavailable' }, { status: problem === 'read-server' ? 500 : 403 })
|
||||
return HttpResponse.json({ tableName: 'person', values, ...(problem === 'group' ? {} : { associatedRecords: { children: [{ tableName: 'child', values: { id: 2, owner: 1, ...(problem === 'value' ? {} : { firstName: problem === 'numeric' ? 5 : 'Child' }) } }] } }) })
|
||||
}),
|
||||
)
|
||||
}
|
||||
it.each(['read', 'metadata', 'group', 'value'])('blocks %s failure even on forced form submit', async problem => {
|
||||
sources(problem)
|
||||
const post = vi.spyOn(apiClient, 'post')
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
await waitFor(() => expect(screen.getByRole('alert')).not.toHaveTextContent('Loading full copy'))
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.submit(view.container.querySelector('form')!)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not announce success when a saved response omits a nonempty requested group', async () => {
|
||||
sources('none')
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('did not confirm every copied association')
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
it('ignores a disabled hidden numeric child draft in Base mode while Full still checks it', async () => {
|
||||
sources('numeric')
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
const amount = await screen.findByRole('spinbutton', { name: /Child amount/ }) as HTMLInputElement
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
|
||||
// jsdom does not implement partial numeric entry; supply the browser's validity result.
|
||||
Object.defineProperty(amount, 'validity', { configurable: true, value: { badInput: true } })
|
||||
const report = vi.spyOn(amount, 'reportValidity')
|
||||
fireEvent.submit(view.container.querySelector('form')!)
|
||||
expect(report).toHaveBeenCalledTimes(1)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Base copy' }))
|
||||
expect(amount.disabled).toBe(false)
|
||||
expect(amount.matches(':disabled')).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
expect((post.mock.calls[0][1] as FormData).has('associations')).toBe(false)
|
||||
expect(report).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['read', 'metadata'])('explains %s denial and lets the user explicitly save a Base Copy', async problem => {
|
||||
sources(problem)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('You do not have access to all the information needed for Full Copy. Choose Base Copy to copy this record only.'))
|
||||
expect(screen.getByRole('alert')).not.toHaveTextContent('status code')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.submit(view.container.querySelector('form')!)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Base copy' }))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
const body = post.mock.calls[0][1] as FormData
|
||||
expect(body.has('associations')).toBe(false)
|
||||
expect(body.get('firstName')).toBe('Parent')
|
||||
})
|
||||
it.each(['read-server', 'metadata-server'])('offers a retry for %s failure without claiming permission denial or silently saving', async problem => {
|
||||
sources(problem)
|
||||
const post = vi.spyOn(apiClient, 'post')
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Full Copy could not load all required information. Reload to try again, or choose Base Copy.'))
|
||||
expect(screen.getByRole('alert')).not.toHaveTextContent('do not have access')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.submit(view.container.querySelector('form')!)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('radio', { name: 'Base copy' })).toBeEnabled()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Developer page — shows debug information including session info, metadata stats, and API health.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Developer page — shows debug information useful during development and support.
|
||||
*
|
||||
* Fixed route at `/app/developer` — takes priority over the dynamic `[slug]` route.
|
||||
*
|
||||
* Displays:
|
||||
* - Session info (auth type, user ID)
|
||||
* - Environment values from backend metadata
|
||||
* - Metadata stats (table / process / report / widget / app counts)
|
||||
* - API health check with response time
|
||||
* - Build info placeholder
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Copy, Check, Activity } from 'lucide-react'
|
||||
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/** Duration in milliseconds before the "copied!" feedback resets to the copy icon. */
|
||||
const COPY_RESET_MS = 2000
|
||||
|
||||
/**
|
||||
* A section card with a heading, optional copy button, and arbitrary content.
|
||||
*/
|
||||
interface SectionCardProps {
|
||||
/** Section heading text. */
|
||||
title: string
|
||||
/** Content to render inside the card. */
|
||||
children: React.ReactNode
|
||||
/** When provided, the "Copy JSON" button copies this value to the clipboard. */
|
||||
copyData?: unknown
|
||||
/** data-qqq-id value for the card wrapper. */
|
||||
dataQqqId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a card section with a header and optional "Copy JSON" button.
|
||||
*
|
||||
* @param props - {@link SectionCardProps}
|
||||
* @returns A bordered card containing the section heading and children.
|
||||
*/
|
||||
function SectionCard({ title, children, copyData, dataQqqId }: SectionCardProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (copyData === undefined) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(copyData, null, 2))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), COPY_RESET_MS)
|
||||
} catch {
|
||||
// Clipboard API unavailable — silent fail
|
||||
}
|
||||
}, [copyData])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-border bg-card p-6 space-y-4"
|
||||
data-qqq-id={dataQqqId}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
{copyData !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={`Copy ${title} as JSON`}
|
||||
data-qqq-id={`button-copy-${title.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1 text-xs font-medium',
|
||||
'text-muted-foreground bg-background hover:bg-accent',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 text-green-600" aria-hidden="true" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Copy JSON
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a simple key/value table.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.rows - Array of `[key, value]` tuples to display.
|
||||
* @returns A definition list styled as a bordered table, or a "No data" message when empty.
|
||||
*/
|
||||
function KeyValueTable({ rows }: { rows: Array<[string, React.ReactNode]> }) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">No data available.</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<dl className="divide-y divide-border rounded-lg border border-border text-sm">
|
||||
{rows.map(([key, val]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1 px-4 py-2.5 sm:flex-row sm:items-baseline sm:gap-4"
|
||||
>
|
||||
<dt className="font-medium text-muted-foreground sm:w-1/3 sm:flex-shrink-0 font-mono">
|
||||
{key}
|
||||
</dt>
|
||||
<dd className="text-foreground sm:flex-1 break-all">{val}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer debug page rendered at the fixed route `/app/developer`.
|
||||
*
|
||||
* Uses `useQContext()` for auth-related state and `useQuery` for metadata.
|
||||
* Provides an API health check that measures round-trip time to the metadata endpoint.
|
||||
*
|
||||
* @returns The developer page content wrapped in a container.
|
||||
*/
|
||||
export default function DeveloperPage() {
|
||||
const { userId } = useQContext()
|
||||
const { authMetadata } = useAuth()
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
// API health check state
|
||||
const [healthStatus, setHealthStatus] = useState<
|
||||
'idle' | 'loading' | 'ok' | 'error'
|
||||
>('idle')
|
||||
const [healthMs, setHealthMs] = useState<number | null>(null)
|
||||
const [healthError, setHealthError] = useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* Fires a GET request to `/metaData` and measures the round-trip time.
|
||||
* Updates `healthStatus`, `healthMs`, and `healthError` accordingly.
|
||||
*/
|
||||
const handleHealthCheck = useCallback(async () => {
|
||||
setHealthStatus('loading')
|
||||
setHealthMs(null)
|
||||
setHealthError(null)
|
||||
const start = performance.now()
|
||||
try {
|
||||
await apiClient.get('/metaData')
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setHealthMs(elapsed)
|
||||
setHealthStatus('ok')
|
||||
} catch (err) {
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setHealthMs(elapsed)
|
||||
setHealthStatus('error')
|
||||
setHealthError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Counts derived from metadata
|
||||
const tableCount = Object.keys(metaData?.tables ?? {}).length
|
||||
const processCount = Object.keys(metaData?.processes ?? {}).length
|
||||
const reportCount = Object.keys(metaData?.reports ?? {}).length
|
||||
const widgetCount = Object.keys(metaData?.widgets ?? {}).length
|
||||
const appCount = Object.keys(metaData?.apps ?? {}).length
|
||||
|
||||
const metaStatsRows: Array<[string, React.ReactNode]> = [
|
||||
['apps', appCount],
|
||||
['tables', tableCount],
|
||||
['processes', processCount],
|
||||
['reports', reportCount],
|
||||
['widgets', widgetCount],
|
||||
]
|
||||
|
||||
const sessionRows: Array<[string, React.ReactNode]> = [
|
||||
['userId', userId ?? '(not set)'],
|
||||
['authType', authMetadata?.type ?? '(unknown)'],
|
||||
['authName', authMetadata?.name ?? '(unknown)'],
|
||||
]
|
||||
|
||||
const envRows: Array<[string, React.ReactNode]> = Object.entries(
|
||||
metaData?.environmentValues ?? {}
|
||||
)
|
||||
|
||||
const buildRows: Array<[string, React.ReactNode]> = [
|
||||
['framework', 'Next.js 15'],
|
||||
['react', '19'],
|
||||
['appVersion', process.env.NEXT_PUBLIC_APP_VERSION ?? '0.1.0'],
|
||||
['apiBaseUrl', process.env.NEXT_PUBLIC_API_BASE_URL ?? '/qqq/v1'],
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-qqq-id="developer-page"
|
||||
>
|
||||
{/* Page heading */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Developer</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Debug information for developers and support staff.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session Info */}
|
||||
<SectionCard
|
||||
title="Session Info"
|
||||
copyData={{ userId, authType: authMetadata?.type, authName: authMetadata?.name }}
|
||||
dataQqqId="developer-section-session"
|
||||
>
|
||||
<KeyValueTable rows={sessionRows} />
|
||||
</SectionCard>
|
||||
|
||||
{/* Environment Values */}
|
||||
<SectionCard
|
||||
title="Environment Values"
|
||||
copyData={metaData?.environmentValues}
|
||||
dataQqqId="developer-section-environment"
|
||||
>
|
||||
{envRows.length > 0 ? (
|
||||
<KeyValueTable rows={envRows} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No environment values returned by the backend.
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Metadata Stats */}
|
||||
<SectionCard
|
||||
title="Metadata Stats"
|
||||
copyData={{ apps: appCount, tables: tableCount, processes: processCount, reports: reportCount, widgets: widgetCount }}
|
||||
dataQqqId="developer-section-metadata"
|
||||
>
|
||||
<KeyValueTable rows={metaStatsRows} />
|
||||
</SectionCard>
|
||||
|
||||
{/* API Health */}
|
||||
<SectionCard
|
||||
title="API Health"
|
||||
dataQqqId="developer-section-health"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHealthCheck}
|
||||
disabled={healthStatus === 'loading'}
|
||||
aria-label="Run API health check"
|
||||
data-qqq-id="button-api-health-check"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
|
||||
'text-primary-foreground bg-primary hover:bg-primary/90',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
{healthStatus === 'loading' ? (
|
||||
<>
|
||||
<span
|
||||
className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Checking…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||
Check API Health
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{healthStatus === 'ok' && healthMs !== null && (
|
||||
<p
|
||||
className="text-sm text-green-700 dark:text-green-400"
|
||||
aria-live="polite"
|
||||
data-qqq-id="developer-health-ok"
|
||||
>
|
||||
API responded in <strong>{healthMs} ms</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{healthStatus === 'error' && (
|
||||
<div
|
||||
className="text-sm text-destructive"
|
||||
aria-live="assertive"
|
||||
data-qqq-id="developer-health-error"
|
||||
>
|
||||
<p>
|
||||
Health check failed
|
||||
{healthMs !== null ? ` after ${healthMs} ms` : ''}.
|
||||
</p>
|
||||
{healthError && (
|
||||
<p className="mt-1 font-mono text-xs">{healthError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Build Info */}
|
||||
<SectionCard
|
||||
title="Build Info"
|
||||
copyData={Object.fromEntries(buildRows.map(([k, v]) => [k, v]))}
|
||||
dataQqqId="developer-section-build"
|
||||
>
|
||||
<KeyValueTable rows={buildRows} />
|
||||
</SectionCard>
|
||||
|
||||
{/* Raw metadata JSON */}
|
||||
{metaData && (
|
||||
<SectionCard
|
||||
title="Raw Metadata (JSON)"
|
||||
copyData={metaData}
|
||||
dataQqqId="developer-section-raw-metadata"
|
||||
>
|
||||
<pre
|
||||
className="overflow-auto rounded-lg bg-muted p-4 text-xs text-muted-foreground max-h-64"
|
||||
aria-label="Raw metadata JSON"
|
||||
>
|
||||
{JSON.stringify(
|
||||
{
|
||||
apps: Object.keys(metaData.apps ?? {}),
|
||||
tables: Object.keys(metaData.tables ?? {}),
|
||||
processes: Object.keys(metaData.processes ?? {}),
|
||||
reports: Object.keys(metaData.reports ?? {}),
|
||||
widgets: Object.keys(metaData.widgets ?? {}),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Dashboard home page — shows system stats, recently viewed records, quick actions, widgets, and app navigation.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Table2,
|
||||
Workflow,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
Plus,
|
||||
Activity,
|
||||
Database,
|
||||
Layers,
|
||||
TrendingUp,
|
||||
Users,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import type { QWidgetMetaData } from '@/types'
|
||||
import { ConnectedWidget } from '@/components/widgets/ConnectedWidget'
|
||||
|
||||
/**
|
||||
* Top-level dashboard home page rendered at `/app`.
|
||||
*
|
||||
* Displays system statistics derived from application metadata, a recently-viewed
|
||||
* records list sourced from localStorage, quick-action links for creatable tables
|
||||
* and runnable processes, home-level widgets from any app definition, and an
|
||||
* applications overview grid.
|
||||
*
|
||||
* @returns A composed page that assembles:
|
||||
* - A time-based greeting header with the authenticated user's first name
|
||||
* - A 4-column stats grid (table count, process count, app count, widget count)
|
||||
* - A recently-viewed records list (up to 8, sourced from localStorage)
|
||||
* - A quick-actions panel (create links for up to 6 insertable tables; run links for up to 4 processes)
|
||||
* - A home-widgets row (up to 6 `<ConnectedWidget>` instances from app metadata)
|
||||
* - An applications overview grid linking to each app's first child route
|
||||
* - A system-info footer showing live table/process counts and the current user's email
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
const { setPageHeader } = useQContext()
|
||||
const { user } = useAuth()
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const { sidebarRoutes } = useAppTreeRoutes(metaData)
|
||||
const appRoutes = sidebarRoutes.filter((r) => r.path !== '/app')
|
||||
|
||||
const appName = metaData?.branding?.appName || 'QQQ Admin'
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader('Dashboard')
|
||||
}, [setPageHeader])
|
||||
|
||||
// Load recent records on mount
|
||||
useEffect(() => {
|
||||
setRecentRecords(getRecentRecords().slice(0, 8))
|
||||
}, [])
|
||||
|
||||
// Compute system stats from metadata
|
||||
const tableCount = metaData ? Object.keys(metaData.tables).length : 0
|
||||
const processCount = metaData ? Object.keys(metaData.processes).length : 0
|
||||
const appCount = metaData ? Object.keys(metaData.apps).length : 0
|
||||
const widgetCount = metaData ? Object.keys(metaData.widgets).length : 0
|
||||
|
||||
// Gather tables that support create for quick actions
|
||||
const creatableTables = metaData
|
||||
? Object.values(metaData.tables)
|
||||
.filter((t) => !t.isHidden && t.insertPermission && t.capabilities?.includes('TABLE_INSERT'))
|
||||
.slice(0, 6)
|
||||
: []
|
||||
|
||||
// Gather runnable processes for quick actions
|
||||
const runnableProcesses = metaData
|
||||
? Object.values(metaData.processes)
|
||||
.filter((p) => !p.isHidden)
|
||||
.slice(0, 4)
|
||||
: []
|
||||
|
||||
// Gather home-level widgets (widgets that are on any app's home)
|
||||
const homeWidgets: QWidgetMetaData[] = []
|
||||
if (metaData) {
|
||||
const seen = new Set<string>()
|
||||
for (const app of Object.values(metaData.apps)) {
|
||||
for (const wName of app.widgets) {
|
||||
if (!seen.has(wName) && metaData.widgets[wName]?.hasPermission) {
|
||||
seen.add(wName)
|
||||
homeWidgets.push(metaData.widgets[wName])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Time-based greeting
|
||||
const hour = new Date().getHours()
|
||||
const greeting = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening'
|
||||
const firstName = user?.name?.split(' ')[0] || ''
|
||||
|
||||
/**
|
||||
* Formats a Unix millisecond timestamp as a human-readable relative time string.
|
||||
*
|
||||
* @param timestamp - Milliseconds since epoch (e.g. from `Date.now()`).
|
||||
* @returns A string such as "Just now", "5m ago", "3h ago", or "2d ago".
|
||||
*/
|
||||
function timeAgo(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return 'Just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8" data-qqq-id="dashboard-home">
|
||||
{/* Welcome header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{greeting}{firstName ? `, ${firstName}` : ''}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Here's an overview of your {appName} system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Tables</span>
|
||||
<Database className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{tableCount}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Data tables available</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Processes</span>
|
||||
<Workflow className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{processCount}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Automated workflows</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Applications</span>
|
||||
<Layers className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{appCount}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">App modules</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Widgets</span>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{widgetCount}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Dashboard components</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout: Recent Records + Quick Actions */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Recent Records — takes 2 columns */}
|
||||
<div className="lg:col-span-2 rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 className="text-lg font-bold text-foreground">Recently Viewed</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{recentRecords.length > 0 ? (
|
||||
recentRecords.map((record) => (
|
||||
<Link
|
||||
key={record.path}
|
||||
href={record.path}
|
||||
className="flex items-center justify-between px-6 py-3.5 transition-colors hover:bg-accent"
|
||||
data-qqq-id={`dashboard-recent-${record.recordId}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{record.recordLabel.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{record.recordLabel}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{record.tableLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-xs text-muted-foreground">
|
||||
{timeAgo(record.viewedAt)}
|
||||
</span>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Activity className="mx-auto h-8 w-8 text-muted-foreground/40" aria-hidden="true" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No recently viewed records</p>
|
||||
<p className="text-xs text-muted-foreground">Records you view will appear here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions — takes 1 column */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-bold text-foreground">Quick Actions</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{creatableTables.map((table) => (
|
||||
<Link
|
||||
key={table.name}
|
||||
href={`/app/${table.name}/create`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
data-qqq-id={`dashboard-create-${table.name}`}
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
Create {table.label}
|
||||
</Link>
|
||||
))}
|
||||
{runnableProcesses.map((process) => (
|
||||
<Link
|
||||
key={process.name}
|
||||
href={`/app/${process.name}`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
data-qqq-id={`dashboard-process-${process.name}`}
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-chart-2/10 text-chart-2">
|
||||
<Workflow className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
{process.label}
|
||||
</Link>
|
||||
))}
|
||||
{creatableTables.length === 0 && runnableProcesses.length === 0 && (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No quick actions available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Home Widgets — rendered from metadata */}
|
||||
{homeWidgets.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold text-foreground">Widgets</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{homeWidgets.slice(0, 6).map((widget) => (
|
||||
<ConnectedWidget key={widget.name} widgetMetaData={widget} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Applications overview */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-foreground">Applications</h2>
|
||||
</div>
|
||||
{appRoutes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{appRoutes.map((route) => (
|
||||
<Link
|
||||
key={route.path}
|
||||
href={route.children?.[0]?.path ?? route.path}
|
||||
className="group rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id={`dashboard-app-${route.name}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<LayoutDashboard className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{route.name}
|
||||
</h3>
|
||||
{route.children && route.children.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{route.children.length} {route.children.length === 1 ? 'item' : 'items'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" aria-hidden="true" />
|
||||
</div>
|
||||
{/* Show child items preview */}
|
||||
{route.children && route.children.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{route.children.slice(0, 5).map((child) => (
|
||||
<span
|
||||
key={child.path}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Table2 className="h-3 w-3" aria-hidden="true" />
|
||||
{child.name}
|
||||
</span>
|
||||
))}
|
||||
{route.children.length > 5 && (
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
+{route.children.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/30 py-16 text-center">
|
||||
<p className="text-muted-foreground">No applications available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System info footer */}
|
||||
<div className="flex items-center gap-6 rounded-xl border border-border bg-card px-6 py-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{tableCount} tables</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Workflow className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{processCount} processes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" aria-hidden="true" />
|
||||
<span>Logged in as {user?.email || 'unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Search results page — displays global search results grouped by table, reads query from URL params.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { globalSearch, type GlobalSearchResult } from '@/lib/api/tables'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
|
||||
/**
|
||||
* Groups an array of global search results by their `tableLabel` (falling back to `tableName`).
|
||||
*
|
||||
* @param results - The flat list of search results returned from the API.
|
||||
* @returns A `Map` keyed by table label, where each value contains the table name and matching records.
|
||||
*/
|
||||
function groupByTable(
|
||||
results: GlobalSearchResult[]
|
||||
): Map<string, { tableName: string; records: GlobalSearchResult[] }> {
|
||||
const groups = new Map<string, { tableName: string; records: GlobalSearchResult[] }>()
|
||||
for (const result of results) {
|
||||
const groupKey = result.tableLabel ?? result.tableName
|
||||
const existing = groups.get(groupKey)
|
||||
if (existing) {
|
||||
existing.records.push(result)
|
||||
} else {
|
||||
groups.set(groupKey, {
|
||||
tableName: result.tableName,
|
||||
records: [result],
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the global search results page at `/app/search`.
|
||||
*
|
||||
* Reads the `q` query parameter from the URL, debounces user input, updates the
|
||||
* URL on change, and fetches results via TanStack Query. Results are grouped by
|
||||
* table label and rendered as linked rows.
|
||||
*
|
||||
* @returns A composed page that assembles:
|
||||
* - A `<Search>`-prefixed text input (pre-filled from `?q=` URL param, debounced 300 ms)
|
||||
* - Loading skeleton cards (two groups with 3 and 2 rows) while the query is in-flight
|
||||
* - An empty-state panel when the search returns zero results
|
||||
* - A prompt panel when no search term has been entered yet
|
||||
* - Result groups rendered as bordered cards, one card per table label, each with linked record rows
|
||||
*/
|
||||
export default function SearchResultsPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { setPageHeader } = useQContext()
|
||||
|
||||
const queryParam = searchParams.get('q') ?? ''
|
||||
const [inputValue, setInputValue] = useState(queryParam)
|
||||
const [debouncedTerm, setDebouncedTerm] = useState(queryParam)
|
||||
|
||||
// Keep input value in sync when URL param changes externally
|
||||
useEffect(() => {
|
||||
setInputValue(queryParam)
|
||||
setDebouncedTerm(queryParam)
|
||||
}, [queryParam])
|
||||
|
||||
// Debounce the input value by 300ms
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedTerm(inputValue)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [inputValue])
|
||||
|
||||
// Update URL when debounced term changes (without full navigation)
|
||||
useEffect(() => {
|
||||
if (debouncedTerm !== queryParam) {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (debouncedTerm) {
|
||||
params.set('q', debouncedTerm)
|
||||
} else {
|
||||
params.delete('q')
|
||||
}
|
||||
router.replace(`/app/search?${params.toString()}`, { scroll: false })
|
||||
}
|
||||
}, [debouncedTerm, queryParam, searchParams, router])
|
||||
|
||||
// Set page header
|
||||
useEffect(() => {
|
||||
setPageHeader('Search Results')
|
||||
}, [setPageHeader])
|
||||
|
||||
const { data: results, isLoading } = useQuery({
|
||||
queryKey: queryKeys.globalSearch(debouncedTerm),
|
||||
queryFn: () => globalSearch(debouncedTerm),
|
||||
enabled: debouncedTerm.length > 0,
|
||||
})
|
||||
|
||||
const grouped = useMemo<
|
||||
Map<string, { tableName: string; records: GlobalSearchResult[] }>
|
||||
>(() => {
|
||||
if (!results || results.length === 0) return new Map()
|
||||
return groupByTable(results)
|
||||
}, [results])
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
setDebouncedTerm(inputValue)
|
||||
}
|
||||
},
|
||||
[inputValue]
|
||||
)
|
||||
|
||||
const hasResults = results && results.length > 0
|
||||
const hasSearched = debouncedTerm.length > 0
|
||||
const showEmpty = hasSearched && !isLoading && !hasResults
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id="search-results-page">
|
||||
{/* Page heading */}
|
||||
{debouncedTerm ? (
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
Search Results for “{debouncedTerm}”
|
||||
</h1>
|
||||
) : (
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
Search
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search across all tables..."
|
||||
aria-label="Search across all tables"
|
||||
className="rounded-xl border border-input bg-card pl-10 pr-4 py-3 text-sm w-full focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="space-y-6" aria-busy="true" aria-live="polite">
|
||||
{/* Skeleton group 1 */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-3 border-b border-border">
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="px-6 py-3">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Skeleton group 2 */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-3 border-b border-border">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="px-6 py-3">
|
||||
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmpty && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16 shadow-sm"
|
||||
data-qqq-id="search-empty-state"
|
||||
>
|
||||
<Search className="h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
No results found for “{debouncedTerm}”
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Try adjusting your search terms or check for typos.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No query state */}
|
||||
{!hasSearched && !isLoading && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16 shadow-sm"
|
||||
data-qqq-id="search-prompt-state"
|
||||
>
|
||||
<Search className="h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Enter a search term above to find records across all tables.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results grouped by table */}
|
||||
{!isLoading && hasResults && (
|
||||
<div className="space-y-6">
|
||||
{Array.from(grouped.entries()).map(([tableLabel, group]) => (
|
||||
<div
|
||||
key={tableLabel}
|
||||
className="rounded-xl border border-border bg-card shadow-sm overflow-hidden"
|
||||
data-qqq-id={`search-group-${group.tableName}`}
|
||||
>
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-border">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{tableLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{group.records.length} {group.records.length === 1 ? 'result' : 'results'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Result rows */}
|
||||
<div className="divide-y divide-border">
|
||||
{group.records.map((result) => (
|
||||
<Link
|
||||
key={`${result.tableName}-${result.recordId}`}
|
||||
href={`/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`}
|
||||
className="block px-6 py-3 hover:bg-accent cursor-pointer transition-colors text-sm text-foreground"
|
||||
data-qqq-id={`search-result-${result.tableName}-${result.recordId}`}
|
||||
>
|
||||
{result.recordLabel}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Dashboard layout — authenticated route group shell providing sidebar, header, banners, command palette, and global keyboard shortcuts.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
/** Dashboard layout — authenticated route group shell providing sidebar, header, banners, command palette, and global keyboard shortcuts. */
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { useQContext, QContextProvider } from '@/lib/context/q-context'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import Header from '@/components/layout/Header'
|
||||
import BannerComponent from '@/components/layout/Banner'
|
||||
import { CommandMenu } from '@/components/feedback/CommandMenu'
|
||||
import { SearchDialog } from '@/components/feedback/SearchDialog'
|
||||
import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcutsDialog'
|
||||
|
||||
/**
|
||||
* Inner layout that renders the full dashboard chrome.
|
||||
*
|
||||
* Must be a child of `QContextProvider` in order to read and write QContext.
|
||||
* Responsibilities:
|
||||
* - Fetches full application metadata via TanStack Query (30-minute stale time).
|
||||
* - Generates sidebar routes and path-to-label map from the app tree.
|
||||
* - Syncs branding, accent color, favicon, and document title to the DOM.
|
||||
* - Sanitizes and injects `customCss` from branding metadata.
|
||||
* - Redirects unauthenticated users to `/login`.
|
||||
* - Registers global keyboard shortcuts: Cmd+K (command palette), `/` (search), `?` (help).
|
||||
* - Renders the desktop sidebar, mobile sidebar drawer, banners, header, and main content slot.
|
||||
*
|
||||
* @param children - The authenticated page content to render in the main slot.
|
||||
* @returns The full dashboard layout, a loading spinner, or `null` during redirect.
|
||||
*/
|
||||
function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const { isAuthenticated, isLoading: authLoading, user, logout } = useAuth()
|
||||
const { setPathToLabelMap, setBranding, setAccentColor, setUserId } = useQContext()
|
||||
|
||||
// Mobile sidebar drawer state
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
// Command palette state
|
||||
const [commandOpen, setCommandOpen] = useState(false)
|
||||
|
||||
// Search dialog state (/ key)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
// Keyboard shortcuts help dialog state
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
|
||||
// Load full application metadata
|
||||
const {
|
||||
data: metaData,
|
||||
isLoading: metaLoading,
|
||||
error: metaError,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
// Generate sidebar routes and path map from app tree
|
||||
const { sidebarRoutes, pathToLabelMap, parentAppMap } = useAppTreeRoutes(metaData)
|
||||
|
||||
// Sync pathToLabelMap to QContext
|
||||
useEffect(() => {
|
||||
if (Object.keys(pathToLabelMap).length > 0) {
|
||||
setPathToLabelMap(pathToLabelMap)
|
||||
}
|
||||
}, [pathToLabelMap, setPathToLabelMap])
|
||||
|
||||
// Sync branding to QContext and inject theme
|
||||
useEffect(() => {
|
||||
if (metaData?.branding) {
|
||||
setBranding(metaData.branding)
|
||||
|
||||
// MED-3: validate color format before applying to CSS custom properties
|
||||
const ACCENT_COLOR_RE = /^#[0-9a-fA-F]{3,8}$|^rgb\(|^rgba\(|^hsl\(|^hsla\(/
|
||||
if (metaData.branding.accentColor && ACCENT_COLOR_RE.test(metaData.branding.accentColor)) {
|
||||
setAccentColor(metaData.branding.accentColor)
|
||||
document.documentElement.style.setProperty(
|
||||
'--qqq-accent-color',
|
||||
metaData.branding.accentColor
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--color-primary',
|
||||
metaData.branding.accentColor
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary',
|
||||
metaData.branding.accentColor
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--ring',
|
||||
metaData.branding.accentColor
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--qqq-sidebar-active-bg',
|
||||
metaData.branding.accentColor
|
||||
)
|
||||
}
|
||||
|
||||
// Update favicon from branding
|
||||
if (metaData.branding.icon) {
|
||||
const faviconEl = document.querySelector("link[rel~='icon']")
|
||||
if (faviconEl instanceof HTMLLinkElement) {
|
||||
faviconEl.href = metaData.branding.icon
|
||||
}
|
||||
}
|
||||
|
||||
// Update document title
|
||||
if (metaData.branding.appName) {
|
||||
document.title = metaData.branding.appName
|
||||
}
|
||||
}
|
||||
}, [metaData?.branding, setBranding, setAccentColor])
|
||||
|
||||
// Inject customCss from branding metadata
|
||||
// MED-2: strip known CSS injection vectors before applying
|
||||
useEffect(() => {
|
||||
if (metaData?.branding?.customCss) {
|
||||
const safe = metaData.branding.customCss
|
||||
.replace(/<\/?\s*style[^>]*>/gi, '') // no embedded style tags
|
||||
.replace(/expression\s*\(/gi, '') // no IE CSS expressions
|
||||
.replace(/@import\b/gi, '') // no @import directives
|
||||
.replace(/javascript\s*:/gi, '') // no javascript: scheme
|
||||
.replace(/url\s*\(\s*["']?\s*data:/gi, '') // no data: URLs
|
||||
|
||||
const styleId = 'qqq-custom-css'
|
||||
const existingStyleEl = document.getElementById(styleId)
|
||||
let styleTag: HTMLStyleElement
|
||||
if (existingStyleEl instanceof HTMLStyleElement) {
|
||||
styleTag = existingStyleEl
|
||||
} else {
|
||||
styleTag = document.createElement('style')
|
||||
styleTag.id = styleId
|
||||
document.head.appendChild(styleTag)
|
||||
}
|
||||
styleTag.textContent = safe
|
||||
}
|
||||
}, [metaData?.branding?.customCss])
|
||||
|
||||
// Sync user ID to QContext
|
||||
useEffect(() => {
|
||||
if (user?.email) {
|
||||
setUserId(user.email)
|
||||
}
|
||||
}, [user?.email, setUserId])
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
const returnTo = encodeURIComponent(window.location.pathname + window.location.search)
|
||||
router.push(`/login?returnTo=${returnTo}`)
|
||||
}
|
||||
}, [isAuthenticated, authLoading, router])
|
||||
|
||||
/**
|
||||
* Returns `true` when focus is in a text input, textarea, select, or contentEditable element.
|
||||
*
|
||||
* Used to suppress single-key shortcuts (`.`, `/`, `?`) while the user is typing.
|
||||
*
|
||||
* @returns Whether the currently focused element is a text-entry control.
|
||||
*/
|
||||
const isInputFocused = useCallback(() => {
|
||||
const active = document.activeElement
|
||||
const tag = (active?.tagName ?? '').toLowerCase()
|
||||
const inputType = active instanceof HTMLInputElement ? active.type : ''
|
||||
const isEditable = active instanceof HTMLElement ? active.isContentEditable : false
|
||||
return tag === 'input' || tag === 'textarea' || tag === 'select' || inputType === 'search' || isEditable
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Global `keydown` handler that drives the application-level keyboard shortcuts.
|
||||
*
|
||||
* - Cmd+K / Ctrl+K — toggles the command palette.
|
||||
* - Escape — closes all overlays (command palette, search dialog, help dialog, mobile sidebar).
|
||||
* - `.` — opens the command palette (only when not in a text field).
|
||||
* - `/` — opens the search dialog (only when not in a text field).
|
||||
* - `?` — opens the keyboard shortcuts dialog (only when not in a text field).
|
||||
*
|
||||
* @param e - The native DOM keyboard event.
|
||||
*/
|
||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setCommandOpen((prev) => !prev)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setCommandOpen(false)
|
||||
setSearchOpen(false)
|
||||
setHelpOpen(false)
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
|
||||
// Single-key shortcuts — only when not focused in a text field
|
||||
if (!isInputFocused() && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (e.key === '.') {
|
||||
e.preventDefault()
|
||||
setCommandOpen(true)
|
||||
}
|
||||
if (e.key === '/') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
}
|
||||
if (e.key === '?') {
|
||||
e.preventDefault()
|
||||
setHelpOpen(true)
|
||||
}
|
||||
}
|
||||
}, [isInputFocused])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
|
||||
}, [handleGlobalKeyDown])
|
||||
|
||||
// Show loading state while auth initializes
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div
|
||||
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Auth loaded but not authenticated — redirect handled above
|
||||
if (!isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Show error if metadata fails to load
|
||||
if (metaError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive">Failed to load application metadata.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{metaError instanceof Error ? metaError.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Skip to main content — accessibility */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
data-qqq-id="skip-to-content"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<Sidebar
|
||||
routes={sidebarRoutes}
|
||||
branding={metaData?.branding}
|
||||
logout={logout}
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
data-qqq-id="sidebar-container"
|
||||
/>
|
||||
|
||||
{/* Mobile Sidebar Drawer */}
|
||||
<Sidebar
|
||||
routes={sidebarRoutes}
|
||||
branding={metaData?.branding}
|
||||
logout={logout}
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
data-qqq-id="sidebar-mobile"
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Environment/status banners */}
|
||||
{metaData?.branding?.banners &&
|
||||
Object.keys(metaData.branding.banners).length > 0 && (
|
||||
<BannerComponent banners={metaData.branding.banners} />
|
||||
)}
|
||||
|
||||
{/* Header — breadcrumbs + search in one row */}
|
||||
<Header
|
||||
appName={metaData?.branding?.appName}
|
||||
onMenuOpen={() => setSidebarOpen(true)}
|
||||
onSearchOpen={() => setSearchOpen(true)}
|
||||
onHelpOpen={() => setHelpOpen(true)}
|
||||
pathToLabelMap={pathToLabelMap}
|
||||
parentAppMap={parentAppMap}
|
||||
/>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6" id="main-content" data-qqq-id="main-content">
|
||||
{metaLoading ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-12"
|
||||
role="status"
|
||||
aria-label="Loading content"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} />
|
||||
|
||||
{/* Search Dialog */}
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
|
||||
{/* Keyboard Shortcuts Help Dialog */}
|
||||
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Outer dashboard layout exported as the Next.js `(dashboard)` route group layout.
|
||||
*
|
||||
* Wraps the inner `DashboardLayoutContent` with `QContextProvider` so that all
|
||||
* authenticated pages share a single QContext instance.
|
||||
*
|
||||
* @param children - The page component rendered inside the authenticated shell.
|
||||
* @returns A `QContextProvider` wrapping the full dashboard layout.
|
||||
*/
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QContextProvider>
|
||||
<DashboardLayoutContent>{children}</DashboardLayoutContent>
|
||||
</QContextProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Global error boundary — Next.js `error.tsx` component that catches unhandled route-level exceptions.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Global error boundary — Next.js `error.tsx` component that catches unhandled
|
||||
* exceptions at the route level.
|
||||
*
|
||||
* Detects 401 / session-expired conditions and renders a "Session Expired"
|
||||
* card with a login link. All other errors render a generic error card with
|
||||
* a "Try Again" button (which invokes `reset()` to re-render the segment)
|
||||
* and a "Go Home" link.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Renders the global route-level error UI.
|
||||
*
|
||||
* Next.js automatically passes `error` and `reset` when any Server or Client
|
||||
* Component in the route segment throws an unhandled error.
|
||||
*
|
||||
* @param props - The Next.js error boundary props.
|
||||
* @param props.error - The thrown error, optionally decorated with a `digest` ID from Next.js.
|
||||
* @param props.reset - Next.js callback that retries rendering the failed route segment.
|
||||
* @returns A full-screen card describing the error with recovery actions.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('[GlobalError]', error)
|
||||
}, [error])
|
||||
|
||||
const is401 =
|
||||
error.message?.includes('401') ||
|
||||
error.message?.toLowerCase().includes('unauthorized') ||
|
||||
error.message?.toLowerCase().includes('session expired')
|
||||
|
||||
if (is401) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-amber-500" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Session Expired
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Your session has expired. Please log in again to continue.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-2 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
data-qqq-id="button-go-to-login"
|
||||
>
|
||||
Go to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error.message || 'An unexpected error occurred.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground">Error ID: {error.digest}</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
data-qqq-id="button-try-again"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
Try Again
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="link-go-home"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||