Compare commits

..
Author SHA1 Message Date
james.maes 52153e889e fix: apply dividerColor to general dividers and enhance test fixtures
- Add CSS rule for --qqq-divider-color on dividers outside sidebar
- Expand withFullCustomTheme.json with more fields, sections, and processes
2026-01-10 22:50:18 -06:00
james.maes 18a6bbc570 docs: update session state and plans for Playwright consolidation
- Update CLAUDE.md with 0.40.0-SNAPSHOT version and current work status
- Add PLAN-playwright-consolidation.md with detailed migration plan
- Update TODO.md and SESSION_STATE.md for session continuity
2026-01-10 22:49:56 -06:00
james.maes 6fc7ecee8b feat(test): add strict Playwright tests for bulk edit, navigation, and query filters
- Add QueryScreen helper class with robust filter builder interactions
- Add bulk-edit.spec.ts with strict step-by-step verification
- Add app-navigation.spec.ts with role-based selectors
- Add query-screen.spec.ts with 13 tests for filter functionality
- Add process step fixtures for bulk edit workflow
- Fix fixture-server to track review step state for multi-POST flows
2026-01-10 22:09:14 -06:00
388 changed files with 11598 additions and 20078 deletions

No files matched your search

+112
View File
@@ -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\/.*/
-12
View File
@@ -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
View File
@@ -1 +0,0 @@
* @KofTwentyTwo
+2 -2
View File
@@ -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
-38
View File
@@ -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:
- "*"
-23
View File
@@ -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
-4
View File
@@ -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
-1
View File
@@ -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
+189 -91
View File
@@ -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 -24
View File
@@ -2,12 +2,10 @@
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](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
+1 -2
View File
@@ -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
-42
View File
@@ -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
+80
View File
@@ -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
+9 -9
View File
@@ -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
+12 -11
View File
@@ -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 -1
View File
@@ -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
+12 -12
View File
@@ -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` |
---
+40 -48
View File
@@ -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
```
+5 -5
View File
@@ -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.
+33 -61
View File
@@ -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`.
+580 -282
View File
@@ -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)`);
}
});
+296
View File
@@ -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);
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

+142
View File
@@ -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 });
});
});
+106
View File
@@ -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 });
});
});
-94
View File
@@ -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([]);
});
+253
View File
@@ -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();
});
});
-345
View File
@@ -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);
}
});
});
+73 -2
View File
@@ -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}`;
-421
View File
@@ -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);
});
});
-305
View File
@@ -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);
}
});
});
-110
View File
@@ -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);
}
+1345 -1853
View File
File diff suppressed because it is too large. Load diff
+10 -29
View File
@@ -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"
}
]
}
+8 -31
View File
@@ -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,
},
],
+46 -67
View File
@@ -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>
-40
View File
@@ -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())
-27
View File
@@ -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
-60
View File
@@ -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}"
-46
View File
@@ -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}"
+39 -89
View File
@@ -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>
+10 -9
View File
@@ -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";
+10 -9
View File
@@ -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";
+12 -14
View File
@@ -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,
+10 -9
View File
@@ -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;
+2 -2
View File
@@ -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()})`;
}
-40
View File
@@ -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);
}
});
});
-24
View File
@@ -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: "/"});
};
+10 -9
View File
@@ -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";
+10 -9
View File
@@ -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";
+10 -9
View File
@@ -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";
+10 -9
View File
@@ -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",
};
///////////////////////////////////////////////////////////////////////////////
+10 -9
View File
@@ -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";
+26 -36
View File
@@ -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"}&nbsp;
actions&nbsp;
<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 = {};
Loaded 100 of 388 files, more files were not shown because too many files have changed in this diff. Show more