Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52153e889e | ||
|
|
18a6bbc570 | ||
|
|
6fc7ecee8b |
No files matched your search
@@ -0,0 +1,112 @@
|
||||
---
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@dev:snapshot
|
||||
|
||||
executors:
|
||||
playwright:
|
||||
docker:
|
||||
- image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
|
||||
jobs:
|
||||
playwright_tests:
|
||||
executor: playwright
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- npm-deps-{{ checksum "package-lock.json" }}
|
||||
- npm-deps-
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: npm ci --legacy-peer-deps
|
||||
- save_cache:
|
||||
key: npm-deps-{{ checksum "package-lock.json" }}
|
||||
paths:
|
||||
- node_modules
|
||||
- run:
|
||||
name: Run Playwright e2e tests
|
||||
command: npx playwright test
|
||||
- store_artifacts:
|
||||
path: playwright-report/
|
||||
destination: playwright-report
|
||||
- store_test_results:
|
||||
path: test-results/
|
||||
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- playwright_tests:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release/.*|hotfix/.*)/
|
||||
tags:
|
||||
only: []
|
||||
- qqq-orb/mvn_frontend_test_only:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release/.*|hotfix/.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- playwright_tests:
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
requires:
|
||||
- playwright_tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
|
||||
publish_feature:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: feature
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- feature/.*
|
||||
tags:
|
||||
only: [/publish.*/]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /hotfix\/.*/
|
||||
@@ -1,12 +0,0 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
# Selenium ITs need the Chrome-capable image; the React build is packaged into the jar.
|
||||
browsers: true
|
||||
node-version: '22.20.0'
|
||||
prebuild: bash scripts/ci-prebuild.sh
|
||||
@@ -1 +0,0 @@
|
||||
* @KofTwentyTwo
|
||||
@@ -43,7 +43,7 @@ This repository does NOT contain:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: LTS version (20.x or higher)
|
||||
- **Node.js**: LTS version (18.x or higher)
|
||||
- **npm**: 8.x or higher
|
||||
- **Git**: For version control
|
||||
- **QQQ Server**: Running instance for testing
|
||||
@@ -154,7 +154,7 @@ When reporting frontend-specific issues, include:
|
||||
|
||||
## 📄 License
|
||||
|
||||
By contributing to this project, you agree that your contributions will be licensed under the same license as the project (Apache License, Version 2.0).
|
||||
By contributing to this project, you agree that your contributions will be licensed under the same license as the project (GNU Affero General Public License v3.0).
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# GitHub Release Configuration
|
||||
# Auto-generates release notes from PR labels
|
||||
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
- duplicate
|
||||
- invalid
|
||||
authors:
|
||||
- dependabot
|
||||
- dependabot[bot]
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
- breaking-change
|
||||
- breaking
|
||||
- title: New Features
|
||||
labels:
|
||||
- enhancement
|
||||
- feature
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- bug
|
||||
- fix
|
||||
- title: Performance Improvements
|
||||
labels:
|
||||
- performance
|
||||
- title: Documentation
|
||||
labels:
|
||||
- documentation
|
||||
- docs
|
||||
- title: Dependencies
|
||||
labels:
|
||||
- dependencies
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
||||
@@ -1,23 +0,0 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
|
||||
# Reviewed findings committed before the Gitea migration; exact historical fingerprints only.
|
||||
267580b44babf4935c152da8fa2320d6059105f3:src/qqq/components/processes/GoogleDriveFolderPicker.tsx:gcp-api-key:43
|
||||
@@ -7,7 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [0.35.0] - 2025-12-29
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -49,10 +49,10 @@ MUI components + generated screens
|
||||
|
||||
### Key Entry Points
|
||||
|
||||
- `src/App.tsx` - Main app, authentication, route generation from metadata
|
||||
- `src/App.tsx` - Main app, authentication, route generation from metadata, theme provider, BrandedHeaderBar
|
||||
- `src/QContext.tsx` - Global state (accentColor, tableMetaData, modalStack, branding)
|
||||
- `src/qqq/utils/qqq/Client.ts` - Singleton wrapper for `@qrunio/qqq-frontend-core` API client
|
||||
- `src/qqq/components/legacy/Theme.ts` - MUI theme configuration
|
||||
- `src/qqq/utils/themeUtils.ts` - Theme CSS variable injection and font loading
|
||||
|
||||
### Source Structure
|
||||
|
||||
@@ -62,7 +62,7 @@ src/qqq/
|
||||
│ ├── base/ # Design tokens: colors.ts, typography.ts, boxShadows.ts
|
||||
│ └── components/ # 55 MUI component style overrides
|
||||
├── components/
|
||||
│ ├── horseshoe/ # Core layout: SideNav, NavBar, Footer, Breadcrumbs
|
||||
│ ├── horseshoe/ # Core layout: SideNav, NavBar, Footer, Breadcrumbs, BrandedHeaderBar
|
||||
│ ├── forms/ # DynamicForm, EntityForm, field components
|
||||
│ ├── widgets/ # Dashboard widgets, charts, statistics
|
||||
│ ├── query/ # Table filtering components
|
||||
@@ -85,15 +85,127 @@ Routes are dynamically created from `QInstance.appTree` metadata in `App.tsx`:
|
||||
- Tables get CRUD routes: `/app/{table}`, `/app/{table}/create`, `/app/{table}/:id`, `/app/{table}/:id/edit`
|
||||
- Processes get execution routes under their parent table or app
|
||||
|
||||
## Pluggable Themes System (Implemented)
|
||||
|
||||
### Overview
|
||||
|
||||
The dashboard supports **runtime theme customization** via CSS custom properties. Theme configuration is defined in `QThemeMetaData` on the Java backend and injected as `--qqq-*` CSS variables by `themeUtils.ts`.
|
||||
|
||||
### Theme Configuration (Java Backend)
|
||||
|
||||
```java
|
||||
qInstance.setTheme(new QThemeMetaData()
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSidebarBackgroundColor("#1E1E2D")
|
||||
.withBrandedHeaderEnabled(true)
|
||||
.withBrandedHeaderTagline("My Application"));
|
||||
```
|
||||
|
||||
### Available Theme Properties (60+)
|
||||
|
||||
| Category | Properties |
|
||||
|----------|------------|
|
||||
| **Core Colors** | `primaryColor`, `secondaryColor`, `backgroundColor`, `surfaceColor`, `textPrimary`, `textSecondary`, `errorColor`, `warningColor`, `successColor`, `infoColor` |
|
||||
| **Typography Base** | `fontFamily`, `headerFontFamily`, `monoFontFamily`, `fontSizeBase`, `fontWeightLight`, `fontWeightRegular`, `fontWeightMedium`, `fontWeightBold` |
|
||||
| **Typography Variants** | `typographyH1FontSize`, `typographyH1FontWeight`, `typographyH1LineHeight`, `typographyH1LetterSpacing`, `typographyH1TextTransform` (same pattern for H2-H6, Body1, Body2, Button, Caption) |
|
||||
| **Branded Header** | `brandedHeaderEnabled`, `brandedHeaderBackgroundColor`, `brandedHeaderTextColor`, `brandedHeaderLogoPath`, `brandedHeaderLogoAltText`, `brandedHeaderHeight`, `brandedHeaderTagline` |
|
||||
| **App Bar** | `appBarBackgroundColor`, `appBarTextColor` |
|
||||
| **Sidebar** | `sidebarBackgroundColor`, `sidebarTextColor`, `sidebarIconColor`, `sidebarSelectedBackgroundColor`, `sidebarSelectedTextColor`, `sidebarHoverBackgroundColor`, `sidebarDividerColor` |
|
||||
| **Tables** | `tableHeaderBackgroundColor`, `tableHeaderTextColor`, `tableRowHoverColor`, `tableRowSelectedColor`, `tableBorderColor` |
|
||||
| **General** | `dividerColor`, `borderColor`, `cardBorderColor`, `borderRadius`, `density`, `iconStyle` |
|
||||
| **Assets** | `logoPath`, `iconPath`, `faviconPath` |
|
||||
| **Custom** | `customCss` (raw CSS injection) |
|
||||
|
||||
### CSS Variables
|
||||
|
||||
All properties become CSS variables with `--qqq-` prefix:
|
||||
- `primaryColor` → `--qqq-primary-color`
|
||||
- `sidebarBackgroundColor` → `--qqq-sidebar-background-color`
|
||||
|
||||
Additional generated variables:
|
||||
- Grey scale: `--qqq-grey-100` through `--qqq-grey-900`
|
||||
- Charts: `--qqq-chart-grid-color`, `--qqq-chart-text-color`, `--qqq-chart-background-color`
|
||||
- Links: `--qqq-link-color`
|
||||
- Spacing: `--qqq-spacing-base`, `--qqq-spacing-small`, `--qqq-spacing-medium`, `--qqq-spacing-large`
|
||||
- Actions: `--qqq-action-active`, `--qqq-action-hover`, `--qqq-action-selected`, `--qqq-action-disabled`
|
||||
- Status variants: `--qqq-info-light`, `--qqq-info-dark`, etc.
|
||||
|
||||
Component-specific variables (override via customCss):
|
||||
- Stepper: `--qqq-stepper-inactive-color`
|
||||
- Tooltip: `--qqq-tooltip-background-color`, `--qqq-tooltip-text-color`, `--qqq-tooltip-shadow`
|
||||
- Inputs: `--qqq-input-border-color`
|
||||
- Menu: `--qqq-menu-hover-color`
|
||||
- Switch: `--qqq-switch-track-color`
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/qqq/utils/themeUtils.ts` - CSS variable injection, font loading
|
||||
- `src/qqq/components/horseshoe/BrandedHeaderBar.tsx` - Optional header banner
|
||||
- `src/qqq/styles/qqq-override-styles.css` - CSS variable overrides for MUI components
|
||||
|
||||
### Documentation
|
||||
|
||||
- `docs/QQQ_THEMING_GUIDE.md` - Complete theming reference for LLMs/developers
|
||||
- `docs/QQQ_CSS_SELECTORS_GUIDE.md` - CSS selector reference for targeting UI elements
|
||||
|
||||
## CSS Selectors System (Implemented)
|
||||
|
||||
### Overview
|
||||
|
||||
All major UI elements have stable `data-qqq-id` attributes for targeted styling via `QThemeMetaData.customCss`. IDs are generated from element names using `sanitizeId()` which converts to lowercase and replaces non-alphanumeric characters with hyphens.
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/qqq/utils/qqqIdUtils.ts` - ID sanitization utilities
|
||||
- `src/qqq/context/QqqIdContext.tsx` - Optional scope context provider
|
||||
|
||||
### Selector Patterns
|
||||
|
||||
| Element | Pattern | Example |
|
||||
|---------|---------|---------|
|
||||
| Buttons | `button-{text}` | `button-save` |
|
||||
| Icon buttons | `button-icon-{icon}` | `button-icon-delete` |
|
||||
| Menu items | `menu-item-{text}` | `menu-item-bulk-edit` |
|
||||
| Nav items | `sidenav-{name}` | `sidenav-orders` |
|
||||
| Inputs | `input-{field}` | `input-first-name` |
|
||||
| Selects | `select-{field}` | `select-status` |
|
||||
| Table headers | `table-header-{field}` | `table-header-order-id` |
|
||||
| Record view | `record-view-{element}-{table}` | `record-view-header-orders` |
|
||||
| Record create | `record-create-{element}-{table}` | `record-create-title-person` |
|
||||
| Record edit | `record-edit-{element}-{table}` | `record-edit-avatar-customer` |
|
||||
| Sections | `record-section-{name}` | `record-section-identity` |
|
||||
| Form sections | `form-section-{name}` | `form-section-details` |
|
||||
| Sidebar items | `sidebar-item-{name}` | `sidebar-item-identity` |
|
||||
| Table cards | `table-card-{name}` | `table-card-orders` |
|
||||
| Process cards | `process-card-{name}` | `process-card-bulk-load` |
|
||||
| App headers | `app-header-{name}` | `app-header-inventory` |
|
||||
|
||||
### Example CSS Usage
|
||||
|
||||
```css
|
||||
/* Hide delete menu item */
|
||||
[data-qqq-id="menu-item-delete"] { display: none; }
|
||||
|
||||
/* Style specific table view header */
|
||||
[data-qqq-id="record-view-header-orders"] {
|
||||
background: linear-gradient(to right, #1976D2, #42A5F5);
|
||||
}
|
||||
|
||||
/* Style all sidebar items */
|
||||
[data-qqq-id^="sidebar-item-"] {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Repository Dependencies
|
||||
|
||||
This project is part of a three-repo system:
|
||||
|
||||
| Repository | Artifact | Current Version |
|
||||
|------------|----------|-----------------|
|
||||
| qqq (backend-core) | `com.kingsrook.qqq:qqq-backend-core` | `0.36.0` |
|
||||
| qqq-frontend-core | `@qrunio/qqq-frontend-core` | `1.4.2` |
|
||||
| qqq-frontend-material-dashboard | `com.kingsrook.qqq:qqq-frontend-material-dashboard` | `0.36.0-RC.4` |
|
||||
| qqq (backend-core) | `com.kingsrook.qqq:qqq-backend-core` | `0.40.0-SNAPSHOT` |
|
||||
| qqq-frontend-core | `@qrunio/qqq-frontend-core` | `1.4.2-SNAPSHOT` |
|
||||
| qqq-frontend-material-dashboard | `com.kingsrook.qqq:qqq-frontend-material-dashboard` | `0.40.0-SNAPSHOT` |
|
||||
|
||||
**Dependency order for commits/publishes:** backend-core → frontend-core → dashboard
|
||||
|
||||
@@ -171,31 +283,58 @@ QQQ backend supports a `material-dashboard-overlay/` directory for serving custo
|
||||
|
||||
3. **Frontend resolution**: Assets are served at web root, and `resolveAssetUrl()` in `PathUtils.ts` handles base path resolution.
|
||||
|
||||
### What the Overlay Does vs. What Pluggable Themes Does
|
||||
|
||||
| Capability | Overlay | Pluggable Themes |
|
||||
|------------|---------|------------------|
|
||||
| Logo/icon | Yes | Yes |
|
||||
| Single accent color | Yes | Yes |
|
||||
| Full color palette | No | Yes |
|
||||
| Typography/fonts | No | Yes |
|
||||
| Spacing system | No | Yes |
|
||||
| Component styles | No | Yes |
|
||||
| Dark mode | No | Yes |
|
||||
| Runtime switching | Partial (accentColor) | Full |
|
||||
|
||||
The overlay system handles **branding assets** (logos, icons). Pluggable themes handles **comprehensive styling** (colors, typography, shadows, component overrides).
|
||||
|
||||
## Testing
|
||||
|
||||
### Visual Regression Tests (Playwright)
|
||||
|
||||
18 Playwright screenshot tests verify UI appearance doesn't regress. Uses **dual-platform snapshots** for CI (Linux) and local development (macOS).
|
||||
### Unified Test Runner
|
||||
|
||||
```bash
|
||||
# Run visual regression tests (uses platform-appropriate snapshots automatically)
|
||||
# Run all tests (Playwright + Selenium)
|
||||
./run-tests.sh
|
||||
|
||||
# Run only Playwright tests
|
||||
./run-tests.sh --playwright
|
||||
|
||||
# Run only Selenium tests
|
||||
./run-tests.sh --selenium
|
||||
|
||||
# Run with visible browser
|
||||
QQQ_SELENIUM_HEADLESS=false ./run-tests.sh --selenium
|
||||
```
|
||||
|
||||
### Playwright E2E Tests
|
||||
|
||||
Playwright tests verify theme CSS variable injection and UI behavior.
|
||||
|
||||
```bash
|
||||
# Run Playwright tests with fixture server
|
||||
npm run e2e
|
||||
|
||||
# Regenerate Linux snapshots (for CI) - requires Docker
|
||||
./scripts/update-snapshots-linux.sh
|
||||
# Run with UI mode for debugging
|
||||
npm run test:e2e:ui
|
||||
|
||||
# Regenerate macOS snapshots (for local dev)
|
||||
./scripts/update-snapshots-mac.sh
|
||||
# Run with visible browser
|
||||
npm run test:e2e:headed
|
||||
```
|
||||
|
||||
Key files:
|
||||
- `playwright.config.ts` - Configuration with `{platform}` in snapshot paths
|
||||
- `playwright.config.ts` - Configuration with dual web servers
|
||||
- `e2e/fixture-server.js` - Node.js server for fixture JSON (port 8001)
|
||||
- `e2e/tests/visual-regression.spec.ts` - 18 screenshot tests
|
||||
- `e2e/snapshots/.../linux/` - Linux snapshots (for CI)
|
||||
- `e2e/snapshots/.../darwin/` - macOS snapshots (for local dev)
|
||||
|
||||
**Dual-Platform Workflow:** Playwright automatically selects the correct snapshots based on platform. Update both when making visual changes.
|
||||
- `e2e/tests/theme.spec.ts` - Theme verification tests (26 tests)
|
||||
|
||||
### Selenium Integration Tests
|
||||
|
||||
@@ -213,11 +352,14 @@ Key test classes:
|
||||
- `QBaseSeleniumTest` - Base class, starts Javalin on 8001
|
||||
- `QSeleniumJavalin` - Serves fixture JSON from `src/test/resources/fixtures/`
|
||||
- `ServerHealthChecker` - Waits for React server readiness
|
||||
- `ThemeIT` - Tests theme CSS variable injection
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
Located in `src/test/resources/fixtures/metaData/`:
|
||||
- `index.json` - Default metaData response (used by visual regression tests)
|
||||
- `index.json` - Default metaData response
|
||||
- `withFullCustomTheme.json` - Theme with all properties set
|
||||
- `withBrandedHeader.json` - Theme with branded header enabled
|
||||
|
||||
### CI/CD
|
||||
|
||||
@@ -236,74 +378,38 @@ Read these files in order:
|
||||
2. `docs/TODO.md` - Active and completed tasks
|
||||
3. This file (`CLAUDE.md`) - Project context
|
||||
|
||||
### Current Status (as of 2026-01-14)
|
||||
### Current Status (as of 2026-01-10)
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Branch | `feature/fix-visual-regressions-128` |
|
||||
| Branch | `feature/consolidate-playwright-tests` |
|
||||
| Version | `0.40.0-SNAPSHOT` |
|
||||
| Latest Commit | `12934bb` - fix: align typography fallbacks with production typography.ts (#128) |
|
||||
| Status | **READY FOR REVIEW** - All typography fixes verified, waiting on Darin |
|
||||
| GitHub Issue | [#128](https://github.com/QRun-IO/qqq-frontend-material-dashboard/issues/128) |
|
||||
| Latest Commit | `6fc7ece` - feat(test): add strict Playwright tests |
|
||||
| CI Status | **WIP** - CI fix in progress |
|
||||
|
||||
### Active Work: Issue #128 Visual Regressions (Round 5 - COMPLETE)
|
||||
### Active Work
|
||||
|
||||
**Status:** All fixes complete and verified, waiting on Darin's approval
|
||||
**Branch:** `feature/consolidate-playwright-tests`
|
||||
|
||||
**Root Cause Identified:** The `createDynamicTheme.ts` fallback values and `DEFAULT_THEME` values didn't match the original `typography.ts` source of truth. The original `Theme.ts` used `typography: {...typography}` (direct spread), so production always used `typography.ts` values directly.
|
||||
|
||||
**What was fixed:**
|
||||
- Round 1-3: CSS scoping with `.qqq-themed` class
|
||||
- Round 4: Added CSS variable fallbacks to all `var(--qqq-*)` instances
|
||||
- Round 5: Aligned ALL 32 typography fallbacks with `typography.ts`
|
||||
|
||||
**Key Typography Corrections (verified against typography.ts):**
|
||||
| Property | Before (WRONG) | After (CORRECT) | Source |
|
||||
|----------|---------------|-----------------|--------|
|
||||
| textPrimary | #344767 | #212121 | colors.ts dark.main |
|
||||
| fontWeightMedium | 500 | 600 | typography.ts:156 |
|
||||
| H3 fontSize/weight | 1.5rem/700 | 1.75rem/600 | typography.ts:202-205 |
|
||||
| H6 fontSize/weight | 0.875rem/600 | 1.125rem/500 | typography.ts:221-224 |
|
||||
| body2 weight | 400 | 300 | typography.ts:251 |
|
||||
| button weight | 500 | 300 | typography.ts:258 |
|
||||
| caption weight | 400 | 300 | typography.ts:266 |
|
||||
|
||||
**Next steps:**
|
||||
1. Wait for Darin to test on branch `feature/fix-visual-regressions-128`
|
||||
2. If approved, create PR to merge into develop
|
||||
3. Publish snapshot after merge
|
||||
Porting Selenium tests to Playwright with strict assertions:
|
||||
- 46 tests passing (1 skipped)
|
||||
- QueryScreenIT, BulkEditIT, app navigation tests ported
|
||||
- CI fix in progress (React dev server timeout in Docker)
|
||||
|
||||
### Test Status
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright themed | 26 | PASS |
|
||||
| Playwright unthemed | 13 | PASS |
|
||||
| Playwright e2e | 46 | PASS (1 skipped) |
|
||||
| Selenium fixture-based | 115 | PASS (not ported) |
|
||||
| Java unit | 3 | PASS |
|
||||
|
||||
### Key Files for Theme System
|
||||
### Key Technical Discoveries
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/qqq/assets/theme/base/typography.ts` | **SOURCE OF TRUTH** for typography values |
|
||||
| `src/qqq/assets/theme/base/colors.ts` | Color definitions (dark.main = #212121) |
|
||||
| `src/qqq/utils/createDynamicTheme.ts` | Builds MUI theme from QThemeMetaData |
|
||||
| `src/qqq/utils/themeUtils.ts` | DEFAULT_THEME values, CSS variable injection |
|
||||
| `src/qqq/components/legacy/Theme.ts` | Original theme (spreads typography.ts directly) |
|
||||
|
||||
### Running Locally for Testing
|
||||
|
||||
```bash
|
||||
# Unthemed (original MUI styling, no CSS variable overrides)
|
||||
THEME_FIXTURE=index npm run fixture-server &
|
||||
HTTPS=true PORT=3000 REACT_APP_PROXY_LOCALHOST_PORT=8001 npm start
|
||||
|
||||
# Themed (full theme applied with CSS variables)
|
||||
THEME_FIXTURE=withFullCustomTheme npm run fixture-server &
|
||||
HTTPS=true PORT=3000 REACT_APP_PROXY_LOCALHOST_PORT=8001 npm start
|
||||
|
||||
# Run all e2e tests (themed + unthemed)
|
||||
npm run e2e:all
|
||||
```
|
||||
1. **Process `nextStep` field** - Frontend requires this to know which step to render
|
||||
2. **MUI Autocomplete inputs** - Don't have `type="text"` attribute, use `input` selector
|
||||
3. **Filter popup behavior** - Escape cancels, clicking outside applies
|
||||
4. **Role-based selectors** - More stable than CSS class selectors
|
||||
|
||||
### Key Documentation Files
|
||||
|
||||
@@ -311,25 +417,17 @@ npm run e2e:all
|
||||
|------|---------|
|
||||
| `docs/SESSION_STATE.md` | Current work status, branch, version, recent commits |
|
||||
| `docs/TODO.md` | Task tracking for current feature |
|
||||
| `docs/PLAN-playwright-consolidation.md` | Test consolidation plan |
|
||||
| `docs/QQQ_THEMING_GUIDE.md` | Complete theming reference |
|
||||
| `docs/QQQ_CSS_SELECTORS_GUIDE.md` | CSS selector patterns reference |
|
||||
| `docs/THEME_TESTING_GUIDE.md` | Guide for testing theme features |
|
||||
|
||||
### Publishing
|
||||
### Publishing Snapshots
|
||||
|
||||
Release candidates publish automatically on `release/*` branches via CircleCI `publish_release_candidate` workflow.
|
||||
Snapshots publish automatically when merged to `develop` via CircleCI `publish_snapshot` workflow.
|
||||
|
||||
Snapshots publish on `develop` via `publish_snapshot` workflow.
|
||||
Manual publish using gitops script:
|
||||
```bash
|
||||
/Users/james.maes/.local/bin/gitops-publish.sh
|
||||
```
|
||||
|
||||
## Knowledge base
|
||||
|
||||
Durable cross-repo knowledge for QQQ lives in the second-brain vault
|
||||
(`$SECOND_BRAIN_VAULT`, i.e. `R:/Git.Local/KofTwentyTwo/second-brain`):
|
||||
|
||||
- Hub: `knowledge/qqq/qqq-hub.md`
|
||||
- This repo's dossier: `knowledge/qqq/repos/qqq-frontend-material-dashboard.md`
|
||||
(includes the full server-API consumption inventory, legacy vs `/qqq/v1`)
|
||||
|
||||
Dossier reviewed at commit `628e36f64e9a` on branch `develop` (2026-07-04).
|
||||
Note: the cross-repo version table earlier in this file is stale (0.36-era);
|
||||
trust `pom.xml` / `package.json` for current pins.
|
||||
This creates a `publish-{commit}` tag that triggers CI/CD to build and publish the JAR.
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
> ℹ️ **Future direction:** The qqq admin UI is being rewritten as [qqq-frontend-next](https://github.com/QRun-IO/qqq-frontend-next) (Next.js 15 + Tailwind 4 + shadcn/ui). `qqq-frontend-material-dashboard` remains the supported choice today and will continue to receive fixes until existing qqq apps complete migration to `qqq-frontend-next`. New qqq apps starting in 2026 should evaluate `qqq-frontend-next` first.
|
||||
|
||||
React admin dashboard for QQQ applications.
|
||||
|
||||
**For:** Teams using QQQ who want a ready-to-use admin UI without writing frontend code
|
||||
**Status:** Stable (on long-term deprecation track — see banner above)
|
||||
**Status:** Stable
|
||||
|
||||
## Why This Exists
|
||||
|
||||
@@ -32,7 +30,7 @@ If you need a custom UI, use [qqq-frontend-core](https://github.com/QRun-IO/qqq-
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Prerequisites:** Node.js 20+, running QQQ backend
|
||||
**Prerequisites:** Node.js 18+, running QQQ backend
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QRun-IO/qqq-frontend-material-dashboard
|
||||
@@ -81,26 +79,6 @@ npm run build
|
||||
mvn clean package -Pci
|
||||
```
|
||||
|
||||
### Verify published JAR notices
|
||||
|
||||
After staging the built UI in `src/main/resources/material-dashboard/` (as the
|
||||
publishing orb does), package with the `release` profile to produce main, sources,
|
||||
tests and Javadoc JARs. Before publication, run the archive guard with the actual
|
||||
version's archive prefix:
|
||||
|
||||
```bash
|
||||
mvn -Prelease -DskipTests -Dgpg.skip=true clean package
|
||||
./scripts/check-packaged-notices.py target/qqq-frontend-material-dashboard-0.41.0-SNAPSHOT
|
||||
```
|
||||
|
||||
This command verifies packaging only: it skips runtime tests and does not sign
|
||||
or publish. Existing runtime and hosted release gates are still required.
|
||||
The guard requires Python 3 and checks that every JAR contains exactly one
|
||||
`META-INF/LICENSE` and `META-INF/NOTICE`, byte-identical to the root files. It exits
|
||||
nonzero for missing archives, missing/duplicate entries or altered text. This is
|
||||
an explicit post-package check; the unchanged Circle orb does not invoke it
|
||||
automatically. It supplements the existing runtime and hosted release gates.
|
||||
|
||||
## Project Status
|
||||
|
||||
**Maturity:** Stable, production use
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
## Goal
|
||||
|
||||
Add stable, semantic CSS selectors so QQQ app developers can target specific UI elements in
|
||||
`MaterialDashboardThemeMetaData.customCss`.
|
||||
Add stable, semantic CSS selectors so QQQ app developers can target specific UI elements in `QThemeMetaData.customCss`.
|
||||
|
||||
## Approach
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# PLAN: Fix Visual Regressions (Issue #128) - COMPLETE
|
||||
|
||||
**Status:** COMPLETE - Waiting on Darin to test
|
||||
**Branch:** `feature/fix-visual-regressions-128`
|
||||
**Latest Commit:** `d4251be`
|
||||
|
||||
## Goal
|
||||
Fix visual regressions caused by PR #125 pluggable themes so that unthemed apps look identical to pre-PR-125.
|
||||
|
||||
## Solution Summary
|
||||
|
||||
The key fix was **scoping CSS override rules to themed apps only**:
|
||||
1. Added `.qqq-themed` class toggle in `injectIslandVariables.ts`
|
||||
2. Scoped all CSS rules in `qqq-override-styles.css` to `.qqq-themed`
|
||||
3. Fixed e2e test infrastructure (`setupProxy.js` missing `/metaData` route)
|
||||
4. Fixed fixture structure (theme must be under `supplementalInstanceMetaData.theme`)
|
||||
|
||||
### Key Insight
|
||||
`QInstance` class in `@qrunio/qqq-frontend-core` only looks for theme at `object.supplementalInstanceMetaData["theme"]`, NOT at root level.
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/qqq/utils/injectIslandVariables.ts` | Added `.qqq-themed` class toggle |
|
||||
| `src/qqq/styles/qqq-override-styles.css` | Scoped rules to `.qqq-themed` |
|
||||
| `src/setupProxy.js` | Added `/metaData` route |
|
||||
| `src/test/resources/fixtures/metaData/withFullCustomTheme.json` | Fixed theme location |
|
||||
|
||||
## Test Results
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright themed | 26 | PASS |
|
||||
| Playwright unthemed | 13 | PASS |
|
||||
| Selenium fixture-based | 115 | PASS |
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Wait for Darin's approval on issue #128
|
||||
2. Create PR to merge into develop
|
||||
3. Publish snapshot after merge
|
||||
@@ -0,0 +1,80 @@
|
||||
# Plan: Consolidate Test Infrastructure to Playwright
|
||||
|
||||
## Goal
|
||||
|
||||
Consolidate from dual test frameworks (Playwright + Selenium) to Playwright-only, fixing CI timeout and simplifying the architecture.
|
||||
|
||||
## Current State
|
||||
|
||||
| Component | Technology | Count |
|
||||
|-----------|------------|-------|
|
||||
| Playwright | TypeScript | 46 tests (strict assertions) |
|
||||
| Selenium | Java/JUnit | ~115 tests (15 IT classes) |
|
||||
| Node.js fixture server | fixture-server.js | For Playwright |
|
||||
| Java Javalin mock | QSeleniumJavalin.java | For Selenium |
|
||||
|
||||
**CI Issue:** React dev server takes >120s to compile in Docker, exceeding Playwright's webserver timeout.
|
||||
|
||||
## Approach
|
||||
|
||||
**Phased migration** - Fix CI first, then incrementally port Selenium tests to Playwright.
|
||||
|
||||
### Phase 1: Fix CI (IN PROGRESS)
|
||||
|
||||
**Strategy:** Enhance fixture-server.js to serve static React build + API fixtures from single server in CI.
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **e2e/fixture-server.js** - Add static file serving with SPA fallback when `BUILD_DIR` env var is set
|
||||
2. **playwright.config.ts** - Conditional config: single server in CI, dual servers locally
|
||||
3. **.circleci/config.yml** - Add `npm run build` step before Playwright tests
|
||||
|
||||
**Status:** WIP - Static file serving implemented but tests timeout in CI mode. Needs debugging.
|
||||
|
||||
### Phase 2: Port Critical Selenium Tests (COMPLETE)
|
||||
|
||||
Ported tests:
|
||||
- QueryScreenIT (13 tests) - Query filtering, boolean operators
|
||||
- BulkEditIT (1 test) - Bulk edit workflow with strict step verification
|
||||
- App navigation (6 tests) - Sidebar, breadcrumbs, navigation
|
||||
|
||||
### Phase 3: Remove Selenium Infrastructure (Future)
|
||||
|
||||
- Delete `src/test/java/**/selenium/`
|
||||
- Remove Selenium dependencies from pom.xml
|
||||
- Simplify run-tests.sh
|
||||
|
||||
## Test Status
|
||||
|
||||
| Suite | Passed | Skipped | Notes |
|
||||
|-------|--------|---------|-------|
|
||||
| theme.spec.ts | 26 | 0 | Theme CSS variables |
|
||||
| query-screen.spec.ts | 12 | 1 | OR filter skipped |
|
||||
| bulk-edit.spec.ts | 1 | 0 | Full workflow |
|
||||
| app-navigation.spec.ts | 6 | 0 | Navigation tests |
|
||||
| saved-report.spec.ts | 1 | 0 | Report creation |
|
||||
| **Total** | **46** | **1** | |
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `e2e/lib/query-screen.ts` | QueryScreen helper class |
|
||||
| `e2e/tests/bulk-edit.spec.ts` | Strict bulk edit test |
|
||||
| `e2e/tests/app-navigation.spec.ts` | Navigation tests |
|
||||
| `e2e/tests/query-screen.spec.ts` | Query filter tests |
|
||||
| `e2e/fixture-server.js` | Process step state tracking |
|
||||
|
||||
## Key Technical Discoveries
|
||||
|
||||
1. **Process step progression** requires `nextStep` field in fixture responses
|
||||
2. **MUI Autocomplete inputs** don't have `type="text"` attribute - use `input` selector
|
||||
3. **Filter popup behavior**: Escape cancels, clicking outside applies
|
||||
4. **Role-based selectors** preferred over CSS class selectors for stability
|
||||
5. **Regex patterns** need `\s+` for variable spacing in query strings
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Debug CI timeout issue with static file serving
|
||||
2. Port remaining Selenium tests as time permits
|
||||
3. Remove Selenium infrastructure once fully migrated
|
||||
@@ -95,10 +95,10 @@ New file: `src/qqq/utils/createDynamicTheme.ts`
|
||||
|
||||
```typescript
|
||||
import { createTheme, Theme } from "@mui/material";
|
||||
import { MaterialDashboardThemeMetaData } from "qqq/models/metadata/MaterialDashboardThemeMetaData";
|
||||
import { QThemeMetaData } from "@qrunio/qqq-frontend-core/lib/model/metaData/QThemeMetaData";
|
||||
import { DEFAULT_THEME } from "./themeUtils";
|
||||
|
||||
export function createDynamicTheme(themeData?: MaterialDashboardThemeMetaData): Theme {
|
||||
export function createDynamicTheme(themeData?: QThemeMetaData): Theme {
|
||||
// Merge with defaults
|
||||
const theme = { ...DEFAULT_THEME, ...filterUndefined(themeData) };
|
||||
|
||||
@@ -152,12 +152,12 @@ New file: `src/qqq/utils/injectIslandVariables.ts`
|
||||
This injects CSS variables ONLY for sidebar, branded header, and non-MUI components.
|
||||
|
||||
```typescript
|
||||
import { MaterialDashboardThemeMetaData } from "qqq/models/metadata/MaterialDashboardThemeMetaData";
|
||||
import { QThemeMetaData } from "@qrunio/qqq-frontend-core/lib/model/metaData/QThemeMetaData";
|
||||
import { DEFAULT_THEME } from "./themeUtils";
|
||||
|
||||
const CSS_VAR_PREFIX = "--qqq-";
|
||||
|
||||
export function injectIslandVariables(themeData?: MaterialDashboardThemeMetaData): void {
|
||||
export function injectIslandVariables(themeData?: QThemeMetaData): void {
|
||||
const root = document.documentElement;
|
||||
const theme = { ...DEFAULT_THEME, ...filterUndefined(themeData) };
|
||||
|
||||
@@ -413,10 +413,10 @@ mvn verify -Pci
|
||||
|
||||
### New Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|-------------------------------------------------------|
|
||||
| `src/qqq/utils/createDynamicTheme.ts` | MUI theme builder from MaterialDashboardThemeMetaData |
|
||||
| `src/qqq/utils/injectIslandVariables.ts` | CSS vars for sidebar/header islands only |
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/qqq/utils/createDynamicTheme.ts` | MUI theme builder from QThemeMetaData |
|
||||
| `src/qqq/utils/injectIslandVariables.ts` | CSS vars for sidebar/header islands only |
|
||||
|
||||
### Files to Modify
|
||||
|
||||
@@ -444,7 +444,7 @@ mvn verify -Pci
|
||||
1. All 26 Playwright tests pass
|
||||
2. CSS override file reduced from 1213 to <400 lines
|
||||
3. `!important` count reduced from 82 to <15
|
||||
4. Theme data flows directly: MaterialDashboardThemeMetaData -> MUI Theme -> Components
|
||||
4. Theme data flows directly: QThemeMetaData -> MUI Theme -> Components
|
||||
5. No visual regressions in app
|
||||
6. CI pipeline passes
|
||||
|
||||
|
||||
@@ -81,21 +81,21 @@ This document outlines the architecture changes needed to support pluggable them
|
||||
Runtime-swappable themes via CSS variables injected from backend metadata.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ QQQ Backend │
|
||||
│ MaterialDashboardThemeMetaData: palette, typography, spacing, icons, shadows │
|
||||
└─────────────────────────┬───────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ QQQ Backend │
|
||||
│ QThemeMetaData: palette, typography, spacing, icons, shadows │
|
||||
└─────────────────────────┬───────────────────────────────────────┘
|
||||
│ /metaData endpoint (expanded)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ qqq-frontend-core │
|
||||
│ + MaterialDashboardThemeMetaData comes along in the QInstance │
|
||||
│ as supplementalMetaData │
|
||||
│ qqq-frontend-core │
|
||||
│ + QThemeMetaData class with full design token structure │
|
||||
│ + ThemeUtils for CSS variable generation │
|
||||
└─────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ qqq-frontend-material-dashboard │
|
||||
│ qqq-frontend-material-dashboard │
|
||||
│ ThemeProvider injects CSS variables to :root │
|
||||
│ All components consume var(--qqq-color-primary) etc. │
|
||||
│ MUI theme reads from CSS variables │
|
||||
@@ -131,7 +131,8 @@ Separate npm packages per theme, selected at build time.
|
||||
### 1. Design Token Schema
|
||||
|
||||
```typescript
|
||||
export interface MaterialDashboardThemeMetaData{
|
||||
// qqq-frontend-core: src/model/metaData/QThemeMetaData.ts
|
||||
export interface QThemeMetaData {
|
||||
name: string;
|
||||
|
||||
palette: {
|
||||
@@ -213,7 +214,7 @@ interface TypographyVariant {
|
||||
|
||||
```typescript
|
||||
// qqq-frontend-dashboard: src/qqq/theme/ThemeInjector.ts
|
||||
export function injectThemeVariables(theme: MaterialDashboardThemeMetaData): void {
|
||||
export function injectThemeVariables(theme: QThemeMetaData): void {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Colors
|
||||
@@ -598,7 +599,7 @@ Create a new repository `qqq-theme-default` with the following structure:
|
||||
"type": "git",
|
||||
"url": "https://github.com/QRun-IO/qqq-theme-default.git"
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
"license": "AGPL-3.0"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# QQQ CSS Selectors Guide
|
||||
|
||||
QQQ automatically generates stable `data-qqq-id` attributes on key UI elements, enabling precise CSS targeting via `MaterialDashboardThemeMetaData.customCss`.
|
||||
QQQ automatically generates stable `data-qqq-id` attributes on key UI elements, enabling precise CSS targeting via `QThemeMetaData.customCss`.
|
||||
|
||||
## ID Format
|
||||
|
||||
|
||||
@@ -6,22 +6,22 @@ This document explains the QQQ pluggable theming system for AI assistants and de
|
||||
|
||||
QQQ uses a **backend-driven theming model**:
|
||||
|
||||
1. **Java Backend**: Define theme in `MaterialDashboardThemeMetaData` object
|
||||
2. **Frontend Model**: TypeScript `MaterialDashboardThemeMetaData` interface mirrors Java class
|
||||
1. **Java Backend**: Define theme in `QThemeMetaData` object
|
||||
2. **Frontend Core**: TypeScript `QThemeMetaData` interface mirrors Java class
|
||||
3. **Frontend Dashboard**: `themeUtils.ts` injects CSS custom properties at runtime
|
||||
|
||||
Theme data flows: `MaterialDashboardThemeMetaData.java` → `/metaData` API → `MaterialDashboardThemeMetaData.ts` → CSS variables (`--qqq-*`)
|
||||
Theme data flows: `QThemeMetaData.java` → `/metaData` API → `QThemeMetaData.ts` → CSS variables (`--qqq-*`)
|
||||
|
||||
## Configuring a Theme (Java Backend)
|
||||
|
||||
Create a `MaterialDashboardThemeMetaData` instance using fluent setters:
|
||||
Create a `QThemeMetaData` instance using fluent setters:
|
||||
|
||||
```java
|
||||
MaterialDashboardThemeMetaData theme = new MaterialDashboardThemeMetaData()
|
||||
QThemeMetaData theme = new QThemeMetaData()
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSidebarBackgroundColor("#1E1E2D");
|
||||
|
||||
qInstance.add(theme);
|
||||
qInstance.setTheme(theme);
|
||||
```
|
||||
|
||||
All properties are optional. Omitted properties use frontend defaults.
|
||||
@@ -251,7 +251,7 @@ The fallback value (after comma) is used if the variable is undefined.
|
||||
|
||||
## Theme Application Flow
|
||||
|
||||
1. Backend serves `MaterialDashboardThemeMetaData` via `/metaData` endpoint
|
||||
1. Backend serves `QThemeMetaData` via `/metaData` endpoint
|
||||
2. Frontend receives theme in `QInstance.theme`
|
||||
3. `themeUtils.ts` calls `injectThemeVariables(theme)`
|
||||
4. Function sets CSS custom properties on `:root`
|
||||
@@ -264,7 +264,7 @@ The fallback value (after comma) is used if the variable is undefined.
|
||||
|
||||
**Minimal** - Override only what differs from defaults:
|
||||
```java
|
||||
new MaterialDashboardThemeMetaData()
|
||||
new QThemeMetaData()
|
||||
.withPrimaryColor("#E91E63")
|
||||
.withBrandedHeaderEnabled(true);
|
||||
```
|
||||
@@ -286,10 +286,10 @@ For dark themes, invert the typical light/dark relationships:
|
||||
|
||||
## File Locations
|
||||
|
||||
| Repository | Key Files |
|
||||
|---------------------------------|-----------|
|
||||
| qqq-backend-core | `MaterialDashboardThemeMetaData.java` |
|
||||
| qqq-frontend-material-dashboard | `MaterialDashboardThemeMetaData.ts` |
|
||||
| Repository | Key Files |
|
||||
|------------|-----------|
|
||||
| qqq-backend-core | `QThemeMetaData.java` |
|
||||
| qqq-frontend-core | `QThemeMetaData.ts` |
|
||||
| qqq-frontend-material-dashboard | `themeUtils.ts`, `qqq-override-styles.css` |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,70 +1,62 @@
|
||||
# Session State - QQQ Frontend Material Dashboard
|
||||
|
||||
**Last Updated:** 2026-01-15
|
||||
**Branch:** `feature/fix-visual-regressions-128`
|
||||
**Last Updated:** 2026-01-10
|
||||
**Branch:** `feature/consolidate-playwright-tests`
|
||||
**Version:** `0.40.0-SNAPSHOT`
|
||||
|
||||
## Current Status
|
||||
|
||||
**ROUND 6 FIXES COMPLETE (ALL 5 ISSUES)** - Ready for Darin to test.
|
||||
**Strict Playwright Tests Complete** - 46 tests passing with explicit assertions. CI fix still WIP.
|
||||
|
||||
## What Was Done This Session
|
||||
## Test Results
|
||||
|
||||
### Issue #128 Visual Regressions - Round 6
|
||||
| Suite | Passed | Skipped | Failed |
|
||||
|-------|--------|---------|--------|
|
||||
| Playwright e2e | 46 | 1 | 0 |
|
||||
|
||||
Fixed 5 regressions reported by Darin (compared to develop branch):
|
||||
### Skipped Tests
|
||||
- `multi-criteria query with OR` - Needs multi-row filter builder support
|
||||
|
||||
| Issue | Root Cause | Fix |
|
||||
|-------|------------|-----|
|
||||
| Sidenav double-highlight | CSS hover rules added in `064e86d` conflicted with JS hover | Removed CSS rules (lines 949-953) |
|
||||
| Chip border radius changed | `shape.borderRadius: 8` vs MUI default 4 | Changed default to 4 |
|
||||
| Menu padding increased | MuiMenuItem override set 8px vs menuItem.ts's ~5px | Removed override |
|
||||
| View field height reduced | Button typography changed (lineHeight 1.5→1.75, fontWeight 300→500) | Reverted to develop values |
|
||||
| Button color change | Button CSS scoped to `.qqq-themed` class (develop was unscoped) | Removed `.qqq-themed` scoping from button rules |
|
||||
## Recent Commits
|
||||
|
||||
### Files Modified
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `6fc7ece` | feat(test): add strict Playwright tests for bulk edit, navigation, and query filters |
|
||||
| `3f7a8ff` | chore: bump to 0.40.0-SNAPSHOT |
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/qqq/styles/qqq-override-styles.css` | Removed sidenav hover CSS; removed `.qqq-themed` scoping from button rules |
|
||||
| `src/qqq/utils/createDynamicTheme.ts` | Fixed borderRadius default (4), removed MuiMenuItem, fixed button typography |
|
||||
| `src/qqq/utils/themeUtils.ts` | Fixed DEFAULT_THEME borderRadius and button typography |
|
||||
| `e2e/tests/round6-regressions.spec.ts` | Added 10 tests for Round 6 fixes (including Issue 5) |
|
||||
## Key Technical Discoveries
|
||||
|
||||
## Test Status
|
||||
1. **Process step `nextStep` field** - Frontend requires this to know which step to render
|
||||
2. **MUI Autocomplete inputs** - Don't have `type="text"` attribute, use plain `input` selector
|
||||
3. **Filter popup behavior** - Escape cancels filters, clicking outside applies them
|
||||
4. **Role-based selectors** - More stable than CSS class selectors
|
||||
5. **Regex `\s+`** - Needed for variable spacing in query strings
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright themed | 26 | PASS |
|
||||
| Round 6 regression tests | 10 | PASS |
|
||||
## Files Modified (Uncommitted)
|
||||
|
||||
- `.circleci/config.yml` - CI build step (WIP)
|
||||
- `playwright.config.ts` - CI conditional config (WIP)
|
||||
- `src/qqq/styles/qqq-override-styles.css` - dividerColor fix
|
||||
- `src/test/resources/fixtures/metaData/withFullCustomTheme.json` - Test fixture
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Commit Round 6 fixes
|
||||
2. Darin to test on branch `feature/fix-visual-regressions-128`
|
||||
3. If approved, create PR to merge into develop
|
||||
1. Debug CI timeout issue with static file serving
|
||||
2. Port SavedViewsIT and remaining Selenium tests
|
||||
3. Remove Selenium infrastructure once fully migrated
|
||||
|
||||
## GitHub Issue
|
||||
## Quick Reference
|
||||
|
||||
https://github.com/QRun-IO/qqq-frontend-material-dashboard/issues/128
|
||||
```bash
|
||||
# Run Playwright tests
|
||||
npx playwright test
|
||||
|
||||
## QQQ #923 license alignment — 2026-09-30
|
||||
# Run specific test file
|
||||
npx playwright test e2e/tests/query-screen.spec.ts
|
||||
|
||||
Owner-approved first-party Apache-2.0 declarations now align across source headers, Maven/npm metadata and current contributor/theme documentation. Attribution, third-party notices, dependency pins and executable source are preserved. The license delta passed independent review. QQQ #937 adds bounded waits before existing saved-view background assertions; production behavior is unchanged. Browser verification requires the HTTPS frontend on port 3001 (proxy 8001), CIRCLECI=true and QQQ_SELENIUM_HEADLESS=true. Publish a new candidate only after validation, review and release gates pass. Earlier work recorded above is unchanged.
|
||||
# Run with UI debugger
|
||||
npx playwright test --ui
|
||||
|
||||
## QQQ #939/#940 dependency security — 2026-09-30
|
||||
|
||||
Isolated patch from `f80d76bffa1a9ba24a304a056eb5d9df606d4ab7`: Jackson BOM 2.21.7, root Axios <1 override 0.34.0 and webpack-dev-middleware override 7.4.6. The published middleware backport preserves the APIs/options used by CRA 5.0.1 and dev-server 4.15.2; its Node >=18.12/webpack 5 requirements fit this project's Node >=20 and webpack 5.105.4. Local verification passed 22 Maven units, 54 JS tests, security regressions, Material serialization and the production build (source-map warnings retained). Full dev-server tests rejected all 27 traversal probes while preserving assets, proxy/service-worker/history hooks, HMR and rebuilds; actual root/subpath `npm start` checks also passed. Evidence: `qqq-license-923/evidence/alignment/material-security-940/` in the coordinating workspace. No UI source or runtime modernization is included.
|
||||
|
||||
Fresh full and production-only npm audits both report 0 High/0 Critical; remaining totals are 15 Moderate/12 Low and 5 Moderate respectively. The old subpath disclosure baseline is retained; the middleware fix rejects traversal even though CRA still strips PUBLIC_URL's slash. The existing CRA source-overlay `_stats` mismatch reproduced identically before/after and is not fixed here. Public frontend-core 0.40.19-SNAPSHOT still pins Axios 0.33.0 outside this root override; #902 is separately coordinated. Independent review, full browser/hosted gates, GitHub alert reconciliation, RC2 integration and publication remain with the coordinator.
|
||||
|
||||
|
||||
## Public frontend-core integration — 2026-09-30
|
||||
|
||||
Pin the verified public frontend-core 0.40.20-SNAPSHOT from core PR48/snapshot job388. Its exact registry integrity, Apache LICENSE/NOTICE and library files match the reviewed package; an isolated public consumer passes four HTTP and three security checks with zero audit findings. Only the root dependency and core lock node changed in this integration. Independent pin review passed.
|
||||
|
||||
The combined Jackson/Axios/middleware/core candidate passes 54 JavaScript tests, production build and full Maven clean verify: 121 tests, zero failures/errors/skips/retries. Full audit reports zero High/Critical, 14 Moderate/12 Low; production reports zero High/Critical and five Moderate. Existing source-map/overlay and tool warnings remain recorded. Evidence is in the coordinating workspace under qqq-license-923/evidence/alignment/material-integrated-940/. Hosted checks, alert reconciliation, RC2 integration and existing publication gates remain; this is not RC/GA acceptance.
|
||||
|
||||
## QQQ #942 / #923 packaged notices — 2026-09-30
|
||||
|
||||
Bounded correction from signed PR162 merge `a37ead3b8c6b5a4819d29d853176374055dc1e8b`: Maven shared archive resources now copy the unchanged root LICENSE/NOTICE into all four published JARs. Resources 3.4.0 matches the prior integrated build. Actual release-profile packaging passed with reused verified public UI and runtime tests skipped; the executable archive guard fails on the existing public JARs and passes on all new local archives. Dependencies, Java/UI behavior and third-party notices are unchanged. Evidence: `qqq-license-923/evidence/alignment/material-notices-942/` in the coordination workspace. Independent review, signed integration, hosted required checks and verification of a newly published timestamped artifact remain; no public correction is claimed.
|
||||
# Test CI mode locally
|
||||
npm run build && CI=true npx playwright test
|
||||
```
|
||||
@@ -22,7 +22,7 @@ The QQQ dashboard now supports runtime theme customization via CSS custom proper
|
||||
Add theme configuration to your QInstance:
|
||||
|
||||
```java
|
||||
MaterialDashboardThemeMetaData theme = new MaterialDashboardThemeMetaData()
|
||||
QThemeMetaData theme = new QThemeMetaData()
|
||||
// Core colors
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSecondaryColor("#9C27B0")
|
||||
@@ -144,7 +144,7 @@ Same pattern for H2-H6, Body1, Body2, Button, Caption.
|
||||
|
||||
### 1. Basic Color Test
|
||||
```java
|
||||
new MaterialDashboardThemeMetaData()
|
||||
new QThemeMetaData()
|
||||
.withPrimaryColor("#FF5722")
|
||||
.withSidebarBackgroundColor("#263238");
|
||||
```
|
||||
@@ -152,7 +152,7 @@ new MaterialDashboardThemeMetaData()
|
||||
|
||||
### 2. Typography Test
|
||||
```java
|
||||
new MaterialDashboardThemeMetaData()
|
||||
new QThemeMetaData()
|
||||
.withFontFamily("Georgia, serif")
|
||||
.withTypographyH1FontSize("3rem")
|
||||
.withTypographyH1FontWeight(800);
|
||||
@@ -161,7 +161,7 @@ new MaterialDashboardThemeMetaData()
|
||||
|
||||
### 3. Branded Header Test
|
||||
```java
|
||||
new MaterialDashboardThemeMetaData()
|
||||
new QThemeMetaData()
|
||||
.withBrandedHeaderEnabled(true)
|
||||
.withBrandedHeaderBackgroundColor("#1a237e")
|
||||
.withBrandedHeaderTextColor("#ffffff")
|
||||
@@ -172,7 +172,7 @@ new MaterialDashboardThemeMetaData()
|
||||
|
||||
### 4. Density Test
|
||||
```java
|
||||
new MaterialDashboardThemeMetaData()
|
||||
new QThemeMetaData()
|
||||
.withDensity("compact");
|
||||
```
|
||||
**Verify:** Reduced spacing throughout the UI.
|
||||
|
||||
@@ -1,77 +1,49 @@
|
||||
# TODO - QQQ Frontend Material Dashboard
|
||||
|
||||
**Last Updated:** 2026-01-14
|
||||
**Last Updated:** 2026-01-10
|
||||
|
||||
## In Progress
|
||||
## Active Tasks
|
||||
|
||||
### Issue #128: Visual Regressions - READY FOR REVIEW
|
||||
### Playwright Consolidation (branch: `feature/consolidate-playwright-tests`)
|
||||
|
||||
**Branch:** `feature/fix-visual-regressions-128`
|
||||
**Status:** All fixes complete, waiting on Darin to test
|
||||
|
||||
- [x] Round 1-3: Add `.qqq-themed` class toggle, scope CSS overrides
|
||||
- [x] Round 4: Add CSS variable fallbacks to all `var(--qqq-*)` instances
|
||||
- [x] Round 5: Align ALL typography fallbacks with `typography.ts`
|
||||
- [x] Verify all 39 e2e tests pass (26 themed + 13 unthemed)
|
||||
- [x] Triple-check all 32 typography values against source of truth
|
||||
- [x] Commit typography fixes
|
||||
- [ ] **WAITING:** Darin to test and approve
|
||||
- [ ] Create PR to merge into develop
|
||||
- [ ] Publish snapshot after merge
|
||||
|
||||
---
|
||||
|
||||
### CI Playwright Timeout (Separate Issue)
|
||||
|
||||
**Branch:** `fix/ci-playwright-timeout`
|
||||
**Status:** Parked - lower priority than visual regressions
|
||||
|
||||
- [x] Diagnosed: webserver timeout (120s not enough for webpack)
|
||||
- [ ] Debug blank page issue in combined fixture server
|
||||
- [ ] Test locally until passing
|
||||
**Phase 1: Fix CI** - IN PROGRESS
|
||||
- [x] Create feature branch
|
||||
- [x] Enhance fixture-server.js with static file serving + SPA fallback
|
||||
- [x] Update playwright.config.ts with CI/local conditional config
|
||||
- [x] Update CircleCI config with `PUBLIC_URL=/ npm run build`
|
||||
- [ ] Debug CI timeout issue (static serving works but tests timeout)
|
||||
- [ ] Test in actual CI environment
|
||||
- [ ] Merge to develop
|
||||
|
||||
**Phase 2: Port Selenium Tests** - MOSTLY COMPLETE
|
||||
- [x] Port QueryScreenIT (13 tests - 12 active, 1 skipped)
|
||||
- [x] Port BulkEditIT (1 test - strict workflow)
|
||||
- [x] Port app navigation tests (6 tests)
|
||||
- [x] Port SavedReportIT (1 test)
|
||||
- [ ] Port SavedViewsIT
|
||||
- [ ] Port remaining Selenium tests
|
||||
|
||||
**Phase 3: Remove Selenium** - NOT STARTED
|
||||
- [ ] Delete `src/test/java/**/selenium/`
|
||||
- [ ] Remove Selenium dependencies from pom.xml
|
||||
- [ ] Simplify run-tests.sh
|
||||
|
||||
## Completed (Recent)
|
||||
|
||||
- [x] **Issue #128 Round 5** - Typography fallbacks aligned with typography.ts
|
||||
- [x] **Issue #128 Round 4** - CSS variable fallbacks added
|
||||
- [x] **Issue #128 Round 3** - Scoped CSS to `.qqq-themed` class
|
||||
- [x] **39 Playwright tests passing** (26 themed + 13 unthemed)
|
||||
- [x] **PR #127 merged** - Playwright e2e test integration
|
||||
- [x] **PR #125 merged** - Pluggable themes + CSS selectors system
|
||||
- [x] **Strict test assertions** - All ported tests use explicit step verification
|
||||
- [x] **Boolean filter tests** - Fixed and enabled (equals yes/no/is empty)
|
||||
- [x] **QueryScreen helper class** - Robust filter builder methods
|
||||
- [x] **Process step state tracking** - fixture-server.js handles multi-POST flows
|
||||
- [x] **Rebase to 0.40.0-SNAPSHOT** - Branch updated from 0.36.0
|
||||
|
||||
## Test Status
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright themed | 26 | PASS |
|
||||
| Playwright unthemed | 13 | PASS |
|
||||
| Playwright e2e | 46 | PASS (1 skipped) |
|
||||
| Selenium fixture-based | 115 | PASS (not ported) |
|
||||
| Java unit | 3 | PASS |
|
||||
|
||||
## Known Issues
|
||||
## Skipped Tests
|
||||
|
||||
- CI Playwright tests may timeout (WIP fix on separate branch)
|
||||
- `seleniumwithqapplication` tests require full QQQ backend (hang locally)
|
||||
|
||||
## QQQ #923 license alignment — 2026-09-30
|
||||
|
||||
Owner-approved first-party Apache-2.0 declarations now align across source headers, Maven/npm metadata and current contributor/theme documentation. Attribution, third-party notices, dependency pins and executable source are preserved. The license delta passed independent review. QQQ #937 adds bounded waits before existing saved-view background assertions; production behavior is unchanged. Browser verification requires the HTTPS frontend on port 3001 (proxy 8001), CIRCLECI=true and QQQ_SELENIUM_HEADLESS=true. Publish a new candidate only after validation, review and release gates pass. Earlier work recorded above is unchanged.
|
||||
|
||||
## QQQ #939/#940 dependency security — 2026-09-30
|
||||
|
||||
- [x] Verify public Jackson 2.21.7/Axios 0.34.0 artifacts and middleware 7.4.6 backport; update BOM, overrides and their lock entries/transitive development dependencies.
|
||||
- [x] Preserve vulnerable Jackson/Axios baselines; pass security regressions, 22 Maven units, 54 JS tests, Material serialization and production build.
|
||||
- [x] Reproduce subpath disclosure on 5.3.4; verify the 7.4.6 override blocks all 27 full-server probes while preserving assets/hooks/HMR/rebuilds and actual root/subpath npm start. Rerun JS/build/audits: 0 High/0 Critical; preserve existing overlay defect evidence and remaining Moderate/Low tracking.
|
||||
- [ ] Coordinator: independently review/integrate this patch, reconcile GitHub alerts under #940/#892, coordinate frontend-core's own Axios pin under #902, and rerun RC2 browser/hosted/release gates before publication.
|
||||
|
||||
|
||||
## Public frontend-core integration — 2026-09-30
|
||||
|
||||
- [x] Verify public frontend-core 0.40.20-SNAPSHOT, pin its exact registry version/integrity and independently review the dependency delta.
|
||||
- [x] Pass the integrated 54 JavaScript tests, production build and 121 Maven/Selenium tests without failures, skips or retries; preserve remaining Moderate/Low audit findings.
|
||||
- [ ] Pass hosted PR checks, reconcile alerts and integrate the accepted changes into the RC2 draft while retaining #892 and the other release gates.
|
||||
|
||||
## QQQ #942 / #923 packaged notices
|
||||
|
||||
- [x] Include exact root LICENSE/NOTICE in main, sources, tests and Javadoc JARs using native Maven resource packaging; preserve existing archive contents.
|
||||
- [x] Prove the archive guard fails on the public baseline and altered notice bytes, and passes on all four actual local archives; document explicit post-package invocation.
|
||||
- [ ] Coordinator: independent review, signed integration and hosted checks; verify all four newly published JARs before claiming public correction, then coordinate the RC input with existing release gates.
|
||||
- `multi-criteria query with OR` - Needs multi-row filter builder support
|
||||
@@ -1,15 +0,0 @@
|
||||
# Visual Regression Testing with Playwright and Docker
|
||||
|
||||
Added visual regression tests to the material dashboard today. 18 Playwright screenshot tests covering the home page, sidebar, nav bar, buttons, typography, cards—the usual suspects.
|
||||
|
||||
Ran into the classic problem: screenshots generated on my Mac don't match CI because fonts render differently on Linux. 1-2px height differences on everything. Tried bumping tolerance but that defeats the purpose.
|
||||
|
||||
The fix was running the entire stack inside Docker—not just Playwright, but the fixture server and React dev server too. Wrote a script that spins up the same container CI uses (`mcr.microsoft.com/playwright:v1.57.0-jammy`), starts everything inside, and regenerates the baselines.
|
||||
|
||||
```bash
|
||||
./scripts/update-snapshots.sh
|
||||
```
|
||||
|
||||
Run that after any visual changes, commit the updated snapshots, and CI will pass. All 18 tests run in about 90 seconds with 1% pixel tolerance.
|
||||
|
||||
The snapshots live in `e2e/snapshots/`. Tests are in `e2e/tests/visual-regression.spec.ts`.
|
||||
@@ -1,7 +1,16 @@
|
||||
/**
|
||||
* Simple fixture server for Playwright e2e tests.
|
||||
* Fixture server for Playwright e2e tests.
|
||||
*
|
||||
* Serves fixture JSON files from src/test/resources/fixtures/
|
||||
* mimicking the Javalin mock server used in Selenium tests.
|
||||
*
|
||||
* In CI mode (BUILD_DIR env var set), also serves the static React build
|
||||
* with SPA fallback routing. This eliminates the need for the React dev
|
||||
* server, avoiding webpack compilation timeout issues in CI.
|
||||
*
|
||||
* Usage:
|
||||
* Local dev: node e2e/fixture-server.js
|
||||
* CI mode: BUILD_DIR=build node e2e/fixture-server.js
|
||||
*/
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
@@ -10,13 +19,72 @@ const path = require('path');
|
||||
const PORT = process.env.FIXTURE_PORT || 8001;
|
||||
const FIXTURES_DIR = path.join(__dirname, '..', 'src', 'test', 'resources', 'fixtures');
|
||||
|
||||
// When BUILD_DIR is set, serve static React build files (CI mode)
|
||||
const BUILD_DIR = process.env.BUILD_DIR
|
||||
? path.join(__dirname, '..', process.env.BUILD_DIR)
|
||||
: null;
|
||||
|
||||
// Determine which metaData fixture to serve based on env var
|
||||
const FIXTURE_NAME = process.env.THEME_FIXTURE || 'index';
|
||||
const FIXTURE_NAME = process.env.THEME_FIXTURE || 'withFullCustomTheme';
|
||||
|
||||
// MIME types for static file serving
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.mjs': 'application/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
'.otf': 'font/otf',
|
||||
'.map': 'application/json',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.webmanifest': 'application/manifest+json',
|
||||
};
|
||||
|
||||
// API route patterns - these are checked BEFORE static file serving
|
||||
const API_PATTERNS = [
|
||||
/^\/metaData/,
|
||||
/^\/api\//,
|
||||
/^\/qqq\//,
|
||||
/^\/data\//,
|
||||
/^\/processes/,
|
||||
/^\/manageSession/,
|
||||
/^\/possibleValues/,
|
||||
/^\/widget/,
|
||||
/^\/serverInfo/,
|
||||
];
|
||||
|
||||
function isApiRoute(pathname) {
|
||||
return API_PATTERNS.some(pattern => pattern.test(pathname));
|
||||
}
|
||||
|
||||
function loadFixture(relativePath) {
|
||||
const fixturePath = path.join(FIXTURES_DIR, relativePath);
|
||||
if (fs.existsSync(fixturePath)) {
|
||||
return JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
} catch (err) {
|
||||
console.error(`Error parsing fixture ${fixturePath}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadFixtureRaw(relativePath) {
|
||||
const fixturePath = path.join(FIXTURES_DIR, relativePath);
|
||||
if (fs.existsSync(fixturePath)) {
|
||||
return fs.readFileSync(fixturePath, 'utf8');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -32,6 +100,491 @@ function serve404(res, pathname) {
|
||||
res.end(JSON.stringify({ error: 'Not found', path: pathname }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a static file from the build directory.
|
||||
* Returns true if file was served, false otherwise.
|
||||
*/
|
||||
function serveStaticFile(res, pathname) {
|
||||
if (!BUILD_DIR) return false;
|
||||
|
||||
// Normalize pathname (remove leading slash for path.join)
|
||||
let relativePath = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||
let filePath = path.join(BUILD_DIR, relativePath);
|
||||
|
||||
// Security: prevent directory traversal
|
||||
if (!filePath.startsWith(BUILD_DIR)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If path is a directory, try index.html
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
}
|
||||
|
||||
// If file exists, serve it
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath);
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Error reading file ${filePath}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* SPA fallback: serve index.html for client-side routes.
|
||||
* This enables React Router to handle routes like /testApp/person.
|
||||
*/
|
||||
function serveSpaFallback(res) {
|
||||
if (!BUILD_DIR) return false;
|
||||
|
||||
const indexPath = path.join(BUILD_DIR, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(indexPath);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error reading index.html:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API fixture routes.
|
||||
* Returns true if request was handled, false otherwise.
|
||||
*/
|
||||
function handleApiRoute(req, res, pathname, searchParams) {
|
||||
// =========================================================================
|
||||
// METADATA ROUTES
|
||||
// =========================================================================
|
||||
|
||||
// /metaData endpoint - return main metadata with theme
|
||||
if (pathname === '/metaData' || pathname === '/metaData/' || pathname === '/api/metaData') {
|
||||
const data = loadFixture(`metaData/${FIXTURE_NAME}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
// Fallback to index.json
|
||||
const fallback = loadFixture('metaData/index.json');
|
||||
if (fallback) {
|
||||
serveJson(res, fallback);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// /metaData/authentication - authentication config
|
||||
if (pathname === '/metaData/authentication' || pathname === '/api/metaData/authentication') {
|
||||
const data = loadFixture('metaData/authentication.json');
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// /metaData/table/{tableName} - table metadata
|
||||
const tableMetaMatch = pathname.match(/^\/metaData\/table\/(\w+)$/);
|
||||
if (tableMetaMatch) {
|
||||
const tableName = tableMetaMatch[1];
|
||||
const data = loadFixture(`metaData/table/${tableName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
// If specific table not found, return generic person table
|
||||
const personData = loadFixture('metaData/table/person.json');
|
||||
if (personData) {
|
||||
serveJson(res, personData);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// /metaData/process/{processName} - process metadata
|
||||
const processMetaMatch = pathname.match(/^\/metaData\/process\/(.+)$/);
|
||||
if (processMetaMatch) {
|
||||
const processName = processMetaMatch[1];
|
||||
const data = loadFixture(`metaData/process/${processName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// QQQ API v1 ROUTES
|
||||
// =========================================================================
|
||||
|
||||
// /qqq/v1/metaData/table/{tableName} - API table metadata
|
||||
const apiTableMetaMatch = pathname.match(/^\/qqq\/v1\/metaData\/table\/(\w+)$/);
|
||||
if (apiTableMetaMatch) {
|
||||
const tableName = apiTableMetaMatch[1];
|
||||
const data = loadFixture(`qqq/v1/metaData/table/${tableName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// /qqq/v1/table/{tableName}/query - table queries
|
||||
const queryMatch = pathname.match(/^\/qqq\/v1\/table\/(\w+)\/query$/);
|
||||
if (queryMatch) {
|
||||
const tableName = queryMatch[1];
|
||||
// Try specific query file first, then index.json
|
||||
let data = loadFixture(`qqq/v1/table/${tableName}/query.json`);
|
||||
if (!data) {
|
||||
data = loadFixture(`qqq/v1/table/${tableName}/index.json`);
|
||||
}
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
// Default query response
|
||||
serveJson(res, {
|
||||
records: [
|
||||
{ values: { id: 1, firstName: 'Test', lastName: 'User' } },
|
||||
{ values: { id: 2, firstName: 'Another', lastName: 'Person' } }
|
||||
],
|
||||
count: 2
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// /qqq/v1/table/{tableName}/count - table counts
|
||||
const countMatch = pathname.match(/^\/qqq\/v1\/table\/(\w+)\/count$/);
|
||||
if (countMatch) {
|
||||
const tableName = countMatch[1];
|
||||
const data = loadFixture(`qqq/v1/table/${tableName}/count.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, { count: 2 });
|
||||
return true;
|
||||
}
|
||||
|
||||
// /qqq/v1/table/{tableName}/variants - table variants
|
||||
const variantsMatch = pathname.match(/^\/qqq\/v1\/table\/(\w+)\/variants$/);
|
||||
if (variantsMatch) {
|
||||
const tableName = variantsMatch[1];
|
||||
const data = loadFixture(`qqq/v1/table/${tableName}/variants.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, { options: [] });
|
||||
return true;
|
||||
}
|
||||
|
||||
// /qqq/v1/table/{tableName}/possibleValues/{fieldName} - possible values
|
||||
const pvMatch = pathname.match(/^\/qqq\/v1\/table\/(\w+)\/possibleValues\/(\w+)$/);
|
||||
if (pvMatch) {
|
||||
const tableName = pvMatch[1];
|
||||
const fieldName = pvMatch[2];
|
||||
// Check for query params like id=1
|
||||
const idParam = searchParams.get('id');
|
||||
if (idParam) {
|
||||
const data = loadFixture(`qqq/v1/table/${tableName}/possibleValues/${fieldName}=${idParam}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const data = loadFixture(`qqq/v1/table/${tableName}/possibleValues/${fieldName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, { options: [] });
|
||||
return true;
|
||||
}
|
||||
|
||||
// /qqq/v1/table/{tableName}/{id} - single record
|
||||
const singleRecordMatch = pathname.match(/^\/qqq\/v1\/table\/(\w+)\/(\d+)$/);
|
||||
if (singleRecordMatch) {
|
||||
const tableName = singleRecordMatch[1];
|
||||
const recordId = singleRecordMatch[2];
|
||||
const data = loadFixture(`qqq/v1/table/${tableName}/${recordId}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
// Default single record response
|
||||
serveJson(res, { values: { id: parseInt(recordId), name: `Record ${recordId}` } });
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DATA ROUTES (legacy/alternate endpoints)
|
||||
// =========================================================================
|
||||
|
||||
// /data/{tableName}/query - data queries
|
||||
const dataQueryMatch = pathname.match(/^\/data\/(\w+)\/query$/);
|
||||
if (dataQueryMatch) {
|
||||
const tableName = dataQueryMatch[1];
|
||||
let data = loadFixture(`data/${tableName}/query.json`);
|
||||
if (!data) {
|
||||
data = loadFixture(`data/${tableName}/index.json`);
|
||||
}
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, { records: [], count: 0 });
|
||||
return true;
|
||||
}
|
||||
|
||||
// /data/{tableName}/count - data counts
|
||||
const dataCountMatch = pathname.match(/^\/data\/(\w+)\/count$/);
|
||||
if (dataCountMatch) {
|
||||
const tableName = dataCountMatch[1];
|
||||
const data = loadFixture(`data/${tableName}/count.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, { count: 0 });
|
||||
return true;
|
||||
}
|
||||
|
||||
// /data/{tableName}/variants - data variants
|
||||
const dataVariantsMatch = pathname.match(/^\/data\/(\w+)\/variants$/);
|
||||
if (dataVariantsMatch) {
|
||||
const tableName = dataVariantsMatch[1];
|
||||
const data = loadFixture(`data/${tableName}/variants.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, { options: [] });
|
||||
return true;
|
||||
}
|
||||
|
||||
// /data/{tableName}/possibleValues/{fieldName} - data possible values
|
||||
const dataPvMatch = pathname.match(/^\/data\/(\w+)\/possibleValues\/(\w+)$/);
|
||||
if (dataPvMatch) {
|
||||
const tableName = dataPvMatch[1];
|
||||
const fieldName = dataPvMatch[2];
|
||||
const idParam = searchParams.get('id');
|
||||
if (idParam) {
|
||||
const data = loadFixture(`data/${tableName}/possibleValues/${fieldName}=${idParam}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const data = loadFixture(`data/${tableName}/possibleValues/${fieldName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, { options: [] });
|
||||
return true;
|
||||
}
|
||||
|
||||
// /data/{tableName}/{id} - single data record
|
||||
const dataSingleMatch = pathname.match(/^\/data\/(\w+)\/(\d+)$/);
|
||||
if (dataSingleMatch) {
|
||||
const tableName = dataSingleMatch[1];
|
||||
const recordId = dataSingleMatch[2];
|
||||
const data = loadFixture(`data/${tableName}/${recordId}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, { values: { id: parseInt(recordId) } });
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PROCESS ROUTES
|
||||
// =========================================================================
|
||||
|
||||
// /processes/querySavedView/init - saved view init (with optional id param)
|
||||
if (pathname === '/processes/querySavedView/init') {
|
||||
const idParam = searchParams.get('id');
|
||||
if (idParam) {
|
||||
const data = loadFixture(`processes/querySavedView/init-id=${idParam}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const data = loadFixture('processes/querySavedView/init.json');
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, { values: {} });
|
||||
return true;
|
||||
}
|
||||
|
||||
// /processes/{processName}/init - process init
|
||||
const processInitMatch = pathname.match(/^\/processes\/([^\/]+)\/init$/);
|
||||
if (processInitMatch) {
|
||||
const processName = processInitMatch[1];
|
||||
const data = loadFixture(`processes/${processName}/init.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
// Default process init response with UUID
|
||||
serveJson(res, {
|
||||
processUUID: '74a03a7d-2f53-4784-9911-3a21f7646c43',
|
||||
values: {}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// /processes/{processName}/{uuid}/step/{stepName} - process step
|
||||
const processStepMatch = pathname.match(/^\/processes\/([^\/]+)\/([^\/]+)\/step\/([^\/]+)$/);
|
||||
if (processStepMatch) {
|
||||
const processName = processStepMatch[1];
|
||||
const processUUID = processStepMatch[2];
|
||||
const stepName = processStepMatch[3];
|
||||
|
||||
console.log(`[PROCESS STEP] ${req.method} ${pathname} -> step: ${stepName}`);
|
||||
|
||||
// Track POST count per process UUID and step for multi-step flows
|
||||
const processStateKey = `${processUUID}-${stepName}-post-count`;
|
||||
if (!global.processState) {
|
||||
global.processState = {};
|
||||
}
|
||||
|
||||
// For POST requests, handle step progression
|
||||
if (req.method === 'POST') {
|
||||
// Increment POST count for this step
|
||||
global.processState[processStateKey] = (global.processState[processStateKey] || 0) + 1;
|
||||
const postCount = global.processState[processStateKey];
|
||||
console.log(`[PROCESS STEP] POST #${postCount} to ${stepName}`);
|
||||
|
||||
// POST to edit step (Next button) - return edit.json with nextStep: review
|
||||
if (stepName === 'edit') {
|
||||
console.log(`[PROCESS STEP] POST to edit - returning edit.json (nextStep: review)`);
|
||||
const data = loadFixture(`processes/${processName}/step/edit.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// POST to review step - handle validation vs submit
|
||||
if (stepName === 'review') {
|
||||
if (postCount === 1) {
|
||||
// First POST to review: validation - return review.json with validation summary
|
||||
console.log(`[PROCESS STEP] POST #1 to review - returning review.json (validation summary)`);
|
||||
const reviewData = loadFixture(`processes/${processName}/step/review.json`);
|
||||
if (reviewData) {
|
||||
serveJson(res, reviewData);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Second+ POST to review: submit - return result.json
|
||||
console.log(`[PROCESS STEP] POST #${postCount} to review - returning result.json (submit)`);
|
||||
const resultData = loadFixture(`processes/${processName}/step/result.json`);
|
||||
if (resultData) {
|
||||
serveJson(res, resultData);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For GET requests, return the step's fixture
|
||||
// Special case: for review step, return initial state (no validationSummary)
|
||||
if (req.method === 'GET' && stepName === 'review') {
|
||||
console.log(`[PROCESS STEP] GET to review - returning review-initial.json (validation options)`);
|
||||
const initialData = loadFixture(`processes/${processName}/step/review-initial.json`);
|
||||
if (initialData) {
|
||||
serveJson(res, initialData);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[PROCESS STEP] ${req.method} to ${stepName} - returning ${stepName}.json`);
|
||||
const data = loadFixture(`processes/${processName}/step/${stepName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, { values: {} });
|
||||
return true;
|
||||
}
|
||||
|
||||
// /processes/{processName}/{uuid}/records - process records
|
||||
const processRecordsMatch = pathname.match(/^\/processes\/([^\/]+)\/([^\/]+)\/records$/);
|
||||
if (processRecordsMatch) {
|
||||
const processName = processRecordsMatch[1];
|
||||
const data = loadFixture(`processes/${processName}/records.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, []);
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// WIDGET ROUTES
|
||||
// =========================================================================
|
||||
|
||||
// /widget/{widgetName} - widget data
|
||||
const widgetMatch = pathname.match(/^\/widget\/(\w+)$/);
|
||||
if (widgetMatch) {
|
||||
const widgetName = widgetMatch[1];
|
||||
const data = loadFixture(`widget/${widgetName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return true;
|
||||
}
|
||||
// Default widget response
|
||||
const emptyData = loadFixture('widget/empty.json');
|
||||
if (emptyData) {
|
||||
serveJson(res, emptyData);
|
||||
return true;
|
||||
}
|
||||
serveJson(res, {});
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SESSION & SERVER ROUTES
|
||||
// =========================================================================
|
||||
|
||||
// /manageSession - session management
|
||||
if (pathname === '/manageSession' || pathname === '/api/manageSession') {
|
||||
serveJson(res, {
|
||||
uuid: 'test-session-uuid',
|
||||
values: {}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// /serverInfo - server info
|
||||
if (pathname === '/serverInfo' || pathname === '/api/serverInfo') {
|
||||
serveJson(res, {
|
||||
startTime: new Date().toISOString(),
|
||||
buildTime: new Date().toISOString(),
|
||||
version: 'test-0.0.0'
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Enable CORS for local development
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
@@ -46,295 +599,40 @@ const server = http.createServer((req, res) => {
|
||||
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
const pathname = url.pathname;
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
// Support per-request fixture selection via ?fixture=name query parameter
|
||||
// This allows unthemed tests to request the 'index' fixture explicitly
|
||||
const requestedFixture = url.searchParams.get('fixture') || FIXTURE_NAME;
|
||||
// 1. Check API routes first (highest priority)
|
||||
if (isApiRoute(pathname)) {
|
||||
if (handleApiRoute(req, res, pathname, searchParams)) {
|
||||
return;
|
||||
}
|
||||
// API route matched pattern but no handler - 404
|
||||
serve404(res, pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
// /metaData endpoint - return main metadata with theme
|
||||
if (pathname === '/metaData' || pathname === '/metaData/' || pathname === '/api/metaData') {
|
||||
const data = loadFixture(`metaData/${requestedFixture}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
// 2. In CI mode, try to serve static file from build directory
|
||||
if (BUILD_DIR) {
|
||||
if (serveStaticFile(res, pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. SPA fallback: serve index.html for client-side routes
|
||||
if (serveSpaFallback(res)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// /metaData/authentication - authentication config
|
||||
if (pathname === '/metaData/authentication' || pathname === '/api/metaData/authentication') {
|
||||
const data = loadFixture('metaData/authentication.json');
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// /metaData/table/{tableName} - table metadata
|
||||
const tableMetaMatch = pathname.match(/^\/metaData\/table\/(\w+)$/);
|
||||
if (tableMetaMatch) {
|
||||
const tableName = tableMetaMatch[1];
|
||||
const data = loadFixture(`metaData/table/${tableName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
// If specific table not found, return generic person table
|
||||
const personData = loadFixture('metaData/table/person.json');
|
||||
if (personData) {
|
||||
serveJson(res, personData);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// /qqq/v1/metaData/table/{tableName} - API table metadata
|
||||
const apiTableMetaMatch = pathname.match(/^\/qqq\/v1\/metaData\/table\/(\w+)$/);
|
||||
if (apiTableMetaMatch) {
|
||||
const tableName = apiTableMetaMatch[1];
|
||||
const data = loadFixture(`qqq/v1/metaData/table/${tableName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// /metaData/process/{processName} - process metadata (supports dots in name like person.bulkEdit)
|
||||
const processMetaMatch = pathname.match(/^\/metaData\/process\/(.+)$/);
|
||||
if (processMetaMatch) {
|
||||
const processName = processMetaMatch[1];
|
||||
const data = loadFixture(`metaData/process/${processName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
// Default process metadata response (wrapped in 'process' key)
|
||||
serveJson(res, {
|
||||
process: {
|
||||
name: processName,
|
||||
label: processName.charAt(0).toUpperCase() + processName.slice(1).replace(/([A-Z])/g, ' $1'),
|
||||
tableName: 'person',
|
||||
frontendSteps: [
|
||||
{ name: 'input', label: 'Input', stepType: 'frontend', formFields: [], components: [] },
|
||||
{ name: 'review', label: 'Review', stepType: 'frontend', recordListFields: [], components: [] },
|
||||
{ name: 'result', label: 'Result', stepType: 'frontend', components: [{ type: 'PROCESS_SUMMARY_RESULTS' }] }
|
||||
]
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /processes/{processName}/init - process init
|
||||
const processInitMatch = pathname.match(/^\/processes\/([\w.\-]+)\/init$/);
|
||||
if (processInitMatch) {
|
||||
const processName = processInitMatch[1];
|
||||
const data = loadFixture(`processes/${processName}/init.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
// Default process init response with stepper and UUID
|
||||
serveJson(res, {
|
||||
processUUID: 'test-process-uuid-12345',
|
||||
values: {},
|
||||
processMetaData: {
|
||||
name: processName,
|
||||
label: processName,
|
||||
frontendSteps: [
|
||||
{ name: 'input', label: 'Input', stepType: 'frontend', formFields: [], components: [] },
|
||||
{ name: 'review', label: 'Review', stepType: 'frontend', recordListFields: [], components: [] },
|
||||
{ name: 'result', label: 'Result', stepType: 'frontend', components: [{ type: 'PROCESS_SUMMARY_RESULTS' }] }
|
||||
]
|
||||
},
|
||||
nextStep: 'input'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /processes/{processName}/{uuid}/records - get process records
|
||||
const processRecordsMatch = pathname.match(/^\/processes\/(\w+)\/([^\/]+)\/records$/);
|
||||
if (processRecordsMatch) {
|
||||
serveJson(res, {
|
||||
records: [],
|
||||
count: 0
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /processes/{processName}/{uuid}/step/{stepName} - process step with UUID
|
||||
const processStepWithUuidMatch = pathname.match(/^\/processes\/(\w+)\/([^\/]+)\/step\/(\w+)$/);
|
||||
if (processStepWithUuidMatch) {
|
||||
const processName = processStepWithUuidMatch[1];
|
||||
const uuid = processStepWithUuidMatch[2];
|
||||
const stepName = processStepWithUuidMatch[3];
|
||||
// Return next step
|
||||
serveJson(res, {
|
||||
processUUID: uuid,
|
||||
values: {},
|
||||
processMetaData: {
|
||||
name: processName,
|
||||
label: processName,
|
||||
frontendSteps: [
|
||||
{ name: 'input', label: 'Input', stepType: 'frontend', formFields: [], components: [] },
|
||||
{ name: 'review', label: 'Review', stepType: 'frontend', recordListFields: [], components: [] },
|
||||
{ name: 'result', label: 'Result', stepType: 'frontend', components: [{ type: 'PROCESS_SUMMARY_RESULTS' }] }
|
||||
]
|
||||
},
|
||||
nextStep: stepName === 'input' ? 'review' : (stepName === 'review' ? 'result' : null),
|
||||
processResults: stepName === 'result' ? { message: 'Process completed successfully!' } : undefined
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /processes/{processName}/step/{stepName} - process step (legacy, no UUID)
|
||||
const processStepMatch = pathname.match(/^\/processes\/(\w+)\/step\/(\w+)$/);
|
||||
if (processStepMatch) {
|
||||
const processName = processStepMatch[1];
|
||||
const stepName = processStepMatch[2];
|
||||
// Return next step
|
||||
serveJson(res, {
|
||||
processUUID: 'test-process-uuid-12345',
|
||||
values: {},
|
||||
processMetaData: {
|
||||
name: processName,
|
||||
label: processName,
|
||||
frontendSteps: [
|
||||
{ name: 'input', label: 'Input', stepType: 'frontend', formFields: [], components: [] },
|
||||
{ name: 'review', label: 'Review', stepType: 'frontend', recordListFields: [], components: [] },
|
||||
{ name: 'result', label: 'Result', stepType: 'frontend', components: [{ type: 'PROCESS_SUMMARY_RESULTS' }] }
|
||||
]
|
||||
},
|
||||
nextStep: stepName === 'input' ? 'review' : (stepName === 'review' ? 'result' : null),
|
||||
processResults: stepName === 'result' ? { message: 'Process completed successfully!' } : undefined
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /widget/{widgetName} - widget data
|
||||
const widgetMatch = pathname.match(/^\/widget\/(.+)$/);
|
||||
if (widgetMatch) {
|
||||
const widgetName = widgetMatch[1];
|
||||
const data = loadFixture(`widget/${widgetName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
// Default empty widget response
|
||||
serveJson(res, {
|
||||
type: 'table',
|
||||
data: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /manageSession - session management
|
||||
if (pathname === '/manageSession' || pathname === '/api/manageSession') {
|
||||
serveJson(res, {
|
||||
uuid: 'test-session-uuid',
|
||||
values: {}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /qqq/v1/table/{tableName}/query - table queries
|
||||
const queryMatch = pathname.match(/^\/qqq\/v1\/table\/(\w+)\/query$/);
|
||||
if (queryMatch) {
|
||||
const tableName = queryMatch[1];
|
||||
const data = loadFixture(`qqq/v1/table/${tableName}/query.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
// Default query response
|
||||
serveJson(res, {
|
||||
records: [
|
||||
{ values: { id: 1, firstName: 'Test', lastName: 'User' } },
|
||||
{ values: { id: 2, firstName: 'Another', lastName: 'Person' } }
|
||||
],
|
||||
count: 2
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /qqq/v1/table/{tableName}/count - table counts
|
||||
const countMatch = pathname.match(/^\/qqq\/v1\/table\/(\w+)\/count$/);
|
||||
if (countMatch) {
|
||||
const tableName = countMatch[1];
|
||||
const data = loadFixture(`qqq/v1/table/${tableName}/count.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
serveJson(res, { count: 2 });
|
||||
return;
|
||||
}
|
||||
|
||||
// /data/{tableName}/query - alternate query endpoint
|
||||
const dataQueryMatch = pathname.match(/^\/data\/(\w+)\/query$/);
|
||||
if (dataQueryMatch) {
|
||||
serveJson(res, {
|
||||
records: [],
|
||||
count: 0
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /data/{tableName}/count - alternate count endpoint
|
||||
const dataCountMatch = pathname.match(/^\/data\/(\w+)\/count$/);
|
||||
if (dataCountMatch) {
|
||||
serveJson(res, { count: 2 });
|
||||
return;
|
||||
}
|
||||
|
||||
// /data/{tableName}/{id} - alternate single record lookup
|
||||
const dataRecordMatch = pathname.match(/^\/data\/(\w+)\/(\d+)$/);
|
||||
if (dataRecordMatch) {
|
||||
const tableName = dataRecordMatch[1];
|
||||
const recordId = dataRecordMatch[2];
|
||||
serveJson(res, {
|
||||
values: {
|
||||
id: parseInt(recordId),
|
||||
firstName: recordId === '1' ? 'John' : 'Jane',
|
||||
lastName: recordId === '1' ? 'Doe' : 'Smith',
|
||||
email: recordId === '1' ? 'john.doe@example.com' : 'jane.smith@example.com',
|
||||
birthDate: '1990-01-15',
|
||||
createDate: '2024-01-01T00:00:00Z',
|
||||
modifyDate: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
tableName: tableName
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /qqq/v1/table/{tableName}/{id} - single record lookup
|
||||
const recordMatch = pathname.match(/^\/qqq\/v1\/table\/(\w+)\/(\d+)$/);
|
||||
if (recordMatch) {
|
||||
const tableName = recordMatch[1];
|
||||
const recordId = recordMatch[2];
|
||||
const data = loadFixture(`qqq/v1/table/${tableName}/${recordId}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
// Default person record response
|
||||
serveJson(res, {
|
||||
values: {
|
||||
id: parseInt(recordId),
|
||||
firstName: recordId === '1' ? 'John' : 'Jane',
|
||||
lastName: recordId === '1' ? 'Doe' : 'Smith',
|
||||
email: recordId === '1' ? 'john.doe@example.com' : 'jane.smith@example.com',
|
||||
birthDate: '1990-01-15',
|
||||
createDate: '2024-01-01T00:00:00Z',
|
||||
modifyDate: '2024-01-01T00:00:00Z'
|
||||
},
|
||||
tableName: tableName
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Default 404
|
||||
// 4. Default 404
|
||||
serve404(res, pathname);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Fixture server running on http://localhost:${PORT}`);
|
||||
console.log(`Using fixture: ${FIXTURE_NAME}`);
|
||||
if (BUILD_DIR) {
|
||||
console.log(`CI mode: Serving static build from ${BUILD_DIR}`);
|
||||
} else {
|
||||
console.log(`Dev mode: API fixtures only (use React dev server for frontend)`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Helper class for interacting with the query screen in Playwright tests.
|
||||
* Mirrors the QueryScreenLib.java from Selenium tests.
|
||||
*/
|
||||
export class QueryScreen {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Wait for the query grid to load (column headers visible).
|
||||
*/
|
||||
async waitForQueryToHaveRan() {
|
||||
// Wait for column headers first (always present when grid loads)
|
||||
await this.page.waitForSelector('.MuiDataGrid-columnHeader', { timeout: 30000 });
|
||||
// Then wait for data cells (indicates query has completed)
|
||||
await this.page.waitForSelector('.MuiDataGrid-cell', { timeout: 30000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a table and wait for breadcrumb.
|
||||
*/
|
||||
async gotoTable(path: string, expectedBreadcrumb: string) {
|
||||
await this.page.goto(path);
|
||||
await this.page.waitForSelector(`[id="root"]`);
|
||||
await this.page.waitForTimeout(1000);
|
||||
// Wait for breadcrumb to contain expected text
|
||||
await expect(this.page.locator('.MuiBreadcrumbs-root, h1, h2, h3')).toContainText(expectedBreadcrumb, { timeout: 30000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to advanced filter mode.
|
||||
*/
|
||||
async gotoAdvancedMode() {
|
||||
await this.page.click('button:has-text("ADVANCED")');
|
||||
await this.page.waitForSelector('button:has-text("FILTER BUILDER")');
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to basic filter mode.
|
||||
*/
|
||||
async gotoBasicMode() {
|
||||
await this.page.click('button:has-text("BASIC")');
|
||||
await this.page.waitForSelector('button:has-text("ADD FILTER")');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the filter builder button.
|
||||
*/
|
||||
async clickFilterBuilderButton() {
|
||||
await this.page.click('button:has-text("FILTER BUILDER")');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a filter condition in advanced mode.
|
||||
* Uses label-based input selection for reliability.
|
||||
*/
|
||||
async addAdvancedQueryFilterInput(
|
||||
index: number,
|
||||
fieldLabel: string,
|
||||
operator: string,
|
||||
value: string | null,
|
||||
booleanOperator: string | null
|
||||
) {
|
||||
// Find the filter popup - contains ADD CONDITION button
|
||||
const filterPopup = this.page.locator('.MuiPaper-root').filter({ has: this.page.locator('button:has-text("ADD CONDITION")') });
|
||||
await filterPopup.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// For additional conditions (index > 0), click Add condition first
|
||||
if (index > 0) {
|
||||
await filterPopup.getByRole('button', { name: /add condition/i }).click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Set the boolean operator (And/Or) if specified
|
||||
if (booleanOperator) {
|
||||
// The boolean selector is a combobox - find the last one since we just added a row
|
||||
const booleanInputs = await filterPopup.locator('input[aria-label]').all();
|
||||
// Look for the And/Or dropdown - it's usually the first input of the new row
|
||||
for (const input of booleanInputs) {
|
||||
const label = await input.getAttribute('aria-label');
|
||||
if (label && (label.includes('And') || label.includes('Or'))) {
|
||||
await input.click();
|
||||
await this.page.waitForTimeout(100);
|
||||
await this.page.getByRole('option', { name: booleanOperator, exact: true }).click();
|
||||
await this.page.waitForTimeout(100);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Click on the Field dropdown (labeled "Field")
|
||||
// Use nth() to get the correct row's field input
|
||||
const fieldInput = filterPopup.locator('input').nth(index === 0 ? 0 : index > 0 ? index * 3 : 0);
|
||||
|
||||
// Try clicking on the first available text input if labeled approach fails
|
||||
const allInputs = await filterPopup.locator('input').all();
|
||||
|
||||
// For single condition (index=0), use first input for Field
|
||||
// Get inputs in order: Field, Operator, (optional Value)
|
||||
if (allInputs.length > 0) {
|
||||
// Click and fill Field
|
||||
await allInputs[0].click();
|
||||
await this.page.waitForTimeout(200);
|
||||
await allInputs[0].fill(fieldLabel);
|
||||
await this.page.waitForTimeout(300);
|
||||
// Select from dropdown - click the first matching option
|
||||
const fieldOptions = await this.page.locator('[role="option"]').all();
|
||||
if (fieldOptions.length > 0) {
|
||||
await fieldOptions[0].click();
|
||||
}
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Re-fetch inputs after field selection (new inputs may appear)
|
||||
const updatedInputs = await filterPopup.locator('input').all();
|
||||
|
||||
// Click and fill Operator (second input after field selection)
|
||||
if (updatedInputs.length > 1) {
|
||||
await updatedInputs[1].click();
|
||||
await this.page.waitForTimeout(200);
|
||||
await updatedInputs[1].fill(operator);
|
||||
await this.page.waitForTimeout(300);
|
||||
// Select from dropdown - click the first matching option
|
||||
const opOptions = await this.page.locator('[role="option"]').all();
|
||||
if (opOptions.length > 0) {
|
||||
await opOptions[0].click();
|
||||
}
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Fill Value if provided (third input, appears for some operators)
|
||||
if (value) {
|
||||
const valueInputs = await filterPopup.locator('input').all();
|
||||
if (valueInputs.length > 2) {
|
||||
await valueInputs[2].click();
|
||||
await valueInputs[2].fill(value);
|
||||
await this.page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the clear filter (X) icon in advanced mode.
|
||||
*/
|
||||
async clickAdvancedFilterClearIcon() {
|
||||
await this.page.hover('.filterBuilderButton');
|
||||
await this.page.click('.filterBuilderXIcon BUTTON');
|
||||
await this.page.click('button:has-text("Yes")');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the filter button badge shows a specific count.
|
||||
*/
|
||||
async assertFilterButtonBadge(count: number) {
|
||||
await expect(this.page.locator('.filterBuilderCountBadge')).toContainText(String(count));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a basic filter by field label.
|
||||
*/
|
||||
async addBasicFilter(fieldLabel: string) {
|
||||
await this.page.click('button:has-text("Add Filter")');
|
||||
await this.page.click(`.fieldListMenuBody-addQuickFilter li:has-text("${fieldLabel}")`);
|
||||
await this.clickBackdrop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a basic filter with field, operator, and value.
|
||||
*/
|
||||
async setBasicFilter(fieldLabel: string, operatorLabel: string, value: string | null) {
|
||||
await this.page.click(`button:has-text("${fieldLabel}")`);
|
||||
await this.page.waitForTimeout(250);
|
||||
await this.page.click('#criteriaOperator');
|
||||
await this.page.click(`li:has-text("${operatorLabel}")`);
|
||||
|
||||
if (value) {
|
||||
await this.page.click('.filterValuesColumn INPUT');
|
||||
await this.page.fill('.filterValuesColumn INPUT', value);
|
||||
}
|
||||
|
||||
await this.clickBackdrop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a basic boolean filter (like Is Employed).
|
||||
*/
|
||||
async setBasicBooleanFilter(fieldLabel: string, operatorLabel: string) {
|
||||
await this.page.click(`button:has-text("${fieldLabel}")`);
|
||||
await this.page.waitForTimeout(250);
|
||||
await this.page.click('#criteriaOperator');
|
||||
await this.page.click(`li:has-text("${operatorLabel}")`);
|
||||
await this.clickBackdrop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a basic filter with possible values (like city dropdown).
|
||||
*/
|
||||
async setBasicFilterPossibleValues(fieldLabel: string, operatorLabel: string, values: string[] | null) {
|
||||
await this.page.click(`button:has-text("${fieldLabel}")`);
|
||||
await this.page.waitForTimeout(250);
|
||||
await this.page.click('#criteriaOperator');
|
||||
await this.page.click(`li:has-text("${operatorLabel}")`);
|
||||
|
||||
if (values && values.length > 0) {
|
||||
await this.page.click('.filterValuesColumn INPUT');
|
||||
for (const value of values) {
|
||||
await this.page.click(`.MuiAutocomplete-listbox li:has-text("${value}")`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.clickBackdrop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open criteria paster and paste values.
|
||||
*/
|
||||
async openCriteriaPasterAndPasteValues(fieldName: string, values: string[]) {
|
||||
await this.page.click(`button:has-text("${fieldName}")`);
|
||||
await this.page.click('#criteriaOperator');
|
||||
await this.page.click('li:has-text("is any of")');
|
||||
await this.page.waitForTimeout(250);
|
||||
await this.page.click('.criteriaPasterButton');
|
||||
await this.page.fill('.criteriaPasterTextArea textarea#outlined-multiline-static', values.join('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for basic filter button to match a regex pattern.
|
||||
*/
|
||||
async waitForBasicFilterButtonMatchingRegex(regex: RegExp) {
|
||||
// Look for filter chip buttons in the filter bar area
|
||||
await expect(this.page.getByRole('button', { name: regex })).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for advanced query string to match a regex pattern.
|
||||
*/
|
||||
async waitForAdvancedQueryStringMatchingRegex(regex: RegExp) {
|
||||
await expect(this.page.locator('.advancedQueryString')).toContainText(regex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close popups by pressing Escape (cancels filters).
|
||||
*/
|
||||
async clickBackdrop() {
|
||||
await this.page.waitForTimeout(200);
|
||||
await this.page.keyboard.press('Escape');
|
||||
await this.page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close filter popup by clicking outside (applies filters).
|
||||
*/
|
||||
async closeFilterPopup() {
|
||||
await this.page.waitForTimeout(200);
|
||||
// Click on the sidebar area to close the popup while applying filters
|
||||
await this.page.mouse.click(50, 400);
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for DataGrid cell containing text.
|
||||
*/
|
||||
async waitForDataGridCellContaining(text: string) {
|
||||
await expect(this.page.locator('.MuiDataGrid-cell', { hasText: text })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert saved view name is on screen.
|
||||
*/
|
||||
async assertSavedViewNameOnScreen(savedViewName: string) {
|
||||
await expect(this.page.locator('h3', { hasText: savedViewName })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert chips in the criteria paster.
|
||||
*/
|
||||
async assertFilterPasterChipCounts(expectedValid: number, expectedInvalid: number) {
|
||||
const chips = await this.page.locator('.MuiChip-root').all();
|
||||
expect(chips.length).toBe(expectedValid + expectedInvalid);
|
||||
|
||||
let validCount = 0;
|
||||
let errorCount = 0;
|
||||
for (const chip of chips) {
|
||||
const classAttr = await chip.getAttribute('class') || '';
|
||||
if (classAttr.includes('MuiChip-colorInfo')) {
|
||||
validCount++;
|
||||
}
|
||||
if (classAttr.includes('MuiChip-colorError')) {
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
expect(validCount).toBe(expectedValid);
|
||||
expect(errorCount).toBe(expectedInvalid);
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,142 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* App Navigation Tests - Strict version
|
||||
*
|
||||
* Tests for app pages and high-level navigation with explicit verification
|
||||
* of each element and step. Uses role-based and structure-based selectors.
|
||||
*/
|
||||
|
||||
test.describe('App Navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
});
|
||||
|
||||
test('app home page displays correctly', async ({ page }) => {
|
||||
// STRICT: Verify sidebar drawer is visible
|
||||
const sidebar = page.locator('.MuiDrawer-paper');
|
||||
await expect(sidebar).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// STRICT: Verify app name link in sidebar
|
||||
const sidebarAppLink = sidebar.getByRole('link', { name: 'Full Theme Test App' });
|
||||
await expect(sidebarAppLink).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// STRICT: Verify main content heading shows app name
|
||||
const mainHeading = page.locator('h3').filter({ hasText: 'Full Theme Test App' });
|
||||
await expect(mainHeading).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// STRICT: Verify "Data" section is visible
|
||||
await expect(page.locator('h6').filter({ hasText: 'Data' })).toBeVisible();
|
||||
|
||||
// STRICT: Verify Person table card is visible in main content
|
||||
const personCard = page.getByRole('link', { name: /Person.*total records/ });
|
||||
await expect(personCard).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('navigate from app page to table page', async ({ page }) => {
|
||||
// Wait for sidebar to be visible
|
||||
const sidebar = page.locator('.MuiDrawer-paper');
|
||||
await expect(sidebar).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// STRICT: Click on Person table card in main content
|
||||
const personCard = page.getByRole('link', { name: /Person.*total records/ });
|
||||
await expect(personCard).toBeVisible({ timeout: 5000 });
|
||||
await personCard.click();
|
||||
|
||||
// STRICT: Verify we navigated to person table - wait for data grid
|
||||
const dataGrid = page.locator('.MuiDataGrid-root');
|
||||
await expect(dataGrid).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// STRICT: Verify URL changed to person table
|
||||
await expect(page).toHaveURL(/.*\/person/);
|
||||
|
||||
// STRICT: Verify breadcrumb shows Full Theme Test App
|
||||
const breadcrumbs = page.locator('.MuiBreadcrumbs-root');
|
||||
await expect(breadcrumbs).toContainText('Full Theme Test App');
|
||||
|
||||
// STRICT: Verify page heading shows Person
|
||||
const heading = page.locator('h3').filter({ hasText: 'Person' });
|
||||
await expect(heading).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('breadcrumb navigation works', async ({ page }) => {
|
||||
// Navigate directly to person table
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
|
||||
// STRICT: Wait for data grid to confirm we're on table page
|
||||
const dataGrid = page.locator('.MuiDataGrid-root');
|
||||
await expect(dataGrid).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// STRICT: Verify breadcrumbs are visible
|
||||
const breadcrumbs = page.locator('.MuiBreadcrumbs-root');
|
||||
await expect(breadcrumbs).toBeVisible();
|
||||
|
||||
// STRICT: Click home icon in breadcrumbs
|
||||
const homeLink = breadcrumbs.getByRole('link').first();
|
||||
await expect(homeLink).toBeVisible();
|
||||
await homeLink.click();
|
||||
|
||||
// STRICT: Verify we navigated - should see app home content
|
||||
await expect(page.locator('h3').filter({ hasText: 'Full Theme Test App' })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Sidebar Navigation', () => {
|
||||
test('sidebar shows correct app structure', async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
|
||||
// STRICT: Wait for data grid to confirm page loaded
|
||||
const dataGrid = page.locator('.MuiDataGrid-root');
|
||||
await expect(dataGrid).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// STRICT: Verify sidebar drawer is visible
|
||||
const sidebar = page.locator('.MuiDrawer-paper');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// STRICT: Verify app name in sidebar as a link
|
||||
const appLink = sidebar.getByRole('link', { name: 'Full Theme Test App' });
|
||||
await expect(appLink).toBeVisible();
|
||||
|
||||
// STRICT: Verify user section exists
|
||||
await expect(sidebar).toContainText('Anonymous');
|
||||
});
|
||||
|
||||
test('logout button is visible and correct', async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
|
||||
// Wait for sidebar to load
|
||||
const sidebar = page.locator('.MuiDrawer-paper');
|
||||
await expect(sidebar).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// STRICT: Verify logout button exists with exact role and name
|
||||
const logoutButton = sidebar.getByRole('button', { name: /log out/i });
|
||||
await expect(logoutButton).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('sidebar app link navigates correctly', async ({ page }) => {
|
||||
// Start at person table
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
|
||||
// STRICT: Wait for data grid
|
||||
const dataGrid = page.locator('.MuiDataGrid-root');
|
||||
await expect(dataGrid).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// STRICT: Find and click app link in sidebar
|
||||
const sidebar = page.locator('.MuiDrawer-paper');
|
||||
const appLink = sidebar.getByRole('link', { name: 'Full Theme Test App' });
|
||||
await expect(appLink).toBeVisible({ timeout: 5000 });
|
||||
await appLink.click();
|
||||
|
||||
// STRICT: Verify navigation occurred - should see app home page
|
||||
await expect(page.locator('h3').filter({ hasText: 'Full Theme Test App' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// STRICT: Verify Person card is visible (app home shows table cards)
|
||||
const personCard = page.getByRole('link', { name: /Person.*total records/ });
|
||||
await expect(personCard).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Bulk Edit Tests - Strict version
|
||||
*
|
||||
* Tests the bulk edit process workflow with explicit verification of each step:
|
||||
* - Edit Values screen must show
|
||||
* - Review screen must show
|
||||
* - Result screen must show with success message
|
||||
*/
|
||||
|
||||
test.describe('Bulk Edit Process', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
// Wait for grid to fully load with data
|
||||
await page.waitForSelector('.MuiDataGrid-cell', { timeout: 30000 });
|
||||
});
|
||||
|
||||
test('bulk edit workflow - strict step verification', async ({ page }) => {
|
||||
// STEP 1: Select records
|
||||
// Click selection button
|
||||
const selectionButton = page.getByRole('button', { name: /selection/i });
|
||||
await expect(selectionButton).toBeVisible({ timeout: 5000 });
|
||||
await selectionButton.click();
|
||||
|
||||
// Select "This page"
|
||||
const thisPageOption = page.getByRole('menuitem', { name: /this page/i });
|
||||
await expect(thisPageOption).toBeVisible({ timeout: 5000 });
|
||||
await thisPageOption.click();
|
||||
|
||||
// Verify selection message appears
|
||||
await expect(page.getByText(/\d+ records on this page are selected/)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// STEP 2: Open bulk edit
|
||||
const actionsButton = page.getByRole('button', { name: /actions/i });
|
||||
await expect(actionsButton).toBeVisible({ timeout: 5000 });
|
||||
await actionsButton.click();
|
||||
|
||||
const bulkEditMenuItem = page.getByRole('menuitem', { name: /bulk edit/i });
|
||||
await expect(bulkEditMenuItem).toBeVisible({ timeout: 5000 });
|
||||
await bulkEditMenuItem.click();
|
||||
|
||||
// STEP 3: EDIT VALUES SCREEN - must be visible
|
||||
await expect(page.getByText('Person Bulk Edit: Edit Values')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify stepper shows EDIT VALUES as active
|
||||
const stepper = page.locator('.MuiStepper-root');
|
||||
await expect(stepper).toBeVisible();
|
||||
|
||||
// Enable firstName field
|
||||
const firstNameSwitch = page.locator('#bulkEditSwitch-firstName');
|
||||
await expect(firstNameSwitch).toBeVisible({ timeout: 5000 });
|
||||
await firstNameSwitch.click();
|
||||
|
||||
// Fill in first name value
|
||||
const firstNameInput = page.locator('input[name="firstName"]');
|
||||
await expect(firstNameInput).toBeVisible({ timeout: 5000 });
|
||||
await firstNameInput.fill('BulkTestName');
|
||||
|
||||
// Click Next to proceed to Review
|
||||
const nextButton = page.getByRole('button', { name: /next/i });
|
||||
await expect(nextButton).toBeVisible();
|
||||
await nextButton.click();
|
||||
|
||||
// STEP 4: REVIEW SCREEN (Validation Options) - must be visible
|
||||
await expect(page.getByText('Person Bulk Edit: Review')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify validation options are shown
|
||||
await expect(page.getByText('How would you like to proceed?')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('Input: 5 Person records.')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// "Perform Validation" should be selected by default
|
||||
const performValidationRadio = page.getByRole('radio', { name: /perform validation/i });
|
||||
await expect(performValidationRadio).toBeVisible();
|
||||
|
||||
// Click Next to proceed to validation summary
|
||||
const reviewNextButton = page.getByRole('button', { name: /next/i });
|
||||
await expect(reviewNextButton).toBeVisible();
|
||||
await reviewNextButton.click();
|
||||
|
||||
// STEP 5: VALIDATION SUMMARY - must be visible
|
||||
// After validation runs, we should see the summary
|
||||
await expect(page.getByText(/Person records will be edited/)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click Submit
|
||||
const submitButton = page.getByRole('button', { name: /submit/i });
|
||||
await expect(submitButton).toBeVisible({ timeout: 5000 });
|
||||
await submitButton.click();
|
||||
|
||||
// STEP 6: RESULT SCREEN - must be visible
|
||||
await expect(page.getByText('Person Bulk Edit: Result')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify success messages
|
||||
await expect(page.getByText(/Person records were processed/)).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText(/Person records were edited/)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Close the dialog
|
||||
const closeButton = page.getByRole('button', { name: /close/i });
|
||||
await expect(closeButton).toBeVisible();
|
||||
await closeButton.click();
|
||||
|
||||
// Verify we're back on the query screen
|
||||
await expect(page.locator('.MuiDataGrid-root')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import {test, expect, Page} from "@playwright/test";
|
||||
import {readFileSync} from "fs";
|
||||
import path from "path";
|
||||
|
||||
/** First-party HTTP fixtures exercise browser request and error behavior; backend authorization is tested separately. */
|
||||
async function configure(page: Page, bridgeReadable = false, denyCount = false, denyQuery = false)
|
||||
{
|
||||
const fixture = (name: string) => JSON.parse(readFileSync(path.resolve(__dirname, "../../src/test/resources/fixtures", name), "utf8"));
|
||||
const global = fixture("metaData/index.json");
|
||||
const person = fixture("qqq/v1/metaData/table/person.json");
|
||||
const toy = {name: "toy", label: "Toy", primaryKeyField: "id", readPermission: true, insertPermission: true,
|
||||
fields: {name: {name: "name", label: "Toy name", type: "STRING"}}};
|
||||
global.tables.pet = {name: "pet", label: "Pet", readPermission: bridgeReadable, insertPermission: true};
|
||||
global.tables.toy = {...toy};
|
||||
person.exposedJoins = [{label: "Toys", isMany: true, joinTable: toy, joinPath: [
|
||||
{name: "personPet", leftTable: "person", rightTable: "pet", type: "ONE_TO_MANY"},
|
||||
{name: "toyPet", leftTable: "toy", rightTable: "pet", type: "MANY_TO_ONE"}
|
||||
]}];
|
||||
const queryInputs: any[] = [];
|
||||
const countInputs: any[] = [];
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.route(/\/metaData\/?(?:\?.*)?$/, route => route.fulfill({json: global}));
|
||||
await page.route("**/metaData/table/person*", route => route.fulfill({json: person}));
|
||||
await page.route("**/qqq/v1/table/person/query*", async route =>
|
||||
{
|
||||
queryInputs.push(route.request().postDataJSON());
|
||||
if (denyQuery) await route.fulfill({status: 403, json: {error: "Permission denied."}});
|
||||
else
|
||||
{
|
||||
// Complete the successful Query after the denied Count is already displayed.
|
||||
if (denyCount) await page.getByRole("alert").filter({hasText: "Cannot count records:"}).waitFor();
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
await page.route("**/qqq/v1/table/person/count*", async route =>
|
||||
{
|
||||
countInputs.push(route.request().postDataJSON());
|
||||
if (denyCount) await route.fulfill({status: 403, json: {error: "Permission denied."}});
|
||||
else await route.continue();
|
||||
});
|
||||
return {queryInputs, countInputs, errors};
|
||||
}
|
||||
|
||||
test("restricted bridge stays out of automatic requests and field choices", async ({page}) =>
|
||||
{
|
||||
const {queryInputs, countInputs, errors} = await configure(page);
|
||||
await page.goto("/peopleApp/greetingsApp/person");
|
||||
await expect.poll(() => queryInputs.length).toBeGreaterThan(0);
|
||||
await expect.poll(() => countInputs.length).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("cell", {name: "Test", exact: true})).toBeVisible();
|
||||
expect(queryInputs.every(input => !input.joins?.some((join: any) => join.joinTable === "toy"))).toBe(true);
|
||||
expect(countInputs.every(input => !input.joins?.some((join: any) => join.joinTable === "toy"))).toBe(true);
|
||||
await page.locator('[data-qqq-id="button-columns"]').click();
|
||||
await expect(page.locator(".fieldListMenuBody-columns")).toBeVisible();
|
||||
await expect(page.locator(".fieldListMenuBody-columns")).not.toContainText("Toy name");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("readable bridge restores query choices and selected joins", async ({page}) =>
|
||||
{
|
||||
const {queryInputs, errors} = await configure(page, true);
|
||||
await page.goto("/peopleApp/greetingsApp/person");
|
||||
await expect.poll(() => queryInputs.length).toBeGreaterThan(0);
|
||||
await page.locator('[data-qqq-id="button-columns"]').click();
|
||||
await page.locator(".fieldListMenuBody-columns").getByText("Toy name", {exact: true}).click();
|
||||
await expect.poll(() => queryInputs.some(input => input.joins?.some((join: any) => join.joinTable === "toy"))).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("denied Count remains visible after successful record loading", async ({page}) =>
|
||||
{
|
||||
const {queryInputs, countInputs, errors} = await configure(page, false, true);
|
||||
await page.goto("/peopleApp/greetingsApp/person");
|
||||
await expect.poll(() => queryInputs.length).toBeGreaterThan(0);
|
||||
await expect.poll(() => countInputs.length).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("cell", {name: "Test", exact: true})).toBeVisible();
|
||||
await expect(page.getByRole("alert").filter({hasText: "Cannot count records:"})).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("saved denied criteria and ordering are retained and show an error", async ({page}) =>
|
||||
{
|
||||
const {queryInputs, countInputs, errors} = await configure(page, false, true, true);
|
||||
const filter = {criteria: [{fieldName: "toy.name", operator: "EQUALS", values: ["restricted"]}], orderBys: [{fieldName: "toy.name", isAscending: true}]};
|
||||
await page.goto("/peopleApp/greetingsApp/person?filter=" + encodeURIComponent(JSON.stringify(filter)));
|
||||
await expect.poll(() => queryInputs.length).toBeGreaterThan(0);
|
||||
await expect.poll(() => countInputs.length).toBeGreaterThan(0);
|
||||
expect(queryInputs[0].filter.criteria).toEqual(filter.criteria);
|
||||
expect(queryInputs[0].filter.orderBys).toEqual(filter.orderBys);
|
||||
expect(countInputs[0].filter.criteria).toEqual(filter.criteria);
|
||||
await expect(page.getByRole("alert").filter({hasText: "Permission denied."}).first()).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { QueryScreen } from '../lib/query-screen';
|
||||
|
||||
/**
|
||||
* Query Screen Tests - Ported from QueryScreenIT.java
|
||||
*
|
||||
* Tests for the record query screen functionality including:
|
||||
* - Building and clearing filters
|
||||
* - Multi-criteria queries with OR
|
||||
* - Boolean operators
|
||||
* - Possible values filters
|
||||
* - Criteria paster functionality
|
||||
*/
|
||||
|
||||
test.describe('Query Screen - Advanced Mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
test('build query, query and clear filters', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
await queryScreen.gotoAdvancedMode();
|
||||
await queryScreen.clickFilterBuilderButton();
|
||||
|
||||
// Add a filter for Id equals 1
|
||||
await queryScreen.addAdvancedQueryFilterInput(0, 'Id', 'equals', '1', null);
|
||||
|
||||
// Close the filter popup by clicking outside (applies the filter)
|
||||
await queryScreen.closeFilterPopup();
|
||||
await queryScreen.assertFilterButtonBadge(1);
|
||||
|
||||
// Clear the filter
|
||||
await queryScreen.clickAdvancedFilterClearIcon();
|
||||
|
||||
// Badge should be gone or show 0
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test.skip('multi-criteria query with OR', async ({ page }) => {
|
||||
// Skip for now - multi-row filter support needs more work
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
await queryScreen.gotoAdvancedMode();
|
||||
await queryScreen.clickFilterBuilderButton();
|
||||
|
||||
// Add first condition: First Name contains "Dar"
|
||||
await queryScreen.addAdvancedQueryFilterInput(0, 'First Name', 'contains', 'Dar', null);
|
||||
|
||||
// Add second condition with OR: First Name contains "Jam"
|
||||
await queryScreen.addAdvancedQueryFilterInput(1, 'First Name', 'contains', 'Jam', 'Or');
|
||||
|
||||
// Close the filter popup by clicking outside (applies the filter)
|
||||
await queryScreen.closeFilterPopup();
|
||||
|
||||
// Verify the query string shows both conditions
|
||||
await queryScreen.waitForAdvancedQueryStringMatchingRegex(/First Name.*contains.*Dar/i);
|
||||
});
|
||||
|
||||
test('advanced boolean operators - equals yes', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
await queryScreen.gotoAdvancedMode();
|
||||
await queryScreen.clickFilterBuilderButton();
|
||||
|
||||
await queryScreen.addAdvancedQueryFilterInput(0, 'Is Employed', 'equals yes', null, null);
|
||||
|
||||
// Close the filter popup by clicking outside (applies the filter)
|
||||
await queryScreen.closeFilterPopup();
|
||||
|
||||
// Verify filter is applied (use \s+ to match variable spacing)
|
||||
await queryScreen.waitForAdvancedQueryStringMatchingRegex(/Is Employed\s+equals\s+yes/i);
|
||||
|
||||
// Clear for next test
|
||||
await queryScreen.clickAdvancedFilterClearIcon();
|
||||
});
|
||||
|
||||
test('advanced boolean operators - equals no', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
await queryScreen.gotoAdvancedMode();
|
||||
await queryScreen.clickFilterBuilderButton();
|
||||
|
||||
await queryScreen.addAdvancedQueryFilterInput(0, 'Is Employed', 'equals no', null, null);
|
||||
|
||||
// Close the filter popup by clicking outside (applies the filter)
|
||||
await queryScreen.closeFilterPopup();
|
||||
await queryScreen.waitForAdvancedQueryStringMatchingRegex(/Is Employed\s+equals\s+no/i);
|
||||
});
|
||||
|
||||
test('advanced boolean operators - is empty', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
await queryScreen.gotoAdvancedMode();
|
||||
await queryScreen.clickFilterBuilderButton();
|
||||
|
||||
await queryScreen.addAdvancedQueryFilterInput(0, 'Is Employed', 'is empty', null, null);
|
||||
|
||||
// Close the filter popup by clicking outside (applies the filter)
|
||||
await queryScreen.closeFilterPopup();
|
||||
await queryScreen.waitForAdvancedQueryStringMatchingRegex(/Is Employed\s+is\s+empty/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Query Screen - Basic Mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
test('basic boolean operators - equals yes', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
|
||||
// Add Is Employed filter
|
||||
await queryScreen.addBasicFilter('Is Employed');
|
||||
|
||||
// Set to equals yes
|
||||
await queryScreen.setBasicBooleanFilter('Is Employed', 'equals yes');
|
||||
|
||||
// Verify filter button shows correct state
|
||||
await queryScreen.waitForBasicFilterButtonMatchingRegex(/Is Employed.*yes/i);
|
||||
});
|
||||
|
||||
test('basic boolean operators - equals no', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
await queryScreen.addBasicFilter('Is Employed');
|
||||
await queryScreen.setBasicBooleanFilter('Is Employed', 'equals no');
|
||||
await queryScreen.waitForBasicFilterButtonMatchingRegex(/Is Employed.*no/i);
|
||||
});
|
||||
|
||||
test('basic boolean operators - is empty', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
await queryScreen.addBasicFilter('Is Employed');
|
||||
await queryScreen.setBasicBooleanFilter('Is Employed', 'is empty');
|
||||
await queryScreen.waitForBasicFilterButtonMatchingRegex(/Is Employed.*is empty/i);
|
||||
});
|
||||
|
||||
test('basic possible values - is any of', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
|
||||
const field = 'Home City';
|
||||
await queryScreen.addBasicFilter(field);
|
||||
|
||||
// This test requires the fixture to have possible values for Home City
|
||||
// For now, we just verify the filter can be added
|
||||
await page.click(`button:has-text("${field}")`);
|
||||
await page.waitForTimeout(250);
|
||||
await page.click('#criteriaOperator');
|
||||
await page.click('li:has-text("is any of")');
|
||||
|
||||
// Verify the operator was set
|
||||
await expect(page.locator('#criteriaOperator')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Query Screen - Criteria Paster', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
test('criteria paster happy path', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
|
||||
// Open criteria paster and paste values
|
||||
await queryScreen.openCriteriaPasterAndPasteValues('id', ['1', '2', '3']);
|
||||
|
||||
// Wait for chips to appear
|
||||
await page.waitForTimeout(500);
|
||||
await queryScreen.assertFilterPasterChipCounts(3, 0);
|
||||
|
||||
// Verify chips have the info (blue) color
|
||||
const chips = await page.locator('.MuiChip-root').all();
|
||||
for (const chip of chips) {
|
||||
const classAttr = await chip.getAttribute('class') || '';
|
||||
expect(classAttr).toContain('MuiChip-colorInfo');
|
||||
}
|
||||
});
|
||||
|
||||
test('criteria paster with invalid value', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
|
||||
// Paste values including an invalid one
|
||||
await queryScreen.openCriteriaPasterAndPasteValues('id', ['1', 'a', '3']);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await queryScreen.assertFilterPasterChipCounts(2, 1);
|
||||
|
||||
// Check for validation error message
|
||||
await expect(page.locator('span:has-text("value is not a number")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('criteria paster with duplicate values', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
|
||||
// Paste values with duplicates
|
||||
const values = ['1', '1', '1', '2', '2'];
|
||||
await queryScreen.openCriteriaPasterAndPasteValues('id', values);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// All 5 values should show as chips
|
||||
await queryScreen.assertFilterPasterChipCounts(values.length, 0);
|
||||
|
||||
// Counter should show "5 values (2 unique)"
|
||||
await expect(page.locator('span:has-text("unique")')).toContainText('5 values (2 unique)');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Query Screen - Data Grid', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
test('data grid displays records', async ({ page }) => {
|
||||
const queryScreen = new QueryScreen(page);
|
||||
|
||||
await queryScreen.waitForQueryToHaveRan();
|
||||
|
||||
// Check that the DataGrid has cells
|
||||
await expect(page.locator('.MuiDataGrid-cell').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('data grid column headers are visible', async ({ page }) => {
|
||||
await page.waitForSelector('.MuiDataGrid-columnHeader');
|
||||
await expect(page.locator('.MuiDataGrid-columnHeader').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,345 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Round 6 Visual Regression Tests
|
||||
*
|
||||
* Tests verify fixes for issues reported by Darin:
|
||||
* 1. Sidenav double-highlight on hover
|
||||
* 2. Chip border radius changed
|
||||
* 3. Menu item padding increased
|
||||
* 4. View field height reduced (button typography)
|
||||
*/
|
||||
|
||||
test.describe('Round 6 Regression Fixes - Themed App', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Use themed fixture
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test.describe('Issue 1: Sidenav Hover - Single Highlight Only', () => {
|
||||
test('sidenav item has only JS-controlled hover styling (no CSS override)', async ({ page }) => {
|
||||
// The fix removed CSS hover rules that were causing double-highlight
|
||||
// Wait for sidebar to be visible (it may be collapsed on smaller screens)
|
||||
const drawer = page.locator('.MuiDrawer-paper');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// Find any visible nav item in the sidebar
|
||||
const sidenavItem = page.locator('.MuiDrawer-paper .MuiListItem-root').first();
|
||||
await expect(sidenavItem).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Get initial background
|
||||
const initialBg = await sidenavItem.evaluate(el => getComputedStyle(el).backgroundColor);
|
||||
|
||||
// Hover and check background changes (should be controlled by JS, not CSS)
|
||||
await sidenavItem.hover();
|
||||
await page.waitForTimeout(300);
|
||||
const hoverBg = await sidenavItem.evaluate(el => getComputedStyle(el).backgroundColor);
|
||||
|
||||
// The hover background should change, but there should not be multiple overlapping backgrounds
|
||||
// The key test is that the page renders without visual artifacts
|
||||
expect(hoverBg).toBeTruthy();
|
||||
});
|
||||
|
||||
test('no redundant CSS hover rules in computed styles', async ({ page }) => {
|
||||
// Check that the removed CSS rule is not applying
|
||||
const result = await page.evaluate(() => {
|
||||
// Look for any sidenav item
|
||||
const item = document.querySelector('.MuiDrawer-paper a, .MuiDrawer-paper .MuiListItem-root');
|
||||
if (!item) return { found: false, hasInlineHover: false };
|
||||
|
||||
// Check if any external stylesheet is applying the removed hover rule
|
||||
// We removed: .MuiDrawer-paper a:hover, .MuiDrawer-paper .MuiListItem-root:hover
|
||||
const sheets = Array.from(document.styleSheets);
|
||||
let hasRemovedRule = false;
|
||||
|
||||
try {
|
||||
for (const sheet of sheets) {
|
||||
try {
|
||||
const rules = Array.from(sheet.cssRules || []);
|
||||
for (const rule of rules) {
|
||||
if (rule instanceof CSSStyleRule) {
|
||||
const selector = rule.selectorText;
|
||||
if (selector &&
|
||||
(selector.includes('.MuiDrawer-paper a:hover') ||
|
||||
selector.includes('.MuiDrawer-paper .MuiListItem-root:hover'))) {
|
||||
// Check if this rule sets background-color
|
||||
if (rule.style.backgroundColor) {
|
||||
hasRemovedRule = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin stylesheets will throw, ignore them
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return { found: true, hasRemovedRule };
|
||||
});
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.hasRemovedRule).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Issue 2: Chip Border Radius (4px default, not 8px)', () => {
|
||||
test('chips use borderRadius based on 4px default (2px for chip)', async ({ page }) => {
|
||||
// Navigate to a page with chips if available, or test with a chip if present
|
||||
// The default borderRadius is 4px, chips use borderRadius/2 = 2px
|
||||
const result = await page.evaluate(() => {
|
||||
// Check the shape.borderRadius from the theme via CSS variable
|
||||
const root = document.documentElement;
|
||||
const borderRadiusVar = getComputedStyle(root).getPropertyValue('--qqq-border-radius').trim();
|
||||
|
||||
// Also check if any chip is rendered
|
||||
const chip = document.querySelector('.MuiChip-root');
|
||||
let chipBorderRadius = '';
|
||||
if (chip) {
|
||||
chipBorderRadius = getComputedStyle(chip).borderRadius;
|
||||
}
|
||||
|
||||
return {
|
||||
cssVar: borderRadiusVar,
|
||||
chipBorderRadius,
|
||||
hasChip: !!chip
|
||||
};
|
||||
});
|
||||
|
||||
// The fixture uses 12px, but default should be 4px
|
||||
// If no chip found, at least verify the CSS variable is set
|
||||
if (result.hasChip) {
|
||||
// Chip borderRadius should be half of the theme borderRadius
|
||||
// With 12px theme (from fixture), chip should be 6px
|
||||
expect(result.chipBorderRadius).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Issue 3: Menu Item Padding (original values, not increased)', () => {
|
||||
test('menu items have correct padding from menuItem.ts (~5px 16px)', async ({ page }) => {
|
||||
// Find a button that opens a menu
|
||||
const menuButton = page.locator('[aria-haspopup="true"]').first();
|
||||
|
||||
if (await menuButton.isVisible().catch(() => false)) {
|
||||
await menuButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const menuItem = document.querySelector('.MuiMenuItem-root');
|
||||
if (!menuItem) return { found: false, padding: '' };
|
||||
|
||||
const styles = getComputedStyle(menuItem);
|
||||
return {
|
||||
found: true,
|
||||
padding: styles.padding,
|
||||
paddingTop: styles.paddingTop,
|
||||
paddingBottom: styles.paddingBottom,
|
||||
paddingLeft: styles.paddingLeft,
|
||||
paddingRight: styles.paddingRight,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
// Original menuItem.ts uses pxToRem(4.8) which is ~5px vertical
|
||||
// Should NOT be 8px (that was our regression)
|
||||
const topPadding = parseFloat(result.paddingTop);
|
||||
const bottomPadding = parseFloat(result.paddingBottom);
|
||||
|
||||
// Padding should be less than 7px (the regression was 8px)
|
||||
expect(topPadding).toBeLessThan(7);
|
||||
expect(bottomPadding).toBeLessThan(7);
|
||||
}
|
||||
} else {
|
||||
// Skip if no menu button found - test passes vacuously
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Issue 5: Button Color (Edit Filters and Columns)', () => {
|
||||
test('text primary buttons have correct color styling (not scoped to .qqq-themed)', async ({ page }) => {
|
||||
// The fix removed .qqq-themed scoping so button color applies to ALL apps
|
||||
const result = await page.evaluate(() => {
|
||||
// Look for any text primary button (like "Edit Filters and Columns")
|
||||
const button = document.querySelector('.MuiButton-textPrimary');
|
||||
if (!button) return { found: false, color: '' };
|
||||
|
||||
const styles = getComputedStyle(button);
|
||||
return {
|
||||
found: true,
|
||||
color: styles.color,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
// Button should have a color applied (not default inherited)
|
||||
expect(result.color).toBeTruthy();
|
||||
// The color should be from our CSS (--qqq-primary-color fallback is #0062FF)
|
||||
// RGB values will vary based on theme, but should not be black/inherit
|
||||
} else {
|
||||
// Skip if no text primary button found
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test('button CSS rules are NOT scoped to .qqq-themed class', async ({ page }) => {
|
||||
// Verify that button color CSS is not restricted to themed apps
|
||||
const result = await page.evaluate(() => {
|
||||
const sheets = Array.from(document.styleSheets);
|
||||
let hasUnscopedButtonRule = false;
|
||||
let hasScopedButtonRule = false;
|
||||
|
||||
try {
|
||||
for (const sheet of sheets) {
|
||||
try {
|
||||
const rules = Array.from(sheet.cssRules || []);
|
||||
for (const rule of rules) {
|
||||
if (rule instanceof CSSStyleRule) {
|
||||
const selector = rule.selectorText;
|
||||
if (selector) {
|
||||
// Check for unscoped button rules (what we want)
|
||||
if (selector === '.MuiButton-textPrimary' ||
|
||||
selector === '.MuiButton-containedPrimary') {
|
||||
hasUnscopedButtonRule = true;
|
||||
}
|
||||
// Check for scoped button rules (what we removed)
|
||||
if (selector.includes('.qqq-themed') &&
|
||||
selector.includes('.MuiButton-')) {
|
||||
hasScopedButtonRule = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin stylesheets will throw, ignore them
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return { hasUnscopedButtonRule, hasScopedButtonRule };
|
||||
});
|
||||
|
||||
// Should have unscoped button rules (matching develop behavior)
|
||||
expect(result.hasUnscopedButtonRule).toBe(true);
|
||||
// Should NOT have .qqq-themed scoped button rules (regression fix)
|
||||
expect(result.hasScopedButtonRule).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Issue 4: Button Typography (view field height)', () => {
|
||||
test('buttons have lineHeight of 1.75 (not 1.5)', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Find any button
|
||||
const button = document.querySelector('.MuiButton-root');
|
||||
if (!button) return { found: false, lineHeight: '' };
|
||||
|
||||
const styles = getComputedStyle(button);
|
||||
return {
|
||||
found: true,
|
||||
lineHeight: styles.lineHeight,
|
||||
fontWeight: styles.fontWeight,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
// lineHeight should be 1.75, not 1.5
|
||||
// Note: computed lineHeight may be in px, so we check it's not the smaller value
|
||||
const lineHeight = parseFloat(result.lineHeight);
|
||||
|
||||
// If it's a ratio (like 1.75), check it directly
|
||||
// If it's in px, it should reflect the larger lineHeight
|
||||
if (lineHeight < 10) {
|
||||
// It's a ratio
|
||||
expect(lineHeight).toBeGreaterThanOrEqual(1.7);
|
||||
}
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test('buttons have fontWeight of 500 or configured value (not 300)', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const button = document.querySelector('.MuiButton-root');
|
||||
if (!button) return { found: false, fontWeight: '' };
|
||||
|
||||
return {
|
||||
found: true,
|
||||
fontWeight: getComputedStyle(button).fontWeight,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
// fontWeight should be >= 500 (develop value), not 300
|
||||
// The fixture sets 600, default should be 500
|
||||
const weight = parseInt(result.fontWeight);
|
||||
expect(weight).toBeGreaterThanOrEqual(500);
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* NOTE: Unthemed tests are in unthemed-regression.spec.ts
|
||||
* This file tests the themed app behavior after Round 6 fixes.
|
||||
*
|
||||
* The key verifications for default values:
|
||||
* - Default borderRadius changed from 8px to 4px (in parseBorderRadius)
|
||||
* - Default button fontWeight changed from 300 to 500
|
||||
* - Default button lineHeight changed from 1.5 to 1.75
|
||||
*
|
||||
* These defaults apply when no theme is configured OR when theme
|
||||
* properties are not explicitly set.
|
||||
*/
|
||||
test.describe('Round 6 - Default Value Verification (via themed fixture override)', () => {
|
||||
test('parseBorderRadius defaults to 4 (verified via code inspection)', async ({ page }) => {
|
||||
// This test verifies our code change by checking the DEFAULT_THEME
|
||||
// The actual rendered value in themed tests uses fixture's 12px
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
|
||||
// Check that the CSS variable is set (fixture uses 12px)
|
||||
const result = await page.evaluate(() => {
|
||||
const cssVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--qqq-border-radius').trim();
|
||||
return { cssVar };
|
||||
});
|
||||
|
||||
// Fixture uses 12px, which proves the theming system works
|
||||
// The default of 4px applies when no theme is configured
|
||||
expect(result.cssVar).toBe('12px');
|
||||
});
|
||||
|
||||
test('DEFAULT_THEME button values are correct (500 weight, 1.75 lineHeight)', async ({ page }) => {
|
||||
// The fixture overrides these, but we verify the themed app works
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const button = document.querySelector('.MuiButton-root');
|
||||
if (!button) return { found: false };
|
||||
|
||||
const styles = getComputedStyle(button);
|
||||
return {
|
||||
found: true,
|
||||
fontWeight: styles.fontWeight,
|
||||
lineHeight: styles.lineHeight,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
// Fixture sets typographyButtonFontWeight: 600
|
||||
// This proves the theming override works
|
||||
const weight = parseInt(result.fontWeight);
|
||||
expect(weight).toBeGreaterThanOrEqual(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {colorsMatch} from "../utils/color-helpers";
|
||||
|
||||
/**
|
||||
* STRICT Theme Verification Tests for QQQ MaterialDashboardThemeMetaData
|
||||
* STRICT Theme Verification Tests for QQQ QThemeMetaData
|
||||
*
|
||||
* ALL tests verify ACTUAL rendered values on components.
|
||||
* Tests FAIL if:
|
||||
@@ -60,6 +59,78 @@ const THEME = {
|
||||
borderColor: '#B0BEC5',
|
||||
};
|
||||
|
||||
// Helper to convert hex to RGB
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
// Helper to parse rgb/rgba string
|
||||
function parseRgb(rgbString: string): { r: number; g: number; b: number; a?: number } | null {
|
||||
// Handle rgb(r, g, b) and rgba(r, g, b, a)
|
||||
const rgbMatch = rgbString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (rgbMatch) {
|
||||
return {
|
||||
r: parseInt(rgbMatch[1]),
|
||||
g: parseInt(rgbMatch[2]),
|
||||
b: parseInt(rgbMatch[3]),
|
||||
a: rgbMatch[4] ? parseFloat(rgbMatch[4]) : undefined
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to parse rgba from expected value (could be hex or rgba string)
|
||||
function parseExpectedColor(color: string): { r: number; g: number; b: number; a?: number } | null {
|
||||
// If it's already rgba format
|
||||
if (color.startsWith('rgba')) {
|
||||
return parseRgb(color);
|
||||
}
|
||||
// If it's hex
|
||||
if (color.startsWith('#')) {
|
||||
const rgb = hexToRgb(color);
|
||||
return { ...rgb, a: undefined };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to check if colors match (with tolerance)
|
||||
function colorsMatch(actual: string, expected: string, tolerance = 2): boolean {
|
||||
// Handle hex-to-hex comparison (case insensitive)
|
||||
if (actual.startsWith('#') && expected.startsWith('#')) {
|
||||
return actual.toLowerCase() === expected.toLowerCase();
|
||||
}
|
||||
|
||||
// Parse actual color (could be rgb, rgba, or hex)
|
||||
let actualRgb = parseRgb(actual);
|
||||
if (!actualRgb && actual.startsWith('#')) {
|
||||
const hex = hexToRgb(actual);
|
||||
actualRgb = { ...hex, a: undefined };
|
||||
}
|
||||
|
||||
const expectedRgb = parseExpectedColor(expected);
|
||||
if (!actualRgb || !expectedRgb) return false;
|
||||
|
||||
const rgbMatch = (
|
||||
Math.abs(actualRgb.r - expectedRgb.r) <= tolerance &&
|
||||
Math.abs(actualRgb.g - expectedRgb.g) <= tolerance &&
|
||||
Math.abs(actualRgb.b - expectedRgb.b) <= tolerance
|
||||
);
|
||||
|
||||
// If both have alpha, compare alpha too
|
||||
if (actualRgb.a !== undefined && expectedRgb.a !== undefined) {
|
||||
return rgbMatch && Math.abs(actualRgb.a - expectedRgb.a) <= 0.05;
|
||||
}
|
||||
|
||||
return rgbMatch;
|
||||
}
|
||||
|
||||
// Helper to format assertion message
|
||||
function colorMismatch(property: string, expected: string, actual: string): string {
|
||||
return `${property}: expected ${expected}, got ${actual}`;
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {colorsMatch, hexToRgb, isTransparentOrNearlyTransparent, parseRgb} from "../utils/color-helpers";
|
||||
|
||||
/**
|
||||
* STRICT Visual Regression Tests for UNTHEMED Apps (Issue #128)
|
||||
*
|
||||
* These tests verify that apps WITHOUT a backend theme look correct.
|
||||
* Run with: THEME_FIXTURE=index npm run e2e:unthemed
|
||||
*
|
||||
* IMPORTANT: These tests require the 'index' fixture (no theme configured).
|
||||
* They will be SKIPPED if running with a themed fixture (e.g., withFullCustomTheme).
|
||||
*
|
||||
* To run these tests:
|
||||
* npm run e2e:unthemed (uses THEME_FIXTURE=index)
|
||||
*
|
||||
* To run all tests properly:
|
||||
* npm run e2e:all (runs themed then unthemed with correct fixtures)
|
||||
*
|
||||
* Tests verify:
|
||||
* 1. Sidebar has correct DEFAULT_THEME colors (dark background, white text)
|
||||
* 2. Navbar/AppBar is NOT white (transparent, not affected by surfaceColor)
|
||||
* 3. CSS variables are injected with DEFAULT_THEME values
|
||||
* 4. Cards/Paper get surfaceColor but AppBar does not
|
||||
*/
|
||||
|
||||
// Helper function to check if themed fixture is loaded and skip if so
|
||||
async function skipIfThemedFixture(page: import('@playwright/test').Page) {
|
||||
const hasTheme = await page.evaluate(() => {
|
||||
const primaryColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--qqq-primary-color').trim();
|
||||
return primaryColor !== '';
|
||||
});
|
||||
|
||||
if (hasTheme) {
|
||||
test.skip(true, 'Skipping unthemed test: themed fixture detected. Run with: npm run e2e:unthemed');
|
||||
}
|
||||
}
|
||||
|
||||
// DEFAULT_THEME values from src/qqq/utils/themeUtils.ts
|
||||
// These must match the values in themeUtils.ts to restore pre-PR-125 appearance
|
||||
const DEFAULT_THEME = {
|
||||
// Core colors
|
||||
primaryColor: '#0062FF',
|
||||
backgroundColor: '#f0f2f5',
|
||||
surfaceColor: '#ffffff',
|
||||
// Sidebar (dark gray matching original gradients.dark.main)
|
||||
sidebarBackgroundColor: '#42424a',
|
||||
sidebarTextColor: '#ffffff',
|
||||
sidebarIconColor: '#ffffff',
|
||||
sidebarSelectedBackgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
sidebarSelectedTextColor: '#ffffff',
|
||||
sidebarHoverBackgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
// Typography (matching original typography.ts)
|
||||
fontFamily: '"SF Pro Display", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||
};
|
||||
|
||||
// Helper to check if color is NOT white (surfaceColor)
|
||||
function isNotWhite(color: string): boolean {
|
||||
// Check if transparent
|
||||
if (isTransparentOrNearlyTransparent(color)) {
|
||||
return true;
|
||||
}
|
||||
// Check if NOT white (#ffffff or rgb(255,255,255))
|
||||
const actualRgb = parseRgb(color);
|
||||
if (!actualRgb) {
|
||||
if (color.startsWith('#')) {
|
||||
const hex = hexToRgb(color);
|
||||
if (!hex) {
|
||||
return true;
|
||||
}
|
||||
return !(hex.r > 250 && hex.g > 250 && hex.b > 250);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Allow some tolerance - if it's close to white, it's a problem
|
||||
return !(actualRgb.r > 250 && actualRgb.g > 250 && actualRgb.b > 250);
|
||||
}
|
||||
|
||||
// Helper to check if a background (which may be a gradient) contains a color
|
||||
function backgroundContainsColor(background: string, expectedColor: string, tolerance = 5): boolean {
|
||||
// Extract all rgb/rgba values from the background string
|
||||
const rgbMatches = background.matchAll(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g);
|
||||
const expectedRgb = expectedColor.startsWith('#') ? hexToRgb(expectedColor) : parseRgb(expectedColor);
|
||||
if (!expectedRgb) return false;
|
||||
|
||||
for (const match of rgbMatches) {
|
||||
const r = parseInt(match[1]);
|
||||
const g = parseInt(match[2]);
|
||||
const b = parseInt(match[3]);
|
||||
if (
|
||||
Math.abs(r - expectedRgb.r) <= tolerance &&
|
||||
Math.abs(g - expectedRgb.g) <= tolerance &&
|
||||
Math.abs(b - expectedRgb.b) <= tolerance
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CSS VARIABLES - Verify they are NOT injected for unthemed apps
|
||||
// CSS variables are 100% optional - CSS uses fallback values
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - CSS Variables Are Optional', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(1000);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('no --qqq-* CSS variables are injected when no theme is configured', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const style = getComputedStyle(root);
|
||||
return {
|
||||
sidebarBg: style.getPropertyValue('--qqq-sidebar-background-color').trim(),
|
||||
sidebarText: style.getPropertyValue('--qqq-sidebar-text-color').trim(),
|
||||
surfaceColor: style.getPropertyValue('--qqq-surface-color').trim(),
|
||||
primaryColor: style.getPropertyValue('--qqq-primary-color').trim(),
|
||||
};
|
||||
});
|
||||
// All CSS variables should be empty (not injected) for unthemed apps
|
||||
expect(result.sidebarBg, 'CSS variable should not be injected for unthemed app').toBe('');
|
||||
expect(result.sidebarText, 'CSS variable should not be injected for unthemed app').toBe('');
|
||||
expect(result.surfaceColor, 'CSS variable should not be injected for unthemed app').toBe('');
|
||||
expect(result.primaryColor, 'CSS variable should not be injected for unthemed app').toBe('');
|
||||
});
|
||||
|
||||
test('body does NOT have .qqq-themed class when no theme is configured', async ({ page }) => {
|
||||
const hasClass = await page.evaluate(() => {
|
||||
return document.body.classList.contains('qqq-themed');
|
||||
});
|
||||
expect(hasClass, 'Body should not have .qqq-themed class for unthemed app').toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// SIDEBAR - Verify dark theme colors via CSS fallbacks
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - Sidebar Colors', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(1000);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('sidebar has dark background via CSS fallback', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper');
|
||||
if (!el) return { found: false, value: '', debug: 'No .MuiDrawer-paper found' };
|
||||
// Use 'background' not 'backgroundColor' because gradients set background-image
|
||||
return { found: true, value: getComputedStyle(el).background, debug: '' };
|
||||
});
|
||||
expect(result.found, `Sidebar not found: ${result.debug}`).toBe(true);
|
||||
// Check that the background contains the expected dark color (gradient includes #42424a)
|
||||
expect(
|
||||
backgroundContainsColor(result.value, DEFAULT_THEME.sidebarBackgroundColor),
|
||||
`Sidebar background should contain ${DEFAULT_THEME.sidebarBackgroundColor}, got ${result.value}`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebar text is white from DEFAULT_THEME', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper .MuiListItemText-primary, .MuiDrawer-paper .MuiTypography-root');
|
||||
if (!el) return { found: false, value: '', debug: 'No sidebar text found' };
|
||||
return { found: true, value: getComputedStyle(el).color, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Sidebar text not found: ${result.debug}`).toBe(true);
|
||||
expect(
|
||||
colorsMatch(result.value, DEFAULT_THEME.sidebarTextColor, 5),
|
||||
`Sidebar text color: expected ${DEFAULT_THEME.sidebarTextColor}, got ${result.value}`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebar icons are white from DEFAULT_THEME', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper .MuiIcon-root, .MuiDrawer-paper .material-icons');
|
||||
if (!el) return { found: false, value: '', debug: 'No sidebar icon found' };
|
||||
return { found: true, value: getComputedStyle(el).color, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Sidebar icon not found: ${result.debug}`).toBe(true);
|
||||
expect(
|
||||
colorsMatch(result.value, DEFAULT_THEME.sidebarIconColor, 5),
|
||||
`Sidebar icon color: expected ${DEFAULT_THEME.sidebarIconColor}, got ${result.value}`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// NAVBAR/APPBAR - Verify NOT white (Issue #128 regression)
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - Navbar NOT White (Issue #128)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(1000);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('CRITICAL: AppBar background is NOT white/surfaceColor', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const appBar = document.querySelector('.MuiAppBar-root');
|
||||
if (!appBar) return { found: false, value: '', debug: 'No .MuiAppBar-root found' };
|
||||
return { found: true, value: getComputedStyle(appBar).backgroundColor, debug: '' };
|
||||
});
|
||||
expect(result.found, `AppBar not found: ${result.debug}`).toBe(true);
|
||||
expect(
|
||||
isNotWhite(result.value),
|
||||
`REGRESSION: AppBar background is white (${result.value}). Should be transparent or non-white.`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('CRITICAL: Breadcrumb area is NOT white', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Find the breadcrumb container or navbar area
|
||||
const selectors = [
|
||||
'.MuiBreadcrumbs-root',
|
||||
'.MuiToolbar-root',
|
||||
'.MuiAppBar-root .MuiBox-root'
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
return { found: true, value: bg, selector: sel };
|
||||
}
|
||||
}
|
||||
return { found: false, value: '', selector: '' };
|
||||
});
|
||||
expect(result.found, 'Breadcrumb/toolbar area not found').toBe(true);
|
||||
expect(
|
||||
isNotWhite(result.value),
|
||||
`REGRESSION: Breadcrumb area (${result.selector}) is white (${result.value}). Should not be white.`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('AppBar does not have --qqq-surface-color applied', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const appBar = document.querySelector('.MuiAppBar-root');
|
||||
if (!appBar) return { found: false, isSurfaceColor: false, value: '' };
|
||||
const bg = getComputedStyle(appBar).backgroundColor;
|
||||
// Check if it matches surfaceColor (#ffffff)
|
||||
const isSurfaceColor = bg === 'rgb(255, 255, 255)' || bg === '#ffffff';
|
||||
return { found: true, isSurfaceColor, value: bg };
|
||||
});
|
||||
expect(result.found, 'AppBar not found').toBe(true);
|
||||
expect(
|
||||
result.isSurfaceColor,
|
||||
`REGRESSION: AppBar has surfaceColor (#ffffff) applied. Value: ${result.value}`
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// CARDS/PAPER - Verify surfaceColor IS applied (but not to AppBar)
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - Cards Use Surface Color', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2500);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('Cards in main content use surfaceColor', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Find a card in main content, NOT in drawer/sidebar
|
||||
const el = document.querySelector('main .MuiCard-root, .MuiCard-root:not(.MuiDrawer-paper *)');
|
||||
if (!el) return { found: false, value: '', debug: 'No card found in main content' };
|
||||
return { found: true, value: getComputedStyle(el).backgroundColor, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Card not found: ${result.debug}`).toBe(true);
|
||||
expect(
|
||||
colorsMatch(result.value, DEFAULT_THEME.surfaceColor, 5),
|
||||
`Card background: expected ${DEFAULT_THEME.surfaceColor}, got ${result.value}`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BRANDED HEADER - Verify NOT shown when disabled
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - Branded Header Disabled', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('branded header is not visible when not configured', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const header = document.querySelector('.qqq-branded-header-bar');
|
||||
if (!header) return { exists: false, height: '0' };
|
||||
const height = getComputedStyle(header).height;
|
||||
const display = getComputedStyle(header).display;
|
||||
return { exists: true, height, display };
|
||||
});
|
||||
// Either the element doesn't exist, or it has 0 height, or it's hidden
|
||||
const isHidden = !result.exists ||
|
||||
result.height === '0px' ||
|
||||
result.height === '0' ||
|
||||
result.display === 'none';
|
||||
expect(isHidden, `Branded header should not be visible. Height: ${result.height}`).toBe(true);
|
||||
});
|
||||
|
||||
test('--qqq-branded-header-height is not set (CSS fallback to 0px)', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--qqq-branded-header-height').trim();
|
||||
return { value };
|
||||
});
|
||||
// CSS variable should be empty (not injected) - CSS fallback handles it
|
||||
expect(
|
||||
result.value === '' || result.value === '0px' || result.value === '0',
|
||||
`--qqq-branded-header-height should be empty or 0px, got ${result.value}`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebar does not overhang content area', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const sidebar = document.querySelector('.MuiDrawer-paper');
|
||||
// Try multiple selectors for main content area
|
||||
const mainContent = document.querySelector('main') ||
|
||||
document.querySelector('[class*="content"]') ||
|
||||
document.querySelector('.MuiBox-root > .MuiBox-root');
|
||||
if (!sidebar) {
|
||||
return { found: false, sidebarTop: 0, contentTop: 0, debug: 'No sidebar found' };
|
||||
}
|
||||
const sidebarRect = sidebar.getBoundingClientRect();
|
||||
// If we can't find main content, just verify sidebar starts at top (no overhang)
|
||||
if (!mainContent) {
|
||||
// Sidebar should start at or near the top of viewport
|
||||
return {
|
||||
found: true,
|
||||
sidebarTop: sidebarRect.top,
|
||||
contentTop: 0,
|
||||
isAligned: sidebarRect.top <= 100, // Should be near top
|
||||
debug: 'Using viewport top as reference'
|
||||
};
|
||||
}
|
||||
const contentRect = mainContent.getBoundingClientRect();
|
||||
return {
|
||||
found: true,
|
||||
sidebarTop: sidebarRect.top,
|
||||
contentTop: contentRect.top,
|
||||
// Sidebar should start at same level or below content
|
||||
isAligned: sidebarRect.top <= contentRect.top + 50,
|
||||
debug: 'Found main content'
|
||||
};
|
||||
});
|
||||
expect(result.found, `Sidebar not found: ${result.debug}`).toBe(true);
|
||||
expect(
|
||||
result.isAligned,
|
||||
`Sidebar overhang: sidebar top (${result.sidebarTop}) should align with content top (${result.contentTop}). Debug: ${result.debug}`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DIAGNOSTIC - Output actual values for debugging
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - Diagnostic Dump', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('output all CSS variables and rendered values', async ({ page }) => {
|
||||
|
||||
const diagnostics = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const getVar = (name: string) => getComputedStyle(root).getPropertyValue(name).trim();
|
||||
const getBg = (selector: string) => {
|
||||
const el = document.querySelector(selector);
|
||||
return el ? getComputedStyle(el).backgroundColor : 'NOT FOUND';
|
||||
};
|
||||
const getColor = (selector: string) => {
|
||||
const el = document.querySelector(selector);
|
||||
return el ? getComputedStyle(el).color : 'NOT FOUND';
|
||||
};
|
||||
|
||||
return {
|
||||
cssVariables: {
|
||||
'--qqq-sidebar-background-color': getVar('--qqq-sidebar-background-color'),
|
||||
'--qqq-sidebar-text-color': getVar('--qqq-sidebar-text-color'),
|
||||
'--qqq-surface-color': getVar('--qqq-surface-color'),
|
||||
'--qqq-primary-color': getVar('--qqq-primary-color'),
|
||||
'--qqq-branded-header-height': getVar('--qqq-branded-header-height'),
|
||||
'--qqq-branded-header-enabled': getVar('--qqq-branded-header-enabled'),
|
||||
},
|
||||
renderedValues: {
|
||||
sidebarBackground: getBg('.MuiDrawer-paper'),
|
||||
sidebarText: getColor('.MuiDrawer-paper .MuiTypography-root'),
|
||||
appBarBackground: getBg('.MuiAppBar-root'),
|
||||
toolbarBackground: getBg('.MuiToolbar-root'),
|
||||
cardBackground: getBg('.MuiCard-root'),
|
||||
bodyBackground: getBg('body'),
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
console.log('=== UNTHEMED APP DIAGNOSTIC ===');
|
||||
console.log(JSON.stringify(diagnostics, null, 2));
|
||||
console.log('===============================');
|
||||
|
||||
// This test always passes - diagnostic only
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* Visual Regression Tests for QQQ Frontend Material Dashboard
|
||||
*
|
||||
* These tests capture baseline screenshots of the unthemed application.
|
||||
* When theming is re-implemented, these tests will ensure the unthemed
|
||||
* appearance remains unchanged.
|
||||
*
|
||||
* Run with: npx playwright test
|
||||
* Update baselines with: npx playwright test --update-snapshots
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Tight tolerance for pixel-level regression detection
|
||||
// Snapshots must be generated on Linux (via Docker) to match CI environment
|
||||
const SCREENSHOT_OPTIONS = {
|
||||
maxDiffPixelRatio: 0.01, // 1% tolerance - pixel-level accuracy
|
||||
};
|
||||
|
||||
test.describe('Visual Regression - Unthemed Baseline', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Wait for app to fully load
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('app home page layout', async ({ page }) => {
|
||||
// Wait for main content to render (the app bar is always visible)
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
// Give time for all content to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Capture full page screenshot
|
||||
await expect(page).toHaveScreenshot('home-page-full.png', {
|
||||
fullPage: true,
|
||||
...SCREENSHOT_OPTIONS,
|
||||
});
|
||||
});
|
||||
|
||||
test('sidebar navigation', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// The sidebar drawer paper contains the navigation
|
||||
const sidebarPaper = page.locator('.MuiDrawer-paper');
|
||||
|
||||
if (await sidebarPaper.count() > 0 && await sidebarPaper.isVisible()) {
|
||||
// Capture sidebar screenshot
|
||||
await expect(sidebarPaper).toHaveScreenshot('sidebar-navigation.png', SCREENSHOT_OPTIONS);
|
||||
} else {
|
||||
// If sidebar is collapsed/hidden, skip this test
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test('top navigation bar', async ({ page }) => {
|
||||
// Wait for app bar to render
|
||||
const appBar = page.locator('.MuiAppBar-root');
|
||||
await expect(appBar).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Capture app bar screenshot
|
||||
await expect(appBar).toHaveScreenshot('top-navigation-bar.png', SCREENSHOT_OPTIONS);
|
||||
});
|
||||
|
||||
test('button styles - contained primary', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find a contained primary button
|
||||
const button = page.locator('.MuiButton-containedPrimary').first();
|
||||
|
||||
if (await button.count() > 0) {
|
||||
await expect(button).toHaveScreenshot('button-contained-primary.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('button styles - text primary', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find a text primary button
|
||||
const button = page.locator('.MuiButton-textPrimary').first();
|
||||
|
||||
if (await button.count() > 0) {
|
||||
await expect(button).toHaveScreenshot('button-text-primary.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('typography hierarchy', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Capture various typography elements if visible
|
||||
const h6 = page.locator('h6').first();
|
||||
const body = page.locator('.MuiTypography-body1').first();
|
||||
|
||||
// Capture h6 (commonly used in dashboard)
|
||||
if (await h6.count() > 0 && await h6.isVisible()) {
|
||||
await expect(h6).toHaveScreenshot('typography-h6.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
|
||||
// Capture body text
|
||||
if (await body.count() > 0 && await body.isVisible()) {
|
||||
await expect(body).toHaveScreenshot('typography-body1.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('card component styles', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find a card component
|
||||
const card = page.locator('.MuiCard-root').first();
|
||||
|
||||
if (await card.count() > 0) {
|
||||
await expect(card).toHaveScreenshot('card-component.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('paper component styles', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find a paper component (excluding cards)
|
||||
const paper = page.locator('.MuiPaper-root:not(.MuiCard-root)').first();
|
||||
|
||||
if (await paper.count() > 0) {
|
||||
await expect(paper).toHaveScreenshot('paper-component.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('input field styles', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find an input field
|
||||
const input = page.locator('.MuiTextField-root, .MuiOutlinedInput-root').first();
|
||||
|
||||
if (await input.count() > 0) {
|
||||
await expect(input).toHaveScreenshot('input-field.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('chip/badge styles', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find a chip component
|
||||
const chip = page.locator('.MuiChip-root').first();
|
||||
|
||||
if (await chip.count() > 0) {
|
||||
await expect(chip).toHaveScreenshot('chip-component.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Visual Regression - Table Views', () => {
|
||||
|
||||
test('data grid header styles', async ({ page }) => {
|
||||
// Navigate to a table view if available
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Look for data grid
|
||||
const dataGrid = page.locator('.MuiDataGrid-root');
|
||||
|
||||
if (await dataGrid.count() > 0) {
|
||||
// Capture data grid header
|
||||
const header = dataGrid.locator('.MuiDataGrid-columnHeaders');
|
||||
if (await header.count() > 0) {
|
||||
await expect(header).toHaveScreenshot('datagrid-header.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('table pagination styles', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Find pagination component
|
||||
const pagination = page.locator('.MuiTablePagination-root');
|
||||
|
||||
if (await pagination.count() > 0) {
|
||||
await expect(pagination).toHaveScreenshot('table-pagination.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Visual Regression - Color Verification', () => {
|
||||
|
||||
test('primary color usage', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify primary buttons are NOT blue (#0062FF was the theming color)
|
||||
const primaryButton = page.locator('.MuiButton-containedPrimary').first();
|
||||
|
||||
if (await primaryButton.count() > 0) {
|
||||
const bgColor = await primaryButton.evaluate(el =>
|
||||
window.getComputedStyle(el).backgroundColor
|
||||
);
|
||||
|
||||
// Should not be the theming blue
|
||||
expect(bgColor).not.toBe('rgb(0, 98, 255)');
|
||||
}
|
||||
});
|
||||
|
||||
test('text colors are correct', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check body text color
|
||||
const bodyText = page.locator('.MuiTypography-body1, .MuiTypography-body2').first();
|
||||
|
||||
if (await bodyText.count() > 0) {
|
||||
const color = await bodyText.evaluate(el =>
|
||||
window.getComputedStyle(el).color
|
||||
);
|
||||
|
||||
// Text should be dark (not the theming blue-gray #344767)
|
||||
expect(color).not.toBe('rgb(52, 71, 103)');
|
||||
}
|
||||
});
|
||||
|
||||
test('sidebar background color', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const sidebar = page.locator('.MuiDrawer-paper');
|
||||
|
||||
if (await sidebar.count() > 0) {
|
||||
const bgColor = await sidebar.evaluate(el =>
|
||||
window.getComputedStyle(el).backgroundColor
|
||||
);
|
||||
|
||||
// Capture the actual color for baseline comparison
|
||||
console.log('Sidebar background color:', bgColor);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Visual Regression - Layout Integrity', () => {
|
||||
|
||||
test('no CSS variable fallback indicators', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check that no elements have CSS variable fallbacks visible
|
||||
// (This would indicate theming variables are leaking through)
|
||||
const pageContent = await page.content();
|
||||
|
||||
// Should not contain any --qqq- CSS variables in computed styles
|
||||
const elements = await page.locator('*').evaluateAll(els =>
|
||||
els.filter(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.cssText.includes('--qqq-');
|
||||
}).length
|
||||
);
|
||||
|
||||
// In unthemed mode, no elements should use --qqq- variables
|
||||
expect(elements).toBe(0);
|
||||
});
|
||||
|
||||
test('consistent border radius on cards', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const cards = page.locator('.MuiCard-root');
|
||||
const count = await cards.count();
|
||||
|
||||
if (count > 0) {
|
||||
const radii = await cards.evaluateAll(els =>
|
||||
els.map(el => window.getComputedStyle(el).borderRadius)
|
||||
);
|
||||
|
||||
// All cards should have consistent border radius
|
||||
const uniqueRadii = [...new Set(radii)];
|
||||
console.log('Card border radii:', uniqueRadii);
|
||||
|
||||
// Verify consistency (allow 1-2 different values for different card types)
|
||||
expect(uniqueRadii.length).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
test('consistent typography sizing', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check h6 elements have consistent size
|
||||
const h6Elements = page.locator('h6');
|
||||
const count = await h6Elements.count();
|
||||
|
||||
if (count > 0) {
|
||||
const fontSizes = await h6Elements.evaluateAll(els =>
|
||||
els.map(el => window.getComputedStyle(el).fontSize)
|
||||
);
|
||||
|
||||
const uniqueSizes = [...new Set(fontSizes)];
|
||||
console.log('H6 font sizes:', uniqueSizes);
|
||||
|
||||
// All h6 elements should have the same font size
|
||||
expect(uniqueSizes.length).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
export type RgbColor = { r: number; g: number; b: number; a?: number };
|
||||
|
||||
/**
|
||||
* Utility functions to help with assertions in theme tests
|
||||
*/
|
||||
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null
|
||||
{
|
||||
const normalized = hex.trim().replace(/^#/, "");
|
||||
if (!/^[a-f\d]+$/i.test(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
let rHex: string;
|
||||
let gHex: string;
|
||||
let bHex: string;
|
||||
|
||||
if (normalized.length === 3)
|
||||
{
|
||||
rHex = normalized[0] + normalized[0];
|
||||
gHex = normalized[1] + normalized[1];
|
||||
bHex = normalized[2] + normalized[2];
|
||||
}
|
||||
else if (normalized.length === 6 || normalized.length === 8)
|
||||
{
|
||||
// Ignore alpha channel for 8-digit hex values (#RRGGBBAA).
|
||||
rHex = normalized.slice(0, 2);
|
||||
gHex = normalized.slice(2, 4);
|
||||
bHex = normalized.slice(4, 6);
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
r: parseInt(rHex, 16),
|
||||
g: parseInt(gHex, 16),
|
||||
b: parseInt(bHex, 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRgb(rgbString: string): RgbColor | null
|
||||
{
|
||||
const rgbMatch = rgbString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (rgbMatch)
|
||||
{
|
||||
return {
|
||||
r: parseInt(rgbMatch[1]),
|
||||
g: parseInt(rgbMatch[2]),
|
||||
b: parseInt(rgbMatch[3]),
|
||||
a: rgbMatch[4] ? parseFloat(rgbMatch[4]) : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseColor(color: string): RgbColor | null
|
||||
{
|
||||
if (color.startsWith("rgb"))
|
||||
{
|
||||
return parseRgb(color);
|
||||
}
|
||||
if (color.startsWith("#"))
|
||||
{
|
||||
const rgb = hexToRgb(color);
|
||||
return rgb ? {...rgb, a: undefined} : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function colorsMatch(actual: string, expected: string, tolerance = 2): boolean
|
||||
{
|
||||
if (actual.startsWith("#") && expected.startsWith("#"))
|
||||
{
|
||||
return actual.toLowerCase() === expected.toLowerCase();
|
||||
}
|
||||
|
||||
const actualRgb = parseColor(actual);
|
||||
const expectedRgb = parseColor(expected);
|
||||
if (!actualRgb || !expectedRgb)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const rgbMatch = (
|
||||
Math.abs(actualRgb.r - expectedRgb.r) <= tolerance &&
|
||||
Math.abs(actualRgb.g - expectedRgb.g) <= tolerance &&
|
||||
Math.abs(actualRgb.b - expectedRgb.b) <= tolerance
|
||||
);
|
||||
|
||||
if (actualRgb.a !== undefined && expectedRgb.a !== undefined)
|
||||
{
|
||||
return rgbMatch && Math.abs(actualRgb.a - expectedRgb.a) <= 0.05;
|
||||
}
|
||||
|
||||
return rgbMatch;
|
||||
}
|
||||
|
||||
export function isTransparentOrNearlyTransparent(color: string): boolean
|
||||
{
|
||||
if (color === "transparent" || color === "rgba(0, 0, 0, 0)")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
const rgb = parseRgb(color);
|
||||
return !!(rgb && rgb.a !== undefined && rgb.a < 0.1);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-material-dashboard",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.41.0-SNAPSHOT",
|
||||
"version": "0.40.0-SNAPSHOT",
|
||||
"homepage": ".",
|
||||
"description": "QQQ Default Dashboard",
|
||||
"dependencies": {
|
||||
@@ -16,7 +15,7 @@
|
||||
"@mui/x-data-grid-pro": "5.17.23",
|
||||
"@mui/x-date-pickers": "7.1.1",
|
||||
"@mui/x-license-pro": "5.12.3",
|
||||
"@qrunio/qqq-frontend-core": "0.40.20-SNAPSHOT",
|
||||
"@qrunio/qqq-frontend-core": "0.40.1-SNAPSHOT",
|
||||
"@react-jvectormap/core": "1.0.1",
|
||||
"@react-jvectormap/unitedstates": "1.0.1",
|
||||
"@react-oauth/google": "0.2.8",
|
||||
@@ -32,13 +31,13 @@
|
||||
"dayjs": "1.11.10",
|
||||
"downshift": "3.2.10",
|
||||
"faker": "5.5.3",
|
||||
"form-data": "4.0.6",
|
||||
"form-data": "4.0.4",
|
||||
"formik": "2.2.9",
|
||||
"html-react-parser": "1.4.8",
|
||||
"html-to-text": "^9.0.5",
|
||||
"http-proxy-middleware": "2.0.9",
|
||||
"jwt-decode": "3.1.2",
|
||||
"lodash": "4.18.1",
|
||||
"lodash": "4.17.21",
|
||||
"oidc-client-ts": "2.4.1",
|
||||
"rapidoc": "9.3.4",
|
||||
"react": "18.0.0",
|
||||
@@ -54,7 +53,7 @@
|
||||
"react-google-drive-picker": "^1.2.0",
|
||||
"react-markdown": "9.0.1",
|
||||
"react-oidc-context": "2.3.1",
|
||||
"react-router-dom": "6.30.3",
|
||||
"react-router-dom": "6.2.1",
|
||||
"react-router-hash-link": "2.4.3",
|
||||
"react-table": "7.7.0",
|
||||
"sass": "1.63.4",
|
||||
@@ -63,7 +62,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "react-scripts build",
|
||||
"build-and-publish": "react-scripts build && rm -rvf src/main/resources/material-dashboard && mkdir -p src/main/resources/material-dashboard && cp -rv build/* src/main/resources/material-dashboard",
|
||||
"build-and-publish": "react-scripts build && rm -rvf src/main/resources/material-dashboard; mkdir -p src/main/resources/material-dashboard; cp -rv build/* src/main/resources/material-dashboard",
|
||||
"clean": "rm -rf node_modules package-lock.json lib",
|
||||
"eject": "react-scripts eject",
|
||||
"clean-and-install": "rm -rf node_modules/ && rm -rf package-lock.json && rm -rf lib/ && npm install --legacy-peer-deps && npm dedupe --force",
|
||||
@@ -76,11 +75,7 @@
|
||||
"test:e2e:ui": "npx playwright test --ui",
|
||||
"test:e2e:headed": "npx playwright test --headed",
|
||||
"fixture-server": "node e2e/fixture-server.js",
|
||||
"fixture-server:unthemed": "THEME_FIXTURE=index node e2e/fixture-server.js",
|
||||
"e2e": "npx playwright test",
|
||||
"e2e:themed": "THEME_FIXTURE=withFullCustomTheme npx playwright test e2e/tests/theme.spec.ts",
|
||||
"e2e:unthemed": "THEME_FIXTURE=index npx playwright test e2e/tests/unthemed-regression.spec.ts",
|
||||
"e2e:all": "npm run e2e:themed && npm run e2e:unthemed"
|
||||
"e2e": "concurrently --kill-others \"npm run fixture-server\" \"npx playwright test\""
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
@@ -106,13 +101,13 @@
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"concurrently": "^8.2.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@types/chroma-js": "2.1.3",
|
||||
"@types/faker": "5.5.9",
|
||||
"@types/react-table": "7.7.9",
|
||||
"@typescript-eslint/eslint-plugin": "5.10.2",
|
||||
"@typescript-eslint/parser": "5.10.2",
|
||||
"concurrently": "^8.2.0",
|
||||
"eslint": "8.8.0",
|
||||
"eslint-import-resolver-typescript": "2.5.0",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
@@ -121,26 +116,12 @@
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"overrides": {
|
||||
"@svgr/webpack": "$@svgr/webpack",
|
||||
"axios@<1.0.0": "0.34.0",
|
||||
"form-data@^4.0.0": "$form-data",
|
||||
"resolve-url-loader": "5.0.0",
|
||||
"serialize-javascript": "7.0.3",
|
||||
"underscore": "1.13.8",
|
||||
"webpack-dev-middleware": "7.4.6"
|
||||
"@svgr/webpack": "$@svgr/webpack"
|
||||
},
|
||||
"main": "qqq-frontend-material-dashboard.js",
|
||||
"module": "lib/qqq-frontend-material-dashboard.js",
|
||||
"files": [
|
||||
"lib",
|
||||
"package.json"
|
||||
],
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(@qrunio/qqq-frontend-core|@mui|@babel/runtime)/)"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const fixturePort = process.env.FIXTURE_PORT || '8001';
|
||||
const dashboardPort = process.env.DASHBOARD_PORT || '3001';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e/tests',
|
||||
fullyParallel: false,
|
||||
@@ -11,50 +8,30 @@ export default defineConfig({
|
||||
workers: 1,
|
||||
reporter: [['html', { open: 'never' }], ['list']],
|
||||
timeout: 60000,
|
||||
// Snapshot configuration for visual regression
|
||||
// Platform-specific snapshots: linux/ for CI, darwin/ for local macOS
|
||||
snapshotDir: './e2e/snapshots',
|
||||
snapshotPathTemplate: '{snapshotDir}/{testFilePath}/{platform}/{arg}{ext}',
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
maxDiffPixels: 100,
|
||||
maxDiffPixelRatio: 0.01,
|
||||
animations: 'disabled',
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL: `https://localhost:${dashboardPort}`,
|
||||
baseURL: 'https://localhost:3001',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
channel: process.env.PLAYWRIGHT_CHANNEL,
|
||||
// Consistent viewport for visual regression
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'themed',
|
||||
testMatch: /.*(?<!unthemed-regression)\.spec\.ts$/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'unthemed',
|
||||
testMatch: /unthemed-regression\.spec\.ts$/,
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
command: `THEME_FIXTURE=${process.env.THEME_FIXTURE || 'withFullCustomTheme'} node e2e/fixture-server.js`,
|
||||
url: `http://localhost:${fixturePort}/metaData`,
|
||||
command: 'node e2e/fixture-server.js',
|
||||
url: 'http://localhost:8001/metaData',
|
||||
reuseExistingServer: true,
|
||||
timeout: 60000, // 1 minute - Docker startup can be slow
|
||||
timeout: 10000,
|
||||
},
|
||||
{
|
||||
command: `HTTPS=true PORT=${dashboardPort} REACT_APP_PROXY_LOCALHOST_PORT=${fixturePort} npm start`,
|
||||
url: `https://localhost:${dashboardPort}`,
|
||||
command: 'HTTPS=true PORT=3001 REACT_APP_PROXY_LOCALHOST_PORT=8001 npm start',
|
||||
url: 'https://localhost:3001',
|
||||
reuseExistingServer: true,
|
||||
timeout: 300000, // 5 minutes - React compilation in Docker takes time
|
||||
timeout: 120000,
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,17 +6,18 @@
|
||||
~ contact@kingsrook.com
|
||||
~ https://github.com/Kingsrook/
|
||||
~
|
||||
~ 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
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU Affero General Public License as
|
||||
~ published by the Free Software Foundation, either version 3 of the
|
||||
~ License, or (at your option) any later version.
|
||||
~
|
||||
~ https://www.apache.org/licenses/LICENSE-2.0
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU Affero General Public License for more details.
|
||||
~
|
||||
~ 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.
|
||||
~ You should have received a copy of the GNU Affero General Public License
|
||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
@@ -35,8 +36,8 @@
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
<name>GNU Affero General Public License v3.0</name>
|
||||
<url>https://www.gnu.org/licenses/agpl-3.0.html</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
@@ -57,8 +58,8 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>0.41.0-SNAPSHOT</revision>
|
||||
<qqq.version>4.0.0</qqq.version>
|
||||
<revision>0.40.0-SNAPSHOT</revision>
|
||||
<qqq.version>0.40.0-SNAPSHOT</qqq.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
@@ -69,13 +70,6 @@
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson</groupId>
|
||||
<artifactId>jackson-bom</artifactId>
|
||||
<version>2.21.7</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
@@ -152,13 +146,13 @@
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.27.7</version>
|
||||
<version>3.23.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>7.2.3</version>
|
||||
<version>6.7.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -170,7 +164,7 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-api</artifactId>
|
||||
<version>2.25.3</version>
|
||||
<version>2.17.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -255,51 +249,7 @@
|
||||
</profiles>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>${project.build.directory}/maven-shared-archive-resources</directory>
|
||||
</resource>
|
||||
</resources>
|
||||
<testResources>
|
||||
<testResource>
|
||||
<directory>src/test/resources</directory>
|
||||
</testResource>
|
||||
<testResource>
|
||||
<directory>${project.build.directory}/maven-shared-archive-resources</directory>
|
||||
</testResource>
|
||||
</testResources>
|
||||
<plugins>
|
||||
<!-- Source and Javadoc JARs also include Maven's shared archive resources. -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<version>3.4.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>package-root-notices</id>
|
||||
<phase>generate-resources</phase>
|
||||
<goals>
|
||||
<goal>copy-resources</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/maven-shared-archive-resources/META-INF</outputDirectory>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>${project.basedir}</directory>
|
||||
<filtering>false</filtering>
|
||||
<includes>
|
||||
<include>LICENSE</include>
|
||||
<include>NOTICE</include>
|
||||
</includes>
|
||||
</resource>
|
||||
</resources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
@@ -348,6 +298,35 @@
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- GPG signing for Maven Central -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-artifacts</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>sign</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Sonatype Central publishing (replaces deploy to distributionManagement) -->
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<version>0.8.0</version>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<!-- Server credentials with this ID must be provided in settings.xml -->
|
||||
<publishingServerId>central</publishingServerId>
|
||||
<autoPublish>true</autoPublish>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check all four published JARs against this checkout's root license files."""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("archive_prefix", type=Path,
|
||||
help="archive path without classifier or .jar suffix")
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
expected = {name: (root / name).read_bytes() for name in ("LICENSE", "NOTICE")}
|
||||
failures = []
|
||||
|
||||
for classifier in ("", "-sources", "-tests", "-javadoc"):
|
||||
archive = Path(f"{args.archive_prefix}{classifier}.jar")
|
||||
try:
|
||||
with ZipFile(archive) as jar:
|
||||
for name, content in expected.items():
|
||||
entry = f"META-INF/{name}"
|
||||
if jar.namelist().count(entry) != 1:
|
||||
failures.append(f"{archive}: expected exactly one {entry}")
|
||||
elif jar.read(entry) != content:
|
||||
failures.append(f"{archive}: {entry} differs from root {name}")
|
||||
except (OSError, BadZipFile) as error:
|
||||
failures.append(f"{archive}: {error}")
|
||||
|
||||
if failures:
|
||||
print("\n".join(failures), file=sys.stderr)
|
||||
return 1
|
||||
print("PASS: main, sources, tests and javadoc JARs contain exact root LICENSE/NOTICE")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
############################################################################
|
||||
## ci-prebuild.sh
|
||||
## CI prebuild for the shared ci-workflows maven.yaml (runs before Maven):
|
||||
## Jest unit tests, the React build that is packaged into the jar, and the
|
||||
## HTTPS dev server on :3001 that the Selenium ITs drive (as the old
|
||||
## qqq-orb frontend-material pipeline did). Maven then runs the ITs headless.
|
||||
############################################################################
|
||||
set -euo pipefail
|
||||
|
||||
npm ci --legacy-peer-deps
|
||||
npm test -- --watchAll=false
|
||||
npm run build-and-publish
|
||||
|
||||
HTTPS=true PORT=3001 REACT_APP_PROXY_LOCALHOST_PORT=8001 \
|
||||
nohup setsid npm run start > /tmp/react-dev-server.log 2>&1 < /dev/null &
|
||||
|
||||
for _ in $(seq 1 150); do
|
||||
if curl -ksf -o /dev/null https://localhost:3001/; then
|
||||
echo "React dev server is up on https://localhost:3001"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "React dev server did not come up on :3001"
|
||||
cat /tmp/react-dev-server.log
|
||||
exit 1
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Generate Playwright snapshots for Linux/CI using Docker
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/update-snapshots-linux.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker must be running
|
||||
# - Run from project root
|
||||
#
|
||||
# This script:
|
||||
# 1. Runs everything inside Docker matching CI environment
|
||||
# 2. Starts the fixture server and React dev server inside Docker
|
||||
# 3. Runs Playwright tests with --update-snapshots flag
|
||||
# 4. Generates snapshots in e2e/snapshots/.../linux/
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${YELLOW}=== Generating Playwright Snapshots via Docker ===${NC}"
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo -e "${RED}Error: Docker is not running. Please start Docker and try again.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use the same Playwright image as CI
|
||||
echo -e "${YELLOW}Running Playwright tests in Docker to update snapshots...${NC}"
|
||||
echo -e "${YELLOW}This will install dependencies, start servers, run tests, and update snapshots inside Docker.${NC}"
|
||||
echo -e "${YELLOW}This may take several minutes...${NC}"
|
||||
|
||||
docker run --rm \
|
||||
-v "$PROJECT_ROOT:/app" \
|
||||
-w /app \
|
||||
-e HTTPS=true \
|
||||
-e PORT=3001 \
|
||||
-e REACT_APP_PROXY_LOCALHOST_PORT=8001 \
|
||||
mcr.microsoft.com/playwright:v1.57.0-jammy \
|
||||
bash -c '
|
||||
echo "=== Installing dependencies..."
|
||||
npm ci --legacy-peer-deps
|
||||
|
||||
echo "=== Running Playwright tests with --update-snapshots..."
|
||||
echo "=== (Playwright will start servers automatically)..."
|
||||
npx playwright test --update-snapshots --reporter=list
|
||||
'
|
||||
|
||||
echo -e "${GREEN}=== Linux snapshots updated successfully! ===${NC}"
|
||||
echo -e "${YELLOW}Snapshots are in e2e/snapshots/.../linux/${NC}"
|
||||
echo -e "${YELLOW}Don't forget to commit the updated snapshots.${NC}"
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Generate Playwright snapshots for macOS (local development)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/update-snapshots-mac.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Run from project root
|
||||
# - Dependencies installed (npm ci --legacy-peer-deps)
|
||||
#
|
||||
# This script:
|
||||
# 1. Runs Playwright tests locally with --update-snapshots flag
|
||||
# 2. Generates snapshots in e2e/snapshots/.../darwin/
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${YELLOW}=== Generating Playwright Snapshots for macOS ===${NC}"
|
||||
|
||||
# Check if node_modules exists
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo -e "${RED}Error: node_modules not found. Run 'npm run install:legacy' first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Playwright is installed
|
||||
if [ ! -d "node_modules/@playwright" ]; then
|
||||
echo -e "${RED}Error: Playwright not found. Run 'npm run install:legacy' first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Running Playwright tests with --update-snapshots...${NC}"
|
||||
npx playwright test --update-snapshots --reporter=list
|
||||
|
||||
echo -e "${GREEN}=== macOS snapshots updated successfully! ===${NC}"
|
||||
echo -e "${YELLOW}Snapshots are in e2e/snapshots/.../darwin/${NC}"
|
||||
echo -e "${YELLOW}Don't forget to commit the updated snapshots.${NC}"
|
||||
@@ -5,25 +5,20 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import {Theme, ThemeProvider} from "@mui/material/styles";
|
||||
import {LicenseInfo} from "@mui/x-license-pro";
|
||||
import {QException} from "@qrunio/qqq-frontend-core/lib/exceptions/QException";
|
||||
import {QAppNodeType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAppNodeType";
|
||||
import {QAppTreeNode} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAppTreeNode";
|
||||
@@ -32,6 +27,15 @@ import {QBrandingMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QB
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QProcessMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import {QThemeMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QThemeMetaData";
|
||||
import {Theme} from "@mui/material";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import {ThemeProvider} from "@mui/material/styles";
|
||||
import {LicenseInfo} from "@mui/x-license-pro";
|
||||
import CommandMenu from "CommandMenu";
|
||||
import QContext from "QContext";
|
||||
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
|
||||
@@ -39,35 +43,33 @@ import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0Authen
|
||||
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
|
||||
import BrandedHeaderBar from "qqq/components/horseshoe/BrandedHeaderBar";
|
||||
import Sidenav from "qqq/components/horseshoe/sidenav/SideNav";
|
||||
import {getBanner, getBannerClassName, getBannerStyles, makeBannerContent} from "qqq/components/misc/Banners";
|
||||
import {createDynamicTheme} from "qqq/utils/createDynamicTheme";
|
||||
import {injectIslandVariables} from "qqq/utils/injectIslandVariables";
|
||||
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
|
||||
import {setMiniSidenav, useMaterialUIController} from "qqq/context";
|
||||
import {MaterialDashboardThemeMetaData} from "qqq/models/metadata/MaterialDashboardThemeMetaData";
|
||||
import AppHome from "qqq/pages/apps/Home";
|
||||
import NoApps from "qqq/pages/apps/NoApps";
|
||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||
import ReportRun from "qqq/pages/processes/ReportRun";
|
||||
import EntityCreate from "qqq/pages/records/create/RecordCreate";
|
||||
import TableDeveloperView from "qqq/pages/records/developer/TableDeveloperView";
|
||||
import {resolveAssetUrl, detectBasePath} from "qqq/utils/PathUtils";
|
||||
import EntityEdit from "qqq/pages/records/edit/RecordEdit";
|
||||
import RecordQuery from "qqq/pages/records/query/RecordQuery";
|
||||
import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView";
|
||||
import RecordView from "qqq/pages/records/view/RecordView";
|
||||
import RecordViewByUniqueKey from "qqq/pages/records/view/RecordViewByUniqueKey";
|
||||
import {createDynamicTheme} from "qqq/utils/createDynamicTheme";
|
||||
import AnalyticsUtils, {AnalyticsModel} from "qqq/utils/analytics/AnalyticsUtils";
|
||||
import {injectIslandVariables} from "qqq/utils/injectIslandVariables";
|
||||
import {detectBasePath, resolveAssetUrl} from "qqq/utils/PathUtils";
|
||||
import GoogleAnalyticsUtils, {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
|
||||
import React, {JSXElementConstructor, Key, ReactElement, ReactNode, useEffect, useMemo, useState,} from "react";
|
||||
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useMemo, useState,} from "react";
|
||||
import {useCookies} from "react-cookie";
|
||||
import {Navigate, Route, Routes, useLocation, useParams, useSearchParams,} from "react-router-dom";
|
||||
import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom";
|
||||
import {Md5} from "ts-md5/dist/md5";
|
||||
|
||||
|
||||
const qController = Client.getInstance();
|
||||
export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
|
||||
const MATERIAL_DASHBOARD_THEME_META_DATA_NAME = "com.kingsrook.qqq.frontend.materialdashboard.model.metadata.MaterialDashboardThemeMetaData";
|
||||
|
||||
interface Props
|
||||
{
|
||||
@@ -81,7 +83,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
|
||||
const [profileRoutes, setProfileRoutes] = useState({});
|
||||
const [branding, setBranding] = useState({} as QBrandingMetaData);
|
||||
const [themeMetaData, setThemeMetaData] = useState(null as MaterialDashboardThemeMetaData | null);
|
||||
const [themeMetaData, setThemeMetaData] = useState(null as QThemeMetaData | null);
|
||||
const [metaData, setMetaData] = useState({} as QInstance);
|
||||
const [needLicenseKey, setNeedLicenseKey] = useState(true);
|
||||
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
|
||||
@@ -91,7 +93,6 @@ export default function App({authenticationMetaData}: Props)
|
||||
const {setupSession: auth0SetupSession, logout: auth0Logout} = useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
|
||||
const {setupSession: oauth2SetupSession, logout: oauth2Logout} = useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext: authenticationMetaData.type === "OAUTH2"});
|
||||
const {setupSession: anonymousSetupSession, logout: anonymousLogout} = useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
|
||||
const [analyticsUtils] = useState(new AnalyticsUtils());
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// tell the client how to do a logout if it sees a 401 //
|
||||
@@ -147,8 +148,6 @@ export default function App({authenticationMetaData}: Props)
|
||||
***************************************************************************/
|
||||
function doLogout()
|
||||
{
|
||||
analyticsUtils.reset();
|
||||
|
||||
if (authenticationMetaData?.type === "AUTH_0")
|
||||
{
|
||||
auth0Logout();
|
||||
@@ -206,7 +205,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
}
|
||||
|
||||
const childList: any[] = [];
|
||||
if (app.children && !app.hideChildrenFromNavigation)
|
||||
if (app.children)
|
||||
{
|
||||
app.children.forEach((child: QAppTreeNode) =>
|
||||
{
|
||||
@@ -251,7 +250,6 @@ export default function App({authenticationMetaData}: Props)
|
||||
dropdown: true,
|
||||
icon: <Icon fontSize="medium">{app.iconName}</Icon>,
|
||||
collapse: childList,
|
||||
["data-qqq-sidenav-item-type"]: "top-level-parent-app"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -496,35 +494,25 @@ export default function App({authenticationMetaData}: Props)
|
||||
{
|
||||
setAccentColor(metaData.branding.accentColor);
|
||||
}
|
||||
if (metaData.branding.accentColorLight)
|
||||
{
|
||||
setAccentColorLight(metaData.branding.accentColorLight);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////
|
||||
// Set theme metadata for dynamic theming //
|
||||
////////////////////////////////////////////
|
||||
const themeMetaDataObject = metaData.supplementalInstanceMetaData?.get(MATERIAL_DASHBOARD_THEME_META_DATA_NAME);
|
||||
if (themeMetaDataObject)
|
||||
///////////////////////////////////////////////
|
||||
// Set theme metadata for dynamic theming //
|
||||
///////////////////////////////////////////////
|
||||
if (metaData.theme)
|
||||
{
|
||||
setThemeMetaData(new MaterialDashboardThemeMetaData(themeMetaDataObject));
|
||||
setThemeMetaData(metaData.theme);
|
||||
}
|
||||
|
||||
const gravatarBase = "https://www.gravatar.com/avatar/";
|
||||
const hash = Md5.hashStr(loggedInUser?.email || "user");
|
||||
let profilePicture = `${gravatarBase}${hash}`;
|
||||
if(metaData?.branding?.gravatarDefault)
|
||||
{
|
||||
profilePicture += `?d=${metaData?.branding?.gravatarDefault}`;
|
||||
}
|
||||
const profilePicture = `${gravatarBase}${hash}`;
|
||||
const profileRoutes = {
|
||||
type: "collapse",
|
||||
name: loggedInUser?.name ?? "Anonymous",
|
||||
key: "username",
|
||||
noCollapse: true,
|
||||
icon: <Avatar src={profilePicture} alt="{loggedInUser?.name}" />,
|
||||
["data-qqq-sidenav-item-type"]: "user-profile"
|
||||
};
|
||||
setProfileRoutes(profileRoutes);
|
||||
|
||||
@@ -596,21 +584,6 @@ export default function App({authenticationMetaData}: Props)
|
||||
})();
|
||||
}, [needToLoadRoutes, isFullyAuthenticated]);
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// write routes to manage redirects specified by the backend //
|
||||
///////////////////////////////////////////////////////////////
|
||||
const getRedirectRoutes = (redirects: Map<string, string>): any =>
|
||||
{
|
||||
const rs = [] as any[];
|
||||
|
||||
for (let from of redirects.keys())
|
||||
{
|
||||
rs.push(<Route key={from} path={from} element={<RedirectRoute to={redirects.get(from)} />} />);
|
||||
}
|
||||
|
||||
return (rs);
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// Open sidenav when mouse enter on mini sidenav //
|
||||
///////////////////////////////////////////////////
|
||||
@@ -693,7 +666,6 @@ export default function App({authenticationMetaData}: Props)
|
||||
}, [themeMetaData]);
|
||||
|
||||
const [pageHeader, setPageHeader] = useState("" as string | JSX.Element);
|
||||
const [pageHeaderRightContent, setPageHeaderRightContent] = useState(null as ReactNode);
|
||||
const [accentColor, setAccentColor] = useState("#0062FF");
|
||||
const [accentColorLight, setAccentColorLight] = useState("#C0D6F7");
|
||||
const [tableMetaData, setTableMetaData] = useState(null);
|
||||
@@ -710,20 +682,14 @@ export default function App({authenticationMetaData}: Props)
|
||||
}, [loggedInUser]);
|
||||
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (isFullyAuthenticated)
|
||||
{
|
||||
analyticsUtils.initialize();
|
||||
}
|
||||
}, [isFullyAuthenticated, analyticsUtils]);
|
||||
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function recordAnalytics(model: AnalyticsModel)
|
||||
{
|
||||
analyticsUtils.recordAnalytics(model);
|
||||
googleAnalyticsUtils.recordAnalytics(model);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
@@ -748,7 +714,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
return (null);
|
||||
}
|
||||
|
||||
return (<Box className={getBannerClassName(banner)} sx={{display: "flex", justifyContent: "center", padding: "0.5rem", position: "sticky", top: "0", zIndex: 1000, ...getBannerStyles(banner)}}>
|
||||
return (<Box className={getBannerClassName(banner)} sx={{display: "flex", justifyContent: "center", padding: "0.5rem", position: "sticky", top: "0", zIndex: 1, ...getBannerStyles(banner)}}>
|
||||
{makeBannerContent(banner)}
|
||||
</Box>);
|
||||
}
|
||||
@@ -759,7 +725,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
***************************************************************************/
|
||||
function pushModalOnStack(modalIdentifier: string): void
|
||||
{
|
||||
if (modalStack.length > 0 && modalStack[modalStack.length] == modalIdentifier)
|
||||
if(modalStack.length > 0 && modalStack[modalStack.length] == modalIdentifier)
|
||||
{
|
||||
console.warn(`Pushing a new modal on the QContext modalStack that is a duplicate of the current top-of-stack [${modalIdentifier}]`)
|
||||
}
|
||||
@@ -773,7 +739,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
***************************************************************************/
|
||||
function popModalOffStack(modalIdentifier: string): void
|
||||
{
|
||||
if (modalStack.length > 0 && modalStack[modalStack.length - 1] != modalIdentifier)
|
||||
if(modalStack.length > 0 && modalStack[modalStack.length - 1] != modalIdentifier)
|
||||
{
|
||||
console.warn(`Request to pop a modal [${modalIdentifier}] off the QContext modalStack that is not currently on top [${modalStack[modalStack.length - 1]}]`)
|
||||
return;
|
||||
@@ -792,25 +758,11 @@ export default function App({authenticationMetaData}: Props)
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* handle routes that need to redirect to another path.
|
||||
*
|
||||
* Uses `replace` so that the browser history doesn't get updated with a new entry.
|
||||
***************************************************************************/
|
||||
function RedirectRoute({to}: { to: string })
|
||||
{
|
||||
const params = useParams();
|
||||
const wildcardPath = params["*"] || "";
|
||||
return <Navigate to={`${to}/${wildcardPath}`} replace />;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
|
||||
appRoutes && (
|
||||
<QContext.Provider value={{
|
||||
pageHeader: pageHeader,
|
||||
pageHeaderRightContent: pageHeaderRightContent,
|
||||
accentColor: accentColor,
|
||||
accentColorLight: accentColorLight,
|
||||
tableMetaData: tableMetaData,
|
||||
@@ -821,7 +773,6 @@ export default function App({authenticationMetaData}: Props)
|
||||
helpHelpActive: helpHelpActive,
|
||||
userId: userId,
|
||||
setPageHeader: (header: string | JSX.Element) => setPageHeader(header),
|
||||
setPageHeaderRightContent: (content: ReactNode) => setPageHeaderRightContent(content),
|
||||
setAccentColor: (accentColor: string) => setAccentColor(accentColor),
|
||||
setAccentColorLight: (accentColorLight: string) => setAccentColorLight(accentColorLight),
|
||||
setTableMetaData: (tableMetaData: QTableMetaData) => setTableMetaData(tableMetaData),
|
||||
@@ -853,7 +804,6 @@ export default function App({authenticationMetaData}: Props)
|
||||
/>
|
||||
<Routes>
|
||||
<Route path="*" element={<Navigate to={defaultRoute} />} />
|
||||
{metaData?.redirects && getRedirectRoutes(metaData.redirects)}
|
||||
{appRoutes && getRoutes(appRoutes)}
|
||||
{profileRoutes && getRoutes([profileRoutes])}
|
||||
</Routes>
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Capability} from "@qrunio/qqq-frontend-core/lib/model/metaData/Capability";
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useAuth0} from "@auth0/auth0-react";
|
||||
|
||||
@@ -5,31 +5,30 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QBrandingMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
||||
import {QProcessMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {AnalyticsModel} from "qqq/utils/analytics/AnalyticsUtils";
|
||||
import {createContext, ReactNode} from "react";
|
||||
import {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
|
||||
import {createContext} from "react";
|
||||
|
||||
interface QContext
|
||||
{
|
||||
pageHeader: string | JSX.Element;
|
||||
setPageHeader?: (header: string | JSX.Element) => void;
|
||||
pageHeaderRightContent?: ReactNode;
|
||||
setPageHeaderRightContent?: (content: ReactNode) => void;
|
||||
|
||||
accentColor: string;
|
||||
setAccentColor?: (color: string) => void;
|
||||
@@ -70,7 +69,6 @@ interface QContext
|
||||
|
||||
const defaultState = {
|
||||
pageHeader: "",
|
||||
pageHeaderRightContent: null as ReactNode,
|
||||
accentColor: "#0062FF",
|
||||
accentColorLight: "#C0D6F7",
|
||||
dotMenuOpen: false,
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster;
|
||||
@@ -30,8 +31,8 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.utils.ClassPathUtils;
|
||||
import com.kingsrook.qqq.backend.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.MaterialDashboardFieldMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.metadata.JavalinRouteProviderMetaData;
|
||||
|
||||
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
@@ -28,7 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaDa
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** instance-level meta-data for this module (handled as QSupplementalTableMetaData)
|
||||
** table-level meta-data for this module (handled as QSupplementalTableMetaData)
|
||||
*******************************************************************************/
|
||||
public class MaterialDashboardInstanceMetaData implements QSupplementalInstanceMetaData
|
||||
{
|
||||
@@ -38,10 +39,6 @@ public class MaterialDashboardInstanceMetaData implements QSupplementalInstanceM
|
||||
|
||||
private Integer queryScreenCopyFullQueryColumnValuesLimit = 100_000;
|
||||
|
||||
private MaterialDashboardTableMetaData.RecordViewActionsPlacement recordViewActionsPlacement = MaterialDashboardTableMetaData.RecordViewActionsPlacement.IN_IDENTITY_SECTION;
|
||||
|
||||
private WeekdayCriteriaSettings weekdayCriteriaSettings = new WeekdayCriteriaSettings();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -158,71 +155,4 @@ public class MaterialDashboardInstanceMetaData implements QSupplementalInstanceM
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardTableMetaData.RecordViewActionsPlacement getRecordViewActionsPlacement()
|
||||
{
|
||||
return (this.recordViewActionsPlacement);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public void setRecordViewActionsPlacement(MaterialDashboardTableMetaData.RecordViewActionsPlacement recordViewActionsPlacement)
|
||||
{
|
||||
this.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardInstanceMetaData withRecordViewActionsPlacement(MaterialDashboardTableMetaData.RecordViewActionsPlacement recordViewActionsPlacement)
|
||||
{
|
||||
this.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for weekdayCriteriaSettings
|
||||
* @see #withWeekdayCriteriaSettings(WeekdayCriteriaSettings)
|
||||
*******************************************************************************/
|
||||
public WeekdayCriteriaSettings getWeekdayCriteriaSettings()
|
||||
{
|
||||
return (this.weekdayCriteriaSettings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for weekdayCriteriaSettings
|
||||
* @see #withWeekdayCriteriaSettings(WeekdayCriteriaSettings)
|
||||
*******************************************************************************/
|
||||
public void setWeekdayCriteriaSettings(WeekdayCriteriaSettings weekdayCriteriaSettings)
|
||||
{
|
||||
this.weekdayCriteriaSettings = weekdayCriteriaSettings;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for weekdayCriteriaSettings
|
||||
*
|
||||
* @param weekdayCriteriaSettings
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardInstanceMetaData withWeekdayCriteriaSettings(WeekdayCriteriaSettings weekdayCriteriaSettings)
|
||||
{
|
||||
this.weekdayCriteriaSettings = weekdayCriteriaSettings;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
@@ -44,12 +45,6 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
*******************************************************************************/
|
||||
public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
{
|
||||
public enum RecordViewActionsPlacement
|
||||
{
|
||||
IN_IDENTITY_SECTION,
|
||||
INLINE_WITH_PAGE_TITLE
|
||||
}
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(MaterialDashboardTableMetaData.class);
|
||||
|
||||
public static final String TYPE = "materialDashboard";
|
||||
@@ -57,8 +52,6 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
private List<List<String>> gotoFieldNames;
|
||||
private List<String> defaultQuickFilterFieldNames;
|
||||
private List<FieldRule> fieldRules;
|
||||
private Boolean showRecordSidebar = true;
|
||||
private RecordViewActionsPlacement recordViewActionsPlacement = RecordViewActionsPlacement.IN_IDENTITY_SECTION;
|
||||
|
||||
private QCodeReference onLoadFormAdjuster = null;
|
||||
|
||||
@@ -269,68 +262,6 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for showRecordSidebar
|
||||
*******************************************************************************/
|
||||
public Boolean getShowRecordSidebar()
|
||||
{
|
||||
return (this.showRecordSidebar);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for showRecordSidebar
|
||||
*******************************************************************************/
|
||||
public void setShowRecordSidebar(Boolean showRecordSidebar)
|
||||
{
|
||||
this.showRecordSidebar = showRecordSidebar;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for showRecordSidebar
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardTableMetaData withShowRecordSidebar(Boolean showRecordSidebar)
|
||||
{
|
||||
this.showRecordSidebar = showRecordSidebar;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public RecordViewActionsPlacement getRecordViewActionsPlacement()
|
||||
{
|
||||
return (this.recordViewActionsPlacement);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public void setRecordViewActionsPlacement(RecordViewActionsPlacement recordViewActionsPlacement)
|
||||
{
|
||||
this.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardTableMetaData withRecordViewActionsPlacement(RecordViewActionsPlacement recordViewActionsPlacement)
|
||||
{
|
||||
this.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldRules
|
||||
*******************************************************************************/
|
||||
@@ -453,9 +384,6 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
clone.onLoadFormAdjuster = onLoadFormAdjuster.clone();
|
||||
}
|
||||
|
||||
clone.showRecordSidebar = showRecordSidebar;
|
||||
clone.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
|
||||
return abstractClone;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Material dashboard instance metadata settings - to control weekday criteria
|
||||
* on date & date-time fields. e.g., day-is-any-of or day-is-non-eof.
|
||||
*
|
||||
* <p>These criteria are on by default - but this object can turn them off with
|
||||
* the {@code enabled} setting.</p>
|
||||
*
|
||||
* <p>Also - since how time zones should apply can be a hotly contested issue,
|
||||
* values for the parameters supported by QQQ's {@code WeekdayOfDateTimeFunction}
|
||||
* can be set as {@code dateTimeFieldFunctionArguments}</p>
|
||||
*
|
||||
* <p>e.g., to always use US/Central time:
|
||||
* <pre>
|
||||
new WeekdayCriteriaSettings().withDateTimeFieldFunctionArguments(
|
||||
Map.of(WeekdayOfDateTimeFunction.PARAM_TIME_ZONE_ID, "US/Central")));</pre></p>
|
||||
*
|
||||
* <p>or to use user/session time zone:
|
||||
* <pre>
|
||||
new WeekdayCriteriaSettings().withDateTimeFieldFunctionArguments(
|
||||
Map.of(WeekdayOfDateTimeFunction.PARAM_USE_SESSION_ZONE_ID, true)));</pre></p>
|
||||
*******************************************************************************/
|
||||
public class WeekdayCriteriaSettings implements Serializable, Cloneable
|
||||
{
|
||||
private Boolean enabled = true;
|
||||
private Map<String, Serializable> dateTimeFieldFunctionArguments = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for enabled
|
||||
* @see #withEnabled(Boolean)
|
||||
*******************************************************************************/
|
||||
public Boolean getEnabled()
|
||||
{
|
||||
return (this.enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for enabled
|
||||
* @see #withEnabled(Boolean)
|
||||
*******************************************************************************/
|
||||
public void setEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for enabled
|
||||
*
|
||||
* @param enabled
|
||||
* TODO document this property
|
||||
*
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public WeekdayCriteriaSettings withEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for dateTimeFieldFunctionArguments
|
||||
* @see #withDateTimeFieldFunctionArguments(Map)
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> getDateTimeFieldFunctionArguments()
|
||||
{
|
||||
return (this.dateTimeFieldFunctionArguments);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for dateTimeFieldFunctionArguments
|
||||
* @see #withDateTimeFieldFunctionArguments(Map)
|
||||
*******************************************************************************/
|
||||
public void setDateTimeFieldFunctionArguments(Map<String, Serializable> dateTimeFieldFunctionArguments)
|
||||
{
|
||||
this.dateTimeFieldFunctionArguments = dateTimeFieldFunctionArguments;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for dateTimeFieldFunctionArguments
|
||||
*
|
||||
* @param dateTimeFieldFunctionArguments
|
||||
* TODO document this property
|
||||
*
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public WeekdayCriteriaSettings withDateTimeFieldFunctionArguments(Map<String, Serializable> dateTimeFieldFunctionArguments)
|
||||
{
|
||||
this.dateTimeFieldFunctionArguments = dateTimeFieldFunctionArguments;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public WeekdayCriteriaSettings clone()
|
||||
{
|
||||
try
|
||||
{
|
||||
WeekdayCriteriaSettings clone = (WeekdayCriteriaSettings) super.clone();
|
||||
|
||||
if(dateTimeFieldFunctionArguments != null)
|
||||
{
|
||||
clone.dateTimeFieldFunctionArguments = new HashMap<>(dateTimeFieldFunctionArguments);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
catch(CloneNotSupportedException e)
|
||||
{
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules;
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.savedreports;
|
||||
|
||||
@@ -19,7 +19,7 @@ import colors from "qqq/assets/theme/base/colors";
|
||||
// Material Dashboard 2 PRO React TS Helper Functions
|
||||
import rgba from "qqq/assets/theme/functions/rgba";
|
||||
|
||||
const { info, white, gradients } = colors;
|
||||
const { info, white } = colors;
|
||||
|
||||
// types
|
||||
type Types = any;
|
||||
@@ -43,7 +43,7 @@ const flatpickr: Types = {
|
||||
|
||||
".flatpickr-day.selected, .flatpickr-day.selected:hover, .flatpickr-day.nextMonthDay.selected, .flatpickr-day.nextMonthDay.selected:hover, .flatpickr-day.nextMonthDay.selected:focus":
|
||||
{
|
||||
background: `${gradients.info.state} !important`,
|
||||
background: "var(--qqq-info-color, #0062FF) !important",
|
||||
color: white.main,
|
||||
border: "none",
|
||||
},
|
||||
|
||||
@@ -20,9 +20,8 @@ import boxShadows from "qqq/assets/theme/base/boxShadows";
|
||||
|
||||
// Material Dashboard 2 PRO React TS Helper Functions
|
||||
import pxToRem from "qqq/assets/theme/functions/pxToRem";
|
||||
import linearGradient from "qqq/assets/theme/functions/linearGradient";
|
||||
|
||||
const { white, gradients, grey, transparent } = colors;
|
||||
const { white, grey, transparent } = colors;
|
||||
const { borderWidth } = borders;
|
||||
const { md } = boxShadows;
|
||||
|
||||
@@ -36,26 +35,26 @@ const switchButton: Types = {
|
||||
|
||||
styleOverrides: {
|
||||
switchBase: {
|
||||
color: gradients.dark.main,
|
||||
color: "var(--qqq-switch-track-color, #42424a)",
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: transparent.main,
|
||||
},
|
||||
|
||||
"&.Mui-checked": {
|
||||
color: gradients.dark.main,
|
||||
color: "var(--qqq-switch-track-color, #42424a)",
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: transparent.main,
|
||||
},
|
||||
|
||||
"& .MuiSwitch-thumb": {
|
||||
borderColor: `${gradients.dark.main} !important`,
|
||||
borderColor: "var(--qqq-switch-track-color, #42424a) !important",
|
||||
},
|
||||
|
||||
"& + .MuiSwitch-track": {
|
||||
backgroundColor: `${gradients.dark.main} !important`,
|
||||
borderColor: `${gradients.dark.main} !important`,
|
||||
backgroundColor: "var(--qqq-switch-track-color, #42424a) !important",
|
||||
borderColor: "var(--qqq-switch-track-color, #42424a) !important",
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
@@ -65,7 +64,7 @@ const switchButton: Types = {
|
||||
},
|
||||
|
||||
"&.Mui-focusVisible .MuiSwitch-thumb": {
|
||||
backgroundImage: linearGradient(gradients.info.main, gradients.info.state),
|
||||
backgroundImage: "linear-gradient(195deg, var(--qqq-info-color, #0062FF), var(--qqq-info-color, #0062FF))",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const menu: Types = {
|
||||
fontSize: size.sm,
|
||||
color: text.main,
|
||||
textAlign: "left",
|
||||
backgroundColor: `${white.main} !important`,
|
||||
backgroundColor: "var(--qqq-surface-color, #FFFFFF) !important",
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,15 +16,12 @@ Coded by www.creative-tim.com
|
||||
// Material Dashboard 2 PRO React TS Base Styles
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import borders from "qqq/assets/theme/base/borders";
|
||||
import boxShadows from "qqq/assets/theme/base/boxShadows";
|
||||
|
||||
// Material Dashboard 2 PRO React TS Helper Functions
|
||||
import pxToRem from "qqq/assets/theme/functions/pxToRem";
|
||||
import linearGradient from "qqq/assets/theme/functions/linearGradient";
|
||||
|
||||
const { transparent, gradients } = colors;
|
||||
const { transparent } = colors;
|
||||
const { borderRadius } = borders;
|
||||
const { colored } = boxShadows;
|
||||
|
||||
// types
|
||||
type Types = any;
|
||||
@@ -32,10 +29,10 @@ type Types = any;
|
||||
const stepper: Types = {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: linearGradient(gradients.info.main, gradients.info.state),
|
||||
background: "var(--qqq-stepper-background, linear-gradient(195deg, var(--qqq-primary-color), var(--qqq-primary-color-dark, var(--qqq-primary-color))))",
|
||||
padding: `${pxToRem(24)} 0 ${pxToRem(16)}`,
|
||||
borderRadius: borderRadius.lg,
|
||||
boxShadow: colored.info,
|
||||
boxShadow: "var(--qqq-stepper-shadow, 0rem 0.25rem 1.25rem 0rem rgba(0, 0, 0, 0.14), 0rem 0.4375rem 0.625rem -0.3125rem rgba(0, 0, 0, 0.4))",
|
||||
|
||||
"&.MuiPaper-root": {
|
||||
backgroundColor: transparent.main,
|
||||
|
||||
@@ -15,9 +15,7 @@ Coded by www.creative-tim.com
|
||||
|
||||
// Material Dashboard 2 PRO React TS Base Styles
|
||||
import borders from "qqq/assets/theme/base/borders";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
|
||||
const { white } = colors;
|
||||
const { borderWidth } = borders;
|
||||
|
||||
// types
|
||||
@@ -26,15 +24,15 @@ type Types = any;
|
||||
const stepConnector: Types = {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: "#9fc9ff",
|
||||
color: "var(--qqq-stepper-inactive-color, rgba(255, 255, 255, 0.5))",
|
||||
transition: "all 200ms linear",
|
||||
|
||||
"&.Mui-active": {
|
||||
color: white.main,
|
||||
color: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
},
|
||||
|
||||
"&.Mui-completed": {
|
||||
color: white.main,
|
||||
color: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -13,14 +13,8 @@ Coded by www.creative-tim.com
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
*/
|
||||
|
||||
// Material Dashboard 2 PRO React TS Base Styles
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
|
||||
// Material Dashboard 2 PRO React TS Helper Functions
|
||||
import pxToRem from "qqq/assets/theme/functions/pxToRem";
|
||||
import boxShadow from "qqq/assets/theme/functions/boxShadow";
|
||||
|
||||
const { white } = colors;
|
||||
|
||||
// types
|
||||
type Types = any;
|
||||
@@ -28,9 +22,9 @@ type Types = any;
|
||||
const stepIcon: Types = {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: "#9fc9ff",
|
||||
fill: "#9fc9ff",
|
||||
stroke: "#9fc9ff",
|
||||
background: "var(--qqq-stepper-inactive-color, rgba(255, 255, 255, 0.5))",
|
||||
fill: "var(--qqq-stepper-inactive-color, rgba(255, 255, 255, 0.5))",
|
||||
stroke: "var(--qqq-stepper-inactive-color, rgba(255, 255, 255, 0.5))",
|
||||
strokeWidth: pxToRem(10),
|
||||
width: pxToRem(13),
|
||||
height: pxToRem(13),
|
||||
@@ -39,19 +33,19 @@ const stepIcon: Types = {
|
||||
transition: "all 200ms linear",
|
||||
|
||||
"&.Mui-active": {
|
||||
background: white.main,
|
||||
fill: white.main,
|
||||
stroke: white.main,
|
||||
borderColor: white.main,
|
||||
boxShadow: boxShadow([0, 0], [0, 2], white.main, 1),
|
||||
background: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
fill: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
stroke: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
borderColor: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
boxShadow: "0rem 0rem 0rem 0.125rem var(--qqq-stepper-active-color, #ffffff)",
|
||||
},
|
||||
|
||||
"&.Mui-completed": {
|
||||
background: white.main,
|
||||
fill: white.main,
|
||||
stroke: white.main,
|
||||
borderColor: white.main,
|
||||
boxShadow: boxShadow([0, 0], [0, 2], white.main, 1),
|
||||
background: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
fill: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
stroke: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
borderColor: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
boxShadow: "0rem 0rem 0rem 0.125rem var(--qqq-stepper-active-color, #ffffff)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,14 +15,11 @@ Coded by www.creative-tim.com
|
||||
|
||||
// Material Dashboard 2 PRO React TS Base Styles
|
||||
import typography from "qqq/assets/theme/base/typography";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
|
||||
// Material Dashboard 2 PRO React TS Helper Functions
|
||||
import pxToRem from "qqq/assets/theme/functions/pxToRem";
|
||||
import rgba from "qqq/assets/theme/functions/rgba";
|
||||
|
||||
const { size, fontWeightRegular } = typography;
|
||||
const { white } = colors;
|
||||
|
||||
// types
|
||||
type Types = any;
|
||||
@@ -33,17 +30,17 @@ const stepLabel: Types = {
|
||||
marginTop: `${pxToRem(8)} !important`,
|
||||
fontWeight: fontWeightRegular,
|
||||
fontSize: size.xs,
|
||||
color: "#9fc9ff",
|
||||
color: "var(--qqq-stepper-inactive-color, rgba(255, 255, 255, 0.5))",
|
||||
textTransform: "uppercase",
|
||||
|
||||
"&.Mui-active": {
|
||||
fontWeight: `${fontWeightRegular} !important`,
|
||||
color: `${rgba(white.main, 0.8)} !important`,
|
||||
color: "var(--qqq-stepper-active-color, rgba(255, 255, 255, 0.9)) !important",
|
||||
},
|
||||
|
||||
"&.Mui-completed": {
|
||||
fontWeight: `${fontWeightRegular} !important`,
|
||||
color: `${rgba(white.main, 0.8)} !important`,
|
||||
color: "var(--qqq-stepper-active-color, rgba(255, 255, 255, 0.9)) !important",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -66,7 +66,7 @@ const tabs: Types = {
|
||||
borderRadius: 0,
|
||||
backgroundColor: white.main,
|
||||
borderBottom: "2px solid",
|
||||
borderBottomColor: colors.info.main,
|
||||
borderBottomColor: "var(--qqq-info-color, #0062FF)",
|
||||
transition: "all 500ms ease",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
|
||||
/***************************************************************************
|
||||
* The original version of this app had the "primary" color set to a bright
|
||||
* red/fuchsia/pink tone (#E91E63). So, whenever we would try to add something
|
||||
* that might have made sense to use "primary" color, we'd see that tone that
|
||||
* we didn't like, and we'd use "info" instead - a nice blue.
|
||||
*
|
||||
* But now, with the backend-provided themes, apps might set their primary
|
||||
* color to something like, let's say teal, and the expectation is to make a
|
||||
* lot of things like the new & save buttons be that primary color.
|
||||
*
|
||||
* But, we don't want to make "info" that teal tone - it should be blue
|
||||
* (e.g,. for an info alert or icon - blue for info is the expectation).
|
||||
*
|
||||
* So the mistake we made all along I think was using "info" instead of
|
||||
* "primary" in lots of UI elements that we added.
|
||||
*
|
||||
* To preserve backward compatibility as much as possible, while also changing
|
||||
* some elements from "info" to "primary", then here's what I've come up with
|
||||
* as an approach:
|
||||
*
|
||||
* 1. Define a CSS variable on `:root`called `--qqq-prefer-info-color-to-primary-color`.
|
||||
* (which flows from MaterialDashboardThemeMetaData.preferInfoColorToPrimaryColor)
|
||||
* - If this variable is 1, then places that /should/ have used "primary"
|
||||
* all along would instead still use "info". We'd set that to 1 to
|
||||
* maintain backward-compatibility (e.g., so the create-new button would
|
||||
* still be info-blue, not primary-fuchsia).
|
||||
* - But then, an app can override that value to `0`, to say "no no, don't
|
||||
* prefer info - instead, use primary where it would have made sense
|
||||
* (but you just didn't want that fuchsia color)". So an app could set
|
||||
* MaterialDashboardThemeMetaData.preferInfoColorToPrimaryColor to false,
|
||||
* or set `--qqq-prefer-info-color-to-primary-color: 0` in their custom CSS
|
||||
* so its create new button would be primary (e.g., teal as mentioned above),
|
||||
* not info-blue.
|
||||
*
|
||||
* 2. Define a family of functions (e.g., this file) that, based on that css
|
||||
* variable, either output `"info"` or `"primary"`; or `var(--qqq-info-color)`
|
||||
* or `var(--qqq-primary-color)`, or the hard-coded fallbacks for info & primary
|
||||
*
|
||||
* 3. Update components that are using `"info"` (or `--qqq-info-color`
|
||||
* (or `var(--qqq-info-color, "#0062FF")`)) to call the appropriate
|
||||
* `preferredInfoOrPrimaryXXX` function. I don't think this is a
|
||||
* "change them all" scenario (because info should still be used where it's
|
||||
* intention is truly 'info'), but, i'm not sure entirely which ones
|
||||
* it should be... my current list looks like:
|
||||
* A. Create-New and Save buttons in DefaultButtons.tsx
|
||||
* B. icon backgrounds on app home screens (Home.tsx)
|
||||
* C. progress bar background on processes (ProcessRun.tsx usage of Stepper)
|
||||
* D. the little pagination buttons on the bottoms of like the TableData widget
|
||||
***************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* private function used by other functions in here to decide if the info
|
||||
* color is preferred over the primary color, based on the css variable
|
||||
* --qqq-prefer-info-color-to-primary-color being equal to 1 or not.
|
||||
*
|
||||
* 1 = prefer info color; anything else = prefer primary color
|
||||
*
|
||||
* Legacy behavior was to always prefer info color; Apps setting their own
|
||||
* primary color may want to use their primary color for more UI elements,
|
||||
* and set that css var to 0.
|
||||
***************************************************************************/
|
||||
function preferInfoColorToPrimaryColor(): boolean {
|
||||
return getComputedStyle(document.body).getPropertyValue('--qqq-prefer-info-color-to-primary-color').trim() === "1";
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* private function that, based on the value of preferInfoColorToPrimaryColor(),
|
||||
* returns the hex code for either the default info color (a blue), or the
|
||||
* default primary color (a red/fuchsia).
|
||||
***************************************************************************/
|
||||
function preferredDefaultColorInfoOrPrimary(): string {
|
||||
return preferInfoColorToPrimaryColor() ? "#0062FF" : "#E91E63";
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* public function that returns either "info" or "primary" based on the
|
||||
* value of preferInfoColorToPrimaryColor().
|
||||
*
|
||||
* So elements that should probably be displayed in the primary color (e.g.,
|
||||
* primary action buttons on screens, or some UI decorations), they can use
|
||||
* this function to get their color name - but - if the primary is undesirable,
|
||||
* then the info color (default blue) can be used instead.
|
||||
***************************************************************************/
|
||||
export function preferredColorNameInfoOrPrimary(): "info" | "primary" {
|
||||
return preferInfoColorToPrimaryColor() ? "info" : "primary";
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* public function that returns a css var(--qqq...color, #default) expression,
|
||||
* based on the value of preferInfoColorToPrimaryColor().
|
||||
*
|
||||
* For use-cases similar to preferredColorNameInfoOrPrimary(), but where we
|
||||
* need a CSS value, not a color name for MUI to interpret.
|
||||
***************************************************************************/
|
||||
export function preferredInfoOrPrimaryColorVarExpression(): string {
|
||||
return `var(--qqq-${preferredColorNameInfoOrPrimary()}-color, ${preferredDefaultColorInfoOrPrimary()})`;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {hasScope} from "./ScopeUtils";
|
||||
|
||||
describe("OAuth scopes", () =>
|
||||
{
|
||||
const driveScope = "https://www.googleapis.com/auth/drive";
|
||||
|
||||
it("accepts the exact scope among granted scopes", () =>
|
||||
{
|
||||
expect(hasScope(driveScope, driveScope)).toBe(true);
|
||||
expect(hasScope(`openid profile ${driveScope} email`, driveScope)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing scopes and substring lookalikes", () =>
|
||||
{
|
||||
for (const scope of ["", "openid profile", `${driveScope}.readonly`, `https://example.com/${driveScope}`, `${driveScope}.example.com`])
|
||||
{
|
||||
expect(hasScope(scope, driveScope)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export function hasScope(grantedScopes: string, requiredScope: string): boolean
|
||||
{
|
||||
return grantedScopes.split(/\s+/).includes(requiredScope);
|
||||
}
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
@@ -49,21 +50,6 @@ export default function useAnonymousAuthenticationModule({setIsFullyAuthenticate
|
||||
setIsFullyAuthenticated(true);
|
||||
Client.setGotAuthenticationInAllControllers();
|
||||
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
|
||||
|
||||
try
|
||||
{
|
||||
const {values} = await qController.manageSession(null, null, null);
|
||||
localStorage.setItem("sessionValues", JSON.stringify(values ?? {}));
|
||||
if (values?.user)
|
||||
{
|
||||
setLoggedInUser(values.user);
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log("manageSession call failed (may be expected for some backends)", e);
|
||||
}
|
||||
|
||||
console.log("Token generation complete.");
|
||||
};
|
||||
|
||||
@@ -74,7 +60,6 @@ export default function useAnonymousAuthenticationModule({setIsFullyAuthenticate
|
||||
const logout = () =>
|
||||
{
|
||||
qController.clearAuthenticationMetaDataLocalStorage();
|
||||
localStorage.removeItem("sessionValues");
|
||||
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
||||
};
|
||||
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useAuth0} from "@auth0/auth0-react";
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {withAuthenticationRequired} from "@auth0/auth0-react";
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import {Auth0Provider, useAuth0} from "@auth0/auth0-react";
|
||||
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
@@ -24,7 +25,6 @@ import HandleAuthorizationError from "HandleAuthorizationError";
|
||||
import jwt_decode from "jwt-decode";
|
||||
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
|
||||
import {MaterialUIControllerProvider} from "qqq/context";
|
||||
import AnalyticsUtils from "qqq/utils/analytics/AnalyticsUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import {useCookies} from "react-cookie";
|
||||
import {useNavigate, useSearchParams} from "react-router-dom";
|
||||
@@ -106,40 +106,6 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const readStoredSessionValues = (): {[key: string]: any} | null =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const rawSessionValues = localStorage.getItem("sessionValues");
|
||||
return (rawSessionValues ? JSON.parse(rawSessionValues) : null);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log("Error parsing sessionValues from localStorage: " + e);
|
||||
return (null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const hasAnalyticsIdentityValues = (sessionValues: {[key: string]: any} | null): boolean =>
|
||||
{
|
||||
const analyticsIdentityValues = AnalyticsUtils.getAnalyticsIdentityValues(sessionValues);
|
||||
return Boolean(
|
||||
analyticsIdentityValues["user_email"]
|
||||
|| sessionValues?.user?.email
|
||||
|| sessionValues?.user?.idReference
|
||||
|| sessionValues?.user?.id
|
||||
|| sessionValues?.user?.userId
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@@ -149,41 +115,26 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
|
||||
{
|
||||
console.log("Loading token from auth0...");
|
||||
const accessToken = await auth0GetAccessTokenSilently();
|
||||
let loggedInUserForApp = auth0User;
|
||||
|
||||
const lsAccessToken = localStorage.getItem("accessToken");
|
||||
if (shouldStoreNewToken(accessToken, lsAccessToken))
|
||||
{
|
||||
console.log("Sending accessToken to backend, requesting a sessionUUID...");
|
||||
const {values} = await qController.manageSession(accessToken, null);
|
||||
const {uuid: values} = await qController.manageSession(accessToken, null);
|
||||
|
||||
localStorage.setItem("accessToken", accessToken);
|
||||
localStorage.setItem("sessionValues", JSON.stringify(values ?? {}));
|
||||
loggedInUserForApp = values?.user ?? auth0User;
|
||||
localStorage.setItem("sessionValues", JSON.stringify(values));
|
||||
console.log("Got new sessionUUID from backend, and stored new accessToken");
|
||||
}
|
||||
else
|
||||
{
|
||||
const storedSessionValues = readStoredSessionValues();
|
||||
if (!hasAnalyticsIdentityValues(storedSessionValues))
|
||||
{
|
||||
console.log("Refreshing session values from existing sessionUUID...");
|
||||
const {values} = await qController.manageSession(null, cookies[SESSION_UUID_COOKIE_NAME]);
|
||||
localStorage.setItem("sessionValues", JSON.stringify(values ?? {}));
|
||||
loggedInUserForApp = values?.user ?? auth0User;
|
||||
}
|
||||
else if (storedSessionValues?.user)
|
||||
{
|
||||
loggedInUserForApp = storedSessionValues.user;
|
||||
}
|
||||
|
||||
console.log("Using existing sessionUUID cookie");
|
||||
}
|
||||
|
||||
setIsFullyAuthenticated(true);
|
||||
Client.setGotAuthenticationInAllControllers();
|
||||
|
||||
setLoggedInUser(loggedInUserForApp);
|
||||
setLoggedInUser(auth0User);
|
||||
console.log("Token load complete.");
|
||||
}
|
||||
catch (e)
|
||||
@@ -191,7 +142,6 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
|
||||
console.log(`Error loading token: ${JSON.stringify(e)}`);
|
||||
qController.clearAuthenticationMetaDataLocalStorage();
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("sessionValues");
|
||||
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
||||
useAuth0Logout();
|
||||
return;
|
||||
@@ -205,7 +155,6 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
|
||||
const logout = () =>
|
||||
{
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("sessionValues");
|
||||
useAuth0Logout({returnTo: window.location.origin});
|
||||
};
|
||||
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
@@ -30,7 +31,6 @@ import {useNavigate, useSearchParams} from "react-router-dom";
|
||||
// ** Singleton instance of the QQQ client controller for API communication * //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
const qController = Client.getInstance();
|
||||
const qControllerV1 = Client.getInstanceV1();
|
||||
|
||||
/**
|
||||
* Properties interface for the useOAuth2AuthenticationModule hook.
|
||||
@@ -95,9 +95,10 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
|
||||
const authOidc: AuthContextProps | null = inOAuthContext ? useAuth() : null;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// ** Cookie management hook for reading and removing session cookies * //
|
||||
// ** Cookie management hook for reading the session UUID cookie * //
|
||||
// ** (Removal is done via direct document.cookie manipulation in logout) * //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
const [cookies, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME, "sessionId"]);
|
||||
const [cookies] = useCookies([SESSION_UUID_COOKIE_NAME]);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** URL search parameters hook for extracting OAuth callback parameters (code, state) * //
|
||||
@@ -185,7 +186,6 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
|
||||
// ** Exchange authorization code for session UUID via backend API * //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
const {uuid: newSessionUuid, values} = await qController.manageSession(null, null, manageSessionRequestBody);
|
||||
localStorage.setItem("sessionValues", JSON.stringify(values ?? {}));
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// ** Update parent component's authentication state * //
|
||||
@@ -265,7 +265,6 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
|
||||
// ** Validate the session UUID with backend and retrieve user data * //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
const {values} = await qController.manageSession(null, sessionUuid, null);
|
||||
localStorage.setItem("sessionValues", JSON.stringify(values ?? {}));
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// ** Update parent component's authentication state * //
|
||||
@@ -338,50 +337,30 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
|
||||
* Logs out the current user by clearing all authentication data and redirecting to the identity provider's logout endpoint.
|
||||
*
|
||||
* This function performs a complete cleanup of the authentication session:
|
||||
* 1. Calls the backend logout endpoint to invalidate the server-side session
|
||||
* 2. Clears authentication metadata from local storage (via QQQ client controller)
|
||||
* 3. Removes the session UUID cookie from the browser
|
||||
* 4. Clears all OIDC-related localStorage items (session state, verifiers, etc.)
|
||||
* 5. Redirects to the identity provider's logout endpoint for server-side session cleanup
|
||||
* 1. Clears authentication metadata from local storage (via QQQ client controller)
|
||||
* 2. Removes the session UUID cookie from the browser
|
||||
* 3. Clears all OIDC-related localStorage items (session state, verifiers, etc.)
|
||||
* 4. Redirects to the identity provider's logout endpoint for server-side session cleanup
|
||||
*
|
||||
* After logout, the user will need to re-authenticate to access protected resources.
|
||||
*
|
||||
* @function logout
|
||||
* @returns {Promise<void>}
|
||||
* @returns {void}
|
||||
*/
|
||||
const logout = async () =>
|
||||
const logout = () =>
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Call backend logout endpoint FIRST to invalidate server-side session * //
|
||||
// ** This ensures the session is deleted from database and cache is cleared * //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
try
|
||||
{
|
||||
await qControllerV1.axiosRequest({
|
||||
method: "POST",
|
||||
url: "/qqq/v1/logout",
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.warn("[OAuth2] Backend logout failed:", e);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Clear authentication metadata stored in local storage by the QQQ client * //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
qController.clearAuthenticationMetaDataLocalStorage();
|
||||
localStorage.removeItem("sessionValues");
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Remove session cookies from the browser * //
|
||||
// ** Backend sets both sessionUUID and sessionId cookies (see issue #339) * //
|
||||
// ** Must clear both to prevent stale session persistence * //
|
||||
// ** Remove the session UUID cookie from the browser * //
|
||||
// ** Backend always sets the cookie at path="/", so we only need to remove it from that path * //
|
||||
// ** Use direct document.cookie manipulation (react-cookie's removeCookie has bugs) * //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
console.log("[OAuth2] Removing session cookies (path: /)");
|
||||
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
||||
removeCookie("sessionId", {path: "/"});
|
||||
console.log("[OAuth2] Removing sessionUUID cookie (path: /)");
|
||||
document.cookie = `${SESSION_UUID_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; max-age=0;`;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Clear all OIDC-related items from localStorage (state, verifiers, etc.) * //
|
||||
@@ -431,7 +410,7 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Extract the OAuth2 identity provider's base URL (authority) from metadata * //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
const authority: string = authenticationMetaData.data.externalBaseUrl || authenticationMetaData.data.baseUrl;
|
||||
const authority: string = authenticationMetaData.data.baseUrl;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// ** Extract the OAuth2 client ID for this application from metadata * //
|
||||
@@ -463,18 +442,13 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
|
||||
const basePath = detectBasePath();
|
||||
const baseUrl = (basePath && basePath !== "/") ? `${window.location.origin}${basePath}` : window.location.origin;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Extract scopes from metadata, with sensible default for backwards compatibility * //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
const scopes = authenticationMetaData.data.scopes || "openid profile email offline_access";
|
||||
|
||||
const oidcConfig = {
|
||||
authority: authority,
|
||||
client_id: clientId,
|
||||
redirect_uri: `${baseUrl}/token`,
|
||||
post_logout_redirect_uri: baseUrl,
|
||||
response_type: "code",
|
||||
scope: scopes,
|
||||
scope: "openid profile email offline_access",
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QException} from "@qrunio/qqq-frontend-core/lib/exceptions/QException";
|
||||
|
||||
@@ -5,23 +5,22 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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.
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import {preferredColorNameInfoOrPrimary} from "qqq/assets/theme/functions/preferInfoColorToPrimaryColor";
|
||||
import {QIcon} from "@qrunio/qqq-frontend-core/lib/model/metaData/QIcon";
|
||||
import React from "react";
|
||||
import {Link} from "react-router-dom";
|
||||
import MDButton from "qqq/components/legacy/MDButton";
|
||||
@@ -43,7 +42,7 @@ export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Elemen
|
||||
return (
|
||||
<Box display="inline-block" ml={standardML} mr={0} width={standardWidth}>
|
||||
<Link to={`${tablePath}/create`}>
|
||||
<MDButton qqqId="create-new" variant="gradient" color={preferredColorNameInfoOrPrimary()} fullWidth startIcon={<Icon>add</Icon>}>
|
||||
<MDButton qqqId="create-new" variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
|
||||
Create New
|
||||
</MDButton>
|
||||
</Link>
|
||||
@@ -68,7 +67,7 @@ export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveBu
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton qqqId="save" type="submit" variant="gradient" color={preferredColorNameInfoOrPrimary()} size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
|
||||
<MDButton qqqId="save" type="submit" variant="gradient" color="info" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
|
||||
{label}
|
||||
</MDButton>
|
||||
</Box>
|
||||
@@ -89,7 +88,7 @@ export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): J
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton qqqId="delete" variant="contained" color="error" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>delete</Icon>} disabled={disabled}>
|
||||
<MDButton qqqId="delete" variant="gradient" color="primary" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>delete</Icon>} disabled={disabled}>
|
||||
Delete
|
||||
</MDButton>
|
||||
</Box>
|
||||
@@ -101,7 +100,7 @@ export function QEditButton(): JSX.Element
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<Link to="edit">
|
||||
<MDButton variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>}>
|
||||
<MDButton qqqId="edit" variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>}>
|
||||
Edit
|
||||
</MDButton>
|
||||
</Link>
|
||||
@@ -113,23 +112,21 @@ interface QActionsMenuButtonProps
|
||||
{
|
||||
isOpen: boolean;
|
||||
onClickHandler: any;
|
||||
label?: string;
|
||||
qIcon?: QIcon;
|
||||
}
|
||||
|
||||
export function QActionsMenuButton({isOpen, onClickHandler, label, qIcon}: QActionsMenuButtonProps): JSX.Element
|
||||
export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box width={standardWidth} ml={1}>
|
||||
<MDButton
|
||||
qqqId="actions-menu"
|
||||
variant={"outlined"}
|
||||
variant={isOpen ? "contained" : "outlined"}
|
||||
color="dark"
|
||||
onClick={onClickHandler}
|
||||
startIcon={<Icon>{qIcon?.name ?? "games"}</Icon>}
|
||||
startIcon={<Icon>games</Icon>}
|
||||
fullWidth
|
||||
>
|
||||
{label ?? "actions"}
|
||||
actions
|
||||
<Icon>keyboard_arrow_down</Icon>
|
||||
</MDButton>
|
||||
</Box>
|
||||
@@ -142,16 +139,15 @@ interface QCancelButtonProps
|
||||
disabled: boolean;
|
||||
label?: string;
|
||||
iconName?: string;
|
||||
qqqId?: string;
|
||||
}
|
||||
|
||||
export function QCancelButton({
|
||||
onClickHandler, disabled, label, iconName, qqqId
|
||||
onClickHandler, disabled, label, iconName,
|
||||
}: QCancelButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton type="button" variant="outlined" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} onClick={onClickHandler} disabled={disabled} qqqId={qqqId}>
|
||||
<MDButton qqqId="cancel" type="button" variant="outlined" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} onClick={onClickHandler} disabled={disabled}>
|
||||
{label}
|
||||
</MDButton>
|
||||
</Box>
|
||||
@@ -161,7 +157,6 @@ export function QCancelButton({
|
||||
QCancelButton.defaultProps = {
|
||||
label: "cancel",
|
||||
iconName: "cancel",
|
||||
qqqId: "cancel"
|
||||
};
|
||||
|
||||
interface QSubmitButtonProps
|
||||
@@ -169,14 +164,13 @@ interface QSubmitButtonProps
|
||||
label?: string;
|
||||
iconName?: string;
|
||||
disabled: boolean;
|
||||
qqqId?: string;
|
||||
}
|
||||
|
||||
export function QSubmitButton({label, iconName, disabled, qqqId}: QSubmitButtonProps): JSX.Element
|
||||
export function QSubmitButton({label, iconName, disabled}: QSubmitButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton type="submit" variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled} qqqId={qqqId}>
|
||||
<MDButton qqqId="submit" type="submit" variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
|
||||
{label}
|
||||
</MDButton>
|
||||
</Box>
|
||||
@@ -186,7 +180,6 @@ export function QSubmitButton({label, iconName, disabled, qqqId}: QSubmitButtonP
|
||||
QSubmitButton.defaultProps = {
|
||||
label: "Submit",
|
||||
iconName: "check",
|
||||
qqqId: "submit"
|
||||
};
|
||||
|
||||
interface QAlternateButtonProps
|
||||
@@ -194,21 +187,18 @@ interface QAlternateButtonProps
|
||||
label: string,
|
||||
iconName?: string,
|
||||
disabled: boolean,
|
||||
onClick?: () => void,
|
||||
qqqId?: string;
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function QAlternateButton({label, iconName, disabled, onClick, qqqId}: QAlternateButtonProps): JSX.Element
|
||||
export function QAlternateButton({label, iconName, disabled, onClick}: QAlternateButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton type="button" variant="gradient" color="secondary" size="small" fullWidth startIcon={iconName && <Icon>{iconName}</Icon>} onClick={onClick} disabled={disabled} qqqId={qqqId}>
|
||||
<MDButton qqqId="alternate" type="button" variant="gradient" color="secondary" size="small" fullWidth startIcon={iconName && <Icon>{iconName}</Icon>} onClick={onClick} disabled={disabled}>
|
||||
{label}
|
||||
</MDButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
QAlternateButton.defaultProps = {
|
||||
qqqId: "alternate"
|
||||
};
|
||||
QAlternateButton.defaultProps = {};
|
||||