Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e775ccf6f | ||
|
|
df8e2e61cf | ||
|
|
6073f1c4b3 | ||
|
|
d28c569b3a | ||
|
|
1137802f8c | ||
|
|
66db82b3ce | ||
|
|
26052f99db | ||
|
|
641961a335 |
No files matched your search
@@ -0,0 +1,31 @@
|
||||
# Use the latest 2.1 version of CircleCI pipeline process engine.
|
||||
# See: https://circleci.com/docs/reference/configuration-reference
|
||||
version: 2.1
|
||||
|
||||
# Define a job to be invoked later in a workflow.
|
||||
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#jobs-overview & https://circleci.com/docs/reference/configuration-reference/#jobs
|
||||
jobs:
|
||||
say-hello:
|
||||
# Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
|
||||
# See: https://circleci.com/docs/guides/execution-managed/executor-intro/ & https://circleci.com/docs/reference/configuration-reference/#executor-job
|
||||
docker:
|
||||
# Specify the version you desire here
|
||||
# See: https://circleci.com/developer/images/image/cimg/base
|
||||
- image: cimg/base:current
|
||||
|
||||
# Add steps to the job
|
||||
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#steps-overview & https://circleci.com/docs/reference/configuration-reference/#steps
|
||||
steps:
|
||||
# Checkout the code as the first step.
|
||||
- checkout
|
||||
- run:
|
||||
name: "Say hello"
|
||||
command: "echo Hello, World!"
|
||||
|
||||
# Orchestrate jobs using workflows
|
||||
# See: https://circleci.com/docs/guides/orchestrate/workflows/ & https://circleci.com/docs/reference/configuration-reference/#workflows
|
||||
workflows:
|
||||
say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow.
|
||||
# Inside the workflow, you define the jobs you want to run.
|
||||
jobs:
|
||||
- say-hello
|
||||
@@ -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,36 @@
|
||||
# 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
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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).
|
||||
@@ -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 @@
|
||||
# PR assets: SQL Workbench theme
|
||||
|
||||
Before (main) and after screenshots of the SQL Workbench, light and dark, desktop and phone (mocked API, production build).
|
||||
# qqq-frontend-next
|
||||
|
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,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,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,16 @@
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
})
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
|
||||
]
|
||||
|
||||
export default eslintConfig
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
typescript: {
|
||||
tsconfigPath: './tsconfig.json',
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth0/auth0-react": "^2.2.4",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.8.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.485.0",
|
||||
"next": "15.2.3",
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"recharts": "^2.15.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"ts-md5": "^1.3.1",
|
||||
"universal-cookie": "^7.2.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^3.0.9",
|
||||
"@vitest/ui": "^3.0.9",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"msw": "^2.12.10",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,85 @@
|
||||
'use client'
|
||||
|
||||
// OAuth2 callback page — handles the redirect from authorization server
|
||||
// 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'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
handleOAuthCallback(code, state)
|
||||
.then(() => {
|
||||
// Get stored returnTo from session storage or default to home
|
||||
const returnTo = sessionStorage.getItem('oauth2ReturnTo') ?? '/'
|
||||
sessionStorage.removeItem('oauth2ReturnTo')
|
||||
router.replace(returnTo)
|
||||
})
|
||||
.catch((err) => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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,97 @@
|
||||
'use client'
|
||||
|
||||
// Login page — authenticates user and redirects to dashboard
|
||||
// Wrapped in Suspense because it uses useSearchParams()
|
||||
|
||||
import React, { Suspense, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { isAuthenticated, isLoading, authMetadata } = useAuth()
|
||||
|
||||
const returnTo = searchParams.get('returnTo') ?? '/'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
// Decode and validate the returnTo URL
|
||||
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])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// If not loading and not authenticated, show login state
|
||||
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">
|
||||
{authMetadata?.type === 'FULLY_ANONYMOUS' || authMetadata?.type === 'MOCK'
|
||||
? 'Setting up anonymous session...'
|
||||
: 'Redirecting to authentication provider...'}
|
||||
</p>
|
||||
<div
|
||||
className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,85 @@
|
||||
'use client'
|
||||
|
||||
// EntityCopy — copy a record (pre-populates form with existing values)
|
||||
// Package 3: full implementation using EntityForm with isCopy=true
|
||||
|
||||
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'
|
||||
|
||||
export default function EntityCopyPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const tableMetaData = metaData?.tables?.[slug]
|
||||
|
||||
const { record, isLoading, isError, error } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(tableMetaData),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Copy ${tableMetaData?.label ?? slug} #${recordId}`)
|
||||
if (tableMetaData) {
|
||||
setTableMetaData(tableMetaData)
|
||||
}
|
||||
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
|
||||
|
||||
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.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}`}>
|
||||
<EntityForm
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
isCopy={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
// RecordDeveloperView — shows raw record data and table metadata as formatted JSON
|
||||
// Useful for debugging field values, types, and backend responses
|
||||
|
||||
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'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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,81 @@
|
||||
'use client'
|
||||
|
||||
// EntityEdit — edit an existing record
|
||||
// Package 3: full implementation using EntityForm + useRecord
|
||||
|
||||
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'
|
||||
|
||||
export default function EntityEditPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const tableMetaData = metaData?.tables?.[slug]
|
||||
|
||||
const { record, isLoading, isError, error } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(tableMetaData),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Edit ${tableMetaData?.label ?? slug} #${recordId}`)
|
||||
if (tableMetaData) {
|
||||
setTableMetaData(tableMetaData)
|
||||
}
|
||||
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
|
||||
|
||||
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,84 @@
|
||||
'use client'
|
||||
|
||||
// RecordView page — view a single record
|
||||
// Package 3: full implementation using RecordView component + useRecord hook
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { 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 } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useRecord } from '@/lib/hooks/use-record'
|
||||
import { RecordView } from '@/components/records/RecordView'
|
||||
|
||||
export default function RecordViewPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const tableMetaData = metaData?.tables?.[slug]
|
||||
|
||||
const { record, isLoading, isError, error, refetch } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(tableMetaData),
|
||||
includeAssociations: true,
|
||||
})
|
||||
|
||||
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 (!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 (
|
||||
<RecordView
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRefetch={refetch}
|
||||
processes={tableProcesses}
|
||||
allTables={metaData?.tables}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
// EntityCreate — create a new record
|
||||
// Package 3: full implementation using EntityForm component
|
||||
|
||||
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'
|
||||
|
||||
export default function EntityCreatePage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const slug = params.slug
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const tableMetaData = metaData?.tables?.[slug]
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Create ${tableMetaData?.label ?? slug}`)
|
||||
if (tableMetaData) {
|
||||
setTableMetaData(tableMetaData)
|
||||
}
|
||||
}, [tableMetaData?.label, slug, tableMetaData, setPageHeader, setTableMetaData])
|
||||
|
||||
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,139 @@
|
||||
'use client'
|
||||
|
||||
// TableDeveloperView — shows raw table metadata as formatted JSON
|
||||
// Useful for debugging field definitions, sections, and permissions
|
||||
|
||||
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'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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,26 @@
|
||||
'use client'
|
||||
|
||||
// RecordViewByUniqueKey — view a record by unique key
|
||||
// Full implementation in Package 3
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
|
||||
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,133 @@
|
||||
'use client'
|
||||
|
||||
// Unified slug page — routes to AppHome, RecordQuery, or ProcessRun
|
||||
// based on what the slug resolves to in the app metadata
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileBarChart } from 'lucide-react'
|
||||
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
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'
|
||||
|
||||
export default function SlugPage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const slug = params.slug
|
||||
|
||||
const { data: metaData } = 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 table = metaData?.tables?.[slug]
|
||||
const process = metaData?.processes?.[slug]
|
||||
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 as { label?: string })?.label ?? slug)
|
||||
} else {
|
||||
setPageHeader(slug)
|
||||
}
|
||||
}, [isApp, isTable, isProcess, isReport, app, table, process, report, slug, setPageHeader, setTableMetaData])
|
||||
|
||||
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} processes={tableProcesses} />
|
||||
}
|
||||
|
||||
// Table loading state (table found but metadata not yet available)
|
||||
if (isTable && !table) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// Process run — Package 4 implementation
|
||||
if (isProcess && process) {
|
||||
return <ProcessRun processName={slug} processMetaData={process} />
|
||||
}
|
||||
|
||||
// Process loading state (process found but metadata not yet available)
|
||||
if (isProcess && !process) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// Report run
|
||||
if (isReport) {
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id={`report-run-${slug}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileBarChart className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
{(report as { label?: string })?.label ?? slug}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
Report — implemented in a future package
|
||||
</p>
|
||||
</div>
|
||||
</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,31 @@
|
||||
'use client'
|
||||
|
||||
// RecordQuery with Saved View — loads a saved filter/view configuration
|
||||
// Full implementation in Package 2
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
|
||||
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,334 @@
|
||||
'use client'
|
||||
|
||||
// Dashboard — top-level home page showing system overview
|
||||
// Demonstrates: stats, recent records, quick actions, app navigation, widget rendering
|
||||
|
||||
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'
|
||||
|
||||
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] || ''
|
||||
|
||||
// Format relative time
|
||||
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,239 @@
|
||||
'use client'
|
||||
|
||||
// Search results page — displays global search results grouped by table
|
||||
// Reads search query from URL params: /app/search?q=searchTerm
|
||||
|
||||
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'
|
||||
|
||||
/** Group search results by tableLabel */
|
||||
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 existing = groups.get(result.tableLabel)
|
||||
if (existing) {
|
||||
existing.records.push(result)
|
||||
} else {
|
||||
groups.set(result.tableLabel, {
|
||||
tableName: result.tableName,
|
||||
records: [result],
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
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,296 @@
|
||||
'use client'
|
||||
|
||||
// Dashboard layout — authenticated route group layout
|
||||
// Renders sidebar, header, breadcrumbs, and banner zones
|
||||
// Includes: mobile sidebar drawer, command palette (Cmd+K), skip link, customCss injection
|
||||
|
||||
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 content — needs QContextProvider to be set up first
|
||||
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)
|
||||
|
||||
if (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 favicon = document.querySelector("link[rel~='icon']") as HTMLLinkElement | null
|
||||
if (favicon) {
|
||||
favicon.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
|
||||
useEffect(() => {
|
||||
if (metaData?.branding?.customCss) {
|
||||
const styleId = 'qqq-custom-css'
|
||||
let styleTag = document.getElementById(styleId) as HTMLStyleElement | null
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style')
|
||||
styleTag.id = styleId
|
||||
document.head.appendChild(styleTag)
|
||||
}
|
||||
styleTag.textContent = metaData.branding.customCss
|
||||
}
|
||||
}, [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])
|
||||
|
||||
// Guard: check if focus is in a text input, textarea, select, or contentEditable
|
||||
const isInputFocused = useCallback(() => {
|
||||
const tag = (document.activeElement?.tagName || '').toLowerCase()
|
||||
const type = (document.activeElement as HTMLInputElement)?.type || ''
|
||||
const isEditable = (document.activeElement as HTMLElement)?.isContentEditable
|
||||
return tag === 'input' || tag === 'textarea' || tag === 'select' || type === 'search' || isEditable
|
||||
}, [])
|
||||
|
||||
// Global keyboard shortcut: Cmd+K / Ctrl+K → command palette, "." → quick nav, "?" → help
|
||||
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)}
|
||||
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 layout — provides QContext to inner layout
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QContextProvider>
|
||||
<DashboardLayoutContent>{children}</DashboardLayoutContent>
|
||||
</QContextProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
// Global error boundary — catches unhandled exceptions at route level
|
||||
// Detects 401 errors and provides appropriate messaging
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Root layout — wraps entire application with providers
|
||||
// This is a Server Component that sets up client-side providers
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import Providers from './providers'
|
||||
import '@/styles/globals.css'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'QQQ Admin',
|
||||
template: '%s | QQQ Admin',
|
||||
},
|
||||
description: 'Low-code application admin interface',
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
||||
<body className="font-sans antialiased" suppressHydrationWarning>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 404 Not Found page
|
||||
|
||||
import Link from 'next/link'
|
||||
import { SearchX } from 'lucide-react'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<div className="flex flex-col items-center gap-6 text-center">
|
||||
<SearchX className="h-16 w-16 text-muted-foreground" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-6xl font-bold text-foreground">404</h1>
|
||||
<p className="mt-2 text-xl text-muted-foreground">Page not found</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
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="link-go-home"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Root page — redirects to dashboard or login
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RootPage() {
|
||||
// Redirect to the dashboard — the (dashboard) layout handles auth
|
||||
redirect('/app')
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
// Client-side providers wrapper
|
||||
// Separated from root layout to avoid Server Component issues with useEffect/contexts
|
||||
|
||||
import React, { useEffect, useState, type ReactNode } from 'react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { AuthProvider } from '@/lib/auth/auth-provider'
|
||||
import { ThemeProvider } from '@/lib/theme/theme-provider'
|
||||
import { injectThemeTokens } from '@/lib/theme/tokens'
|
||||
import { Toaster } from '@/components/feedback/Toast'
|
||||
|
||||
// Inject default theme tokens on first render
|
||||
if (typeof window !== 'undefined') {
|
||||
injectThemeTokens()
|
||||
}
|
||||
|
||||
const isMockMode = process.env.NEXT_PUBLIC_MOCK_API === 'true'
|
||||
|
||||
export default function Providers({ children }: { children: ReactNode }) {
|
||||
// When mock mode is enabled, block rendering until the MSW service worker
|
||||
// is fully started — otherwise AuthProvider fires its first API call before
|
||||
// MSW is ready and gets a real 404.
|
||||
const [mockReady, setMockReady] = useState(!isMockMode)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMockMode) return
|
||||
import('@/mocks/init')
|
||||
.then(({ initMocks }) => initMocks())
|
||||
.then(() => setMockReady(true))
|
||||
.catch((e) => {
|
||||
console.error('[MSW] Failed to start mock worker:', e)
|
||||
setMockReady(true) // unblock the app anyway
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!mockReady) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center gap-3 text-indigo-500">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
|
||||
<span className="text-sm font-medium">Starting mock API…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors
|
||||
closeButton
|
||||
toastOptions={{ duration: 4000 }}
|
||||
aria-live="polite"
|
||||
data-qqq-id="toast-container"
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
// CommandMenu — Cmd+K / Ctrl+K command palette
|
||||
// Fuzzy-searches all sidebar routes from QContext
|
||||
// Navigate with arrow keys, Enter to navigate, Escape to close
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Search, Table2, Workflow, BarChart3, LayoutGrid, X } from 'lucide-react'
|
||||
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface CommandMenuItem {
|
||||
id: string
|
||||
label: string
|
||||
path: string
|
||||
type: 'app' | 'table' | 'process' | 'report'
|
||||
breadcrumb?: string
|
||||
}
|
||||
|
||||
function buildMenuItems(pathToLabelMap: Record<string, string>): CommandMenuItem[] {
|
||||
const items: CommandMenuItem[] = []
|
||||
|
||||
for (const [path, label] of Object.entries(pathToLabelMap)) {
|
||||
// Skip parametric paths and dev/utility pages
|
||||
if (path.includes(':') || path.includes('/create') || path.includes('/dev') || path.includes('/key') || path.includes('/savedView')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = path.split('/').filter(Boolean) // e.g. ['app', 'employees'] or ['app', 'crm', 'employees']
|
||||
|
||||
let type: CommandMenuItem['type'] = 'table'
|
||||
let breadcrumb: string | undefined
|
||||
|
||||
if (parts.length === 2 && parts[0] === 'app') {
|
||||
// Could be app home or table
|
||||
type = 'app'
|
||||
} else if (parts.length === 3) {
|
||||
type = 'table'
|
||||
const parentPath = `/${parts[0]}/${parts[1]}`
|
||||
breadcrumb = pathToLabelMap[parentPath]
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: path,
|
||||
label,
|
||||
path,
|
||||
type,
|
||||
breadcrumb,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function TypeIcon({ type }: { type: CommandMenuItem['type'] }) {
|
||||
switch (type) {
|
||||
case 'app':
|
||||
return <LayoutGrid className="h-4 w-4 text-blue-500" aria-hidden="true" />
|
||||
case 'table':
|
||||
return <Table2 className="h-4 w-4 text-green-500" aria-hidden="true" />
|
||||
case 'process':
|
||||
return <Workflow className="h-4 w-4 text-purple-500" aria-hidden="true" />
|
||||
case 'report':
|
||||
return <BarChart3 className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||
}
|
||||
}
|
||||
|
||||
interface CommandMenuProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CommandMenu({ open, onClose }: CommandMenuProps) {
|
||||
const router = useRouter()
|
||||
const { pathToLabelMap } = useQContext()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const items = buildMenuItems(pathToLabelMap)
|
||||
|
||||
// Clear search when closed
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(path: string) => {
|
||||
router.push(path)
|
||||
onClose()
|
||||
},
|
||||
[router, onClose]
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
// Backdrop
|
||||
<div
|
||||
className="fixed inset-0 z-[2000] flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
|
||||
data-qqq-id="command-menu-backdrop"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Command palette */}
|
||||
<div
|
||||
className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-lg"
|
||||
data-qqq-id="command-menu"
|
||||
role="dialog"
|
||||
aria-label="Command palette"
|
||||
aria-modal="true"
|
||||
>
|
||||
<Command shouldFilter={true} label="Command palette">
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<Command.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search pages, tables, processes..."
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
autoFocus
|
||||
data-qqq-id="command-menu-search"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label="Close command palette"
|
||||
data-qqq-id="button-command-menu-close"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
<Command.List
|
||||
className="max-h-80 overflow-y-auto py-2"
|
||||
aria-label="Navigation results"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</Command.Empty>
|
||||
|
||||
{items.length > 0 && (
|
||||
<Command.Group
|
||||
heading={
|
||||
<span className="px-4 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Navigation
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
value={`${item.label} ${item.breadcrumb ?? ''}`}
|
||||
onSelect={() => handleSelect(item.path)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm',
|
||||
'text-foreground',
|
||||
'hover:bg-accent',
|
||||
'aria-selected:bg-primary/10 aria-selected:text-primary',
|
||||
'outline-none transition-colors'
|
||||
)}
|
||||
data-qqq-id={`command-item-${item.id.replace(/\//g, '-')}`}
|
||||
>
|
||||
<TypeIcon type={item.type} />
|
||||
<span className="flex-1 truncate font-medium">{item.label}</span>
|
||||
{item.breadcrumb && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{item.breadcrumb}
|
||||
</span>
|
||||
)}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
</Command.List>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd>{' '}
|
||||
navigate
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}
|
||||
open
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">esc</kbd>{' '}
|
||||
close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
// EmptyState — Reusable empty state component
|
||||
// Used when a query returns zero results or no data is available
|
||||
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
export interface EmptyStateProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
action?: { label: string; onClick: () => void }
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/30 px-6 py-12 text-center',
|
||||
className
|
||||
)}
|
||||
data-qqq-id="empty-state"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon ?? <Inbox className="h-7 w-7" />}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3
|
||||
className="text-sm font-semibold text-foreground"
|
||||
data-qqq-id="empty-state-title"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p
|
||||
className="mt-1 max-w-xs text-sm text-muted-foreground"
|
||||
data-qqq-id="empty-state-description"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className={cn(
|
||||
'mt-4 inline-flex items-center rounded-md px-4 py-2 text-sm font-medium',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
data-qqq-id="button-empty-state-action"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
// ErrorBoundary — Reusable React class-based error boundary
|
||||
// Catches render errors in its children subtree
|
||||
|
||||
import React, { type ReactNode, type ErrorInfo } from 'react'
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
export interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('[ErrorBoundary] Caught error:', error, info)
|
||||
this.props.onError?.(error, info)
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-xl border border-red-200 bg-red-50 p-8 text-center',
|
||||
this.props.className
|
||||
)}
|
||||
role="alert"
|
||||
data-qqq-id="error-boundary"
|
||||
>
|
||||
<AlertCircle className="mb-3 h-10 w-10 text-destructive" aria-hidden="true" />
|
||||
<h3 className="text-base font-semibold text-destructive">
|
||||
Something went wrong
|
||||
</h3>
|
||||
{this.state.error && (
|
||||
<p className="mt-1 max-w-xs text-sm text-destructive">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReset}
|
||||
className={cn(
|
||||
'mt-4 inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
data-qqq-id="button-error-boundary-retry"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
// KeyboardShortcutsDialog — "?" help dialog showing available keyboard shortcuts
|
||||
// Organized by context: Global, Table Query, Record View
|
||||
// Uses Radix Dialog primitive with project design tokens
|
||||
|
||||
import React from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface ShortcutEntry {
|
||||
keys: string[]
|
||||
description: string
|
||||
}
|
||||
|
||||
interface ShortcutSection {
|
||||
title: string
|
||||
shortcuts: ShortcutEntry[]
|
||||
}
|
||||
|
||||
const shortcutSections: ShortcutSection[] = [
|
||||
{
|
||||
title: 'Global',
|
||||
shortcuts: [
|
||||
{ keys: ['.'], description: 'Quick Navigation' },
|
||||
{ keys: ['/'], description: 'Search Records' },
|
||||
{ keys: ['?'], description: 'Keyboard Shortcuts' },
|
||||
{ keys: ['\u2318', 'K'], description: 'Command Palette' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Table Query Page',
|
||||
shortcuts: [
|
||||
{ keys: ['n'], description: 'New Record' },
|
||||
{ keys: ['r'], description: 'Refresh' },
|
||||
{ keys: ['f'], description: 'Toggle Filters' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Record View Page',
|
||||
shortcuts: [
|
||||
{ keys: ['n'], description: 'New Record' },
|
||||
{ keys: ['e'], description: 'Edit' },
|
||||
{ keys: ['c'], description: 'Copy' },
|
||||
{ keys: ['d'], description: 'Delete' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd
|
||||
className={cn(
|
||||
'inline-flex h-6 min-w-6 items-center justify-center rounded border',
|
||||
'border-border bg-muted px-1.5 font-mono text-xs font-medium text-foreground'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
interface KeyboardShortcutsDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsDialog({ open, onClose }: KeyboardShortcutsDialogProps) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className="fixed inset-0 z-[2000] bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
data-qqq-id="keyboard-shortcuts-overlay"
|
||||
/>
|
||||
<Dialog.Content
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-[2001] w-full max-w-md -translate-x-1/2 -translate-y-1/2',
|
||||
'rounded-xl border border-border bg-card shadow-lg',
|
||||
'focus:outline-none'
|
||||
)}
|
||||
data-qqq-id="keyboard-shortcuts-dialog"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<Dialog.Title className="text-base font-semibold text-foreground">
|
||||
Keyboard Shortcuts
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label="Close keyboard shortcuts"
|
||||
data-qqq-id="button-keyboard-shortcuts-close"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Shortcut sections */}
|
||||
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
|
||||
{shortcutSections.map((section, sectionIndex) => (
|
||||
<div key={section.title} className={cn(sectionIndex > 0 && 'mt-5')}>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{section.shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.description}
|
||||
className="flex items-center justify-between py-1"
|
||||
>
|
||||
<span className="text-sm text-foreground">{shortcut.description}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, keyIndex) => (
|
||||
<React.Fragment key={keyIndex}>
|
||||
{keyIndex > 0 && (
|
||||
<span className="text-xs text-muted-foreground">+</span>
|
||||
)}
|
||||
<Kbd>{key}</Kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-6 py-3">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Press <Kbd>?</Kbd> anytime to show this dialog
|
||||
</p>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
'use client'
|
||||
|
||||
// SearchDialog — "/" key command palette for searching records
|
||||
// Shows recent records when idle, live API search results when typing
|
||||
// Enter on a result navigates to record; Enter with no selection goes to full search page
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search, Clock, ArrowRight, X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { globalSearch } from '@/lib/api/tables'
|
||||
import type { GlobalSearchResult } from '@/lib/api/tables'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function getInitials(label: string): string {
|
||||
const words = label.trim().split(/\s+/)
|
||||
if (words.length === 0) return '?'
|
||||
if (words.length === 1) return words[0].charAt(0).toUpperCase()
|
||||
return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase()
|
||||
}
|
||||
|
||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||
if (!query || query.length < 2) return <>{text}</>
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(`(${escaped})`, 'gi')
|
||||
const parts = text.split(regex)
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-primary/20 text-foreground rounded-sm px-0.5">{part}</mark>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
const router = useRouter()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
|
||||
// Debounced search term for API calls
|
||||
const [debouncedTerm, setDebouncedTerm] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedTerm(searchTerm), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchTerm])
|
||||
|
||||
// TanStack Query for search results
|
||||
const { data: searchResults = [], isLoading: isSearching } = useQuery<GlobalSearchResult[]>({
|
||||
queryKey: queryKeys.globalSearch(debouncedTerm),
|
||||
queryFn: () => globalSearch(debouncedTerm),
|
||||
enabled: open && debouncedTerm.length >= 2,
|
||||
staleTime: 1000 * 30,
|
||||
})
|
||||
|
||||
// Load recent records when dialog opens; clear state when closed
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRecentRecords(getRecentRecords().slice(0, 10))
|
||||
setSearchTerm('')
|
||||
setDebouncedTerm('')
|
||||
setSelectedIndex(-1)
|
||||
// Focus after animation frame
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Reset selected index when results change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
}, [searchResults, recentRecords, searchTerm])
|
||||
|
||||
// Build navigable items
|
||||
const navigableItems: Array<{ path: string; label: string; tableLabel?: string }> = []
|
||||
if (searchTerm.length >= 2 && searchResults.length > 0) {
|
||||
for (const result of searchResults) {
|
||||
navigableItems.push({
|
||||
path: `/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`,
|
||||
label: result.recordLabel,
|
||||
tableLabel: result.tableLabel || result.tableName,
|
||||
})
|
||||
}
|
||||
} else if (searchTerm.length < 2 && recentRecords.length > 0) {
|
||||
for (const record of recentRecords) {
|
||||
navigableItems.push({
|
||||
path: record.path,
|
||||
label: record.recordLabel,
|
||||
tableLabel: record.tableLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
onClose()
|
||||
router.push(path)
|
||||
},
|
||||
[router, onClose]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) =>
|
||||
prev < navigableItems.length - 1 ? prev + 1 : prev
|
||||
)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < navigableItems.length) {
|
||||
handleNavigate(navigableItems[selectedIndex].path)
|
||||
} else if (searchTerm.trim()) {
|
||||
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
break
|
||||
}
|
||||
},
|
||||
[selectedIndex, navigableItems, searchTerm, handleNavigate, onClose]
|
||||
)
|
||||
|
||||
// Group search results by table
|
||||
const groupedResults: Record<string, GlobalSearchResult[]> = {}
|
||||
if (searchTerm.length >= 2) {
|
||||
for (const result of searchResults) {
|
||||
const key = result.tableLabel || result.tableName
|
||||
if (!groupedResults[key]) groupedResults[key] = []
|
||||
groupedResults[key].push(result)
|
||||
}
|
||||
}
|
||||
|
||||
let runningIndex = 0
|
||||
const showRecent = searchTerm.length < 2 && recentRecords.length > 0
|
||||
const showResults = searchTerm.length >= 2
|
||||
const showEmpty = searchTerm.length >= 2 && !isSearching && searchResults.length === 0
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[2000] flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
|
||||
data-qqq-id="search-dialog-backdrop"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-lg overflow-hidden"
|
||||
data-qqq-id="search-dialog"
|
||||
role="dialog"
|
||||
aria-label="Search records"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search records..."
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
autoFocus
|
||||
aria-label="Search records"
|
||||
aria-expanded={true}
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
data-qqq-id="search-dialog-input"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<kbd className="hidden sm:inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
|
||||
esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-80 overflow-y-auto" role="listbox" aria-label="Search results">
|
||||
{/* Recent records */}
|
||||
{showRecent && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
Recently Viewed
|
||||
</div>
|
||||
{recentRecords.map((record, index) => (
|
||||
<button
|
||||
key={record.path}
|
||||
onClick={() => handleNavigate(record.path)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors',
|
||||
selectedIndex === index ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||
{getInitials(record.recordLabel)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{record.recordLabel}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{record.tableLabel}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Searching spinner */}
|
||||
{showResults && isSearching && (
|
||||
<div className="flex items-center justify-center px-4 py-8">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Searching...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grouped search results */}
|
||||
{showResults && !isSearching && !showEmpty && (
|
||||
Object.entries(groupedResults).map(([tableLabel, results]) => {
|
||||
const groupItems = results.map((result, resultIdx) => {
|
||||
const itemIndex = runningIndex
|
||||
runningIndex++
|
||||
return (
|
||||
<button
|
||||
key={`${result.tableName}-${result.recordId}-${resultIdx}`}
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
`/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`
|
||||
)
|
||||
}
|
||||
onMouseEnter={() => setSelectedIndex(itemIndex)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors',
|
||||
selectedIndex === itemIndex ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === itemIndex}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||
{getInitials(result.recordLabel)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
<HighlightedText text={result.recordLabel} query={searchTerm} />
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{result.tableLabel || result.tableName}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<div key={tableLabel}>
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||
{tableLabel}
|
||||
</div>
|
||||
{groupItems}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmpty && (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No results found for “{searchTerm}”
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No recent records and no search */}
|
||||
{!showRecent && !showResults && (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Start typing to search records...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex gap-4">
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd>{' '}navigate
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}
|
||||
{searchTerm.trim() ? 'open' : 'select'}
|
||||
</span>
|
||||
</div>
|
||||
{searchTerm.trim() && (
|
||||
<button
|
||||
onClick={() => handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
View all results
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
// Skeleton — Reusable skeleton loader variants
|
||||
// Usage:
|
||||
// <Skeleton.Text lines={3} />
|
||||
// <Skeleton.Card />
|
||||
// <Skeleton.Table rows={5} cols={4} />
|
||||
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface SkeletonBaseProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Single animated bar
|
||||
function Bar({ className }: SkeletonBaseProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded bg-muted',
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Text lines placeholder
|
||||
interface TextProps {
|
||||
lines?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Text({ lines = 3, className }: TextProps) {
|
||||
const widths = ['w-full', 'w-4/5', 'w-3/5', 'w-full', 'w-2/3', 'w-1/2']
|
||||
return (
|
||||
<div
|
||||
className={cn('space-y-2', className)}
|
||||
role="status"
|
||||
aria-label="Loading text"
|
||||
aria-busy="true"
|
||||
>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Bar key={i} className={cn('h-4', widths[i % widths.length])} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Card placeholder
|
||||
interface CardProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Card({ className }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-card p-5',
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-label="Loading card"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="animate-pulse space-y-4">
|
||||
{/* Header line */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Bar className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Bar className="h-4 w-1/3" />
|
||||
<Bar className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Body lines */}
|
||||
<div className="space-y-2">
|
||||
<Bar className="h-4 w-full" />
|
||||
<Bar className="h-4 w-5/6" />
|
||||
<Bar className="h-20 w-full rounded" />
|
||||
<Bar className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Table rows placeholder
|
||||
interface TableProps {
|
||||
rows?: number
|
||||
cols?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Table({ rows = 5, cols = 4, className }: TableProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('overflow-hidden rounded-lg border border-border', className)}
|
||||
role="status"
|
||||
aria-label="Loading table"
|
||||
aria-busy="true"
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex animate-pulse gap-4 border-b border-border bg-muted px-4 py-3">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Bar key={i} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
{/* Data rows */}
|
||||
{Array.from({ length: rows }).map((_, row) => (
|
||||
<div
|
||||
key={row}
|
||||
className="flex animate-pulse gap-4 border-b border-border px-4 py-3 last:border-0"
|
||||
>
|
||||
{Array.from({ length: cols }).map((_, col) => (
|
||||
<Bar
|
||||
key={col}
|
||||
className={cn('h-4 flex-1', col === 0 ? 'w-1/4' : 'w-auto')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Namespace export — Skeleton.Text, Skeleton.Card, Skeleton.Table
|
||||
export const Skeleton = { Text, Card, Table }
|
||||
@@ -0,0 +1,6 @@
|
||||
'use client'
|
||||
|
||||
// Toast — Re-exports the Toaster from sonner for use in the providers tree
|
||||
// Add <Toaster /> to the root providers to enable toast notifications globally
|
||||
|
||||
export { Toaster } from 'sonner'
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
// DynamicForm — renders form fields from metadata using React Hook Form
|
||||
// This is the CORE REUSABLE FORM component used by EntityForm (Package 3)
|
||||
// and will be reused by ProcessRun (Package 4).
|
||||
|
||||
import React from 'react'
|
||||
import type { Control, UseFormRegister, FieldErrors } from 'react-hook-form'
|
||||
|
||||
import type { QFieldMetaData, QTableSection, QTableMetaData } from '@/types'
|
||||
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
import { DynamicFormField } from './DynamicFormField'
|
||||
|
||||
export interface DynamicFormProps {
|
||||
// React Hook Form internals — passed in from parent
|
||||
register: UseFormRegister<Record<string, unknown>>
|
||||
control: Control<Record<string, unknown>>
|
||||
errors: FieldErrors<Record<string, unknown>>
|
||||
|
||||
// Metadata for rendering
|
||||
tableMetaData?: QTableMetaData
|
||||
fields?: QFieldMetaData[]
|
||||
sections?: QTableSection[]
|
||||
|
||||
// Which fields to include (if undefined, all editable non-hidden fields)
|
||||
fieldNamesToInclude?: string[]
|
||||
|
||||
// Context for possible values (table or process)
|
||||
possibleValueContext?: PossibleValueContext
|
||||
|
||||
// Whether the entire form is disabled
|
||||
disabled?: boolean
|
||||
|
||||
// Optional label shown at top
|
||||
formLabel?: string
|
||||
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicForm renders form fields from metadata.
|
||||
*
|
||||
* Usage patterns:
|
||||
* 1. Table-based: pass tableMetaData (uses its sections for layout)
|
||||
* 2. Process-based: pass fields directly (flat list, no sections)
|
||||
* 3. Both: pass both tableMetaData and fieldNamesToInclude to filter
|
||||
*/
|
||||
export function DynamicForm({
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
tableMetaData,
|
||||
fields,
|
||||
sections,
|
||||
fieldNamesToInclude,
|
||||
possibleValueContext,
|
||||
disabled = false,
|
||||
formLabel,
|
||||
className,
|
||||
}: DynamicFormProps) {
|
||||
// Determine the set of fields to render
|
||||
const resolvedFields: QFieldMetaData[] = []
|
||||
|
||||
if (fields) {
|
||||
// Direct fields list (process mode)
|
||||
const includeSet = fieldNamesToInclude ? new Set(fieldNamesToInclude) : null
|
||||
for (const f of fields) {
|
||||
if (f.isHidden) continue
|
||||
if (includeSet && !includeSet.has(f.name)) continue
|
||||
resolvedFields.push(f)
|
||||
}
|
||||
} else if (tableMetaData) {
|
||||
// Table metadata mode — respect sections for layout
|
||||
const includeSet = fieldNamesToInclude ? new Set(fieldNamesToInclude) : null
|
||||
const allFields = tableMetaData.fields
|
||||
const fieldOrder: string[] = []
|
||||
|
||||
// Collect field names in section order
|
||||
const resolvedSections = sections ?? tableMetaData.sections
|
||||
for (const section of resolvedSections) {
|
||||
if (section.isHidden) continue
|
||||
for (const fn of section.fieldNames) {
|
||||
if (!fieldOrder.includes(fn)) fieldOrder.push(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// Add any fields not in sections
|
||||
for (const fn of Object.keys(allFields)) {
|
||||
if (!fieldOrder.includes(fn)) fieldOrder.push(fn)
|
||||
}
|
||||
|
||||
for (const fn of fieldOrder) {
|
||||
const f = allFields[fn]
|
||||
if (!f) continue
|
||||
if (f.isHidden) continue
|
||||
if (includeSet && !includeSet.has(fn)) continue
|
||||
resolvedFields.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedFields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// If we have table sections, render section-grouped layout
|
||||
const hasSections =
|
||||
tableMetaData &&
|
||||
tableMetaData.sections &&
|
||||
tableMetaData.sections.filter((s) => !s.isHidden).length > 0 &&
|
||||
!fields
|
||||
|
||||
if (hasSections && tableMetaData) {
|
||||
const resolvedSections = sections ?? tableMetaData.sections
|
||||
return (
|
||||
<div className={cn('space-y-6', className)} data-qqq-id="dynamic-form">
|
||||
{formLabel && (
|
||||
<h3 className="text-base font-semibold text-foreground">{formLabel}</h3>
|
||||
)}
|
||||
{resolvedSections
|
||||
.filter((s) => !s.isHidden)
|
||||
.map((section) => {
|
||||
const sectionFields = section.fieldNames
|
||||
.map((fn) => tableMetaData.fields[fn])
|
||||
.filter((f): f is QFieldMetaData => {
|
||||
if (!f) return false
|
||||
if (f.isHidden) return false
|
||||
if (fieldNamesToInclude && !fieldNamesToInclude.includes(f.name)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (sectionFields.length === 0) return null
|
||||
|
||||
const gridCols = section.gridColumns ?? 2
|
||||
|
||||
return (
|
||||
<div
|
||||
key={section.name}
|
||||
className="space-y-4"
|
||||
data-qqq-id={`form-section-${section.name}`}
|
||||
>
|
||||
{section.label && (
|
||||
<div className="border-b border-border pb-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{section.label}
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-4',
|
||||
gridCols === 1 ? 'grid-cols-1' :
|
||||
gridCols === 2 ? 'grid-cols-1 sm:grid-cols-2' :
|
||||
gridCols === 3 ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' :
|
||||
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4'
|
||||
)}
|
||||
>
|
||||
{sectionFields.map((f) => (
|
||||
<div
|
||||
key={f.name}
|
||||
className={cn(
|
||||
f.gridColumns === 1 ? 'col-span-1' :
|
||||
f.gridColumns === 2 ? 'col-span-1 sm:col-span-2' : undefined
|
||||
)}
|
||||
>
|
||||
<DynamicFormField
|
||||
field={f}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
possibleValueContext={possibleValueContext}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Flat layout (no sections — process mode or simple tables)
|
||||
return (
|
||||
<div className={cn('space-y-4', className)} data-qqq-id="dynamic-form">
|
||||
{formLabel && (
|
||||
<h3 className="text-base font-semibold text-foreground">{formLabel}</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{resolvedFields.map((f) => (
|
||||
<div
|
||||
key={f.name}
|
||||
className={cn(
|
||||
f.gridColumns === 1 ? 'col-span-1' :
|
||||
f.gridColumns === 2 ? 'col-span-1 sm:col-span-2' : undefined
|
||||
)}
|
||||
>
|
||||
<DynamicFormField
|
||||
field={f}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
possibleValueContext={possibleValueContext}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
'use client'
|
||||
|
||||
// DynamicFormField — renders a single form field based on QFieldMetaData type
|
||||
// Used by DynamicForm to render each field in a metadata-driven form
|
||||
// Includes help tooltip support via field.helpContents
|
||||
|
||||
import React from 'react'
|
||||
import type { Control, UseFormRegister, FieldError, FieldErrors } from 'react-hook-form'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
|
||||
import type { QFieldMetaData } from '@/types'
|
||||
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
import { TextField } from './field-types/TextField'
|
||||
import { NumberField } from './field-types/NumberField'
|
||||
import { BooleanField } from './field-types/BooleanField'
|
||||
import { DateField } from './field-types/DateField'
|
||||
import { DateTimeField } from './field-types/DateTimeField'
|
||||
import { PasswordField } from './field-types/PasswordField'
|
||||
import { FileUploadField } from './field-types/FileUploadField'
|
||||
import { PossibleValueSelect } from './PossibleValueSelect'
|
||||
|
||||
interface DynamicFormFieldProps {
|
||||
field: QFieldMetaData
|
||||
register: UseFormRegister<Record<string, unknown>>
|
||||
control: Control<Record<string, unknown>>
|
||||
errors: FieldErrors<Record<string, unknown>>
|
||||
disabled?: boolean
|
||||
possibleValueContext?: PossibleValueContext
|
||||
}
|
||||
|
||||
/** Renders a help tooltip icon next to a field label when helpContents is available */
|
||||
function FieldHelpTooltip({ field }: { field: QFieldMetaData }) {
|
||||
const helpContent = field.helpContents?.[0]
|
||||
if (!helpContent?.content) return null
|
||||
|
||||
const helpId = `field-help-content-${field.name}`
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Provider delayDuration={300}>
|
||||
<TooltipPrimitive.Root>
|
||||
<TooltipPrimitive.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
data-qqq-id={`field-help-${field.name}`}
|
||||
aria-label={`Help for ${field.label}`}
|
||||
className={cn(
|
||||
'ml-1 inline-flex items-center rounded-full p-0.5',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
id={helpId}
|
||||
side="top"
|
||||
sideOffset={4}
|
||||
className={cn(
|
||||
'z-50 max-w-xs rounded-md border border-border bg-popover px-3 py-2 text-sm shadow-md',
|
||||
'text-popover-foreground',
|
||||
'animate-in fade-in-0 zoom-in-95'
|
||||
)}
|
||||
>
|
||||
{helpContent.title && (
|
||||
<p className="mb-1 font-semibold">{helpContent.title}</p>
|
||||
)}
|
||||
<p>{helpContent.content}</p>
|
||||
{helpContent.links && helpContent.links.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{helpContent.links.map((link, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-xs text-primary hover:underline"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<TooltipPrimitive.Arrow className="fill-border" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</TooltipPrimitive.Root>
|
||||
</TooltipPrimitive.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wraps a field rendering with an optional help tooltip and aria-describedby linkage */
|
||||
function FieldWithHelp({
|
||||
field,
|
||||
children,
|
||||
}: {
|
||||
field: QFieldMetaData
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const hasHelp = field.helpContents && field.helpContents.length > 0 && field.helpContents[0]?.content
|
||||
|
||||
if (!hasHelp) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
aria-describedby={`field-help-content-${field.name}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DynamicFormField({
|
||||
field,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
disabled = false,
|
||||
possibleValueContext,
|
||||
}: DynamicFormFieldProps) {
|
||||
if (field.isHidden) return null
|
||||
if (!field.isEditable && !disabled) return null
|
||||
|
||||
const fieldId = `field-${field.name}`
|
||||
const fieldError = errors[field.name] as FieldError | undefined
|
||||
const isDisabled = disabled || !field.isEditable
|
||||
const dataQqqId = field.name
|
||||
|
||||
const hasHelp = field.helpContents && field.helpContents.length > 0 && field.helpContents[0]?.content
|
||||
const helpDescribedBy = hasHelp ? `field-help-content-${field.name}` : undefined
|
||||
|
||||
// Fields with possibleValues use PossibleValueSelect (async combobox)
|
||||
if (field.possibleValueSourceName) {
|
||||
const pvContext: PossibleValueContext = possibleValueContext ?? { type: 'standalone' }
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<PossibleValueSelect
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
control={control as Control<Record<string, unknown>>}
|
||||
fieldName={field.name}
|
||||
context={pvContext}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
required={field.isRequired}
|
||||
data-qqq-id={dataQqqId}
|
||||
/>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</FieldWithHelp>
|
||||
)
|
||||
}
|
||||
|
||||
// Check for FILE_UPLOAD adornment (overrides type rendering)
|
||||
const hasFileUpload = field.adornments?.some((a) => a.type === 'FILE_UPLOAD')
|
||||
if (hasFileUpload || field.type === 'BLOB') {
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<FileUploadField
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
control={control as Control<Record<string, unknown>>}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
required={field.isRequired}
|
||||
data-qqq-id={dataQqqId}
|
||||
/>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</FieldWithHelp>
|
||||
)
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'STRING':
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<TextField
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
registration={register(field.name)}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
maxLength={field.maxLength}
|
||||
required={field.isRequired}
|
||||
data-qqq-id={dataQqqId}
|
||||
/>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</FieldWithHelp>
|
||||
)
|
||||
|
||||
case 'TEXT':
|
||||
case 'HTML':
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={`field-label-${dataQqqId}`}
|
||||
>
|
||||
{field.label}
|
||||
{field.isRequired && (
|
||||
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
|
||||
)}
|
||||
</label>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</div>
|
||||
<textarea
|
||||
id={fieldId}
|
||||
{...register(field.name)}
|
||||
disabled={isDisabled}
|
||||
aria-required={field.isRequired}
|
||||
aria-invalid={fieldError ? true : undefined}
|
||||
aria-describedby={
|
||||
[fieldError ? `${fieldId}-error` : undefined, helpDescribedBy]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
}
|
||||
data-qqq-id={dataQqqId}
|
||||
rows={4}
|
||||
className={`w-full rounded-md border px-3 py-2 text-sm text-foreground bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring disabled:cursor-not-allowed disabled:bg-muted transition-colors duration-150 resize-y ${
|
||||
fieldError ? 'border-destructive focus:ring-destructive' : 'border-input'
|
||||
}`}
|
||||
/>
|
||||
{fieldError && (
|
||||
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{fieldError.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FieldWithHelp>
|
||||
)
|
||||
|
||||
case 'INTEGER':
|
||||
case 'LONG':
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<NumberField
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
registration={register(field.name, { valueAsNumber: true })}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
required={field.isRequired}
|
||||
step={1}
|
||||
data-qqq-id={dataQqqId}
|
||||
/>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</FieldWithHelp>
|
||||
)
|
||||
|
||||
case 'DECIMAL':
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<NumberField
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
registration={register(field.name, { valueAsNumber: true })}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
required={field.isRequired}
|
||||
step="any"
|
||||
data-qqq-id={dataQqqId}
|
||||
/>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</FieldWithHelp>
|
||||
)
|
||||
|
||||
case 'BOOLEAN':
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<BooleanField
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
control={control as Control<Record<string, unknown>>}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
required={field.isRequired}
|
||||
data-qqq-id={dataQqqId}
|
||||
/>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</FieldWithHelp>
|
||||
)
|
||||
|
||||
case 'DATE':
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<DateField
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
registration={register(field.name)}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
required={field.isRequired}
|
||||
data-qqq-id={dataQqqId}
|
||||
/>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</FieldWithHelp>
|
||||
)
|
||||
|
||||
case 'DATE_TIME':
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<DateTimeField
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
registration={register(field.name)}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
required={field.isRequired}
|
||||
data-qqq-id={dataQqqId}
|
||||
/>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</FieldWithHelp>
|
||||
)
|
||||
|
||||
case 'TIME':
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={`field-label-${dataQqqId}`}
|
||||
>
|
||||
{field.label}
|
||||
{field.isRequired && (
|
||||
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
|
||||
)}
|
||||
</label>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</div>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="time"
|
||||
{...register(field.name)}
|
||||
disabled={isDisabled}
|
||||
aria-required={field.isRequired}
|
||||
aria-invalid={fieldError ? true : undefined}
|
||||
aria-describedby={
|
||||
[fieldError ? `${fieldId}-error` : undefined, helpDescribedBy]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={`w-full rounded-md border px-3 py-2 text-sm text-foreground bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring disabled:cursor-not-allowed disabled:bg-muted transition-colors duration-150 ${
|
||||
fieldError ? 'border-destructive focus:ring-destructive' : 'border-input'
|
||||
}`}
|
||||
/>
|
||||
{fieldError && (
|
||||
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{fieldError.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FieldWithHelp>
|
||||
)
|
||||
|
||||
case 'PASSWORD':
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<PasswordField
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
registration={register(field.name)}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
maxLength={field.maxLength}
|
||||
required={field.isRequired}
|
||||
data-qqq-id={dataQqqId}
|
||||
/>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</FieldWithHelp>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<FieldWithHelp field={field}>
|
||||
<TextField
|
||||
id={fieldId}
|
||||
label={field.label}
|
||||
registration={register(field.name)}
|
||||
error={fieldError}
|
||||
disabled={isDisabled}
|
||||
maxLength={field.maxLength}
|
||||
required={field.isRequired}
|
||||
data-qqq-id={dataQqqId}
|
||||
/>
|
||||
{hasHelp && <FieldHelpTooltip field={field} />}
|
||||
</FieldWithHelp>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
'use client'
|
||||
|
||||
// EntityForm — full create/edit form for a record
|
||||
// Wraps DynamicForm with React Hook Form + Zod validation + save/cancel actions
|
||||
// Includes unsaved changes guard for both browser navigation and client-side navigation
|
||||
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Loader2, Save, X } from 'lucide-react'
|
||||
|
||||
import type { QTableMetaData, QRecord } from '@/types'
|
||||
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
|
||||
import { insertRecord, updateRecord } from '@/lib/api/tables'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { zodSchemaFromTableMetadata, defaultValuesFromRecord } from '@/lib/utils/zod-from-metadata'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { toast } from '@/lib/hooks/use-toast'
|
||||
|
||||
import { DynamicForm } from './DynamicForm'
|
||||
import { UnsavedChangesDialog } from './UnsavedChangesDialog'
|
||||
|
||||
export interface EntityFormProps {
|
||||
// Required
|
||||
tableMetaData: QTableMetaData
|
||||
|
||||
// For edit mode: existing record values
|
||||
record?: QRecord
|
||||
|
||||
// Control
|
||||
isModal?: boolean
|
||||
isCopy?: boolean
|
||||
disabled?: boolean
|
||||
|
||||
// Override heading
|
||||
overrideHeading?: string
|
||||
saveButtonLabel?: string
|
||||
|
||||
// Callbacks
|
||||
onSuccess?: (record: QRecord) => void
|
||||
onCancel?: () => void
|
||||
|
||||
// Default values to pre-populate (overrides record values)
|
||||
defaultValues?: Record<string, unknown>
|
||||
|
||||
// Specific fields to show (if not set, all editable fields shown)
|
||||
fieldNamesToInclude?: string[]
|
||||
|
||||
// Possible value context
|
||||
possibleValueContext?: PossibleValueContext
|
||||
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EntityForm({
|
||||
tableMetaData,
|
||||
record,
|
||||
isModal = false,
|
||||
isCopy = false,
|
||||
disabled = false,
|
||||
overrideHeading,
|
||||
saveButtonLabel = 'Save',
|
||||
onSuccess,
|
||||
onCancel,
|
||||
defaultValues: propDefaultValues,
|
||||
fieldNamesToInclude,
|
||||
possibleValueContext,
|
||||
className,
|
||||
}: EntityFormProps) {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const isEdit = Boolean(record) && !isCopy
|
||||
|
||||
// Unsaved changes dialog state
|
||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false)
|
||||
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
|
||||
|
||||
// Build Zod schema from metadata (memoized to avoid expensive recomputation)
|
||||
const schema = useMemo(
|
||||
() => zodSchemaFromTableMetadata(tableMetaData, fieldNamesToInclude),
|
||||
[tableMetaData, fieldNamesToInclude]
|
||||
)
|
||||
|
||||
// Build default values (memoized to keep a stable reference for useForm)
|
||||
const mergedDefaults = useMemo(() => {
|
||||
const computedDefaults: Record<string, unknown> = record
|
||||
? defaultValuesFromRecord(tableMetaData, record.values)
|
||||
: {}
|
||||
return { ...computedDefaults, ...propDefaultValues }
|
||||
}, [tableMetaData, record, propDefaultValues])
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
reset,
|
||||
} = useForm<Record<string, unknown>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: mergedDefaults,
|
||||
})
|
||||
|
||||
// Reset when record changes (e.g., navigating between records)
|
||||
useEffect(() => {
|
||||
if (record) {
|
||||
reset(defaultValuesFromRecord(tableMetaData, record.values))
|
||||
}
|
||||
}, [record, tableMetaData, reset])
|
||||
|
||||
// Browser-level navigation guard (tab close, URL change, refresh)
|
||||
// Both e.preventDefault() and e.returnValue are required for cross-browser support:
|
||||
// Chrome/Edge require returnValue to be set, Firefox/Safari rely on preventDefault().
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [isDirty])
|
||||
|
||||
// Navigation helper that checks for unsaved changes before proceeding
|
||||
const guardedNavigate = useCallback(
|
||||
(navigateFn: () => void) => {
|
||||
if (isDirty) {
|
||||
setPendingNavigation(() => navigateFn)
|
||||
setShowUnsavedDialog(true)
|
||||
} else {
|
||||
navigateFn()
|
||||
}
|
||||
},
|
||||
[isDirty]
|
||||
)
|
||||
|
||||
const handleConfirmLeave = useCallback(() => {
|
||||
setShowUnsavedDialog(false)
|
||||
if (pendingNavigation) {
|
||||
pendingNavigation()
|
||||
setPendingNavigation(null)
|
||||
}
|
||||
}, [pendingNavigation])
|
||||
|
||||
const handleCancelLeave = useCallback(() => {
|
||||
setShowUnsavedDialog(false)
|
||||
setPendingNavigation(null)
|
||||
}, [])
|
||||
|
||||
// --- Mutations ---
|
||||
const insertMutation = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) =>
|
||||
insertRecord(tableMetaData.name, values),
|
||||
onSuccess: (savedRecord) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(tableMetaData.name) })
|
||||
toast.success(`${tableMetaData.label} created successfully.`)
|
||||
if (onSuccess) {
|
||||
onSuccess(savedRecord)
|
||||
} else {
|
||||
const pk = savedRecord.values[tableMetaData.primaryKeyField]
|
||||
router.push(`/app/${tableMetaData.name}/${pk}`)
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`Failed to create ${tableMetaData.label}: ${err.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) => {
|
||||
const pk = record!.values[tableMetaData.primaryKeyField] as string | number
|
||||
return updateRecord(tableMetaData.name, pk, values)
|
||||
},
|
||||
onSuccess: (savedRecord) => {
|
||||
const pk = savedRecord.values[tableMetaData.primaryKeyField] as string | number
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord(tableMetaData.name, pk) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(tableMetaData.name) })
|
||||
toast.success(`${tableMetaData.label} saved successfully.`)
|
||||
if (onSuccess) {
|
||||
onSuccess(savedRecord)
|
||||
} else {
|
||||
router.push(`/app/${tableMetaData.name}/${pk}`)
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`Failed to save ${tableMetaData.label}: ${err.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const activeMutation = isEdit ? updateMutation : insertMutation
|
||||
const mutationError = activeMutation.error as Error | null
|
||||
|
||||
const onSubmit = async (values: Record<string, unknown>) => {
|
||||
await activeMutation.mutateAsync(values)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
const doCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
} else if (isEdit && record) {
|
||||
const pk = record.values[tableMetaData.primaryKeyField]
|
||||
router.push(`/app/${tableMetaData.name}/${pk}`)
|
||||
} else {
|
||||
router.push(`/app/${tableMetaData.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
guardedNavigate(doCancel)
|
||||
}
|
||||
|
||||
// Determine heading
|
||||
const heading = overrideHeading ?? (
|
||||
isEdit
|
||||
? `Edit ${tableMetaData.label}`
|
||||
: isCopy
|
||||
? `Copy ${tableMetaData.label}`
|
||||
: `Create ${tableMetaData.label}`
|
||||
)
|
||||
|
||||
// Possible value context — default to table context
|
||||
const pvContext: PossibleValueContext = possibleValueContext ?? {
|
||||
type: 'table',
|
||||
tableName: tableMetaData.name,
|
||||
}
|
||||
|
||||
const formContent = (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
noValidate
|
||||
className={cn('flex flex-col gap-6', className)}
|
||||
data-qqq-id={`entity-form-${tableMetaData.name}`}
|
||||
>
|
||||
{/* Heading — only if not modal */}
|
||||
{!isModal && (
|
||||
<div className="border-b border-border pb-4">
|
||||
<h2 className="text-xl font-semibold text-foreground">{heading}</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mutation error alert */}
|
||||
{mutationError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<strong>Error: </strong>
|
||||
{mutationError.message || 'An error occurred while saving.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<DynamicForm
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
tableMetaData={tableMetaData}
|
||||
fieldNamesToInclude={fieldNamesToInclude}
|
||||
possibleValueContext={pvContext}
|
||||
disabled={disabled || isSubmitting}
|
||||
/>
|
||||
|
||||
{/* Actions — sticky on mobile, static on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
'sticky bottom-0 z-10 bg-background border-t border-border py-3 mt-4 -mx-6 px-6',
|
||||
'flex items-center justify-end gap-3',
|
||||
'md:static md:border-t md:mt-6 md:mx-0 md:px-0'
|
||||
)}
|
||||
data-qqq-id="entity-form-actions"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
data-qqq-id="button-cancel"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md border border-input px-4 py-2 text-sm font-medium',
|
||||
'text-foreground bg-background hover:bg-accent',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || isSubmitting || (!isDirty && isEdit)}
|
||||
data-qqq-id="button-save"
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{isSubmitting ? 'Saving...' : saveButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isModal ? (
|
||||
<div data-qqq-id={`entity-form-modal-${tableMetaData.name}`}>
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{heading}</h2>
|
||||
</div>
|
||||
<div className="p-6">{formContent}</div>
|
||||
</div>
|
||||
) : (
|
||||
formContent
|
||||
)}
|
||||
|
||||
{/* Unsaved changes confirmation dialog */}
|
||||
<UnsavedChangesDialog
|
||||
open={showUnsavedDialog}
|
||||
onStay={handleCancelLeave}
|
||||
onLeave={handleConfirmLeave}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
// PossibleValueSelect — async combobox for fields with possibleValueSourceName
|
||||
// Debounced search hits the backend possible values endpoint
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import type { Control, FieldError } from 'react-hook-form'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { Check, ChevronDown, Loader2, X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import type { QPossibleValue } from '@/types'
|
||||
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
|
||||
import {
|
||||
fetchTablePossibleValues,
|
||||
fetchProcessPossibleValues,
|
||||
fetchPossibleValues,
|
||||
} from '@/lib/api/possible-values'
|
||||
|
||||
interface PossibleValueSelectProps {
|
||||
id: string
|
||||
label: string
|
||||
name: string
|
||||
control: Control<Record<string, unknown>>
|
||||
fieldName: string
|
||||
context: PossibleValueContext
|
||||
error?: FieldError
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
placeholder?: string
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
export function PossibleValueSelect({
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
control,
|
||||
fieldName,
|
||||
context,
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
placeholder,
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: PossibleValueSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [options, setOptions] = useState<QPossibleValue[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [selectedOption, setSelectedOption] = useState<QPossibleValue | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const fetchOptions = useCallback(
|
||||
async (term: string) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const request = { searchTerm: term || undefined }
|
||||
let results: QPossibleValue[]
|
||||
if (context.type === 'table') {
|
||||
results = await fetchTablePossibleValues(context.tableName, fieldName, request)
|
||||
} else if (context.type === 'process') {
|
||||
results = await fetchProcessPossibleValues(context.processName, fieldName, request)
|
||||
} else {
|
||||
results = await fetchPossibleValues(fieldName, request)
|
||||
}
|
||||
setOptions(results)
|
||||
} catch {
|
||||
setOptions([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[context, fieldName]
|
||||
)
|
||||
|
||||
const debouncedFetch = useCallback(
|
||||
(term: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => fetchOptions(term), 300)
|
||||
},
|
||||
[fetchOptions]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchOptions(searchTerm)
|
||||
}
|
||||
}, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const term = e.target.value
|
||||
setSearchTerm(term)
|
||||
debouncedFetch(term)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
// Sync selected option when field value changes externally
|
||||
const handleSelect = (option: QPossibleValue) => {
|
||||
field.onChange(option.id)
|
||||
setSelectedOption(option)
|
||||
setIsOpen(false)
|
||||
setSearchTerm('')
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
field.onChange(null)
|
||||
setSelectedOption(null)
|
||||
setSearchTerm('')
|
||||
}
|
||||
|
||||
const displayValue = selectedOption?.label ?? (field.value ? String(field.value) : '')
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div
|
||||
id={id}
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
aria-controls={`${id}-listbox`}
|
||||
data-qqq-id={dataQqqId}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setIsOpen((o) => !o)
|
||||
if (!isOpen) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-h-10 w-full cursor-pointer items-center justify-between rounded-md border px-3 py-2',
|
||||
'bg-background text-sm',
|
||||
'focus-within:ring-2 focus-within:ring-ring focus-within:border-ring',
|
||||
'transition-colors duration-150',
|
||||
disabled && 'cursor-not-allowed opacity-50 bg-muted',
|
||||
error ? 'border-destructive' : 'border-input'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 truncate text-sm',
|
||||
displayValue
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{displayValue || placeholder || `-- Select ${label} --`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{Boolean(field.value) && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Clear ${label}`}
|
||||
onClick={handleClear}
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-150',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 right-0 top-full z-50 mt-1',
|
||||
'rounded-md border border-border bg-background shadow-lg',
|
||||
'max-h-60 overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-border p-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search..."
|
||||
aria-label={`Search ${label} options`}
|
||||
className={cn(
|
||||
'w-full rounded border border-input px-2 py-1 text-sm',
|
||||
'bg-muted text-foreground',
|
||||
'focus:outline-none focus:ring-1 focus:ring-ring',
|
||||
'placeholder:text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
id={`${id}-listbox`}
|
||||
role="listbox"
|
||||
aria-label={`${label} options`}
|
||||
className="max-h-44 overflow-y-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<li className="flex items-center justify-center py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading...
|
||||
</li>
|
||||
) : options.length === 0 ? (
|
||||
<li className="py-4 text-center text-sm text-muted-foreground">
|
||||
No options found
|
||||
</li>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = String(field.value) === String(option.id)
|
||||
return (
|
||||
<li
|
||||
key={String(option.id)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => handleSelect(option)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-between px-3 py-2 text-sm',
|
||||
'hover:bg-accent',
|
||||
isSelected && 'bg-accent text-primary'
|
||||
)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{isSelected && (
|
||||
<Check className="h-4 w-4 text-primary" aria-hidden="true" />
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
// UnsavedChangesDialog — confirmation dialog shown when navigating away with unsaved form changes
|
||||
// Uses Radix AlertDialog primitives for accessible modal behavior
|
||||
|
||||
import React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface UnsavedChangesDialogProps {
|
||||
open: boolean
|
||||
onStay: () => void
|
||||
onLeave: () => void
|
||||
}
|
||||
|
||||
export function UnsavedChangesDialog({ open, onStay, onLeave }: UnsavedChangesDialogProps) {
|
||||
// Radix AlertDialog manages focus automatically (focus trap + initial focus).
|
||||
// Manual focus management via useEffect/ref.focus() races with Radix's
|
||||
// internal focus trap and is intentionally omitted here.
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<AlertDialogPrimitive.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onStay() }}>
|
||||
<AlertDialogPrimitive.Portal>
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className="fixed inset-0 z-50 bg-black/50"
|
||||
onClick={onStay}
|
||||
/>
|
||||
<AlertDialogPrimitive.Content
|
||||
data-qqq-id="unsaved-changes-dialog"
|
||||
aria-describedby="unsaved-changes-description"
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2',
|
||||
'rounded-xl border border-border bg-background shadow-lg',
|
||||
'focus:outline-none'
|
||||
)}
|
||||
onEscapeKeyDown={onStay}
|
||||
>
|
||||
<AlertDialogPrimitive.Title className="sr-only">
|
||||
Unsaved Changes
|
||||
</AlertDialogPrimitive.Title>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-yellow-100">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Unsaved Changes
|
||||
</h2>
|
||||
</div>
|
||||
<p
|
||||
id="unsaved-changes-description"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
You have unsaved changes. Are you sure you want to leave? Your changes will be lost.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-xl bg-muted px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStay}
|
||||
data-qqq-id="unsaved-changes-stay"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md border border-input px-4 py-2 text-sm font-medium',
|
||||
'text-foreground bg-background hover:bg-accent',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
Stay
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLeave}
|
||||
data-qqq-id="unsaved-changes-leave"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
|
||||
'text-destructive-foreground bg-destructive hover:bg-destructive/90',
|
||||
'focus:outline-none focus:ring-2 focus:ring-destructive focus:ring-offset-2',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
Leave
|
||||
</button>
|
||||
</div>
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPrimitive.Portal>
|
||||
</AlertDialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
// BooleanField — toggle switch for boolean form fields
|
||||
// Supports three-state cycling (null -> true -> false -> null) when field is NOT required.
|
||||
// When required, uses two-state (true/false) only.
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import type { Control, FieldError } from 'react-hook-form'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { Minus } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface BooleanFieldProps {
|
||||
id: string
|
||||
label: string
|
||||
name: string
|
||||
control: Control<Record<string, unknown>>
|
||||
error?: FieldError
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current boolean state from a field value.
|
||||
* Returns true, false, or null (for indeterminate/three-state).
|
||||
*/
|
||||
function resolveBoolState(value: unknown): boolean | null {
|
||||
if (value === null || value === undefined) return null
|
||||
if (value === true || value === 'true') return true
|
||||
if (value === false || value === 'false') return false
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the aria-checked value for the current boolean state.
|
||||
* Uses role="checkbox" (not "switch") because checkbox supports aria-checked="mixed"
|
||||
* for the indeterminate/null state in three-state mode.
|
||||
*/
|
||||
function ariaCheckedValue(state: boolean | null): 'true' | 'false' | 'mixed' {
|
||||
if (state === true) return 'true'
|
||||
if (state === false) return 'false'
|
||||
return 'mixed'
|
||||
}
|
||||
|
||||
export function BooleanField({
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
control,
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: BooleanFieldProps) {
|
||||
// Cycle logic depends on whether the field is required
|
||||
const getNextValue = useCallback(
|
||||
(current: boolean | null): boolean | null => {
|
||||
if (required) {
|
||||
// Two-state: true <-> false
|
||||
return !current
|
||||
}
|
||||
// Three-state: null -> true -> false -> null
|
||||
if (current === null) return true
|
||||
if (current === true) return false
|
||||
return null
|
||||
},
|
||||
[required]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const boolState = resolveBoolState(field.value)
|
||||
const ariaVal = ariaCheckedValue(boolState)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
id={id}
|
||||
aria-checked={ariaVal}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
disabled={disabled}
|
||||
data-qqq-id={dataQqqId}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
field.onChange(getNextValue(boolState))
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center',
|
||||
'rounded-full border-2 border-transparent',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'transition-colors duration-200',
|
||||
boolState === true
|
||||
? 'bg-green-600'
|
||||
: boolState === false
|
||||
? 'bg-muted'
|
||||
: 'bg-muted',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none inline-flex h-5 w-5 items-center justify-center rounded-full bg-white shadow-sm',
|
||||
'transition-transform duration-200',
|
||||
boolState === true
|
||||
? 'translate-x-5'
|
||||
: boolState === false
|
||||
? 'translate-x-0'
|
||||
: 'translate-x-2.5'
|
||||
)}
|
||||
>
|
||||
{/* Show dash indicator for null/indeterminate state */}
|
||||
{boolState === null && (
|
||||
<Minus className="h-3 w-3 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'text-sm font-medium text-foreground',
|
||||
disabled && 'opacity-50'
|
||||
)}
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface DateFieldProps {
|
||||
id: string
|
||||
label: string
|
||||
registration: UseFormRegisterReturn
|
||||
error?: FieldError
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
export function DateField({
|
||||
id,
|
||||
label,
|
||||
registration,
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: DateFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="date"
|
||||
{...registration}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
|
||||
'bg-background',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
|
||||
'transition-colors duration-150',
|
||||
error
|
||||
? 'border-destructive focus:ring-destructive'
|
||||
: 'border-input'
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface DateTimeFieldProps {
|
||||
id: string
|
||||
label: string
|
||||
registration: UseFormRegisterReturn
|
||||
error?: FieldError
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
export function DateTimeField({
|
||||
id,
|
||||
label,
|
||||
registration,
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: DateTimeFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="datetime-local"
|
||||
{...registration}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
|
||||
'bg-background',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
|
||||
'transition-colors duration-150',
|
||||
error
|
||||
? 'border-destructive focus:ring-destructive'
|
||||
: 'border-input'
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import React, { useRef, useState } from 'react'
|
||||
import type { Control, FieldError } from 'react-hook-form'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { Upload, X, File as FileIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface FileUploadFieldProps {
|
||||
id: string
|
||||
label: string
|
||||
name: string
|
||||
control: Control<Record<string, unknown>>
|
||||
error?: FieldError
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
accept?: string
|
||||
existingFileName?: string
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
export function FileUploadField({
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
control,
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
accept,
|
||||
existingFileName,
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: FileUploadFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const currentFile = field.value instanceof File ? (field.value as File) : null
|
||||
const displayName = currentFile?.name ?? existingFileName
|
||||
|
||||
const handleFiles = (files: FileList | null) => {
|
||||
if (files && files.length > 0) {
|
||||
field.onChange(files[0])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
id={id}
|
||||
role="region"
|
||||
aria-label={`${label} upload area`}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
if (!disabled) handleFiles(e.dataTransfer.files)
|
||||
}}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-md border-2 border-dashed p-6 text-center',
|
||||
'transition-colors duration-150 cursor-pointer',
|
||||
dragOver && !disabled ? 'border-primary bg-accent' : 'border-input',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
error && 'border-destructive',
|
||||
'hover:border-primary'
|
||||
)}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
|
||||
e.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{displayName ? (
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<FileIcon className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||
<span className="max-w-[200px] truncate">{displayName}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${displayName}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
field.onChange(null)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
}}
|
||||
className="ml-1 rounded p-0.5 text-muted-foreground hover:text-destructive focus:outline-none focus:ring-2 focus:ring-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mb-2 h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-primary">Click to upload</span>{' '}
|
||||
or drag and drop
|
||||
</p>
|
||||
{accept && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{accept}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
id={`${id}-input`}
|
||||
accept={accept}
|
||||
disabled={disabled}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="sr-only"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface NumberFieldProps {
|
||||
id: string
|
||||
label: string
|
||||
registration: UseFormRegisterReturn
|
||||
error?: FieldError
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
step?: string | number
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
export function NumberField({
|
||||
id,
|
||||
label,
|
||||
registration,
|
||||
error,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
required = false,
|
||||
step,
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: NumberFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
step={step}
|
||||
{...registration}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
|
||||
'bg-background',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
|
||||
'transition-colors duration-150',
|
||||
error
|
||||
? 'border-destructive focus:ring-destructive'
|
||||
: 'border-input'
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface PasswordFieldProps {
|
||||
id: string
|
||||
label: string
|
||||
registration: UseFormRegisterReturn
|
||||
error?: FieldError
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
maxLength?: number
|
||||
required?: boolean
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
export function PasswordField({
|
||||
id,
|
||||
label,
|
||||
registration,
|
||||
error,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
maxLength,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: PasswordFieldProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
{...registration}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 pr-10 text-sm text-foreground',
|
||||
'bg-background',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
|
||||
'transition-colors duration-150',
|
||||
error
|
||||
? 'border-destructive focus:ring-destructive'
|
||||
: 'border-input'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
disabled={disabled}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
className={cn(
|
||||
'absolute right-3 top-1/2 -translate-y-1/2',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring rounded',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import type { QPossibleValue } from '@/types'
|
||||
|
||||
interface SelectFieldProps {
|
||||
id: string
|
||||
label: string
|
||||
registration: UseFormRegisterReturn
|
||||
options: QPossibleValue[]
|
||||
error?: FieldError
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
placeholder?: string
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
id,
|
||||
label,
|
||||
registration,
|
||||
options,
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
placeholder,
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: SelectFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
{...registration}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
|
||||
'bg-background',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
|
||||
'transition-colors duration-150',
|
||||
error
|
||||
? 'border-destructive focus:ring-destructive'
|
||||
: 'border-input'
|
||||
)}
|
||||
>
|
||||
{!required && (
|
||||
<option value="">
|
||||
{placeholder ?? `-- Select ${label} --`}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={String(option.id)} value={String(option.id)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
interface TextFieldProps {
|
||||
id: string
|
||||
label: string
|
||||
registration: UseFormRegisterReturn
|
||||
error?: FieldError
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
maxLength?: number
|
||||
required?: boolean
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
id,
|
||||
label,
|
||||
registration,
|
||||
error,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
maxLength,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: TextFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
{...registration}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
|
||||
'bg-background',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
|
||||
'transition-colors duration-150',
|
||||
error
|
||||
? 'border-destructive focus:ring-destructive'
|
||||
: 'border-input'
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Tests for Banner component
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
|
||||
import BannerComponent from './Banner'
|
||||
import type { Banner } from '@/types'
|
||||
|
||||
describe('Banner', () => {
|
||||
const banners: Record<string, Banner> = {
|
||||
QFMD_TOP_OF_SITE: {
|
||||
text: 'This is a test environment',
|
||||
severity: 'warning',
|
||||
dismissible: true,
|
||||
},
|
||||
}
|
||||
|
||||
it('should render banner text', () => {
|
||||
render(<BannerComponent banners={banners} />)
|
||||
expect(screen.getByText('This is a test environment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have role="alert"', () => {
|
||||
render(<BannerComponent banners={banners} />)
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dismiss button for dismissible banners', () => {
|
||||
render(<BannerComponent banners={banners} />)
|
||||
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide banner after dismiss', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BannerComponent banners={banners} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }))
|
||||
expect(screen.queryByText('This is a test environment')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show dismiss button for non-dismissible banners', () => {
|
||||
const nonDismissibleBanners: Record<string, Banner> = {
|
||||
QFMD_TOP_OF_SITE: {
|
||||
text: 'Persistent banner',
|
||||
severity: 'info',
|
||||
dismissible: false,
|
||||
},
|
||||
}
|
||||
render(<BannerComponent banners={nonDismissibleBanners} />)
|
||||
expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onDismiss callback when dismissed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDismiss = vi.fn()
|
||||
render(<BannerComponent banners={banners} onDismiss={onDismiss} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }))
|
||||
expect(onDismiss).toHaveBeenCalledWith('QFMD_TOP_OF_SITE')
|
||||
})
|
||||
|
||||
it('should render nothing for empty banners', () => {
|
||||
const { container } = render(<BannerComponent banners={{}} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should have data-qqq-id on banner element', () => {
|
||||
render(<BannerComponent banners={banners} />)
|
||||
expect(screen.getByRole('alert').closest('[data-qqq-id]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render multiple banners', () => {
|
||||
const multiBanners: Record<string, Banner> = {
|
||||
banner1: { text: 'First banner', severity: 'info', dismissible: false },
|
||||
banner2: { text: 'Second banner', severity: 'warning', dismissible: true },
|
||||
}
|
||||
render(<BannerComponent banners={multiBanners} />)
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||