Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489946dd8a |
No files matched your search
@@ -1,19 +1,115 @@
|
||||
---
|
||||
version: 2.1
|
||||
setup: true
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
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: Build React app
|
||||
command: npm run build
|
||||
- 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:
|
||||
setup:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /.*/
|
||||
run_playwright: << pipeline.parameters.run_playwright >>
|
||||
parameters:
|
||||
run_playwright:
|
||||
type: boolean
|
||||
default: false
|
||||
- playwright_tests:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release/.*|hotfix/.*)/
|
||||
tags:
|
||||
only: []
|
||||
- qqq-orb/mvn_frontend_test_only:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release/.*|hotfix/.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- playwright_tests:
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
requires:
|
||||
- playwright_tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
|
||||
publish_feature:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: feature
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- feature/.*
|
||||
tags:
|
||||
only: [/publish.*/]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /hotfix\/.*/
|
||||
@@ -1 +0,0 @@
|
||||
* @KofTwentyTwo
|
||||
@@ -1,38 +0,0 @@
|
||||
# GitHub Release Configuration
|
||||
# Auto-generates release notes from PR labels
|
||||
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
- duplicate
|
||||
- invalid
|
||||
authors:
|
||||
- dependabot
|
||||
- dependabot[bot]
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
- breaking-change
|
||||
- breaking
|
||||
- title: New Features
|
||||
labels:
|
||||
- enhancement
|
||||
- feature
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- bug
|
||||
- fix
|
||||
- title: Performance Improvements
|
||||
labels:
|
||||
- performance
|
||||
- title: Documentation
|
||||
labels:
|
||||
- documentation
|
||||
- docs
|
||||
- title: Dependencies
|
||||
labels:
|
||||
- dependencies
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
||||
@@ -1,23 +0,0 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,3 +0,0 @@
|
||||
profile: frontend-material
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
@@ -7,7 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [0.35.0] - 2025-12-29
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -49,10 +49,10 @@ MUI components + generated screens
|
||||
|
||||
### Key Entry Points
|
||||
|
||||
- `src/App.tsx` - Main app, authentication, route generation from metadata
|
||||
- `src/App.tsx` - Main app, authentication, route generation from metadata, theme provider, BrandedHeaderBar
|
||||
- `src/QContext.tsx` - Global state (accentColor, tableMetaData, modalStack, branding)
|
||||
- `src/qqq/utils/qqq/Client.ts` - Singleton wrapper for `@qrunio/qqq-frontend-core` API client
|
||||
- `src/qqq/components/legacy/Theme.ts` - MUI theme configuration
|
||||
- `src/qqq/utils/themeUtils.ts` - Theme CSS variable injection and font loading
|
||||
|
||||
### Source Structure
|
||||
|
||||
@@ -62,7 +62,7 @@ src/qqq/
|
||||
│ ├── base/ # Design tokens: colors.ts, typography.ts, boxShadows.ts
|
||||
│ └── components/ # 55 MUI component style overrides
|
||||
├── components/
|
||||
│ ├── horseshoe/ # Core layout: SideNav, NavBar, Footer, Breadcrumbs
|
||||
│ ├── horseshoe/ # Core layout: SideNav, NavBar, Footer, Breadcrumbs, BrandedHeaderBar
|
||||
│ ├── forms/ # DynamicForm, EntityForm, field components
|
||||
│ ├── widgets/ # Dashboard widgets, charts, statistics
|
||||
│ ├── query/ # Table filtering components
|
||||
@@ -85,15 +85,127 @@ Routes are dynamically created from `QInstance.appTree` metadata in `App.tsx`:
|
||||
- Tables get CRUD routes: `/app/{table}`, `/app/{table}/create`, `/app/{table}/:id`, `/app/{table}/:id/edit`
|
||||
- Processes get execution routes under their parent table or app
|
||||
|
||||
## Pluggable Themes System (Implemented)
|
||||
|
||||
### Overview
|
||||
|
||||
The dashboard supports **runtime theme customization** via CSS custom properties. Theme configuration is defined in `QThemeMetaData` on the Java backend and injected as `--qqq-*` CSS variables by `themeUtils.ts`.
|
||||
|
||||
### Theme Configuration (Java Backend)
|
||||
|
||||
```java
|
||||
qInstance.setTheme(new QThemeMetaData()
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSidebarBackgroundColor("#1E1E2D")
|
||||
.withBrandedHeaderEnabled(true)
|
||||
.withBrandedHeaderTagline("My Application"));
|
||||
```
|
||||
|
||||
### Available Theme Properties (60+)
|
||||
|
||||
| Category | Properties |
|
||||
|----------|------------|
|
||||
| **Core Colors** | `primaryColor`, `secondaryColor`, `backgroundColor`, `surfaceColor`, `textPrimary`, `textSecondary`, `errorColor`, `warningColor`, `successColor`, `infoColor` |
|
||||
| **Typography Base** | `fontFamily`, `headerFontFamily`, `monoFontFamily`, `fontSizeBase`, `fontWeightLight`, `fontWeightRegular`, `fontWeightMedium`, `fontWeightBold` |
|
||||
| **Typography Variants** | `typographyH1FontSize`, `typographyH1FontWeight`, `typographyH1LineHeight`, `typographyH1LetterSpacing`, `typographyH1TextTransform` (same pattern for H2-H6, Body1, Body2, Button, Caption) |
|
||||
| **Branded Header** | `brandedHeaderEnabled`, `brandedHeaderBackgroundColor`, `brandedHeaderTextColor`, `brandedHeaderLogoPath`, `brandedHeaderLogoAltText`, `brandedHeaderHeight`, `brandedHeaderTagline` |
|
||||
| **App Bar** | `appBarBackgroundColor`, `appBarTextColor` |
|
||||
| **Sidebar** | `sidebarBackgroundColor`, `sidebarTextColor`, `sidebarIconColor`, `sidebarSelectedBackgroundColor`, `sidebarSelectedTextColor`, `sidebarHoverBackgroundColor`, `sidebarDividerColor` |
|
||||
| **Tables** | `tableHeaderBackgroundColor`, `tableHeaderTextColor`, `tableRowHoverColor`, `tableRowSelectedColor`, `tableBorderColor` |
|
||||
| **General** | `dividerColor`, `borderColor`, `cardBorderColor`, `borderRadius`, `density`, `iconStyle` |
|
||||
| **Assets** | `logoPath`, `iconPath`, `faviconPath` |
|
||||
| **Custom** | `customCss` (raw CSS injection) |
|
||||
|
||||
### CSS Variables
|
||||
|
||||
All properties become CSS variables with `--qqq-` prefix:
|
||||
- `primaryColor` → `--qqq-primary-color`
|
||||
- `sidebarBackgroundColor` → `--qqq-sidebar-background-color`
|
||||
|
||||
Additional generated variables:
|
||||
- Grey scale: `--qqq-grey-100` through `--qqq-grey-900`
|
||||
- Charts: `--qqq-chart-grid-color`, `--qqq-chart-text-color`, `--qqq-chart-background-color`
|
||||
- Links: `--qqq-link-color`
|
||||
- Spacing: `--qqq-spacing-base`, `--qqq-spacing-small`, `--qqq-spacing-medium`, `--qqq-spacing-large`
|
||||
- Actions: `--qqq-action-active`, `--qqq-action-hover`, `--qqq-action-selected`, `--qqq-action-disabled`
|
||||
- Status variants: `--qqq-info-light`, `--qqq-info-dark`, etc.
|
||||
|
||||
Component-specific variables (override via customCss):
|
||||
- Stepper: `--qqq-stepper-inactive-color`
|
||||
- Tooltip: `--qqq-tooltip-background-color`, `--qqq-tooltip-text-color`, `--qqq-tooltip-shadow`
|
||||
- Inputs: `--qqq-input-border-color`
|
||||
- Menu: `--qqq-menu-hover-color`
|
||||
- Switch: `--qqq-switch-track-color`
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/qqq/utils/themeUtils.ts` - CSS variable injection, font loading
|
||||
- `src/qqq/components/horseshoe/BrandedHeaderBar.tsx` - Optional header banner
|
||||
- `src/qqq/styles/qqq-override-styles.css` - CSS variable overrides for MUI components
|
||||
|
||||
### Documentation
|
||||
|
||||
- `docs/QQQ_THEMING_GUIDE.md` - Complete theming reference for LLMs/developers
|
||||
- `docs/QQQ_CSS_SELECTORS_GUIDE.md` - CSS selector reference for targeting UI elements
|
||||
|
||||
## CSS Selectors System (Implemented)
|
||||
|
||||
### Overview
|
||||
|
||||
All major UI elements have stable `data-qqq-id` attributes for targeted styling via `QThemeMetaData.customCss`. IDs are generated from element names using `sanitizeId()` which converts to lowercase and replaces non-alphanumeric characters with hyphens.
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/qqq/utils/qqqIdUtils.ts` - ID sanitization utilities
|
||||
- `src/qqq/context/QqqIdContext.tsx` - Optional scope context provider
|
||||
|
||||
### Selector Patterns
|
||||
|
||||
| Element | Pattern | Example |
|
||||
|---------|---------|---------|
|
||||
| Buttons | `button-{text}` | `button-save` |
|
||||
| Icon buttons | `button-icon-{icon}` | `button-icon-delete` |
|
||||
| Menu items | `menu-item-{text}` | `menu-item-bulk-edit` |
|
||||
| Nav items | `sidenav-{name}` | `sidenav-orders` |
|
||||
| Inputs | `input-{field}` | `input-first-name` |
|
||||
| Selects | `select-{field}` | `select-status` |
|
||||
| Table headers | `table-header-{field}` | `table-header-order-id` |
|
||||
| Record view | `record-view-{element}-{table}` | `record-view-header-orders` |
|
||||
| Record create | `record-create-{element}-{table}` | `record-create-title-person` |
|
||||
| Record edit | `record-edit-{element}-{table}` | `record-edit-avatar-customer` |
|
||||
| Sections | `record-section-{name}` | `record-section-identity` |
|
||||
| Form sections | `form-section-{name}` | `form-section-details` |
|
||||
| Sidebar items | `sidebar-item-{name}` | `sidebar-item-identity` |
|
||||
| Table cards | `table-card-{name}` | `table-card-orders` |
|
||||
| Process cards | `process-card-{name}` | `process-card-bulk-load` |
|
||||
| App headers | `app-header-{name}` | `app-header-inventory` |
|
||||
|
||||
### Example CSS Usage
|
||||
|
||||
```css
|
||||
/* Hide delete menu item */
|
||||
[data-qqq-id="menu-item-delete"] { display: none; }
|
||||
|
||||
/* Style specific table view header */
|
||||
[data-qqq-id="record-view-header-orders"] {
|
||||
background: linear-gradient(to right, #1976D2, #42A5F5);
|
||||
}
|
||||
|
||||
/* Style all sidebar items */
|
||||
[data-qqq-id^="sidebar-item-"] {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Repository Dependencies
|
||||
|
||||
This project is part of a three-repo system:
|
||||
|
||||
| Repository | Artifact | Current Version |
|
||||
|------------|----------|-----------------|
|
||||
| qqq (backend-core) | `com.kingsrook.qqq:qqq-backend-core` | `0.36.0` |
|
||||
| qqq-frontend-core | `@qrunio/qqq-frontend-core` | `1.4.2` |
|
||||
| qqq-frontend-material-dashboard | `com.kingsrook.qqq:qqq-frontend-material-dashboard` | `0.36.0-RC.4` |
|
||||
| qqq (backend-core) | `com.kingsrook.qqq:qqq-backend-core` | `0.36.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.36.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,34 @@ 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` |
|
||||
| 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) |
|
||||
| Branch | `develop` |
|
||||
| Version | `0.36.0-SNAPSHOT` |
|
||||
| Latest Commit | `bb6d0dd` - docs: update session state after Playwright integration complete |
|
||||
| CI Status | All passing, snapshot published |
|
||||
|
||||
### Active Work: Issue #128 Visual Regressions (Round 5 - COMPLETE)
|
||||
### Recent Merges
|
||||
|
||||
**Status:** All fixes complete and verified, waiting on Darin's approval
|
||||
|
||||
**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
|
||||
| PR | Description |
|
||||
|----|-------------|
|
||||
| #127 | Playwright e2e test integration (26 tests) |
|
||||
| #125 | Pluggable themes + CSS selectors system |
|
||||
| #123 | Virtual fields support |
|
||||
| #122 | Form adjusters |
|
||||
|
||||
### Test Status
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright themed | 26 | PASS |
|
||||
| Playwright unthemed | 13 | PASS |
|
||||
| Playwright e2e | 26 | PASS |
|
||||
| Selenium fixture-based | 115 | PASS |
|
||||
| Java unit | 3 | PASS |
|
||||
| Selenium full-server | 19 | Infrastructure issue - hangs locally |
|
||||
|
||||
### Key Files for Theme System
|
||||
|
||||
| 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
|
||||
```
|
||||
The `seleniumwithqapplication` tests require a full QQQ backend server and hang during local execution. They may work in CI.
|
||||
|
||||
### Key Documentation Files
|
||||
|
||||
@@ -314,22 +416,17 @@ npm run e2e:all
|
||||
| `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 |
|
||||
| `docs/PLAN-css-selectors.md` | CSS selectors implementation plan (COMPLETE) |
|
||||
| `docs/PLAN-pluggable-themes-mui-refactor.md` | MUI refactor plan (COMPLETE) |
|
||||
| `docs/TODO-hardcoded-colors.md` | Hardcoded color conversion checklist (COMPLETE) |
|
||||
|
||||
### Publishing
|
||||
### Publishing Snapshots
|
||||
|
||||
Release candidates publish automatically on `release/*` branches via CircleCI `publish_release_candidate` workflow.
|
||||
Snapshots publish automatically when merged to `develop` via CircleCI `publish_snapshot` workflow.
|
||||
|
||||
Snapshots publish on `develop` via `publish_snapshot` workflow.
|
||||
Manual publish using gitops script:
|
||||
```bash
|
||||
/Users/james.maes/.local/bin/gitops-publish.sh
|
||||
```
|
||||
|
||||
## Knowledge base
|
||||
|
||||
Durable cross-repo knowledge for QQQ lives in the second-brain vault
|
||||
(`$SECOND_BRAIN_VAULT`, i.e. `R:/Git.Local/KofTwentyTwo/second-brain`):
|
||||
|
||||
- Hub: `knowledge/qqq/qqq-hub.md`
|
||||
- This repo's dossier: `knowledge/qqq/repos/qqq-frontend-material-dashboard.md`
|
||||
(includes the full server-API consumption inventory, legacy vs `/qqq/v1`)
|
||||
|
||||
Dossier reviewed at commit `628e36f64e9a` on branch `develop` (2026-07-04).
|
||||
Note: the cross-repo version table earlier in this file is stale (0.36-era);
|
||||
trust `pom.xml` / `package.json` for current pins.
|
||||
This creates a `publish-{commit}` tag that triggers CI/CD to build and publish the JAR.
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
> **QQQ 4.0 dashboard:** Material Dashboard `0.41.0` is the standard dashboard for QQQ 4.0. [qqq-frontend-next](https://github.com/QRun-IO/qqq-frontend-next) is a compatible preview. Widget parity remains incomplete and is deferred in [#550](https://github.com/QRun-IO/qqq/issues/550).
|
||||
|
||||
React admin dashboard for QQQ applications.
|
||||
|
||||
**For:** Teams using QQQ who want a ready-to-use admin UI without writing frontend code
|
||||
**Status:** Stable; standard dashboard for QQQ 4.0.
|
||||
**Status:** Stable
|
||||
|
||||
## Why This Exists
|
||||
|
||||
@@ -84,7 +82,7 @@ mvn clean package -Pci
|
||||
## Project Status
|
||||
|
||||
**Maturity:** Stable, production use
|
||||
**Versioning:** Independently versioned; Material `0.41.0` accompanies QQQ `4.0.0`.
|
||||
**Breaking changes:** Follows QQQ versioning
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# PLAN: Fix CI Playwright Tests
|
||||
|
||||
## Goal
|
||||
|
||||
Fix Playwright e2e tests failing in CircleCI due to webserver timeout.
|
||||
|
||||
## Problem
|
||||
|
||||
The React dev server (`npm start`) times out in CI because webpack compilation takes >120s in the Playwright Docker container.
|
||||
|
||||
## Approach
|
||||
|
||||
Combine the fixture server to serve both:
|
||||
1. Static React build (from `build/` directory)
|
||||
2. API fixtures (from `src/test/resources/fixtures/`)
|
||||
|
||||
This eliminates the need for a proxy and speeds up startup.
|
||||
|
||||
## Changes Made
|
||||
|
||||
1. **package.json** - Added `serve` package (may not be needed now)
|
||||
2. **e2e/fixture-server.js** - Added static file serving for React build
|
||||
3. **playwright.config.ts** - Simplified to use single server on port 8001
|
||||
4. **.circleci/config.yml** - Added `npm run build` step before tests
|
||||
|
||||
## Current Status: IN PROGRESS
|
||||
|
||||
Tests still fail locally - React app renders blank page. Need to debug:
|
||||
- Check if all required API endpoints are mocked
|
||||
- Verify static file paths resolve correctly
|
||||
- Check browser console for JS errors
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Run fixture server manually, open browser, check console errors
|
||||
2. Compare working Selenium setup to see what's different
|
||||
3. May need to add more API endpoint stubs to fixture-server.js
|
||||
4. Test with `npx playwright test --debug` for interactive debugging
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
## Goal
|
||||
|
||||
Add stable, semantic CSS selectors so QQQ app developers can target specific UI elements in
|
||||
`MaterialDashboardThemeMetaData.customCss`.
|
||||
Add stable, semantic CSS selectors so QQQ app developers can target specific UI elements in `QThemeMetaData.customCss`.
|
||||
|
||||
## Approach
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# PLAN: Fix Visual Regressions (Issue #128) - COMPLETE
|
||||
|
||||
**Status:** COMPLETE - Waiting on Darin to test
|
||||
**Branch:** `feature/fix-visual-regressions-128`
|
||||
**Latest Commit:** `d4251be`
|
||||
|
||||
## Goal
|
||||
Fix visual regressions caused by PR #125 pluggable themes so that unthemed apps look identical to pre-PR-125.
|
||||
|
||||
## Solution Summary
|
||||
|
||||
The key fix was **scoping CSS override rules to themed apps only**:
|
||||
1. Added `.qqq-themed` class toggle in `injectIslandVariables.ts`
|
||||
2. Scoped all CSS rules in `qqq-override-styles.css` to `.qqq-themed`
|
||||
3. Fixed e2e test infrastructure (`setupProxy.js` missing `/metaData` route)
|
||||
4. Fixed fixture structure (theme must be under `supplementalInstanceMetaData.theme`)
|
||||
|
||||
### Key Insight
|
||||
`QInstance` class in `@qrunio/qqq-frontend-core` only looks for theme at `object.supplementalInstanceMetaData["theme"]`, NOT at root level.
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/qqq/utils/injectIslandVariables.ts` | Added `.qqq-themed` class toggle |
|
||||
| `src/qqq/styles/qqq-override-styles.css` | Scoped rules to `.qqq-themed` |
|
||||
| `src/setupProxy.js` | Added `/metaData` route |
|
||||
| `src/test/resources/fixtures/metaData/withFullCustomTheme.json` | Fixed theme location |
|
||||
|
||||
## Test Results
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright themed | 26 | PASS |
|
||||
| Playwright unthemed | 13 | PASS |
|
||||
| Selenium fixture-based | 115 | PASS |
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Wait for Darin's approval on issue #128
|
||||
2. Create PR to merge into develop
|
||||
3. Publish snapshot after merge
|
||||
@@ -95,10 +95,10 @@ New file: `src/qqq/utils/createDynamicTheme.ts`
|
||||
|
||||
```typescript
|
||||
import { createTheme, Theme } from "@mui/material";
|
||||
import { MaterialDashboardThemeMetaData } from "qqq/models/metadata/MaterialDashboardThemeMetaData";
|
||||
import { QThemeMetaData } from "@qrunio/qqq-frontend-core/lib/model/metaData/QThemeMetaData";
|
||||
import { DEFAULT_THEME } from "./themeUtils";
|
||||
|
||||
export function createDynamicTheme(themeData?: MaterialDashboardThemeMetaData): Theme {
|
||||
export function createDynamicTheme(themeData?: QThemeMetaData): Theme {
|
||||
// Merge with defaults
|
||||
const theme = { ...DEFAULT_THEME, ...filterUndefined(themeData) };
|
||||
|
||||
@@ -152,12 +152,12 @@ New file: `src/qqq/utils/injectIslandVariables.ts`
|
||||
This injects CSS variables ONLY for sidebar, branded header, and non-MUI components.
|
||||
|
||||
```typescript
|
||||
import { MaterialDashboardThemeMetaData } from "qqq/models/metadata/MaterialDashboardThemeMetaData";
|
||||
import { QThemeMetaData } from "@qrunio/qqq-frontend-core/lib/model/metaData/QThemeMetaData";
|
||||
import { DEFAULT_THEME } from "./themeUtils";
|
||||
|
||||
const CSS_VAR_PREFIX = "--qqq-";
|
||||
|
||||
export function injectIslandVariables(themeData?: MaterialDashboardThemeMetaData): void {
|
||||
export function injectIslandVariables(themeData?: QThemeMetaData): void {
|
||||
const root = document.documentElement;
|
||||
const theme = { ...DEFAULT_THEME, ...filterUndefined(themeData) };
|
||||
|
||||
@@ -413,10 +413,10 @@ mvn verify -Pci
|
||||
|
||||
### New Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|-------------------------------------------------------|
|
||||
| `src/qqq/utils/createDynamicTheme.ts` | MUI theme builder from MaterialDashboardThemeMetaData |
|
||||
| `src/qqq/utils/injectIslandVariables.ts` | CSS vars for sidebar/header islands only |
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/qqq/utils/createDynamicTheme.ts` | MUI theme builder from QThemeMetaData |
|
||||
| `src/qqq/utils/injectIslandVariables.ts` | CSS vars for sidebar/header islands only |
|
||||
|
||||
### Files to Modify
|
||||
|
||||
@@ -444,7 +444,7 @@ mvn verify -Pci
|
||||
1. All 26 Playwright tests pass
|
||||
2. CSS override file reduced from 1213 to <400 lines
|
||||
3. `!important` count reduced from 82 to <15
|
||||
4. Theme data flows directly: MaterialDashboardThemeMetaData -> MUI Theme -> Components
|
||||
4. Theme data flows directly: QThemeMetaData -> MUI Theme -> Components
|
||||
5. No visual regressions in app
|
||||
6. CI pipeline passes
|
||||
|
||||
|
||||
@@ -81,21 +81,21 @@ This document outlines the architecture changes needed to support pluggable them
|
||||
Runtime-swappable themes via CSS variables injected from backend metadata.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ QQQ Backend │
|
||||
│ MaterialDashboardThemeMetaData: palette, typography, spacing, icons, shadows │
|
||||
└─────────────────────────┬───────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ QQQ Backend │
|
||||
│ QThemeMetaData: palette, typography, spacing, icons, shadows │
|
||||
└─────────────────────────┬───────────────────────────────────────┘
|
||||
│ /metaData endpoint (expanded)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ qqq-frontend-core │
|
||||
│ + MaterialDashboardThemeMetaData comes along in the QInstance │
|
||||
│ as supplementalMetaData │
|
||||
│ qqq-frontend-core │
|
||||
│ + QThemeMetaData class with full design token structure │
|
||||
│ + ThemeUtils for CSS variable generation │
|
||||
└─────────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ qqq-frontend-material-dashboard │
|
||||
│ qqq-frontend-material-dashboard │
|
||||
│ ThemeProvider injects CSS variables to :root │
|
||||
│ All components consume var(--qqq-color-primary) etc. │
|
||||
│ MUI theme reads from CSS variables │
|
||||
@@ -131,7 +131,8 @@ Separate npm packages per theme, selected at build time.
|
||||
### 1. Design Token Schema
|
||||
|
||||
```typescript
|
||||
export interface MaterialDashboardThemeMetaData{
|
||||
// qqq-frontend-core: src/model/metaData/QThemeMetaData.ts
|
||||
export interface QThemeMetaData {
|
||||
name: string;
|
||||
|
||||
palette: {
|
||||
@@ -213,7 +214,7 @@ interface TypographyVariant {
|
||||
|
||||
```typescript
|
||||
// qqq-frontend-dashboard: src/qqq/theme/ThemeInjector.ts
|
||||
export function injectThemeVariables(theme: MaterialDashboardThemeMetaData): void {
|
||||
export function injectThemeVariables(theme: QThemeMetaData): void {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Colors
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# QQQ CSS Selectors Guide
|
||||
|
||||
QQQ automatically generates stable `data-qqq-id` attributes on key UI elements, enabling precise CSS targeting via `MaterialDashboardThemeMetaData.customCss`.
|
||||
QQQ automatically generates stable `data-qqq-id` attributes on key UI elements, enabling precise CSS targeting via `QThemeMetaData.customCss`.
|
||||
|
||||
## ID Format
|
||||
|
||||
|
||||
@@ -6,22 +6,22 @@ This document explains the QQQ pluggable theming system for AI assistants and de
|
||||
|
||||
QQQ uses a **backend-driven theming model**:
|
||||
|
||||
1. **Java Backend**: Define theme in `MaterialDashboardThemeMetaData` object
|
||||
2. **Frontend Model**: TypeScript `MaterialDashboardThemeMetaData` interface mirrors Java class
|
||||
1. **Java Backend**: Define theme in `QThemeMetaData` object
|
||||
2. **Frontend Core**: TypeScript `QThemeMetaData` interface mirrors Java class
|
||||
3. **Frontend Dashboard**: `themeUtils.ts` injects CSS custom properties at runtime
|
||||
|
||||
Theme data flows: `MaterialDashboardThemeMetaData.java` → `/metaData` API → `MaterialDashboardThemeMetaData.ts` → CSS variables (`--qqq-*`)
|
||||
Theme data flows: `QThemeMetaData.java` → `/metaData` API → `QThemeMetaData.ts` → CSS variables (`--qqq-*`)
|
||||
|
||||
## Configuring a Theme (Java Backend)
|
||||
|
||||
Create a `MaterialDashboardThemeMetaData` instance using fluent setters:
|
||||
Create a `QThemeMetaData` instance using fluent setters:
|
||||
|
||||
```java
|
||||
MaterialDashboardThemeMetaData theme = new MaterialDashboardThemeMetaData()
|
||||
QThemeMetaData theme = new QThemeMetaData()
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSidebarBackgroundColor("#1E1E2D");
|
||||
|
||||
qInstance.add(theme);
|
||||
qInstance.setTheme(theme);
|
||||
```
|
||||
|
||||
All properties are optional. Omitted properties use frontend defaults.
|
||||
@@ -251,7 +251,7 @@ The fallback value (after comma) is used if the variable is undefined.
|
||||
|
||||
## Theme Application Flow
|
||||
|
||||
1. Backend serves `MaterialDashboardThemeMetaData` via `/metaData` endpoint
|
||||
1. Backend serves `QThemeMetaData` via `/metaData` endpoint
|
||||
2. Frontend receives theme in `QInstance.theme`
|
||||
3. `themeUtils.ts` calls `injectThemeVariables(theme)`
|
||||
4. Function sets CSS custom properties on `:root`
|
||||
@@ -264,7 +264,7 @@ The fallback value (after comma) is used if the variable is undefined.
|
||||
|
||||
**Minimal** - Override only what differs from defaults:
|
||||
```java
|
||||
new MaterialDashboardThemeMetaData()
|
||||
new QThemeMetaData()
|
||||
.withPrimaryColor("#E91E63")
|
||||
.withBrandedHeaderEnabled(true);
|
||||
```
|
||||
@@ -286,10 +286,10 @@ For dark themes, invert the typical light/dark relationships:
|
||||
|
||||
## File Locations
|
||||
|
||||
| Repository | Key Files |
|
||||
|---------------------------------|-----------|
|
||||
| qqq-backend-core | `MaterialDashboardThemeMetaData.java` |
|
||||
| qqq-frontend-material-dashboard | `MaterialDashboardThemeMetaData.ts` |
|
||||
| Repository | Key Files |
|
||||
|------------|-----------|
|
||||
| qqq-backend-core | `QThemeMetaData.java` |
|
||||
| qqq-frontend-core | `QThemeMetaData.ts` |
|
||||
| qqq-frontend-material-dashboard | `themeUtils.ts`, `qqq-override-styles.css` |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,49 +1,76 @@
|
||||
# Session State - QQQ Frontend Material Dashboard
|
||||
|
||||
**Last Updated:** 2026-01-15
|
||||
**Branch:** `feature/fix-visual-regressions-128`
|
||||
**Version:** `0.40.0-SNAPSHOT`
|
||||
**Last Updated:** 2026-01-10
|
||||
**Branch:** `develop`
|
||||
**Version:** `0.36.0-SNAPSHOT`
|
||||
|
||||
## Current Status
|
||||
|
||||
**ROUND 6 FIXES COMPLETE (ALL 5 ISSUES)** - Ready for Darin to test.
|
||||
**IN PROGRESS:** Fixing CI Playwright test failures.
|
||||
|
||||
The `publish_snapshot` workflow (pipeline #1361) failed because the React dev server times out in CI (120s not enough for webpack compilation).
|
||||
|
||||
## What Was Done This Session
|
||||
|
||||
### Issue #128 Visual Regressions - Round 6
|
||||
1. Diagnosed CI failure - webserver timeout in `playwright_tests` job
|
||||
2. Added `serve` package for static file serving
|
||||
3. Modified `e2e/fixture-server.js` to serve both static React build AND API fixtures
|
||||
4. Updated `playwright.config.ts` to use single combined server
|
||||
5. Updated `.circleci/config.yml` to run `npm run build` before tests
|
||||
6. Tests still fail locally - React app renders blank page (debugging needed)
|
||||
|
||||
Fixed 5 regressions reported by Darin (compared to develop branch):
|
||||
## Files Modified (Uncommitted)
|
||||
|
||||
| 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 |
|
||||
- `package.json` - Added serve dependency
|
||||
- `package-lock.json` - Updated
|
||||
- `e2e/fixture-server.js` - Added static file serving
|
||||
- `playwright.config.ts` - Simplified to single server
|
||||
- `.circleci/config.yml` - Added build step
|
||||
|
||||
### Files Modified
|
||||
## Next Steps (Resume Here)
|
||||
|
||||
| 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) |
|
||||
1. **Debug blank page issue:**
|
||||
- Run `node e2e/fixture-server.js` manually
|
||||
- Open http://localhost:8001 in browser
|
||||
- Check browser console for JavaScript errors
|
||||
- May need to add missing API endpoint stubs
|
||||
|
||||
2. **Compare with Selenium setup:**
|
||||
- Selenium tests work - check what's different
|
||||
- Look at `QSeleniumJavalin.java` for endpoint list
|
||||
|
||||
3. **Test with Playwright debug mode:**
|
||||
- `npx playwright test --debug`
|
||||
- Step through to see what requests fail
|
||||
|
||||
4. **Once tests pass locally:**
|
||||
- Commit and push
|
||||
- Monitor CI pipeline
|
||||
|
||||
## Test Status
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright themed | 26 | PASS |
|
||||
| Round 6 regression tests | 10 | PASS |
|
||||
| Playwright e2e | 26 | FAILING (blank page) |
|
||||
| Selenium fixture-based | 115 | PASS |
|
||||
| Java unit | 3 | PASS |
|
||||
|
||||
## Next Steps
|
||||
## Quick Reference
|
||||
|
||||
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
|
||||
```bash
|
||||
# Build React app (required before Playwright tests)
|
||||
npm run build
|
||||
|
||||
## GitHub Issue
|
||||
# Run fixture server manually for debugging
|
||||
node e2e/fixture-server.js
|
||||
|
||||
https://github.com/QRun-IO/qqq-frontend-material-dashboard/issues/128
|
||||
# Run Playwright in debug mode
|
||||
npx playwright test --debug
|
||||
|
||||
# Run single test
|
||||
npx playwright test --grep "primaryColor"
|
||||
```
|
||||
|
||||
## Plan Document
|
||||
|
||||
See `docs/PLAN-ci-playwright-fix.md` for detailed approach.
|
||||
@@ -22,7 +22,7 @@ The QQQ dashboard now supports runtime theme customization via CSS custom proper
|
||||
Add theme configuration to your QInstance:
|
||||
|
||||
```java
|
||||
MaterialDashboardThemeMetaData theme = new MaterialDashboardThemeMetaData()
|
||||
QThemeMetaData theme = new QThemeMetaData()
|
||||
// Core colors
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSecondaryColor("#9C27B0")
|
||||
@@ -144,7 +144,7 @@ Same pattern for H2-H6, Body1, Body2, Button, Caption.
|
||||
|
||||
### 1. Basic Color Test
|
||||
```java
|
||||
new MaterialDashboardThemeMetaData()
|
||||
new QThemeMetaData()
|
||||
.withPrimaryColor("#FF5722")
|
||||
.withSidebarBackgroundColor("#263238");
|
||||
```
|
||||
@@ -152,7 +152,7 @@ new MaterialDashboardThemeMetaData()
|
||||
|
||||
### 2. Typography Test
|
||||
```java
|
||||
new MaterialDashboardThemeMetaData()
|
||||
new QThemeMetaData()
|
||||
.withFontFamily("Georgia, serif")
|
||||
.withTypographyH1FontSize("3rem")
|
||||
.withTypographyH1FontWeight(800);
|
||||
@@ -161,7 +161,7 @@ new MaterialDashboardThemeMetaData()
|
||||
|
||||
### 3. Branded Header Test
|
||||
```java
|
||||
new MaterialDashboardThemeMetaData()
|
||||
new QThemeMetaData()
|
||||
.withBrandedHeaderEnabled(true)
|
||||
.withBrandedHeaderBackgroundColor("#1a237e")
|
||||
.withBrandedHeaderTextColor("#ffffff")
|
||||
@@ -172,7 +172,7 @@ new MaterialDashboardThemeMetaData()
|
||||
|
||||
### 4. Density Test
|
||||
```java
|
||||
new MaterialDashboardThemeMetaData()
|
||||
new QThemeMetaData()
|
||||
.withDensity("compact");
|
||||
```
|
||||
**Verify:** Reduced spacing throughout the UI.
|
||||
|
||||
@@ -1,53 +1,39 @@
|
||||
# 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
|
||||
### Fix CI Playwright Tests (IN PROGRESS)
|
||||
|
||||
**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
|
||||
- [ ] Merge to develop
|
||||
- [x] Added `serve` package for static file serving
|
||||
- [x] Updated playwright.config.ts to use combined server approach
|
||||
- [x] Updated CircleCI to pre-build React app before tests
|
||||
- [x] Modified fixture-server.js to serve static files
|
||||
- [ ] **Debug why React app renders blank page**
|
||||
- [ ] Test locally with `--debug` flag
|
||||
- [ ] Check browser console for JS errors
|
||||
- [ ] Verify all required API endpoints are mocked
|
||||
- [ ] Push fix once tests pass locally
|
||||
|
||||
## 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] **Playwright tests integrated** - 26 tests from v1 branch
|
||||
- [x] **run-tests.sh improved** - Unified script for Playwright + Selenium
|
||||
- [x] **CircleCI config updated** - Playwright job added to pipelines
|
||||
|
||||
## Test Status
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright themed | 26 | PASS |
|
||||
| Playwright unthemed | 13 | PASS |
|
||||
| Playwright e2e | 26 | FAILING (blank page) |
|
||||
| Selenium fixture-based | 115 | PASS |
|
||||
| Java unit | 3 | PASS |
|
||||
| Selenium full-server | 19 | Blocked (infrastructure) |
|
||||
|
||||
## Known Issues
|
||||
|
||||
- CI Playwright tests may timeout (WIP fix on separate branch)
|
||||
- `seleniumwithqapplication` tests require full QQQ backend (hang locally)
|
||||
- CI Playwright tests fail - webserver timeout (being fixed)
|
||||
- React app renders blank in combined fixture server (debugging)
|
||||
- `seleniumwithqapplication` tests hang locally (require full QQQ backend)
|
||||
@@ -1,15 +0,0 @@
|
||||
# Visual Regression Testing with Playwright and Docker
|
||||
|
||||
Added visual regression tests to the material dashboard today. 18 Playwright screenshot tests covering the home page, sidebar, nav bar, buttons, typography, cards—the usual suspects.
|
||||
|
||||
Ran into the classic problem: screenshots generated on my Mac don't match CI because fonts render differently on Linux. 1-2px height differences on everything. Tried bumping tolerance but that defeats the purpose.
|
||||
|
||||
The fix was running the entire stack inside Docker—not just Playwright, but the fixture server and React dev server too. Wrote a script that spins up the same container CI uses (`mcr.microsoft.com/playwright:v1.57.0-jammy`), starts everything inside, and regenerates the baselines.
|
||||
|
||||
```bash
|
||||
./scripts/update-snapshots.sh
|
||||
```
|
||||
|
||||
Run that after any visual changes, commit the updated snapshots, and CI will pass. All 18 tests run in about 90 seconds with 1% pixel tolerance.
|
||||
|
||||
The snapshots live in `e2e/snapshots/`. Tests are in `e2e/tests/visual-regression.spec.ts`.
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* Simple fixture server for Playwright e2e tests.
|
||||
* Serves fixture JSON files from src/test/resources/fixtures/
|
||||
* mimicking the Javalin mock server used in Selenium tests.
|
||||
* Combined fixture server for Playwright e2e tests.
|
||||
* Serves:
|
||||
* 1. Static React build from build/ directory
|
||||
* 2. Fixture JSON files from src/test/resources/fixtures/
|
||||
*
|
||||
* This allows e2e tests to run against a production build
|
||||
* without needing a proxy between frontend and API.
|
||||
*/
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
@@ -9,9 +13,28 @@ const path = require('path');
|
||||
|
||||
const PORT = process.env.FIXTURE_PORT || 8001;
|
||||
const FIXTURES_DIR = path.join(__dirname, '..', 'src', 'test', 'resources', 'fixtures');
|
||||
const BUILD_DIR = path.join(__dirname, '..', 'build');
|
||||
|
||||
// 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',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.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',
|
||||
'.map': 'application/json'
|
||||
};
|
||||
|
||||
function loadFixture(relativePath) {
|
||||
const fixturePath = path.join(FIXTURES_DIR, relativePath);
|
||||
@@ -32,6 +55,39 @@ function serve404(res, pathname) {
|
||||
res.end(JSON.stringify({ error: 'Not found', path: pathname }));
|
||||
}
|
||||
|
||||
function serveStaticFile(res, pathname) {
|
||||
// Map pathname to file in build directory
|
||||
let filePath = path.join(BUILD_DIR, pathname);
|
||||
|
||||
// For SPA routing, serve index.html for non-file paths
|
||||
if (!path.extname(pathname)) {
|
||||
filePath = path.join(BUILD_DIR, 'index.html');
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Fall back to index.html for SPA routing
|
||||
filePath = path.join(BUILD_DIR, 'index.html');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Enable CORS for local development
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
@@ -47,13 +103,9 @@ const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// 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;
|
||||
|
||||
// /metaData endpoint - return main metadata with theme
|
||||
if (pathname === '/metaData' || pathname === '/metaData/' || pathname === '/api/metaData') {
|
||||
const data = loadFixture(`metaData/${requestedFixture}.json`);
|
||||
const data = loadFixture(`metaData/${FIXTURE_NAME}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
@@ -97,131 +149,15 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// /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`);
|
||||
// /processes/querySavedView/init - saved view init
|
||||
if (pathname === '/processes/querySavedView/init') {
|
||||
const data = loadFixture('processes/querySavedView/init.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: []
|
||||
});
|
||||
// Default response if fixture not found
|
||||
serveJson(res, { values: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,56 +213,8 @@ const server = http.createServer((req, res) => {
|
||||
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
|
||||
});
|
||||
// Try serving static file (for React build)
|
||||
if (serveStaticFile(res, pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -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([]);
|
||||
});
|
||||
@@ -1,345 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Round 6 Visual Regression Tests
|
||||
*
|
||||
* Tests verify fixes for issues reported by Darin:
|
||||
* 1. Sidenav double-highlight on hover
|
||||
* 2. Chip border radius changed
|
||||
* 3. Menu item padding increased
|
||||
* 4. View field height reduced (button typography)
|
||||
*/
|
||||
|
||||
test.describe('Round 6 Regression Fixes - Themed App', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Use themed fixture
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test.describe('Issue 1: Sidenav Hover - Single Highlight Only', () => {
|
||||
test('sidenav item has only JS-controlled hover styling (no CSS override)', async ({ page }) => {
|
||||
// The fix removed CSS hover rules that were causing double-highlight
|
||||
// Wait for sidebar to be visible (it may be collapsed on smaller screens)
|
||||
const drawer = page.locator('.MuiDrawer-paper');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// Find any visible nav item in the sidebar
|
||||
const sidenavItem = page.locator('.MuiDrawer-paper .MuiListItem-root').first();
|
||||
await expect(sidenavItem).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Get initial background
|
||||
const initialBg = await sidenavItem.evaluate(el => getComputedStyle(el).backgroundColor);
|
||||
|
||||
// Hover and check background changes (should be controlled by JS, not CSS)
|
||||
await sidenavItem.hover();
|
||||
await page.waitForTimeout(300);
|
||||
const hoverBg = await sidenavItem.evaluate(el => getComputedStyle(el).backgroundColor);
|
||||
|
||||
// The hover background should change, but there should not be multiple overlapping backgrounds
|
||||
// The key test is that the page renders without visual artifacts
|
||||
expect(hoverBg).toBeTruthy();
|
||||
});
|
||||
|
||||
test('no redundant CSS hover rules in computed styles', async ({ page }) => {
|
||||
// Check that the removed CSS rule is not applying
|
||||
const result = await page.evaluate(() => {
|
||||
// Look for any sidenav item
|
||||
const item = document.querySelector('.MuiDrawer-paper a, .MuiDrawer-paper .MuiListItem-root');
|
||||
if (!item) return { found: false, hasInlineHover: false };
|
||||
|
||||
// Check if any external stylesheet is applying the removed hover rule
|
||||
// We removed: .MuiDrawer-paper a:hover, .MuiDrawer-paper .MuiListItem-root:hover
|
||||
const sheets = Array.from(document.styleSheets);
|
||||
let hasRemovedRule = false;
|
||||
|
||||
try {
|
||||
for (const sheet of sheets) {
|
||||
try {
|
||||
const rules = Array.from(sheet.cssRules || []);
|
||||
for (const rule of rules) {
|
||||
if (rule instanceof CSSStyleRule) {
|
||||
const selector = rule.selectorText;
|
||||
if (selector &&
|
||||
(selector.includes('.MuiDrawer-paper a:hover') ||
|
||||
selector.includes('.MuiDrawer-paper .MuiListItem-root:hover'))) {
|
||||
// Check if this rule sets background-color
|
||||
if (rule.style.backgroundColor) {
|
||||
hasRemovedRule = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin stylesheets will throw, ignore them
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return { found: true, hasRemovedRule };
|
||||
});
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.hasRemovedRule).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Issue 2: Chip Border Radius (4px default, not 8px)', () => {
|
||||
test('chips use borderRadius based on 4px default (2px for chip)', async ({ page }) => {
|
||||
// Navigate to a page with chips if available, or test with a chip if present
|
||||
// The default borderRadius is 4px, chips use borderRadius/2 = 2px
|
||||
const result = await page.evaluate(() => {
|
||||
// Check the shape.borderRadius from the theme via CSS variable
|
||||
const root = document.documentElement;
|
||||
const borderRadiusVar = getComputedStyle(root).getPropertyValue('--qqq-border-radius').trim();
|
||||
|
||||
// Also check if any chip is rendered
|
||||
const chip = document.querySelector('.MuiChip-root');
|
||||
let chipBorderRadius = '';
|
||||
if (chip) {
|
||||
chipBorderRadius = getComputedStyle(chip).borderRadius;
|
||||
}
|
||||
|
||||
return {
|
||||
cssVar: borderRadiusVar,
|
||||
chipBorderRadius,
|
||||
hasChip: !!chip
|
||||
};
|
||||
});
|
||||
|
||||
// The fixture uses 12px, but default should be 4px
|
||||
// If no chip found, at least verify the CSS variable is set
|
||||
if (result.hasChip) {
|
||||
// Chip borderRadius should be half of the theme borderRadius
|
||||
// With 12px theme (from fixture), chip should be 6px
|
||||
expect(result.chipBorderRadius).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Issue 3: Menu Item Padding (original values, not increased)', () => {
|
||||
test('menu items have correct padding from menuItem.ts (~5px 16px)', async ({ page }) => {
|
||||
// Find a button that opens a menu
|
||||
const menuButton = page.locator('[aria-haspopup="true"]').first();
|
||||
|
||||
if (await menuButton.isVisible().catch(() => false)) {
|
||||
await menuButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const menuItem = document.querySelector('.MuiMenuItem-root');
|
||||
if (!menuItem) return { found: false, padding: '' };
|
||||
|
||||
const styles = getComputedStyle(menuItem);
|
||||
return {
|
||||
found: true,
|
||||
padding: styles.padding,
|
||||
paddingTop: styles.paddingTop,
|
||||
paddingBottom: styles.paddingBottom,
|
||||
paddingLeft: styles.paddingLeft,
|
||||
paddingRight: styles.paddingRight,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
// Original menuItem.ts uses pxToRem(4.8) which is ~5px vertical
|
||||
// Should NOT be 8px (that was our regression)
|
||||
const topPadding = parseFloat(result.paddingTop);
|
||||
const bottomPadding = parseFloat(result.paddingBottom);
|
||||
|
||||
// Padding should be less than 7px (the regression was 8px)
|
||||
expect(topPadding).toBeLessThan(7);
|
||||
expect(bottomPadding).toBeLessThan(7);
|
||||
}
|
||||
} else {
|
||||
// Skip if no menu button found - test passes vacuously
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Issue 5: Button Color (Edit Filters and Columns)', () => {
|
||||
test('text primary buttons have correct color styling (not scoped to .qqq-themed)', async ({ page }) => {
|
||||
// The fix removed .qqq-themed scoping so button color applies to ALL apps
|
||||
const result = await page.evaluate(() => {
|
||||
// Look for any text primary button (like "Edit Filters and Columns")
|
||||
const button = document.querySelector('.MuiButton-textPrimary');
|
||||
if (!button) return { found: false, color: '' };
|
||||
|
||||
const styles = getComputedStyle(button);
|
||||
return {
|
||||
found: true,
|
||||
color: styles.color,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
// Button should have a color applied (not default inherited)
|
||||
expect(result.color).toBeTruthy();
|
||||
// The color should be from our CSS (--qqq-primary-color fallback is #0062FF)
|
||||
// RGB values will vary based on theme, but should not be black/inherit
|
||||
} else {
|
||||
// Skip if no text primary button found
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test('button CSS rules are NOT scoped to .qqq-themed class', async ({ page }) => {
|
||||
// Verify that button color CSS is not restricted to themed apps
|
||||
const result = await page.evaluate(() => {
|
||||
const sheets = Array.from(document.styleSheets);
|
||||
let hasUnscopedButtonRule = false;
|
||||
let hasScopedButtonRule = false;
|
||||
|
||||
try {
|
||||
for (const sheet of sheets) {
|
||||
try {
|
||||
const rules = Array.from(sheet.cssRules || []);
|
||||
for (const rule of rules) {
|
||||
if (rule instanceof CSSStyleRule) {
|
||||
const selector = rule.selectorText;
|
||||
if (selector) {
|
||||
// Check for unscoped button rules (what we want)
|
||||
if (selector === '.MuiButton-textPrimary' ||
|
||||
selector === '.MuiButton-containedPrimary') {
|
||||
hasUnscopedButtonRule = true;
|
||||
}
|
||||
// Check for scoped button rules (what we removed)
|
||||
if (selector.includes('.qqq-themed') &&
|
||||
selector.includes('.MuiButton-')) {
|
||||
hasScopedButtonRule = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin stylesheets will throw, ignore them
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return { hasUnscopedButtonRule, hasScopedButtonRule };
|
||||
});
|
||||
|
||||
// Should have unscoped button rules (matching develop behavior)
|
||||
expect(result.hasUnscopedButtonRule).toBe(true);
|
||||
// Should NOT have .qqq-themed scoped button rules (regression fix)
|
||||
expect(result.hasScopedButtonRule).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Issue 4: Button Typography (view field height)', () => {
|
||||
test('buttons have lineHeight of 1.75 (not 1.5)', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Find any button
|
||||
const button = document.querySelector('.MuiButton-root');
|
||||
if (!button) return { found: false, lineHeight: '' };
|
||||
|
||||
const styles = getComputedStyle(button);
|
||||
return {
|
||||
found: true,
|
||||
lineHeight: styles.lineHeight,
|
||||
fontWeight: styles.fontWeight,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
// lineHeight should be 1.75, not 1.5
|
||||
// Note: computed lineHeight may be in px, so we check it's not the smaller value
|
||||
const lineHeight = parseFloat(result.lineHeight);
|
||||
|
||||
// If it's a ratio (like 1.75), check it directly
|
||||
// If it's in px, it should reflect the larger lineHeight
|
||||
if (lineHeight < 10) {
|
||||
// It's a ratio
|
||||
expect(lineHeight).toBeGreaterThanOrEqual(1.7);
|
||||
}
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test('buttons have fontWeight of 500 or configured value (not 300)', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const button = document.querySelector('.MuiButton-root');
|
||||
if (!button) return { found: false, fontWeight: '' };
|
||||
|
||||
return {
|
||||
found: true,
|
||||
fontWeight: getComputedStyle(button).fontWeight,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
// fontWeight should be >= 500 (develop value), not 300
|
||||
// The fixture sets 600, default should be 500
|
||||
const weight = parseInt(result.fontWeight);
|
||||
expect(weight).toBeGreaterThanOrEqual(500);
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* NOTE: Unthemed tests are in unthemed-regression.spec.ts
|
||||
* This file tests the themed app behavior after Round 6 fixes.
|
||||
*
|
||||
* The key verifications for default values:
|
||||
* - Default borderRadius changed from 8px to 4px (in parseBorderRadius)
|
||||
* - Default button fontWeight changed from 300 to 500
|
||||
* - Default button lineHeight changed from 1.5 to 1.75
|
||||
*
|
||||
* These defaults apply when no theme is configured OR when theme
|
||||
* properties are not explicitly set.
|
||||
*/
|
||||
test.describe('Round 6 - Default Value Verification (via themed fixture override)', () => {
|
||||
test('parseBorderRadius defaults to 4 (verified via code inspection)', async ({ page }) => {
|
||||
// This test verifies our code change by checking the DEFAULT_THEME
|
||||
// The actual rendered value in themed tests uses fixture's 12px
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
|
||||
// Check that the CSS variable is set (fixture uses 12px)
|
||||
const result = await page.evaluate(() => {
|
||||
const cssVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--qqq-border-radius').trim();
|
||||
return { cssVar };
|
||||
});
|
||||
|
||||
// Fixture uses 12px, which proves the theming system works
|
||||
// The default of 4px applies when no theme is configured
|
||||
expect(result.cssVar).toBe('12px');
|
||||
});
|
||||
|
||||
test('DEFAULT_THEME button values are correct (500 weight, 1.75 lineHeight)', async ({ page }) => {
|
||||
// The fixture overrides these, but we verify the themed app works
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const button = document.querySelector('.MuiButton-root');
|
||||
if (!button) return { found: false };
|
||||
|
||||
const styles = getComputedStyle(button);
|
||||
return {
|
||||
found: true,
|
||||
fontWeight: styles.fontWeight,
|
||||
lineHeight: styles.lineHeight,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.found) {
|
||||
// Fixture sets typographyButtonFontWeight: 600
|
||||
// This proves the theming override works
|
||||
const weight = parseInt(result.fontWeight);
|
||||
expect(weight).toBeGreaterThanOrEqual(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {colorsMatch} from "../utils/color-helpers";
|
||||
|
||||
/**
|
||||
* STRICT Theme Verification Tests for QQQ MaterialDashboardThemeMetaData
|
||||
* STRICT Theme Verification Tests for QQQ QThemeMetaData
|
||||
*
|
||||
* ALL tests verify ACTUAL rendered values on components.
|
||||
* Tests FAIL if:
|
||||
@@ -60,6 +59,78 @@ const THEME = {
|
||||
borderColor: '#B0BEC5',
|
||||
};
|
||||
|
||||
// Helper to convert hex to RGB
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
// Helper to parse rgb/rgba string
|
||||
function parseRgb(rgbString: string): { r: number; g: number; b: number; a?: number } | null {
|
||||
// Handle rgb(r, g, b) and rgba(r, g, b, a)
|
||||
const rgbMatch = rgbString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (rgbMatch) {
|
||||
return {
|
||||
r: parseInt(rgbMatch[1]),
|
||||
g: parseInt(rgbMatch[2]),
|
||||
b: parseInt(rgbMatch[3]),
|
||||
a: rgbMatch[4] ? parseFloat(rgbMatch[4]) : undefined
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to parse rgba from expected value (could be hex or rgba string)
|
||||
function parseExpectedColor(color: string): { r: number; g: number; b: number; a?: number } | null {
|
||||
// If it's already rgba format
|
||||
if (color.startsWith('rgba')) {
|
||||
return parseRgb(color);
|
||||
}
|
||||
// If it's hex
|
||||
if (color.startsWith('#')) {
|
||||
const rgb = hexToRgb(color);
|
||||
return { ...rgb, a: undefined };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to check if colors match (with tolerance)
|
||||
function colorsMatch(actual: string, expected: string, tolerance = 2): boolean {
|
||||
// Handle hex-to-hex comparison (case insensitive)
|
||||
if (actual.startsWith('#') && expected.startsWith('#')) {
|
||||
return actual.toLowerCase() === expected.toLowerCase();
|
||||
}
|
||||
|
||||
// Parse actual color (could be rgb, rgba, or hex)
|
||||
let actualRgb = parseRgb(actual);
|
||||
if (!actualRgb && actual.startsWith('#')) {
|
||||
const hex = hexToRgb(actual);
|
||||
actualRgb = { ...hex, a: undefined };
|
||||
}
|
||||
|
||||
const expectedRgb = parseExpectedColor(expected);
|
||||
if (!actualRgb || !expectedRgb) return false;
|
||||
|
||||
const rgbMatch = (
|
||||
Math.abs(actualRgb.r - expectedRgb.r) <= tolerance &&
|
||||
Math.abs(actualRgb.g - expectedRgb.g) <= tolerance &&
|
||||
Math.abs(actualRgb.b - expectedRgb.b) <= tolerance
|
||||
);
|
||||
|
||||
// If both have alpha, compare alpha too
|
||||
if (actualRgb.a !== undefined && expectedRgb.a !== undefined) {
|
||||
return rgbMatch && Math.abs(actualRgb.a - expectedRgb.a) <= 0.05;
|
||||
}
|
||||
|
||||
return rgbMatch;
|
||||
}
|
||||
|
||||
// Helper to format assertion message
|
||||
function colorMismatch(property: string, expected: string, actual: string): string {
|
||||
return `${property}: expected ${expected}, got ${actual}`;
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {colorsMatch, hexToRgb, isTransparentOrNearlyTransparent, parseRgb} from "../utils/color-helpers";
|
||||
|
||||
/**
|
||||
* STRICT Visual Regression Tests for UNTHEMED Apps (Issue #128)
|
||||
*
|
||||
* These tests verify that apps WITHOUT a backend theme look correct.
|
||||
* Run with: THEME_FIXTURE=index npm run e2e:unthemed
|
||||
*
|
||||
* IMPORTANT: These tests require the 'index' fixture (no theme configured).
|
||||
* They will be SKIPPED if running with a themed fixture (e.g., withFullCustomTheme).
|
||||
*
|
||||
* To run these tests:
|
||||
* npm run e2e:unthemed (uses THEME_FIXTURE=index)
|
||||
*
|
||||
* To run all tests properly:
|
||||
* npm run e2e:all (runs themed then unthemed with correct fixtures)
|
||||
*
|
||||
* Tests verify:
|
||||
* 1. Sidebar has correct DEFAULT_THEME colors (dark background, white text)
|
||||
* 2. Navbar/AppBar is NOT white (transparent, not affected by surfaceColor)
|
||||
* 3. CSS variables are injected with DEFAULT_THEME values
|
||||
* 4. Cards/Paper get surfaceColor but AppBar does not
|
||||
*/
|
||||
|
||||
// Helper function to check if themed fixture is loaded and skip if so
|
||||
async function skipIfThemedFixture(page: import('@playwright/test').Page) {
|
||||
const hasTheme = await page.evaluate(() => {
|
||||
const primaryColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--qqq-primary-color').trim();
|
||||
return primaryColor !== '';
|
||||
});
|
||||
|
||||
if (hasTheme) {
|
||||
test.skip(true, 'Skipping unthemed test: themed fixture detected. Run with: npm run e2e:unthemed');
|
||||
}
|
||||
}
|
||||
|
||||
// DEFAULT_THEME values from src/qqq/utils/themeUtils.ts
|
||||
// These must match the values in themeUtils.ts to restore pre-PR-125 appearance
|
||||
const DEFAULT_THEME = {
|
||||
// Core colors
|
||||
primaryColor: '#0062FF',
|
||||
backgroundColor: '#f0f2f5',
|
||||
surfaceColor: '#ffffff',
|
||||
// Sidebar (dark gray matching original gradients.dark.main)
|
||||
sidebarBackgroundColor: '#42424a',
|
||||
sidebarTextColor: '#ffffff',
|
||||
sidebarIconColor: '#ffffff',
|
||||
sidebarSelectedBackgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
sidebarSelectedTextColor: '#ffffff',
|
||||
sidebarHoverBackgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
// Typography (matching original typography.ts)
|
||||
fontFamily: '"SF Pro Display", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||
};
|
||||
|
||||
// Helper to check if color is NOT white (surfaceColor)
|
||||
function isNotWhite(color: string): boolean {
|
||||
// Check if transparent
|
||||
if (isTransparentOrNearlyTransparent(color)) {
|
||||
return true;
|
||||
}
|
||||
// Check if NOT white (#ffffff or rgb(255,255,255))
|
||||
const actualRgb = parseRgb(color);
|
||||
if (!actualRgb) {
|
||||
if (color.startsWith('#')) {
|
||||
const hex = hexToRgb(color);
|
||||
if (!hex) {
|
||||
return true;
|
||||
}
|
||||
return !(hex.r > 250 && hex.g > 250 && hex.b > 250);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Allow some tolerance - if it's close to white, it's a problem
|
||||
return !(actualRgb.r > 250 && actualRgb.g > 250 && actualRgb.b > 250);
|
||||
}
|
||||
|
||||
// Helper to check if a background (which may be a gradient) contains a color
|
||||
function backgroundContainsColor(background: string, expectedColor: string, tolerance = 5): boolean {
|
||||
// Extract all rgb/rgba values from the background string
|
||||
const rgbMatches = background.matchAll(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g);
|
||||
const expectedRgb = expectedColor.startsWith('#') ? hexToRgb(expectedColor) : parseRgb(expectedColor);
|
||||
if (!expectedRgb) return false;
|
||||
|
||||
for (const match of rgbMatches) {
|
||||
const r = parseInt(match[1]);
|
||||
const g = parseInt(match[2]);
|
||||
const b = parseInt(match[3]);
|
||||
if (
|
||||
Math.abs(r - expectedRgb.r) <= tolerance &&
|
||||
Math.abs(g - expectedRgb.g) <= tolerance &&
|
||||
Math.abs(b - expectedRgb.b) <= tolerance
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CSS VARIABLES - Verify they are NOT injected for unthemed apps
|
||||
// CSS variables are 100% optional - CSS uses fallback values
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - CSS Variables Are Optional', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(1000);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('no --qqq-* CSS variables are injected when no theme is configured', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const style = getComputedStyle(root);
|
||||
return {
|
||||
sidebarBg: style.getPropertyValue('--qqq-sidebar-background-color').trim(),
|
||||
sidebarText: style.getPropertyValue('--qqq-sidebar-text-color').trim(),
|
||||
surfaceColor: style.getPropertyValue('--qqq-surface-color').trim(),
|
||||
primaryColor: style.getPropertyValue('--qqq-primary-color').trim(),
|
||||
};
|
||||
});
|
||||
// All CSS variables should be empty (not injected) for unthemed apps
|
||||
expect(result.sidebarBg, 'CSS variable should not be injected for unthemed app').toBe('');
|
||||
expect(result.sidebarText, 'CSS variable should not be injected for unthemed app').toBe('');
|
||||
expect(result.surfaceColor, 'CSS variable should not be injected for unthemed app').toBe('');
|
||||
expect(result.primaryColor, 'CSS variable should not be injected for unthemed app').toBe('');
|
||||
});
|
||||
|
||||
test('body does NOT have .qqq-themed class when no theme is configured', async ({ page }) => {
|
||||
const hasClass = await page.evaluate(() => {
|
||||
return document.body.classList.contains('qqq-themed');
|
||||
});
|
||||
expect(hasClass, 'Body should not have .qqq-themed class for unthemed app').toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// SIDEBAR - Verify dark theme colors via CSS fallbacks
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - Sidebar Colors', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(1000);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('sidebar has dark background via CSS fallback', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper');
|
||||
if (!el) return { found: false, value: '', debug: 'No .MuiDrawer-paper found' };
|
||||
// Use 'background' not 'backgroundColor' because gradients set background-image
|
||||
return { found: true, value: getComputedStyle(el).background, debug: '' };
|
||||
});
|
||||
expect(result.found, `Sidebar not found: ${result.debug}`).toBe(true);
|
||||
// Check that the background contains the expected dark color (gradient includes #42424a)
|
||||
expect(
|
||||
backgroundContainsColor(result.value, DEFAULT_THEME.sidebarBackgroundColor),
|
||||
`Sidebar background should contain ${DEFAULT_THEME.sidebarBackgroundColor}, got ${result.value}`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebar text is white from DEFAULT_THEME', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper .MuiListItemText-primary, .MuiDrawer-paper .MuiTypography-root');
|
||||
if (!el) return { found: false, value: '', debug: 'No sidebar text found' };
|
||||
return { found: true, value: getComputedStyle(el).color, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Sidebar text not found: ${result.debug}`).toBe(true);
|
||||
expect(
|
||||
colorsMatch(result.value, DEFAULT_THEME.sidebarTextColor, 5),
|
||||
`Sidebar text color: expected ${DEFAULT_THEME.sidebarTextColor}, got ${result.value}`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebar icons are white from DEFAULT_THEME', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper .MuiIcon-root, .MuiDrawer-paper .material-icons');
|
||||
if (!el) return { found: false, value: '', debug: 'No sidebar icon found' };
|
||||
return { found: true, value: getComputedStyle(el).color, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Sidebar icon not found: ${result.debug}`).toBe(true);
|
||||
expect(
|
||||
colorsMatch(result.value, DEFAULT_THEME.sidebarIconColor, 5),
|
||||
`Sidebar icon color: expected ${DEFAULT_THEME.sidebarIconColor}, got ${result.value}`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// NAVBAR/APPBAR - Verify NOT white (Issue #128 regression)
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - Navbar NOT White (Issue #128)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(1000);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('CRITICAL: AppBar background is NOT white/surfaceColor', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const appBar = document.querySelector('.MuiAppBar-root');
|
||||
if (!appBar) return { found: false, value: '', debug: 'No .MuiAppBar-root found' };
|
||||
return { found: true, value: getComputedStyle(appBar).backgroundColor, debug: '' };
|
||||
});
|
||||
expect(result.found, `AppBar not found: ${result.debug}`).toBe(true);
|
||||
expect(
|
||||
isNotWhite(result.value),
|
||||
`REGRESSION: AppBar background is white (${result.value}). Should be transparent or non-white.`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('CRITICAL: Breadcrumb area is NOT white', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Find the breadcrumb container or navbar area
|
||||
const selectors = [
|
||||
'.MuiBreadcrumbs-root',
|
||||
'.MuiToolbar-root',
|
||||
'.MuiAppBar-root .MuiBox-root'
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
return { found: true, value: bg, selector: sel };
|
||||
}
|
||||
}
|
||||
return { found: false, value: '', selector: '' };
|
||||
});
|
||||
expect(result.found, 'Breadcrumb/toolbar area not found').toBe(true);
|
||||
expect(
|
||||
isNotWhite(result.value),
|
||||
`REGRESSION: Breadcrumb area (${result.selector}) is white (${result.value}). Should not be white.`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('AppBar does not have --qqq-surface-color applied', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const appBar = document.querySelector('.MuiAppBar-root');
|
||||
if (!appBar) return { found: false, isSurfaceColor: false, value: '' };
|
||||
const bg = getComputedStyle(appBar).backgroundColor;
|
||||
// Check if it matches surfaceColor (#ffffff)
|
||||
const isSurfaceColor = bg === 'rgb(255, 255, 255)' || bg === '#ffffff';
|
||||
return { found: true, isSurfaceColor, value: bg };
|
||||
});
|
||||
expect(result.found, 'AppBar not found').toBe(true);
|
||||
expect(
|
||||
result.isSurfaceColor,
|
||||
`REGRESSION: AppBar has surfaceColor (#ffffff) applied. Value: ${result.value}`
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// CARDS/PAPER - Verify surfaceColor IS applied (but not to AppBar)
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - Cards Use Surface Color', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2500);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('Cards in main content use surfaceColor', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Find a card in main content, NOT in drawer/sidebar
|
||||
const el = document.querySelector('main .MuiCard-root, .MuiCard-root:not(.MuiDrawer-paper *)');
|
||||
if (!el) return { found: false, value: '', debug: 'No card found in main content' };
|
||||
return { found: true, value: getComputedStyle(el).backgroundColor, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Card not found: ${result.debug}`).toBe(true);
|
||||
expect(
|
||||
colorsMatch(result.value, DEFAULT_THEME.surfaceColor, 5),
|
||||
`Card background: expected ${DEFAULT_THEME.surfaceColor}, got ${result.value}`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BRANDED HEADER - Verify NOT shown when disabled
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - Branded Header Disabled', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('branded header is not visible when not configured', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const header = document.querySelector('.qqq-branded-header-bar');
|
||||
if (!header) return { exists: false, height: '0' };
|
||||
const height = getComputedStyle(header).height;
|
||||
const display = getComputedStyle(header).display;
|
||||
return { exists: true, height, display };
|
||||
});
|
||||
// Either the element doesn't exist, or it has 0 height, or it's hidden
|
||||
const isHidden = !result.exists ||
|
||||
result.height === '0px' ||
|
||||
result.height === '0' ||
|
||||
result.display === 'none';
|
||||
expect(isHidden, `Branded header should not be visible. Height: ${result.height}`).toBe(true);
|
||||
});
|
||||
|
||||
test('--qqq-branded-header-height is not set (CSS fallback to 0px)', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--qqq-branded-header-height').trim();
|
||||
return { value };
|
||||
});
|
||||
// CSS variable should be empty (not injected) - CSS fallback handles it
|
||||
expect(
|
||||
result.value === '' || result.value === '0px' || result.value === '0',
|
||||
`--qqq-branded-header-height should be empty or 0px, got ${result.value}`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebar does not overhang content area', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const sidebar = document.querySelector('.MuiDrawer-paper');
|
||||
// Try multiple selectors for main content area
|
||||
const mainContent = document.querySelector('main') ||
|
||||
document.querySelector('[class*="content"]') ||
|
||||
document.querySelector('.MuiBox-root > .MuiBox-root');
|
||||
if (!sidebar) {
|
||||
return { found: false, sidebarTop: 0, contentTop: 0, debug: 'No sidebar found' };
|
||||
}
|
||||
const sidebarRect = sidebar.getBoundingClientRect();
|
||||
// If we can't find main content, just verify sidebar starts at top (no overhang)
|
||||
if (!mainContent) {
|
||||
// Sidebar should start at or near the top of viewport
|
||||
return {
|
||||
found: true,
|
||||
sidebarTop: sidebarRect.top,
|
||||
contentTop: 0,
|
||||
isAligned: sidebarRect.top <= 100, // Should be near top
|
||||
debug: 'Using viewport top as reference'
|
||||
};
|
||||
}
|
||||
const contentRect = mainContent.getBoundingClientRect();
|
||||
return {
|
||||
found: true,
|
||||
sidebarTop: sidebarRect.top,
|
||||
contentTop: contentRect.top,
|
||||
// Sidebar should start at same level or below content
|
||||
isAligned: sidebarRect.top <= contentRect.top + 50,
|
||||
debug: 'Found main content'
|
||||
};
|
||||
});
|
||||
expect(result.found, `Sidebar not found: ${result.debug}`).toBe(true);
|
||||
expect(
|
||||
result.isAligned,
|
||||
`Sidebar overhang: sidebar top (${result.sidebarTop}) should align with content top (${result.contentTop}). Debug: ${result.debug}`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DIAGNOSTIC - Output actual values for debugging
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Unthemed App - Diagnostic Dump', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2000);
|
||||
await skipIfThemedFixture(page);
|
||||
});
|
||||
|
||||
test('output all CSS variables and rendered values', async ({ page }) => {
|
||||
|
||||
const diagnostics = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const getVar = (name: string) => getComputedStyle(root).getPropertyValue(name).trim();
|
||||
const getBg = (selector: string) => {
|
||||
const el = document.querySelector(selector);
|
||||
return el ? getComputedStyle(el).backgroundColor : 'NOT FOUND';
|
||||
};
|
||||
const getColor = (selector: string) => {
|
||||
const el = document.querySelector(selector);
|
||||
return el ? getComputedStyle(el).color : 'NOT FOUND';
|
||||
};
|
||||
|
||||
return {
|
||||
cssVariables: {
|
||||
'--qqq-sidebar-background-color': getVar('--qqq-sidebar-background-color'),
|
||||
'--qqq-sidebar-text-color': getVar('--qqq-sidebar-text-color'),
|
||||
'--qqq-surface-color': getVar('--qqq-surface-color'),
|
||||
'--qqq-primary-color': getVar('--qqq-primary-color'),
|
||||
'--qqq-branded-header-height': getVar('--qqq-branded-header-height'),
|
||||
'--qqq-branded-header-enabled': getVar('--qqq-branded-header-enabled'),
|
||||
},
|
||||
renderedValues: {
|
||||
sidebarBackground: getBg('.MuiDrawer-paper'),
|
||||
sidebarText: getColor('.MuiDrawer-paper .MuiTypography-root'),
|
||||
appBarBackground: getBg('.MuiAppBar-root'),
|
||||
toolbarBackground: getBg('.MuiToolbar-root'),
|
||||
cardBackground: getBg('.MuiCard-root'),
|
||||
bodyBackground: getBg('body'),
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
console.log('=== UNTHEMED APP DIAGNOSTIC ===');
|
||||
console.log(JSON.stringify(diagnostics, null, 2));
|
||||
console.log('===============================');
|
||||
|
||||
// This test always passes - diagnostic only
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* Visual Regression Tests for QQQ Frontend Material Dashboard
|
||||
*
|
||||
* These tests capture baseline screenshots of the unthemed application.
|
||||
* When theming is re-implemented, these tests will ensure the unthemed
|
||||
* appearance remains unchanged.
|
||||
*
|
||||
* Run with: npx playwright test
|
||||
* Update baselines with: npx playwright test --update-snapshots
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Tight tolerance for pixel-level regression detection
|
||||
// Snapshots must be generated on Linux (via Docker) to match CI environment
|
||||
const SCREENSHOT_OPTIONS = {
|
||||
maxDiffPixelRatio: 0.01, // 1% tolerance - pixel-level accuracy
|
||||
};
|
||||
|
||||
test.describe('Visual Regression - Unthemed Baseline', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Wait for app to fully load
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('app home page layout', async ({ page }) => {
|
||||
// Wait for main content to render (the app bar is always visible)
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
// Give time for all content to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Capture full page screenshot
|
||||
await expect(page).toHaveScreenshot('home-page-full.png', {
|
||||
fullPage: true,
|
||||
...SCREENSHOT_OPTIONS,
|
||||
});
|
||||
});
|
||||
|
||||
test('sidebar navigation', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// The sidebar drawer paper contains the navigation
|
||||
const sidebarPaper = page.locator('.MuiDrawer-paper');
|
||||
|
||||
if (await sidebarPaper.count() > 0 && await sidebarPaper.isVisible()) {
|
||||
// Capture sidebar screenshot
|
||||
await expect(sidebarPaper).toHaveScreenshot('sidebar-navigation.png', SCREENSHOT_OPTIONS);
|
||||
} else {
|
||||
// If sidebar is collapsed/hidden, skip this test
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test('top navigation bar', async ({ page }) => {
|
||||
// Wait for app bar to render
|
||||
const appBar = page.locator('.MuiAppBar-root');
|
||||
await expect(appBar).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Capture app bar screenshot
|
||||
await expect(appBar).toHaveScreenshot('top-navigation-bar.png', SCREENSHOT_OPTIONS);
|
||||
});
|
||||
|
||||
test('button styles - contained primary', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find a contained primary button
|
||||
const button = page.locator('.MuiButton-containedPrimary').first();
|
||||
|
||||
if (await button.count() > 0) {
|
||||
await expect(button).toHaveScreenshot('button-contained-primary.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('button styles - text primary', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find a text primary button
|
||||
const button = page.locator('.MuiButton-textPrimary').first();
|
||||
|
||||
if (await button.count() > 0) {
|
||||
await expect(button).toHaveScreenshot('button-text-primary.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('typography hierarchy', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Capture various typography elements if visible
|
||||
const h6 = page.locator('h6').first();
|
||||
const body = page.locator('.MuiTypography-body1').first();
|
||||
|
||||
// Capture h6 (commonly used in dashboard)
|
||||
if (await h6.count() > 0 && await h6.isVisible()) {
|
||||
await expect(h6).toHaveScreenshot('typography-h6.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
|
||||
// Capture body text
|
||||
if (await body.count() > 0 && await body.isVisible()) {
|
||||
await expect(body).toHaveScreenshot('typography-body1.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('card component styles', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find a card component
|
||||
const card = page.locator('.MuiCard-root').first();
|
||||
|
||||
if (await card.count() > 0) {
|
||||
await expect(card).toHaveScreenshot('card-component.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('paper component styles', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find a paper component (excluding cards)
|
||||
const paper = page.locator('.MuiPaper-root:not(.MuiCard-root)').first();
|
||||
|
||||
if (await paper.count() > 0) {
|
||||
await expect(paper).toHaveScreenshot('paper-component.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('input field styles', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find an input field
|
||||
const input = page.locator('.MuiTextField-root, .MuiOutlinedInput-root').first();
|
||||
|
||||
if (await input.count() > 0) {
|
||||
await expect(input).toHaveScreenshot('input-field.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
test('chip/badge styles', async ({ page }) => {
|
||||
// Wait for app to load
|
||||
await page.waitForSelector('.MuiAppBar-root', { timeout: 10000 });
|
||||
|
||||
// Find a chip component
|
||||
const chip = page.locator('.MuiChip-root').first();
|
||||
|
||||
if (await chip.count() > 0) {
|
||||
await expect(chip).toHaveScreenshot('chip-component.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Visual Regression - Table Views', () => {
|
||||
|
||||
test('data grid header styles', async ({ page }) => {
|
||||
// Navigate to a table view if available
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Look for data grid
|
||||
const dataGrid = page.locator('.MuiDataGrid-root');
|
||||
|
||||
if (await dataGrid.count() > 0) {
|
||||
// Capture data grid header
|
||||
const header = dataGrid.locator('.MuiDataGrid-columnHeaders');
|
||||
if (await header.count() > 0) {
|
||||
await expect(header).toHaveScreenshot('datagrid-header.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('table pagination styles', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Find pagination component
|
||||
const pagination = page.locator('.MuiTablePagination-root');
|
||||
|
||||
if (await pagination.count() > 0) {
|
||||
await expect(pagination).toHaveScreenshot('table-pagination.png', SCREENSHOT_OPTIONS);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Visual Regression - Color Verification', () => {
|
||||
|
||||
test('primary color usage', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify primary buttons are NOT blue (#0062FF was the theming color)
|
||||
const primaryButton = page.locator('.MuiButton-containedPrimary').first();
|
||||
|
||||
if (await primaryButton.count() > 0) {
|
||||
const bgColor = await primaryButton.evaluate(el =>
|
||||
window.getComputedStyle(el).backgroundColor
|
||||
);
|
||||
|
||||
// Should not be the theming blue
|
||||
expect(bgColor).not.toBe('rgb(0, 98, 255)');
|
||||
}
|
||||
});
|
||||
|
||||
test('text colors are correct', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check body text color
|
||||
const bodyText = page.locator('.MuiTypography-body1, .MuiTypography-body2').first();
|
||||
|
||||
if (await bodyText.count() > 0) {
|
||||
const color = await bodyText.evaluate(el =>
|
||||
window.getComputedStyle(el).color
|
||||
);
|
||||
|
||||
// Text should be dark (not the theming blue-gray #344767)
|
||||
expect(color).not.toBe('rgb(52, 71, 103)');
|
||||
}
|
||||
});
|
||||
|
||||
test('sidebar background color', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const sidebar = page.locator('.MuiDrawer-paper');
|
||||
|
||||
if (await sidebar.count() > 0) {
|
||||
const bgColor = await sidebar.evaluate(el =>
|
||||
window.getComputedStyle(el).backgroundColor
|
||||
);
|
||||
|
||||
// Capture the actual color for baseline comparison
|
||||
console.log('Sidebar background color:', bgColor);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Visual Regression - Layout Integrity', () => {
|
||||
|
||||
test('no CSS variable fallback indicators', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check that no elements have CSS variable fallbacks visible
|
||||
// (This would indicate theming variables are leaking through)
|
||||
const pageContent = await page.content();
|
||||
|
||||
// Should not contain any --qqq- CSS variables in computed styles
|
||||
const elements = await page.locator('*').evaluateAll(els =>
|
||||
els.filter(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.cssText.includes('--qqq-');
|
||||
}).length
|
||||
);
|
||||
|
||||
// In unthemed mode, no elements should use --qqq- variables
|
||||
expect(elements).toBe(0);
|
||||
});
|
||||
|
||||
test('consistent border radius on cards', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const cards = page.locator('.MuiCard-root');
|
||||
const count = await cards.count();
|
||||
|
||||
if (count > 0) {
|
||||
const radii = await cards.evaluateAll(els =>
|
||||
els.map(el => window.getComputedStyle(el).borderRadius)
|
||||
);
|
||||
|
||||
// All cards should have consistent border radius
|
||||
const uniqueRadii = [...new Set(radii)];
|
||||
console.log('Card border radii:', uniqueRadii);
|
||||
|
||||
// Verify consistency (allow 1-2 different values for different card types)
|
||||
expect(uniqueRadii.length).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
test('consistent typography sizing', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check h6 elements have consistent size
|
||||
const h6Elements = page.locator('h6');
|
||||
const count = await h6Elements.count();
|
||||
|
||||
if (count > 0) {
|
||||
const fontSizes = await h6Elements.evaluateAll(els =>
|
||||
els.map(el => window.getComputedStyle(el).fontSize)
|
||||
);
|
||||
|
||||
const uniqueSizes = [...new Set(fontSizes)];
|
||||
console.log('H6 font sizes:', uniqueSizes);
|
||||
|
||||
// All h6 elements should have the same font size
|
||||
expect(uniqueSizes.length).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
export type RgbColor = { r: number; g: number; b: number; a?: number };
|
||||
|
||||
/**
|
||||
* Utility functions to help with assertions in theme tests
|
||||
*/
|
||||
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null
|
||||
{
|
||||
const normalized = hex.trim().replace(/^#/, "");
|
||||
if (!/^[a-f\d]+$/i.test(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
let rHex: string;
|
||||
let gHex: string;
|
||||
let bHex: string;
|
||||
|
||||
if (normalized.length === 3)
|
||||
{
|
||||
rHex = normalized[0] + normalized[0];
|
||||
gHex = normalized[1] + normalized[1];
|
||||
bHex = normalized[2] + normalized[2];
|
||||
}
|
||||
else if (normalized.length === 6 || normalized.length === 8)
|
||||
{
|
||||
// Ignore alpha channel for 8-digit hex values (#RRGGBBAA).
|
||||
rHex = normalized.slice(0, 2);
|
||||
gHex = normalized.slice(2, 4);
|
||||
bHex = normalized.slice(4, 6);
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
r: parseInt(rHex, 16),
|
||||
g: parseInt(gHex, 16),
|
||||
b: parseInt(bHex, 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRgb(rgbString: string): RgbColor | null
|
||||
{
|
||||
const rgbMatch = rgbString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (rgbMatch)
|
||||
{
|
||||
return {
|
||||
r: parseInt(rgbMatch[1]),
|
||||
g: parseInt(rgbMatch[2]),
|
||||
b: parseInt(rgbMatch[3]),
|
||||
a: rgbMatch[4] ? parseFloat(rgbMatch[4]) : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseColor(color: string): RgbColor | null
|
||||
{
|
||||
if (color.startsWith("rgb"))
|
||||
{
|
||||
return parseRgb(color);
|
||||
}
|
||||
if (color.startsWith("#"))
|
||||
{
|
||||
const rgb = hexToRgb(color);
|
||||
return rgb ? {...rgb, a: undefined} : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function colorsMatch(actual: string, expected: string, tolerance = 2): boolean
|
||||
{
|
||||
if (actual.startsWith("#") && expected.startsWith("#"))
|
||||
{
|
||||
return actual.toLowerCase() === expected.toLowerCase();
|
||||
}
|
||||
|
||||
const actualRgb = parseColor(actual);
|
||||
const expectedRgb = parseColor(expected);
|
||||
if (!actualRgb || !expectedRgb)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const rgbMatch = (
|
||||
Math.abs(actualRgb.r - expectedRgb.r) <= tolerance &&
|
||||
Math.abs(actualRgb.g - expectedRgb.g) <= tolerance &&
|
||||
Math.abs(actualRgb.b - expectedRgb.b) <= tolerance
|
||||
);
|
||||
|
||||
if (actualRgb.a !== undefined && expectedRgb.a !== undefined)
|
||||
{
|
||||
return rgbMatch && Math.abs(actualRgb.a - expectedRgb.a) <= 0.05;
|
||||
}
|
||||
|
||||
return rgbMatch;
|
||||
}
|
||||
|
||||
export function isTransparentOrNearlyTransparent(color: string): boolean
|
||||
{
|
||||
if (color === "transparent" || color === "rgba(0, 0, 0, 0)")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
const rgb = parseRgb(color);
|
||||
return !!(rgb && rgb.a !== undefined && rgb.a < 0.1);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-material-dashboard",
|
||||
"version": "0.41.0-SNAPSHOT",
|
||||
"version": "0.36.0-SNAPSHOT",
|
||||
"homepage": ".",
|
||||
"description": "QQQ Default Dashboard",
|
||||
"dependencies": {
|
||||
@@ -15,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.11-SNAPSHOT",
|
||||
"@qrunio/qqq-frontend-core": "1.4.2-SNAPSHOT",
|
||||
"@react-jvectormap/core": "1.0.1",
|
||||
"@react-jvectormap/unitedstates": "1.0.1",
|
||||
"@react-oauth/google": "0.2.8",
|
||||
@@ -53,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",
|
||||
@@ -62,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",
|
||||
@@ -75,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": [
|
||||
@@ -117,6 +113,7 @@
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-react": "7.28.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"serve": "^14.2.5",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -127,10 +124,5 @@
|
||||
"files": [
|
||||
"lib",
|
||||
"package.json"
|
||||
],
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(@qrunio/qqq-frontend-core|@mui|@babel/runtime)/)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,51 +8,21 @@ 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: 'http://localhost:8001',
|
||||
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`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 60000, // 1 minute - Docker startup can be slow
|
||||
},
|
||||
{
|
||||
command: `HTTPS=true PORT=${dashboardPort} REACT_APP_PROXY_LOCALHOST_PORT=${fixturePort} npm start`,
|
||||
url: `https://localhost:${dashboardPort}`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 300000, // 5 minutes - React compilation in Docker takes time
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'node e2e/fixture-server.js',
|
||||
url: 'http://localhost:8001',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30000,
|
||||
},
|
||||
});
|
||||
@@ -58,8 +58,8 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>0.41.0</revision>
|
||||
<qqq.version>4.0.0</qqq.version>
|
||||
<revision>0.36.0-SNAPSHOT</revision>
|
||||
<qqq.version>0.36.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>
|
||||
@@ -152,7 +152,7 @@
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>7.2.3</version>
|
||||
<version>6.7.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -164,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>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Generate Playwright snapshots for Linux/CI using Docker
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/update-snapshots-linux.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker must be running
|
||||
# - Run from project root
|
||||
#
|
||||
# This script:
|
||||
# 1. Runs everything inside Docker matching CI environment
|
||||
# 2. Starts the fixture server and React dev server inside Docker
|
||||
# 3. Runs Playwright tests with --update-snapshots flag
|
||||
# 4. Generates snapshots in e2e/snapshots/.../linux/
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${YELLOW}=== Generating Playwright Snapshots via Docker ===${NC}"
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo -e "${RED}Error: Docker is not running. Please start Docker and try again.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use the same Playwright image as CI
|
||||
echo -e "${YELLOW}Running Playwright tests in Docker to update snapshots...${NC}"
|
||||
echo -e "${YELLOW}This will install dependencies, start servers, run tests, and update snapshots inside Docker.${NC}"
|
||||
echo -e "${YELLOW}This may take several minutes...${NC}"
|
||||
|
||||
docker run --rm \
|
||||
-v "$PROJECT_ROOT:/app" \
|
||||
-w /app \
|
||||
-e HTTPS=true \
|
||||
-e PORT=3001 \
|
||||
-e REACT_APP_PROXY_LOCALHOST_PORT=8001 \
|
||||
mcr.microsoft.com/playwright:v1.57.0-jammy \
|
||||
bash -c '
|
||||
echo "=== Installing dependencies..."
|
||||
npm ci --legacy-peer-deps
|
||||
|
||||
echo "=== Running Playwright tests with --update-snapshots..."
|
||||
echo "=== (Playwright will start servers automatically)..."
|
||||
npx playwright test --update-snapshots --reporter=list
|
||||
'
|
||||
|
||||
echo -e "${GREEN}=== Linux snapshots updated successfully! ===${NC}"
|
||||
echo -e "${YELLOW}Snapshots are in e2e/snapshots/.../linux/${NC}"
|
||||
echo -e "${YELLOW}Don't forget to commit the updated snapshots.${NC}"
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Generate Playwright snapshots for macOS (local development)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/update-snapshots-mac.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Run from project root
|
||||
# - Dependencies installed (npm ci --legacy-peer-deps)
|
||||
#
|
||||
# This script:
|
||||
# 1. Runs Playwright tests locally with --update-snapshots flag
|
||||
# 2. Generates snapshots in e2e/snapshots/.../darwin/
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${YELLOW}=== Generating Playwright Snapshots for macOS ===${NC}"
|
||||
|
||||
# Check if node_modules exists
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo -e "${RED}Error: node_modules not found. Run 'npm run install:legacy' first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Playwright is installed
|
||||
if [ ! -d "node_modules/@playwright" ]; then
|
||||
echo -e "${RED}Error: Playwright not found. Run 'npm run install:legacy' first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Running Playwright tests with --update-snapshots...${NC}"
|
||||
npx playwright test --update-snapshots --reporter=list
|
||||
|
||||
echo -e "${GREEN}=== macOS snapshots updated successfully! ===${NC}"
|
||||
echo -e "${YELLOW}Snapshots are in e2e/snapshots/.../darwin/${NC}"
|
||||
echo -e "${YELLOW}Don't forget to commit the updated snapshots.${NC}"
|
||||
@@ -19,12 +19,6 @@
|
||||
* 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";
|
||||
@@ -33,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";
|
||||
@@ -40,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
|
||||
{
|
||||
@@ -82,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 });
|
||||
@@ -92,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 //
|
||||
@@ -148,8 +148,6 @@ export default function App({authenticationMetaData}: Props)
|
||||
***************************************************************************/
|
||||
function doLogout()
|
||||
{
|
||||
analyticsUtils.reset();
|
||||
|
||||
if (authenticationMetaData?.type === "AUTH_0")
|
||||
{
|
||||
auth0Logout();
|
||||
@@ -207,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) =>
|
||||
{
|
||||
@@ -252,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"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -497,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);
|
||||
|
||||
@@ -597,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 //
|
||||
///////////////////////////////////////////////////
|
||||
@@ -694,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);
|
||||
@@ -711,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);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
@@ -749,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>);
|
||||
}
|
||||
@@ -760,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}]`)
|
||||
}
|
||||
@@ -774,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;
|
||||
@@ -793,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,
|
||||
@@ -822,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),
|
||||
@@ -854,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>
|
||||
|
||||
@@ -22,15 +22,13 @@
|
||||
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;
|
||||
@@ -71,7 +69,6 @@ interface QContext
|
||||
|
||||
const defaultState = {
|
||||
pageHeader: "",
|
||||
pageHeaderRightContent: null as ReactNode,
|
||||
accentColor: "#0062FF",
|
||||
accentColorLight: "#C0D6F7",
|
||||
dotMenuOpen: false,
|
||||
|
||||
@@ -31,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;
|
||||
|
||||
|
||||
|
||||
@@ -29,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
|
||||
{
|
||||
@@ -39,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();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -159,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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -45,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";
|
||||
@@ -58,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;
|
||||
|
||||
@@ -270,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
|
||||
*******************************************************************************/
|
||||
@@ -454,9 +384,6 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
clone.onLoadFormAdjuster = onLoadFormAdjuster.clone();
|
||||
}
|
||||
|
||||
clone.showRecordSidebar = showRecordSidebar;
|
||||
clone.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
|
||||
return abstractClone;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,157 +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/
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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;
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()})`;
|
||||
}
|
||||
@@ -50,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.");
|
||||
};
|
||||
|
||||
@@ -75,7 +60,6 @@ export default function useAnonymousAuthenticationModule({setIsFullyAuthenticate
|
||||
const logout = () =>
|
||||
{
|
||||
qController.clearAuthenticationMetaDataLocalStorage();
|
||||
localStorage.removeItem("sessionValues");
|
||||
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
||||
};
|
||||
|
||||
|
||||
@@ -25,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";
|
||||
@@ -107,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
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@@ -150,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)
|
||||
@@ -192,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;
|
||||
@@ -206,7 +155,6 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
|
||||
const logout = () =>
|
||||
{
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("sessionValues");
|
||||
useAuth0Logout({returnTo: window.location.origin});
|
||||
};
|
||||
|
||||
|
||||
@@ -31,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.
|
||||
@@ -96,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) * //
|
||||
@@ -186,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 * //
|
||||
@@ -266,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 * //
|
||||
@@ -339,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.) * //
|
||||
@@ -432,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 * //
|
||||
@@ -464,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",
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
|
||||
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";
|
||||
@@ -44,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>
|
||||
@@ -69,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>
|
||||
@@ -90,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>
|
||||
@@ -102,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>
|
||||
@@ -114,23 +112,21 @@ interface QActionsMenuButtonProps
|
||||
{
|
||||
isOpen: boolean;
|
||||
onClickHandler: any;
|
||||
label?: string;
|
||||
qIcon?: QIcon;
|
||||
}
|
||||
|
||||
export function QActionsMenuButton({isOpen, onClickHandler, label, qIcon}: QActionsMenuButtonProps): JSX.Element
|
||||
export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box width={standardWidth} ml={1}>
|
||||
<MDButton
|
||||
qqqId="actions-menu"
|
||||
variant={"outlined"}
|
||||
variant={isOpen ? "contained" : "outlined"}
|
||||
color="dark"
|
||||
onClick={onClickHandler}
|
||||
startIcon={<Icon>{qIcon?.name ?? "games"}</Icon>}
|
||||
startIcon={<Icon>games</Icon>}
|
||||
fullWidth
|
||||
>
|
||||
{label ?? "actions"}
|
||||
actions
|
||||
<Icon>keyboard_arrow_down</Icon>
|
||||
</MDButton>
|
||||
</Box>
|
||||
@@ -143,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>
|
||||
@@ -162,7 +157,6 @@ export function QCancelButton({
|
||||
QCancelButton.defaultProps = {
|
||||
label: "cancel",
|
||||
iconName: "cancel",
|
||||
qqqId: "cancel"
|
||||
};
|
||||
|
||||
interface QSubmitButtonProps
|
||||
@@ -170,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>
|
||||
@@ -187,7 +180,6 @@ export function QSubmitButton({label, iconName, disabled, qqqId}: QSubmitButtonP
|
||||
QSubmitButton.defaultProps = {
|
||||
label: "Submit",
|
||||
iconName: "check",
|
||||
qqqId: "submit"
|
||||
};
|
||||
|
||||
interface QAlternateButtonProps
|
||||
@@ -195,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 = {};
|
||||
@@ -33,6 +33,7 @@ import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {generateSelectId} from "qqq/utils/qqqIdUtils";
|
||||
|
||||
interface Props
|
||||
{
|
||||
@@ -428,11 +429,13 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
|
||||
autocompleteSX = getAutocompleteOutlinedStyle(isDisabled);
|
||||
}
|
||||
|
||||
const dataQqqId = generateSelectId(undefined, name ?? fieldName, fieldLabel);
|
||||
const autocomplete = (
|
||||
<Box>
|
||||
<Autocomplete
|
||||
id={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"}
|
||||
name={name}
|
||||
data-qqq-id={dataQqqId}
|
||||
sx={autocompleteSX}
|
||||
open={open}
|
||||
fullWidth
|
||||
|
||||
@@ -19,19 +19,19 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QThemeMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QThemeMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {MaterialDashboardThemeMetaData} from "qqq/models/metadata/MaterialDashboardThemeMetaData";
|
||||
import React from "react";
|
||||
|
||||
interface Props
|
||||
{
|
||||
theme?: MaterialDashboardThemeMetaData | null;
|
||||
theme?: QThemeMetaData | null;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** BrandedHeaderBar - renders a branded header bar when enabled via theme.
|
||||
** Uses CSS variables injected from MaterialDashboardThemeMetaData for styling.
|
||||
** Uses CSS variables injected from QThemeMetaData for styling.
|
||||
*******************************************************************************/
|
||||
export default function BrandedHeaderBar({theme}: Props): JSX.Element | null
|
||||
{
|
||||
@@ -53,8 +53,8 @@ export default function BrandedHeaderBar({theme}: Props): JSX.Element | null
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 1rem",
|
||||
backgroundColor: "var(--qqq-branded-header-background-color, #1a2035)",
|
||||
color: "var(--qqq-branded-header-text-color, #ffffff)",
|
||||
backgroundColor: "var(--qqq-branded-header-background-color)",
|
||||
color: "var(--qqq-branded-header-text-color)",
|
||||
height: "var(--qqq-branded-header-height, 48px)",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
@@ -82,7 +82,7 @@ export default function BrandedHeaderBar({theme}: Props): JSX.Element | null
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: "var(--qqq-branded-header-text-color, #ffffff)",
|
||||
color: "var(--qqq-branded-header-text-color)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -65,7 +65,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
const route = useLocation().pathname.split("/").slice(1);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {pageHeader, pageHeaderRightContent, setDotMenuOpen} = useContext(QContext);
|
||||
const {pageHeader, setDotMenuOpen} = useContext(QContext);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -269,11 +269,10 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
</Toolbar>
|
||||
{
|
||||
pageHeader &&
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" flexWrap={{xs: "wrap", md: "nowrap"}} columnGap={2} rowGap={1}>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<MDTypography pb="0.5rem" variant="h3" color={light ? "white" : "dark"}>
|
||||
{pageHeader}
|
||||
</MDTypography>
|
||||
{pageHeaderRightContent && <Box display="flex">{pageHeaderRightContent}</Box>}
|
||||
</Box>
|
||||
}
|
||||
</AppBar>
|
||||
|
||||
@@ -210,7 +210,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}
|
||||
|
||||
// Render all the routes from the routes.js (All the visible items on the Sidenav)
|
||||
const renderRoutes = routes.map(
|
||||
({type, name, icon, title, collapse, noCollapse, key, href, route, ...routeRest}: any) =>
|
||||
({type, name, icon, title, collapse, noCollapse, key, href, route}: any) =>
|
||||
{
|
||||
let returnValue;
|
||||
|
||||
@@ -231,7 +231,6 @@ function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}
|
||||
icon={icon}
|
||||
active={key === collapseName}
|
||||
noCollapse={noCollapse}
|
||||
{...routeRest}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
@@ -262,7 +261,6 @@ function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}
|
||||
open={openCollapse === key}
|
||||
noCollapse={noCollapse}
|
||||
onClick={() => (!noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null)}
|
||||
{...routeRest}
|
||||
>
|
||||
{collapse ? renderCollapse(collapse) : null}
|
||||
</SideNavCollapse>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {Theme} from "@mui/material/styles";
|
||||
import {ReactNode} from "react";
|
||||
import {item, itemArrow, itemContent} from "qqq/components/horseshoe/sidenav/styles/SideNavItem";
|
||||
import {useMaterialUIController} from "qqq/context";
|
||||
import {generateNavItemId} from "qqq/utils/qqqIdUtils";
|
||||
|
||||
// Declaring props types for SideNavCollapse
|
||||
interface Props {
|
||||
@@ -45,11 +46,15 @@ function SideNavItem({color, name, active, nested, children, open, ...rest}: Pro
|
||||
const [controller] = useMaterialUIController();
|
||||
const {miniSidenav, transparentSidenav, whiteSidenav, darkMode} = controller;
|
||||
|
||||
// Generate data-qqq-id for CSS targeting
|
||||
const dataQqqId = generateNavItemId(undefined, name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
{...rest}
|
||||
component="li"
|
||||
data-qqq-id={dataQqqId}
|
||||
sx={(theme) => item(theme, {active, color, transparentSidenav, whiteSidenav, darkMode})}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -26,7 +26,7 @@ function collapseItem(theme: Theme, ownerState: any)
|
||||
const {palette, transitions, breakpoints, boxShadows, borders, functions} = theme;
|
||||
const {active, transparentSidenav, whiteSidenav, darkMode} = ownerState;
|
||||
|
||||
const {white, transparent, dark, grey} = palette;
|
||||
const {white, transparent, grey} = palette;
|
||||
const {md} = boxShadows;
|
||||
const {borderRadius} = borders;
|
||||
const {pxToRem, rgba} = functions;
|
||||
@@ -55,7 +55,7 @@ function collapseItem(theme: Theme, ownerState: any)
|
||||
|
||||
return backgroundValue;
|
||||
},
|
||||
color: "var(--qqq-sidebar-text-color, #ffffff)",
|
||||
color: "var(--qqq-sidebar-text-color)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
@@ -79,24 +79,22 @@ function collapseItem(theme: Theme, ownerState: any)
|
||||
},
|
||||
|
||||
"&:hover, &:focus": {
|
||||
backgroundColor: "var(--qqq-sidebar-hover-background-color, rgba(255, 255, 255, 0.1))",
|
||||
backgroundColor: "var(--qqq-sidebar-hover-background-color)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collapseIconBox(theme: Theme, ownerState: any)
|
||||
{
|
||||
const {palette, transitions, borders, functions} = theme;
|
||||
const {transparentSidenav, whiteSidenav, darkMode} = ownerState;
|
||||
const {transitions, borders, functions} = theme;
|
||||
|
||||
const {white, dark} = palette;
|
||||
const {borderRadius} = borders;
|
||||
const {pxToRem} = functions;
|
||||
|
||||
return {
|
||||
minWidth: pxToRem(32),
|
||||
minHeight: pxToRem(32),
|
||||
color: "var(--qqq-sidebar-icon-color, #ffffff)",
|
||||
color: "var(--qqq-sidebar-icon-color)",
|
||||
borderRadius: borderRadius.md,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
@@ -106,13 +104,13 @@ function collapseIconBox(theme: Theme, ownerState: any)
|
||||
}),
|
||||
|
||||
"& svg, svg g": {
|
||||
color: "var(--qqq-sidebar-icon-color, #ffffff)",
|
||||
color: "var(--qqq-sidebar-icon-color)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const collapseIcon = (_theme: Theme, {active}: any) => ({
|
||||
color: active ? "var(--qqq-sidebar-selected-text-color, #ffffff)" : "var(--qqq-sidebar-icon-color, #ffffff)",
|
||||
color: active ? "var(--qqq-sidebar-selected-text-color)" : "var(--qqq-sidebar-icon-color)",
|
||||
});
|
||||
|
||||
function collapseText(theme: any, ownerState: any)
|
||||
@@ -146,21 +144,19 @@ function collapseText(theme: any, ownerState: any)
|
||||
|
||||
function collapseArrow(theme: Theme, ownerState: any)
|
||||
{
|
||||
const {palette, typography, transitions, breakpoints, functions} = theme;
|
||||
const {noCollapse, transparentSidenav, whiteSidenav, miniSidenav, open, active, darkMode} =
|
||||
ownerState;
|
||||
const {typography, transitions, breakpoints, functions} = theme;
|
||||
const {noCollapse, miniSidenav, transparentSidenav, open, active} = ownerState;
|
||||
|
||||
const {white, dark} = palette;
|
||||
const {size} = typography;
|
||||
const {pxToRem, rgba} = functions;
|
||||
const {pxToRem} = functions;
|
||||
|
||||
return {
|
||||
fontSize: `${size.lg} !important`,
|
||||
fontWeight: 700,
|
||||
marginBottom: pxToRem(-1),
|
||||
transform: open ? "rotate(0)" : "rotate(-180deg)",
|
||||
color: open || active ? "var(--qqq-sidebar-selected-text-color, #ffffff)" : "var(--qqq-sidebar-icon-color, #ffffff)",
|
||||
opacity: open || active ? 1 : 0.5,
|
||||
color: open || active ? "var(--qqq-sidebar-selected-text-color)" : "var(--qqq-sidebar-icon-color)",
|
||||
opacity: open || active ? 1 : 0.6,
|
||||
transition: transitions.create(["color", "transform", "opacity"], {
|
||||
easing: transitions.easing.easeInOut,
|
||||
duration: transitions.duration.shorter,
|
||||
|
||||
@@ -23,12 +23,11 @@ import {Theme} from "@mui/material/styles";
|
||||
|
||||
function item(theme: Theme | any, ownerState: any)
|
||||
{
|
||||
const {palette, borders, functions, transitions} = theme;
|
||||
const {active, color, transparentSidenav, whiteSidenav, darkMode} = ownerState;
|
||||
const {palette, borders, transitions} = theme;
|
||||
const {active, color} = ownerState;
|
||||
|
||||
const {transparent, white, grey} = palette;
|
||||
const {transparent} = palette;
|
||||
const {borderRadius} = borders;
|
||||
const {rgba} = functions;
|
||||
|
||||
return {
|
||||
pl: 3,
|
||||
@@ -39,25 +38,16 @@ function item(theme: Theme | any, ownerState: any)
|
||||
cursor: "pointer",
|
||||
backgroundColor: () =>
|
||||
{
|
||||
let backgroundValue = transparent.main;
|
||||
|
||||
if (
|
||||
(active === "isParent" && !transparentSidenav && !whiteSidenav) ||
|
||||
(active === "isParent" && transparentSidenav && darkMode)
|
||||
)
|
||||
if (active === "isParent")
|
||||
{
|
||||
backgroundValue = rgba(white.main, 0.2);
|
||||
}
|
||||
else if (active === "isParent" && transparentSidenav)
|
||||
{
|
||||
return "var(--qqq-sidebar-selected-background-color, rgba(255, 255, 255, 0.2))";
|
||||
return "var(--qqq-sidebar-selected-background-color)";
|
||||
}
|
||||
else if (active)
|
||||
{
|
||||
backgroundValue = palette[color].main;
|
||||
return palette[color].main;
|
||||
}
|
||||
|
||||
return backgroundValue;
|
||||
return transparent.main;
|
||||
},
|
||||
transition: transitions.create("background-color", {
|
||||
easing: transitions.easing.easeInOut,
|
||||
@@ -65,17 +55,16 @@ function item(theme: Theme | any, ownerState: any)
|
||||
}),
|
||||
|
||||
"&:hover, &:focus": {
|
||||
backgroundColor: !active ? "var(--qqq-sidebar-hover-background-color, rgba(255, 255, 255, 0.1))" : undefined,
|
||||
backgroundColor: !active ? "var(--qqq-sidebar-hover-background-color)" : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function itemContent(theme: Theme, ownerState: any)
|
||||
{
|
||||
const {palette, typography, transitions, functions} = theme;
|
||||
const {miniSidenav, name, active, transparentSidenav, whiteSidenav, darkMode} = ownerState;
|
||||
const {typography, transitions, functions} = theme;
|
||||
const {miniSidenav, active} = ownerState;
|
||||
|
||||
const {white, dark} = palette;
|
||||
const {size, fontWeightRegular, fontWeightLight} = typography;
|
||||
const {pxToRem} = functions;
|
||||
|
||||
@@ -90,7 +79,7 @@ function itemContent(theme: Theme, ownerState: any)
|
||||
position: "relative",
|
||||
|
||||
"& span": {
|
||||
color: active ? "var(--qqq-sidebar-selected-text-color, #ffffff)" : "var(--qqq-sidebar-text-color, #ffffff)",
|
||||
color: active ? "var(--qqq-sidebar-selected-text-color)" : "var(--qqq-sidebar-text-color)",
|
||||
fontWeight: active ? fontWeightRegular : fontWeightLight,
|
||||
fontSize: size.sm,
|
||||
opacity: miniSidenav ? 0 : 1,
|
||||
@@ -101,7 +90,7 @@ function itemContent(theme: Theme, ownerState: any)
|
||||
},
|
||||
|
||||
"&::before": {
|
||||
color: active ? "var(--qqq-sidebar-selected-text-color, #ffffff)" : "var(--qqq-sidebar-text-color, #ffffff)",
|
||||
color: active ? "var(--qqq-sidebar-selected-text-color)" : "var(--qqq-sidebar-text-color)",
|
||||
fontWeight: fontWeightRegular,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -118,21 +107,19 @@ function itemContent(theme: Theme, ownerState: any)
|
||||
|
||||
function itemArrow(theme: Theme, ownerState: any)
|
||||
{
|
||||
const {palette, typography, transitions, breakpoints, functions} = theme;
|
||||
const {noCollapse, transparentSidenav, whiteSidenav, miniSidenav, open, active, darkMode} =
|
||||
ownerState;
|
||||
const {typography, transitions, breakpoints, functions} = theme;
|
||||
const {noCollapse, transparentSidenav, miniSidenav, open, active} = ownerState;
|
||||
|
||||
const {white, dark} = palette;
|
||||
const {size} = typography;
|
||||
const {pxToRem, rgba} = functions;
|
||||
const {pxToRem} = functions;
|
||||
|
||||
return {
|
||||
fontSize: `${size.lg} !important`,
|
||||
fontWeight: 700,
|
||||
marginBottom: pxToRem(-1),
|
||||
transform: open ? "rotate(0)" : "rotate(-180deg)",
|
||||
color: open || active ? "var(--qqq-sidebar-selected-text-color, #ffffff)" : "var(--qqq-sidebar-icon-color, #ffffff)",
|
||||
opacity: open || active ? 1 : 0.5,
|
||||
color: open || active ? "var(--qqq-sidebar-selected-text-color)" : "var(--qqq-sidebar-icon-color)",
|
||||
opacity: open || active ? 1 : 0.6,
|
||||
transition: transitions.create(["color", "transform", "opacity"], {
|
||||
easing: transitions.easing.easeInOut,
|
||||
duration: transitions.duration.shorter,
|
||||
|
||||
@@ -91,12 +91,12 @@ const MDBadgeDot: FC<Props> = forwardRef(
|
||||
];
|
||||
|
||||
const colorValues = {
|
||||
"primary": "#e91e63",
|
||||
"primary": "var(--qqq-primary-color, #e91e63)",
|
||||
"secondary": "#7b809a",
|
||||
"info": accentColor,
|
||||
"success": "#4CAF50",
|
||||
"warning": "#fb8c00",
|
||||
"error": "#F44335",
|
||||
"success": "var(--qqq-success-color, #4CAF50)",
|
||||
"warning": "var(--qqq-warning-color, #fb8c00)",
|
||||
"error": "var(--qqq-error-color, #F44335)",
|
||||
"light": "#f0f2f5",
|
||||
"dark": "#344767",
|
||||
"custom1": "#8c28c2",
|
||||
|
||||
@@ -167,11 +167,24 @@ export default styled(Button)(({theme, ownerState}: { theme?: Theme | any; owner
|
||||
// styles for the button with variant="gradient"
|
||||
const gradientStyles = () =>
|
||||
{
|
||||
// background value - always use gradients for consistent appearance
|
||||
// Map color names to CSS variable names
|
||||
const colorToCssVar: Record<string, string> = {
|
||||
primary: "var(--qqq-primary-color)",
|
||||
secondary: "var(--qqq-secondary-color)",
|
||||
info: "var(--qqq-info-color)",
|
||||
success: "var(--qqq-success-color)",
|
||||
warning: "var(--qqq-warning-color)",
|
||||
error: "var(--qqq-error-color)",
|
||||
};
|
||||
|
||||
// Use CSS variable if available, otherwise fall back to gradient
|
||||
const cssVarColor = colorToCssVar[color];
|
||||
const backgroundValue =
|
||||
color === "white" || !gradients[color]
|
||||
? white.main
|
||||
: linearGradient(gradients[color].main, gradients[color].state);
|
||||
: cssVarColor
|
||||
? cssVarColor
|
||||
: linearGradient(gradients[color].main, gradients[color].state);
|
||||
|
||||
// boxShadow value
|
||||
const boxShadowValue = colored[color]
|
||||
|
||||
@@ -23,6 +23,7 @@ import {ButtonProps} from "@mui/material";
|
||||
import {FC, forwardRef, ReactNode} from "react";
|
||||
import MDButtonRoot from "qqq/components/legacy/MDButton/MDButtonRoot";
|
||||
import {useMaterialUIController} from "qqq/context";
|
||||
import {generateButtonId} from "qqq/utils/qqqIdUtils";
|
||||
|
||||
// Declaring props types for MDButton
|
||||
export interface Props extends Omit<ButtonProps, "color" | "variant"> {
|
||||
@@ -42,6 +43,7 @@ export interface Props extends Omit<ButtonProps, "color" | "variant"> {
|
||||
circular?: boolean;
|
||||
iconOnly?: boolean;
|
||||
children?: ReactNode;
|
||||
qqqId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -51,12 +53,25 @@ const MDButton: FC<Props> = forwardRef(
|
||||
const [controller] = useMaterialUIController();
|
||||
const {darkMode} = controller;
|
||||
|
||||
// Extract icon name from startIcon if present (for icon-only buttons)
|
||||
let iconName: string | undefined;
|
||||
if (rest.startIcon && typeof rest.startIcon === "object")
|
||||
{
|
||||
const iconProps = (rest.startIcon as any).props;
|
||||
if (iconProps?.children)
|
||||
{
|
||||
iconName = String(iconProps.children);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate data-qqq-id for CSS targeting
|
||||
const dataQqqId = generateButtonId(qqqId, children, iconName);
|
||||
|
||||
return (
|
||||
<MDButtonRoot
|
||||
{...rest}
|
||||
ref={ref}
|
||||
data-qqq-id={qqqId}
|
||||
data-button-variant={variant}
|
||||
data-qqq-id={dataQqqId}
|
||||
ownerState={{color, variant, size, circular, iconOnly, darkMode}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,60 +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/
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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 {expect, it} from "@jest/globals";
|
||||
import {ThemeProvider} from "@mui/material/styles";
|
||||
import React from "react";
|
||||
import {createRoot} from "react-dom/client";
|
||||
import {act} from "react-dom/test-utils";
|
||||
import theme from "qqq/components/legacy/Theme";
|
||||
import MDInput from "./index";
|
||||
|
||||
/*******************************************************************************
|
||||
** A disabled field must receive native input semantics as well as styling.
|
||||
*******************************************************************************/
|
||||
it("updates native disabled state when the form metadata changes", () =>
|
||||
{
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const render = (disabled: boolean) => act(() => root.render(<ThemeProvider theme={theme}>
|
||||
<MDInput name="createDate" type="datetime-local" disabled={disabled} />
|
||||
</ThemeProvider>));
|
||||
try
|
||||
{
|
||||
render(true);
|
||||
expect(host.querySelector("input").disabled).toBe(true);
|
||||
host.querySelector("input").focus();
|
||||
expect(document.activeElement).not.toBe(host.querySelector("input"));
|
||||
render(false);
|
||||
expect(host.querySelector("input").disabled).toBe(false);
|
||||
host.querySelector("input").focus();
|
||||
expect(document.activeElement).toBe(host.querySelector("input"));
|
||||
render(true);
|
||||
expect(host.querySelector("input").disabled).toBe(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
@@ -31,7 +31,7 @@ interface Props extends Omit<OutlinedTextFieldProps | StandardTextFieldProps, "v
|
||||
}
|
||||
|
||||
const MDInput: FC<Props | any> = forwardRef(({error, success, disabled, ...rest}, ref) => (
|
||||
<MDInputRoot {...rest} ref={ref} disabled={disabled} ownerState={{error, success, disabled}} />
|
||||
<MDInputRoot {...rest} ref={ref} ownerState={{error, success, disabled}} />
|
||||
));
|
||||
|
||||
// Declaring default props for MDInput
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import {preferredColorNameInfoOrPrimary} from "qqq/assets/theme/functions/preferInfoColorToPrimaryColor";
|
||||
import {createContext, FC, forwardRef, ReactNode, useContext, useMemo} from "react";
|
||||
import MDPaginationItemRoot from "qqq/components/legacy/MDPagination/MDPaginationItemRoot";
|
||||
|
||||
@@ -55,11 +54,6 @@ const MDPagination: FC<Props | any> = forwardRef(
|
||||
const context: any = useContext(Context);
|
||||
const paginationSize = context ? context.size : undefined;
|
||||
|
||||
if(!color)
|
||||
{
|
||||
color = preferredColorNameInfoOrPrimary();
|
||||
}
|
||||
|
||||
const providerValue = useMemo(
|
||||
() => ({
|
||||
variant,
|
||||
@@ -102,6 +96,7 @@ const MDPagination: FC<Props | any> = forwardRef(
|
||||
MDPagination.defaultProps = {
|
||||
item: false,
|
||||
variant: "gradient",
|
||||
color: "info",
|
||||
size: "medium",
|
||||
active: false,
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export default styled(LinearProgress)(
|
||||
{
|
||||
backgroundValue = gradients[color]
|
||||
? linearGradient(gradients[color].main, gradients[color].state)
|
||||
: linearGradient(gradients.info.main, gradients.info.state);
|
||||
: "linear-gradient(195deg, var(--qqq-info-color, #0062FF), var(--qqq-info-color, #0062FF))";
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -47,8 +47,7 @@ interface FieldAutoCompleteProps
|
||||
autocompleteSlotProps?: any,
|
||||
hasError?: boolean,
|
||||
noOptionsText?: string,
|
||||
omitExposedJoins?: string[],
|
||||
includeVirtualFields: "all" | "none" | "querySelectable" | "queryCriteria"
|
||||
omitExposedJoins?: string[]
|
||||
}
|
||||
|
||||
FieldAutoComplete.defaultProps =
|
||||
@@ -64,24 +63,11 @@ FieldAutoComplete.defaultProps =
|
||||
autocompleteSlotProps: null,
|
||||
hasError: false,
|
||||
noOptionsText: "No options",
|
||||
includeVirtualFields: "none"
|
||||
};
|
||||
|
||||
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string, includeVirtualFields: "all" | "none" | "querySelectable" | "queryCriteria" = "none")
|
||||
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string)
|
||||
{
|
||||
const availableFields = [...tableMetaData.fields.values()]
|
||||
if (includeVirtualFields != "none")
|
||||
{
|
||||
tableMetaData.virtualFields?.forEach(virtualField =>
|
||||
{
|
||||
if (includeVirtualFields == "all" || (includeVirtualFields == "querySelectable" && virtualField.isQuerySelectable) || (includeVirtualFields == "queryCriteria" && virtualField.isQueryCriteria))
|
||||
{
|
||||
availableFields.push(virtualField);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sortedFields = availableFields.sort((a, b) => a.label.localeCompare(b.label));
|
||||
const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
for (let i = 0; i < sortedFields.length; i++)
|
||||
{
|
||||
const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name;
|
||||
@@ -104,12 +90,12 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a
|
||||
/*******************************************************************************
|
||||
** Component for rendering a list of field names from a table as an auto-complete.
|
||||
*******************************************************************************/
|
||||
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError, noOptionsText, omitExposedJoins, includeVirtualFields}: FieldAutoCompleteProps): JSX.Element
|
||||
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError, noOptionsText, omitExposedJoins}: FieldAutoCompleteProps): JSX.Element
|
||||
{
|
||||
const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null);
|
||||
|
||||
const fieldOptions: any[] = [];
|
||||
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName, includeVirtualFields);
|
||||
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName);
|
||||
let fieldsGroupBy = null;
|
||||
|
||||
const availableExposedJoins = useMemo(() =>
|
||||
|
||||
@@ -36,7 +36,6 @@ import DialogTitle from "@mui/material/DialogTitle";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {preferredColorNameInfoOrPrimary} from "qqq/assets/theme/functions/preferInfoColorToPrimaryColor";
|
||||
import {QCancelButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import MDButton from "qqq/components/legacy/MDButton";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
@@ -298,7 +297,7 @@ function GotoRecordDialog(props: Props): JSX.Element
|
||||
<Grid item xs={1} pl={2}>
|
||||
{
|
||||
(index == option.length - 1) &&
|
||||
<MDButton id={`gotoButton-${optionIndex}`} type="submit" variant="gradient" color={preferredColorNameInfoOrPrimary()} size="small" onClick={() => optionGoClicked(optionIndex)} fullWidth startIcon={<Icon>double_arrow</Icon>} disabled={isOptionSubmitButtonDisabled(optionIndex)}>Go</MDButton>
|
||||
<MDButton id={`gotoButton-${optionIndex}`} type="submit" variant="gradient" color="info" size="small" onClick={() => optionGoClicked(optionIndex)} fullWidth startIcon={<Icon>double_arrow</Icon>} disabled={isOptionSubmitButtonDisabled(optionIndex)}>Go</MDButton>
|
||||
}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -27,6 +27,7 @@ import Card from "@mui/material/Card";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import {Theme} from "@mui/material/styles";
|
||||
import React from "react";
|
||||
import {HashLink} from "react-router-hash-link";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import {sanitizeId} from "qqq/utils/qqqIdUtils";
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ import Tooltip from "@mui/material/Tooltip";
|
||||
import {TooltipProps} from "@mui/material/Tooltip/Tooltip";
|
||||
import FormData from "form-data";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import {BulkLoadMapping, BulkLoadTableStructure, FileDescription} from "qqq/models/processes/BulkLoadModels";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
|
||||
@@ -33,24 +33,27 @@ import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {TooltipProps} from "@mui/material/Tooltip/Tooltip";
|
||||
import {QController} from "@qrunio/qqq-frontend-core/lib/controllers/QController";
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QJobComplete} from "@qrunio/qqq-frontend-core/lib/model/processes/QJobComplete";
|
||||
import {QJobError} from "@qrunio/qqq-frontend-core/lib/model/processes/QJobError";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import FormData from "form-data";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import {UseSavedViewsResult} from "qqq/components/query/useSavedViews";
|
||||
import {generateButtonId} from "qqq/utils/qqqIdUtils";
|
||||
import RecordQueryView from "qqq/models/query/RecordQueryView";
|
||||
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
|
||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||
import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
|
||||
import React, {useContext, useState} from "react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||
import {useLocation, useNavigate} from "react-router-dom";
|
||||
|
||||
interface Props
|
||||
{
|
||||
useSavedViewsResult: UseSavedViewsResult;
|
||||
qController: QController;
|
||||
metaData: QInstance;
|
||||
tableMetaData: QTableMetaData;
|
||||
currentSavedView: QRecord;
|
||||
@@ -62,23 +65,28 @@ interface Props
|
||||
queryScreenUsage: QueryScreenUsage;
|
||||
}
|
||||
|
||||
function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView, queryScreenUsage}: Props): JSX.Element
|
||||
function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView, queryScreenUsage}: Props): JSX.Element
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
|
||||
const yourSavedViews: QRecord[] = useSavedViewsResult?.yourSavedViews.filter((view: QRecord) => view.values.get("type") != "quickView") ?? [];
|
||||
const viewsSharedWithYou: QRecord[] = useSavedViewsResult?.viewsSharedWithYou.filter((view: QRecord) => view.values.get("type") != "quickView") ?? [];
|
||||
|
||||
const [savedViews, setSavedViews] = useState([] as QRecord[]);
|
||||
const [yourSavedViews, setYourSavedViews] = useState([] as QRecord[]);
|
||||
const [viewsSharedWithYou, setViewsSharedWithYou] = useState([] as QRecord[]);
|
||||
const [savedViewsMenu, setSavedViewsMenu] = useState(null);
|
||||
const [savedViewsHaveLoaded, setSavedViewsHaveLoaded] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [savePopupOpen, setSavePopupOpen] = useState(false);
|
||||
const [isSaveAs, setIsSaveAs] = useState(false);
|
||||
const [isRename, setIsRename] = useState(false);
|
||||
const [isDelete, setIsDelete] = useState(false);
|
||||
const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false);
|
||||
const [isSaveFilterAs, setIsSaveFilterAs] = useState(false);
|
||||
const [isRenameFilter, setIsRenameFilter] = useState(false);
|
||||
const [isDeleteFilter, setIsDeleteFilter] = useState(false);
|
||||
const [savedViewNameInputValue, setSavedViewNameInputValue] = useState(null as string);
|
||||
const [popupAlertContent, setPopupAlertContent] = useState("");
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
|
||||
|
||||
const SAVE_OPTION = "Save...";
|
||||
const DUPLICATE_OPTION = "Duplicate...";
|
||||
const RENAME_OPTION = "Rename...";
|
||||
@@ -99,6 +107,19 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget);
|
||||
const closeSavedViewsMenu = () => setSavedViewsMenu(null);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// load filters on first run, then monitor location or metadata changes //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
loadSavedViews()
|
||||
.then(() =>
|
||||
{
|
||||
setSavedViewsHaveLoaded(true);
|
||||
});
|
||||
}, [location, tableMetaData]);
|
||||
|
||||
|
||||
const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView;
|
||||
const viewDiffs = SavedViewUtils.diffViews(tableMetaData, baseView, view);
|
||||
let viewIsModified = false;
|
||||
@@ -107,12 +128,47 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
viewIsModified = true;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** make request to load all saved filters from backend
|
||||
*******************************************************************************/
|
||||
async function loadSavedViews()
|
||||
{
|
||||
if (!tableMetaData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("tableName", tableMetaData.name);
|
||||
|
||||
let savedViews = await makeSavedViewRequest("querySavedView", formData);
|
||||
setSavedViews(savedViews);
|
||||
|
||||
const yourSavedViews: QRecord[] = [];
|
||||
const viewsSharedWithYou: QRecord[] = [];
|
||||
for (let i = 0; i < savedViews.length; i++)
|
||||
{
|
||||
const record = savedViews[i];
|
||||
if (record.values.get("userId") == currentUserId)
|
||||
{
|
||||
yourSavedViews.push(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewsSharedWithYou.push(record);
|
||||
}
|
||||
}
|
||||
setYourSavedViews(yourSavedViews);
|
||||
setViewsSharedWithYou(viewsSharedWithYou);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** fired when a saved record is clicked from the dropdown
|
||||
*******************************************************************************/
|
||||
const handleSavedViewRecordOnClick = async (record: QRecord) =>
|
||||
{
|
||||
setSavePopupOpen(false);
|
||||
setSaveFilterPopupOpen(false);
|
||||
closeSavedViewsMenu();
|
||||
viewOnChangeCallback(record.values.get("id"));
|
||||
if (isQueryScreen)
|
||||
@@ -127,12 +183,13 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
*******************************************************************************/
|
||||
const handleDropdownOptionClick = (optionName: string) =>
|
||||
{
|
||||
setSaveOptionsOpen(false);
|
||||
setPopupAlertContent("");
|
||||
closeSavedViewsMenu();
|
||||
setSavePopupOpen(true);
|
||||
setIsSaveAs(false);
|
||||
setIsRename(false);
|
||||
setIsDelete(false);
|
||||
setSaveFilterPopupOpen(true);
|
||||
setIsSaveFilterAs(false);
|
||||
setIsRenameFilter(false);
|
||||
setIsDeleteFilter(false);
|
||||
|
||||
switch (optionName)
|
||||
{
|
||||
@@ -144,10 +201,10 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
break;
|
||||
case DUPLICATE_OPTION:
|
||||
setSavedViewNameInputValue("");
|
||||
setIsSaveAs(true);
|
||||
setIsSaveFilterAs(true);
|
||||
break;
|
||||
case CLEAR_OPTION:
|
||||
setSavePopupOpen(false);
|
||||
setSaveFilterPopupOpen(false);
|
||||
viewOnChangeCallback(null);
|
||||
if (isQueryScreen)
|
||||
{
|
||||
@@ -159,10 +216,10 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
{
|
||||
setSavedViewNameInputValue(currentSavedView.values.get("label"));
|
||||
}
|
||||
setIsRename(true);
|
||||
setIsRenameFilter(true);
|
||||
break;
|
||||
case DELETE_OPTION:
|
||||
setIsDelete(true);
|
||||
setIsDeleteFilter(true);
|
||||
break;
|
||||
case NEW_REPORT_OPTION:
|
||||
createNewReport();
|
||||
@@ -191,7 +248,7 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
/*******************************************************************************
|
||||
** fired when save or delete button saved on confirmation dialogs
|
||||
*******************************************************************************/
|
||||
async function handleDialogButtonOnClick()
|
||||
async function handleFilterDialogButtonOnClick()
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -199,12 +256,13 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formData = new FormData();
|
||||
if (isDelete)
|
||||
if (isDeleteFilter)
|
||||
{
|
||||
formData.append("id", currentSavedView.values.get("id"));
|
||||
await useSavedViewsResult.makeSavedViewRequest("deleteSavedView", formData);
|
||||
await makeSavedViewRequest("deleteSavedView", formData);
|
||||
|
||||
setSavePopupOpen(false);
|
||||
setSaveFilterPopupOpen(false);
|
||||
setSaveOptionsOpen(false);
|
||||
|
||||
await (async () =>
|
||||
{
|
||||
@@ -231,10 +289,10 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
|
||||
formData.append("viewJson", JSON.stringify(viewObject));
|
||||
|
||||
if (isSaveAs || isRename || currentSavedView == null)
|
||||
if (isSaveFilterAs || isRenameFilter || currentSavedView == null)
|
||||
{
|
||||
formData.append("label", savedViewNameInputValue);
|
||||
if (currentSavedView != null && isRename)
|
||||
if (currentSavedView != null && isRenameFilter)
|
||||
{
|
||||
formData.append("id", currentSavedView.values.get("id"));
|
||||
}
|
||||
@@ -244,18 +302,20 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
formData.append("id", currentSavedView.values.get("id"));
|
||||
formData.append("label", currentSavedView?.values.get("label"));
|
||||
}
|
||||
const recordList = await useSavedViewsResult.makeSavedViewRequest("storeSavedView", formData);
|
||||
const recordList = await makeSavedViewRequest("storeSavedView", formData);
|
||||
await (async () =>
|
||||
{
|
||||
if (recordList && recordList.length > 0)
|
||||
{
|
||||
useSavedViewsResult.loadSavedViews();
|
||||
setSavedViewsHaveLoaded(false);
|
||||
loadSavedViews();
|
||||
handleSavedViewRecordOnClick(recordList[0]);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
setSavePopupOpen(false);
|
||||
setSaveFilterPopupOpen(false);
|
||||
setSaveOptionsOpen(false);
|
||||
}
|
||||
catch (e: any)
|
||||
{
|
||||
@@ -279,10 +339,33 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** hides/shows the save options
|
||||
*******************************************************************************/
|
||||
const handleToggleSaveOptions = () =>
|
||||
{
|
||||
setSaveOptionsOpen((prevOpen) => !prevOpen);
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** closes save options menu (on clickaway)
|
||||
*******************************************************************************/
|
||||
const handleSaveOptionsMenuClose = (event: Event) =>
|
||||
{
|
||||
if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveOptionsOpen(false);
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** stores the current dialog input text to state
|
||||
*******************************************************************************/
|
||||
const handleSaveDialogInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
|
||||
const handleSaveFilterInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
|
||||
{
|
||||
setSavedViewNameInputValue(event.target.value);
|
||||
};
|
||||
@@ -291,11 +374,54 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
/*******************************************************************************
|
||||
** closes current dialog
|
||||
*******************************************************************************/
|
||||
const handleSavePopupClose = () =>
|
||||
const handleSaveFilterPopupClose = () =>
|
||||
{
|
||||
setSavePopupOpen(false);
|
||||
setSaveFilterPopupOpen(false);
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** make a request to the backend for various savedView processes
|
||||
*******************************************************************************/
|
||||
async function makeSavedViewRequest(processName: string, formData: FormData): Promise<QRecord[]>
|
||||
{
|
||||
/////////////////////////
|
||||
// fetch saved filters //
|
||||
/////////////////////////
|
||||
let savedViews = [] as QRecord[];
|
||||
try
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// we don't want this job to go async, so, pass a large timeout //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000);
|
||||
const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders());
|
||||
if (processResult instanceof QJobError)
|
||||
{
|
||||
const jobError = processResult as QJobError;
|
||||
throw (jobError.error);
|
||||
}
|
||||
else
|
||||
{
|
||||
const result = processResult as QJobComplete;
|
||||
if (result.values.savedViewList)
|
||||
{
|
||||
for (let i = 0; i < result.values.savedViewList.length; i++)
|
||||
{
|
||||
const qRecord = new QRecord(result.values.savedViewList[i]);
|
||||
savedViews.push(qRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
|
||||
return (savedViews);
|
||||
}
|
||||
|
||||
const hasStorePermission = metaData?.processes.has("storeSavedView");
|
||||
const hasDeletePermission = metaData?.processes.has("deleteSavedView");
|
||||
const hasQueryPermission = metaData?.processes.has("querySavedView");
|
||||
@@ -439,33 +565,23 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
);
|
||||
|
||||
let buttonText = "Views";
|
||||
let buttonBackground = "unset";
|
||||
let buttonBorder = colors.grayLines.main + " !important";
|
||||
let buttonBackground = "none";
|
||||
let buttonBorder = colors.grayLines.main;
|
||||
let buttonColor = colors.gray.main;
|
||||
let buttonVariant: "outlined" | "contained" | "text" = "outlined";
|
||||
let buttonColorName: "secondary" | "primary" = "secondary";
|
||||
let buttonState: string = "empty";
|
||||
|
||||
const savedViewId = currentSavedView?.values.get("id");
|
||||
if (currentSavedView && ([...yourSavedViews, ...viewsSharedWithYou]).map(view => view.values.get("id")).includes(savedViewId))
|
||||
if (currentSavedView)
|
||||
{
|
||||
if (viewIsModified)
|
||||
{
|
||||
buttonBackground = accentColorLight + " !important";
|
||||
buttonBorder = buttonBackground + " !important";
|
||||
buttonColor = accentColor + " !important";
|
||||
buttonVariant = "contained";
|
||||
buttonColorName = "primary";
|
||||
buttonState = "dirty";
|
||||
buttonBackground = accentColorLight;
|
||||
buttonBorder = buttonBackground;
|
||||
buttonColor = accentColor;
|
||||
}
|
||||
else
|
||||
{
|
||||
buttonBackground = accentColor + " !important";
|
||||
buttonBorder = buttonBackground + " !important";
|
||||
buttonColor = "#FFFFFF" + " !important";
|
||||
buttonVariant = "contained";
|
||||
buttonColorName = "primary";
|
||||
buttonState = "clean";
|
||||
buttonBackground = accentColor;
|
||||
buttonBorder = buttonBackground;
|
||||
buttonColor = "#FFFFFF";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +589,6 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
border: `1px solid ${buttonBorder}`,
|
||||
backgroundColor: buttonBackground,
|
||||
color: buttonColor,
|
||||
boxShadow: "none",
|
||||
"&:focus:not(:hover)": {
|
||||
color: buttonColor,
|
||||
backgroundColor: buttonBackground,
|
||||
@@ -481,8 +596,6 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
"&:hover": {
|
||||
color: buttonColor,
|
||||
backgroundColor: buttonBackground,
|
||||
boxShadow: "none",
|
||||
filter: "unset"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -498,7 +611,7 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
|
||||
const haveInputText = (savedViewNameInputValue != null && savedViewNameInputValue.trim() != "");
|
||||
|
||||
if (isSaveAs || isRename || currentSavedView == null)
|
||||
if (isSaveFilterAs || isRenameFilter || currentSavedView == null)
|
||||
{
|
||||
if (!haveInputText)
|
||||
{
|
||||
@@ -522,9 +635,6 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
<>
|
||||
<Box order="1" mr={"0.5rem"}>
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
color={buttonColorName}
|
||||
data-button-state={buttonState}
|
||||
onClick={openSavedViewsMenu}
|
||||
data-qqq-id="button-views"
|
||||
sx={{
|
||||
@@ -624,8 +734,8 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
</Box>
|
||||
{
|
||||
<Dialog
|
||||
open={savePopupOpen}
|
||||
onClose={handleSavePopupClose}
|
||||
open={saveFilterPopupOpen}
|
||||
onClose={handleSaveFilterPopupClose}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
onKeyPress={(e) =>
|
||||
@@ -634,21 +744,21 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
// make user actually hit delete button //
|
||||
// but for other modes, let Enter submit the form //
|
||||
////////////////////////////////////////////////////
|
||||
if (e.key == "Enter" && !isDelete)
|
||||
if (e.key == "Enter" && !isDeleteFilter)
|
||||
{
|
||||
handleDialogButtonOnClick();
|
||||
handleFilterDialogButtonOnClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{
|
||||
currentSavedView ? (
|
||||
isDelete ? (
|
||||
isDeleteFilter ? (
|
||||
<DialogTitle id="alert-dialog-title">Delete View</DialogTitle>
|
||||
) : (
|
||||
isSaveAs ? (
|
||||
isSaveFilterAs ? (
|
||||
<DialogTitle id="alert-dialog-title">Save View As</DialogTitle>
|
||||
) : (
|
||||
isRename ? (
|
||||
isRenameFilter ? (
|
||||
<DialogTitle id="alert-dialog-title">Rename View</DialogTitle>
|
||||
) : (
|
||||
<DialogTitle id="alert-dialog-title">Update Existing View</DialogTitle>
|
||||
@@ -666,10 +776,10 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
</Box>
|
||||
) : ("")}
|
||||
{
|
||||
(!currentSavedView || isSaveAs || isRename) && !isDelete ? (
|
||||
(!currentSavedView || isSaveFilterAs || isRenameFilter) && !isDeleteFilter ? (
|
||||
<Box>
|
||||
{
|
||||
isSaveAs ? (
|
||||
isSaveFilterAs ? (
|
||||
<Box mb={3}>Enter a name for this new saved view.</Box>
|
||||
) : (
|
||||
<Box mb={3}>Enter a new name for this saved view.</Box>
|
||||
@@ -682,7 +792,7 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
inputProps={{width: "100%", maxLength: 100}}
|
||||
value={savedViewNameInputValue}
|
||||
sx={{width: "100%"}}
|
||||
onChange={handleSaveDialogInputChange}
|
||||
onChange={handleSaveFilterInputChange}
|
||||
onFocus={event =>
|
||||
{
|
||||
event.target.select();
|
||||
@@ -690,7 +800,7 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
isDelete ? (
|
||||
isDeleteFilter ? (
|
||||
<Box>Are you sure you want to delete the view {`'${currentSavedView?.values.get("label")}'`}?</Box>
|
||||
) : (
|
||||
<Box>Are you sure you want to update the view {`'${currentSavedView?.values.get("label")}'`}?</Box>
|
||||
@@ -699,12 +809,12 @@ function SavedViews({useSavedViewsResult, metaData, tableMetaData, currentSavedV
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<QCancelButton onClickHandler={handleSavePopupClose} disabled={false} />
|
||||
<QCancelButton onClickHandler={handleSaveFilterPopupClose} disabled={false} />
|
||||
{
|
||||
isDelete ?
|
||||
<QDeleteButton onClickHandler={handleDialogButtonOnClick} disabled={isSubmitting} />
|
||||
isDeleteFilter ?
|
||||
<QDeleteButton onClickHandler={handleFilterDialogButtonOnClick} disabled={isSubmitting} />
|
||||
:
|
||||
<QSaveButton label="Save" onClickHandler={handleDialogButtonOnClick} disabled={isSaveButtonDisabled()} />
|
||||
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={isSaveButtonDisabled()} />
|
||||
}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -29,7 +29,6 @@ import Icon from "@mui/material/Icon";
|
||||
import RadioGroup from "@mui/material/RadioGroup";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {useFormikContext} from "formik";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import {BulkLoadField, FileDescription} from "qqq/models/processes/BulkLoadModels";
|
||||
|
||||
@@ -33,7 +33,6 @@ import Grid from "@mui/material/Grid";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
import {useFormikContext} from "formik";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm";
|
||||
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
|
||||
@@ -25,7 +25,6 @@ import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTabl
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import Box from "@mui/material/Box";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
|
||||
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
|
||||
import {BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
|
||||
|
||||
@@ -23,10 +23,9 @@ import Box from "@mui/material/Box";
|
||||
import Card from "@mui/material/Card";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import {preferredColorNameInfoOrPrimary} from "qqq/assets/theme/functions/preferInfoColorToPrimaryColor";
|
||||
import {ReactNode} from "react";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import {sanitizeId} from "qqq/utils/qqqIdUtils";
|
||||
|
||||
interface Props
|
||||
{
|
||||
@@ -40,6 +39,7 @@ interface Props
|
||||
};
|
||||
icon: ReactNode;
|
||||
isDisabled?: boolean;
|
||||
qqqId?: string;
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -48,13 +48,11 @@ function ProcessLinkCard({
|
||||
color, isReport, title, percentage, icon, isDisabled, qqqId
|
||||
}: Props): JSX.Element
|
||||
{
|
||||
if(!color)
|
||||
{
|
||||
color = preferredColorNameInfoOrPrimary();
|
||||
}
|
||||
const cardType = isReport ? "report-card" : "process-card";
|
||||
const generatedId = qqqId ? `${cardType}-${sanitizeId(qqqId)}` : `${cardType}-${sanitizeId(title)}`;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card data-qqq-id={generatedId}>
|
||||
<Box display="flex" justifyContent="space-between" pt={3} px={2} title={isDisabled ? `You do not have permission to access this ${isReport ? "report" : "process"}` : ""}>
|
||||
<Box
|
||||
color={color === "light" ? "#000000" : "#FFFFFF"}
|
||||
@@ -69,9 +67,9 @@ function ProcessLinkCard({
|
||||
borderRadius: "10px",
|
||||
backgroundColor: isDisabled
|
||||
? "var(--qqq-secondary-color, #7b809a)"
|
||||
: `var(--qqq-${color}-color, #0062FF)`
|
||||
: "var(--qqq-info-color, #0062FF)"
|
||||
}}
|
||||
data-qqq-id={`${qqqId}-icon`}
|
||||
data-qqq-id={`${generatedId}-icon`}
|
||||
>
|
||||
<Icon fontSize="medium" color="inherit">
|
||||
{icon}
|
||||
@@ -113,6 +111,7 @@ function ProcessLinkCard({
|
||||
}
|
||||
|
||||
ProcessLinkCard.defaultProps = {
|
||||
color: "info",
|
||||
isReport: false,
|
||||
percentage: {
|
||||
color: "success",
|
||||
@@ -120,6 +119,7 @@ ProcessLinkCard.defaultProps = {
|
||||
label: "",
|
||||
},
|
||||
isDisabled: false,
|
||||
qqqId: undefined,
|
||||
};
|
||||
|
||||
export default ProcessLinkCard;
|
||||
@@ -84,10 +84,10 @@ function ProcessSummaryResults({
|
||||
</List>
|
||||
);
|
||||
|
||||
let headerColor = `var(--qqq-success-color, ${colors.success.main})`;
|
||||
let headerColor = "var(--qqq-success-color)";
|
||||
if(processValues.status == "ERROR")
|
||||
{
|
||||
headerColor = `var(--qqq-error-color, ${colors.error.main})`;
|
||||
headerColor = "var(--qqq-error-color)";
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -269,7 +269,7 @@ function ValidationReview({
|
||||
|
||||
const recordPreviewWidget = step.recordListFields && (
|
||||
<Box border="1px solid var(--qqq-border-color, rgb(70%, 70%, 70%))" borderRadius="10px" p={2} mt={2}>
|
||||
<Box mx={2} mt={-5} p={1} sx={{width: "fit-content", borderColor: "var(--qqq-border-color, rgb(70%, 70%, 70%))", borderWidth: "2px", borderStyle: "solid", borderRadius: ".25em", backgroundColor: "var(--qqq-surface-color, #FFFFFF)"}} width="initial" color="var(--qqq-text-primary, #344767)">
|
||||
<Box mx={2} mt={-5} p={1} sx={{width: "fit-content", borderColor: "var(--qqq-border-color, rgb(70%, 70%, 70%))", borderWidth: "2px", borderStyle: "solid", borderRadius: ".25em", backgroundColor: "var(--qqq-surface-color, #FFFFFF)"}} width="initial" color="var(--qqq-text-primary)">
|
||||
<Typography>Preview</Typography>
|
||||
</Box>
|
||||
<Box p={3} pb={0}>
|
||||
|
||||
@@ -34,7 +34,6 @@ import {SelectChangeEvent} from "@mui/material/Select/Select";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {preferredColorNameInfoOrPrimary} from "qqq/assets/theme/functions/preferInfoColorToPrimaryColor";
|
||||
import React, {ReactNode, useState} from "react";
|
||||
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
|
||||
@@ -206,7 +205,7 @@ function AdvancedDateTimeFilterValues({type, expression, onSave, forcedOpen}: Pr
|
||||
return (
|
||||
<Box>
|
||||
<Tooltip title={`Define a custom ${type == QFieldType.DATE ? "date" : "date-time"} condition`}>
|
||||
<Icon onClick={openDialog} fontSize="small" color={preferredColorNameInfoOrPrimary()} sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}}>settings</Icon>
|
||||
<Icon onClick={openDialog} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}}>settings</Icon>
|
||||
</Tooltip>
|
||||
{
|
||||
isOpen &&
|
||||
|
||||
@@ -24,7 +24,6 @@ import {FilterVariableExpression} from "@qrunio/qqq-frontend-core/lib/model/quer
|
||||
import Box from "@mui/material/Box";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {preferredColorNameInfoOrPrimary} from "qqq/assets/theme/functions/preferInfoColorToPrimaryColor";
|
||||
import CriteriaDateField from "qqq/components/query/CriteriaDateField";
|
||||
import React, {SyntheticEvent, useState} from "react";
|
||||
|
||||
@@ -59,7 +58,7 @@ export default function AssignFilterVariable({valueIndex, field, valueChangeHand
|
||||
return <Box display="flex" alignItems="flex-end">
|
||||
<Box>
|
||||
<Tooltip title={`Use a variable as the value for the ${field.label} field`} placement="bottom">
|
||||
<Icon fontSize="small" color={preferredColorNameInfoOrPrimary()} sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={handleVariableButtonOnClick}>functions</Icon>
|
||||
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={handleVariableButtonOnClick}>functions</Icon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>;
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
*/
|
||||
|
||||
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QTableSection} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableSection";
|
||||
import {QCriteriaOperator} from "@qrunio/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
||||
import {QFilterCriteria} from "@qrunio/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||
import {QFilterOrderBy} from "@qrunio/qqq-frontend-core/lib/model/query/QFilterOrderBy";
|
||||
import {QQueryFilter} from "@qrunio/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
@@ -32,18 +41,6 @@ import ToggleButton from "@mui/material/ToggleButton";
|
||||
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro";
|
||||
import {ApiVersion} from "@qrunio/qqq-frontend-core/lib/controllers/QControllerV1";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QTableSection} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableSection";
|
||||
import {QTableVariant} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableVariant";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {QCriteriaOperator} from "@qrunio/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
||||
import {QFilterCriteria} from "@qrunio/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||
import {QFilterOrderBy} from "@qrunio/qqq-frontend-core/lib/model/query/QFilterOrderBy";
|
||||
import {QQueryFilter} from "@qrunio/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
@@ -52,29 +49,26 @@ import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
|
||||
import FieldListMenu from "qqq/components/query/FieldListMenu";
|
||||
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
|
||||
import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter";
|
||||
import QuickSavedViews from "qqq/components/query/QuickSavedViews";
|
||||
import {UseSavedViewsResult} from "qqq/components/query/useSavedViews";
|
||||
import XIcon from "qqq/components/query/XIcon";
|
||||
import RecordQueryView from "qqq/models/query/RecordQueryView";
|
||||
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
|
||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react";
|
||||
|
||||
const borderGray = colors.grayLines.main;
|
||||
|
||||
interface BasicAndAdvancedQueryControlsProps
|
||||
{
|
||||
metaData: QInstance;
|
||||
tableMetaData: QTableMetaData;
|
||||
apiVersion: ApiVersion;
|
||||
tableVariant?: QTableVariant;
|
||||
|
||||
savedViewsComponent: JSX.Element;
|
||||
columnMenuComponent: JSX.Element;
|
||||
|
||||
quickFilterFieldNames: string[];
|
||||
setQuickFilterFieldNames: (names: string[]) => void;
|
||||
|
||||
queryFilter: QQueryFilter;
|
||||
setQueryFilter: (queryFilter: QQueryFilter) => void;
|
||||
|
||||
gridApiRef: React.MutableRefObject<GridApiPro>;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -84,19 +78,18 @@ interface BasicAndAdvancedQueryControlsProps
|
||||
queryFilterJSON: string;
|
||||
|
||||
queryScreenUsage: QueryScreenUsage;
|
||||
|
||||
allowVariables?: boolean;
|
||||
|
||||
mode: string;
|
||||
setMode: (mode: string) => void;
|
||||
omitExposedJoins?: string[];
|
||||
|
||||
useSavedViewsResult?: UseSavedViewsResult;
|
||||
viewOnChangeCallback?: (selectedSavedViewId: number) => Promise<void>;
|
||||
currentSavedView: QRecord;
|
||||
activeView?: RecordQueryView;
|
||||
omitExposedJoins?: string[];
|
||||
}
|
||||
|
||||
let debounceTimeout: string | number | NodeJS.Timeout;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** function to generate an element that says how a filter is sorted.
|
||||
*******************************************************************************/
|
||||
@@ -125,7 +118,7 @@ export function getCurrentSortIndicator(queryFilter: QQueryFilter, tableMetaData
|
||||
*******************************************************************************/
|
||||
const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) =>
|
||||
{
|
||||
const {metaData, tableMetaData, apiVersion, tableVariant, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode, queryScreenUsage, useSavedViewsResult, viewOnChangeCallback, currentSavedView, activeView} = props;
|
||||
const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode, queryScreenUsage} = props;
|
||||
|
||||
/////////////////////
|
||||
// state variables //
|
||||
@@ -624,6 +617,8 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
</>;
|
||||
}
|
||||
|
||||
const borderGray = colors.grayLines.main;
|
||||
|
||||
const sortMenuComponent = (
|
||||
<FieldListMenu
|
||||
idPrefix="sort"
|
||||
@@ -636,7 +631,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
fieldEndAdornment={<Box whiteSpace="nowrap"><Icon>arrow_upward</Icon><Icon>arrow_downward</Icon></Box>}
|
||||
handleAdornmentClick={handleSetSortArrowClick}
|
||||
omitExposedJoins={props.omitExposedJoins}
|
||||
includeVirtualFields="queryCriteria"
|
||||
/>);
|
||||
|
||||
const filterBuilderMouseEvents =
|
||||
@@ -648,9 +642,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
return (
|
||||
<Box pb={mode == "advanced" ? "0.25rem" : "0"}>
|
||||
|
||||
{ /* Zeroth row: Quick Views */}
|
||||
<QuickSavedViews metaData={metaData} tableMetaData={tableMetaData} apiVersion={apiVersion} queryScreenUsage={queryScreenUsage} currentSavedView={currentSavedView} tableVariant={tableVariant} viewOnChangeCallback={viewOnChangeCallback} activeView={activeView} useSavedViewsResult={useSavedViewsResult} />
|
||||
|
||||
{/* First row: Saved Views button (with Columns button in the middle of it), then space-between, then basic|advanced toggle */}
|
||||
<Box display="flex" justifyContent="space-between" pt={"0.5rem"} pb={"0.5rem"}>
|
||||
<Box display="flex">
|
||||
@@ -693,7 +684,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
key={fieldName}
|
||||
allowVariables={props.allowVariables}
|
||||
fullFieldName={fieldName}
|
||||
metaData={metaData}
|
||||
tableMetaData={tableMetaData}
|
||||
updateCriteria={updateQuickCriteria}
|
||||
criteriaParam={getQuickCriteriaParam(fieldName)}
|
||||
@@ -714,7 +704,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
return (defaultQuickFilterFieldNameMap[fieldName] ? null : <QuickFilter
|
||||
key={fieldName}
|
||||
fullFieldName={fieldName}
|
||||
metaData={metaData}
|
||||
tableMetaData={tableMetaData}
|
||||
updateCriteria={updateQuickCriteria}
|
||||
criteriaParam={getQuickCriteriaParam(fieldName)}
|
||||
@@ -737,7 +726,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
isModeSelectOne={true}
|
||||
handleSelectedField={handleFieldListMenuSelection}
|
||||
omitExposedJoins={props.omitExposedJoins}
|
||||
includeVirtualFields="queryCriteria"
|
||||
/>
|
||||
}
|
||||
</>
|
||||
|
||||
@@ -40,7 +40,6 @@ import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
|
||||
import {EvaluatedExpression} from "qqq/components/query/EvaluatedExpression";
|
||||
import {makeTextField} from "qqq/components/query/FilterCriteriaRowValues";
|
||||
import React, {SyntheticEvent, useReducer, useState} from "react";
|
||||
import {preferredColorNameInfoOrPrimary} from "qqq/assets/theme/functions/preferInfoColorToPrimaryColor";
|
||||
|
||||
|
||||
export type Expression = NowWithOffsetExpression | ThisOrLastPeriodExpression | NowExpression | FilterVariableExpression;
|
||||
@@ -241,7 +240,7 @@ export default function CriteriaDateField({valueIndex, label, idPrefix, field, c
|
||||
(!isExpression || currentExpression?.type != "FilterVariableExpression") && (
|
||||
<><Box>
|
||||
<Tooltip title={`Choose a common relative ${field.type == QFieldType.DATE ? "date" : "date-time"} expression`} placement="bottom">
|
||||
<Icon fontSize="small" color={preferredColorNameInfoOrPrimary()} sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={openRelativeDateTimeMenu}>date_range</Icon>
|
||||
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={openRelativeDateTimeMenu}>date_range</Icon>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
open={relativeDateTimeOpen}
|
||||
|
||||
@@ -33,7 +33,6 @@ import TextField from "@mui/material/TextField";
|
||||
import {GridColDef, GridSlotsComponentsProps, useGridApiContext, useGridSelector} from "@mui/x-data-grid-pro";
|
||||
import {GridColumnsPanelProps} from "@mui/x-data-grid/components/panel/GridColumnsPanel";
|
||||
import {gridColumnDefinitionsSelector, gridColumnVisibilityModelSelector} from "@mui/x-data-grid/hooks/features/columns/gridColumnsSelector";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import React, {createRef, forwardRef, useEffect, useReducer, useRef, useState} from "react";
|
||||
|
||||
declare module "@mui/x-data-grid"
|
||||
@@ -85,8 +84,9 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
|
||||
|
||||
if (props.tableMetaData.exposedJoins)
|
||||
{
|
||||
for (const exposedJoin of TableUtils.getReadableExposedJoins(props.tableMetaData, props.metaData))
|
||||
for (let i = 0; i < props.tableMetaData.exposedJoins.length; i++)
|
||||
{
|
||||
const exposedJoin = props.tableMetaData.exposedJoins[i];
|
||||
if (props.metaData.tables.has(exposedJoin.joinTable.name))
|
||||
{
|
||||
tables.push(exposedJoin.joinTable);
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2024. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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 {expect, it, jest} from "@jest/globals";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import React from "react";
|
||||
import {createRoot} from "react-dom/client";
|
||||
import {act} from "react-dom/test-utils";
|
||||
import FieldListMenu from "./FieldListMenu";
|
||||
|
||||
/*******************************************************************************
|
||||
** Revoked and restored query choices must follow current metadata without
|
||||
** changing the relationship descriptor used by authorized creation controls.
|
||||
*******************************************************************************/
|
||||
it("updates joined field choices when omitted paths change", () =>
|
||||
{
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
jest.useFakeTimers();
|
||||
const scrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
const table = new QTableMetaData({name: "person", label: "Person", fields: {id: {name: "id", label: "Person ID", type: "INTEGER"}}, exposedJoins: [
|
||||
{label: "Pets", isMany: true, joinPath: [], joinTable: {name: "pet", label: "Pet", readPermission: false, insertPermission: true,
|
||||
fields: {name: {name: "name", label: "Pet name", type: "STRING"}}}}
|
||||
]});
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const selected = jest.fn();
|
||||
const render = (omit: string[]) => act(() => root.render(<FieldListMenu idPrefix="live-fields" tableMetaData={table}
|
||||
buttonProps={{}} buttonChildren="Choose fields" isModeSelectOne={true} handleSelectedField={selected} omitExposedJoins={omit} />));
|
||||
try
|
||||
{
|
||||
render([]);
|
||||
expect(document.querySelector(".fieldListMenuBody-live-fields").textContent).toContain("Pet name");
|
||||
act(() => (document.querySelector("[data-qqq-id=\"button-live-fields\"]") as HTMLElement).click());
|
||||
act(() => jest.runOnlyPendingTimers());
|
||||
act(() =>
|
||||
{
|
||||
document.querySelector("input").dispatchEvent(new KeyboardEvent("keydown", {key: "End", bubbles: true}));
|
||||
});
|
||||
act(() => jest.runOnlyPendingTimers());
|
||||
render(["pet"]);
|
||||
act(() =>
|
||||
{
|
||||
document.querySelector("input").dispatchEvent(new KeyboardEvent("keydown", {key: "Enter", bubbles: true}));
|
||||
});
|
||||
act(() => jest.runOnlyPendingTimers());
|
||||
expect(selected).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".fieldListMenuBody-live-fields").textContent).not.toContain("Pet name");
|
||||
expect(document.querySelector(".fieldListMenuBody-live-fields").textContent).toContain("Person ID");
|
||||
render([]);
|
||||
expect(document.querySelector(".fieldListMenuBody-live-fields").textContent).toContain("Pet name");
|
||||
expect(table.exposedJoins).toHaveLength(1);
|
||||
expect(table.exposedJoins[0].joinTable.insertPermission).toBe(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
jest.useRealTimers();
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
delete (globalThis as any).IS_REACT_ACT_ENVIRONMENT;
|
||||
}
|
||||
});
|
||||
@@ -32,7 +32,6 @@ import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {QVirtualFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QVirtualFieldMetaData";
|
||||
import {sanitizeId} from "qqq/utils/qqqIdUtils";
|
||||
import React, {useMemo, useState} from "react";
|
||||
|
||||
@@ -53,15 +52,13 @@ interface FieldListMenuProps
|
||||
handleToggleField?: (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => void,
|
||||
fieldEndAdornment?: JSX.Element,
|
||||
handleAdornmentClick?: (field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent<any>) => void,
|
||||
omitExposedJoins?: string[],
|
||||
includeVirtualFields: "all" | "none" | "querySelectable" | "queryCriteria"
|
||||
omitExposedJoins?: string[]
|
||||
}
|
||||
|
||||
FieldListMenu.defaultProps = {
|
||||
showTableHeaderEvenIfNoExposedJoins: false,
|
||||
isModeSelectOne: false,
|
||||
isModeToggle: false,
|
||||
includeVirtualFields: "none"
|
||||
};
|
||||
|
||||
interface TableWithFields
|
||||
@@ -74,12 +71,13 @@ interface TableWithFields
|
||||
** Component to render a list of fields from a table (and its join tables)
|
||||
** which can be interacted with...
|
||||
*******************************************************************************/
|
||||
export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick, omitExposedJoins, includeVirtualFields}: FieldListMenuProps): JSX.Element
|
||||
export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick, omitExposedJoins}: FieldListMenuProps): JSX.Element
|
||||
{
|
||||
const [menuAnchorElement, setMenuAnchorElement] = useState(null);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [focusedIndex, setFocusedIndex] = useState(null as number);
|
||||
|
||||
const [fieldsByTable, setFieldsByTable] = useState([] as TableWithFields[]);
|
||||
const [collapsedTables, setCollapsedTables] = useState({} as { [tableName: string]: boolean });
|
||||
|
||||
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
|
||||
@@ -122,13 +120,39 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Derive fields from current options; cached groups retain joins after access changes. //
|
||||
// Keep only user-controlled collapsed state, which is independent of available fields. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
const fieldsByTable: TableWithFields[] = availableExposedJoins.length > 0
|
||||
? [tableMetaData, ...availableExposedJoins.map(join => join.joinTable)].map(table => ({table, fields: getTableFieldsAsAlphabeticalArray(table)}))
|
||||
: [{fields: getTableFieldsAsAlphabeticalArray(tableMetaData)}];
|
||||
/////////////////////
|
||||
// init some stuff //
|
||||
/////////////////////
|
||||
if (fieldsByTable.length == 0)
|
||||
{
|
||||
collapsedTables[tableMetaData.name] = false;
|
||||
|
||||
if (availableExposedJoins?.length > 0)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
fieldsByTable.push({table: tableMetaData, fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
|
||||
|
||||
for (let i = 0; i < availableExposedJoins?.length; i++)
|
||||
{
|
||||
const joinTable = availableExposedJoins[i].joinTable;
|
||||
fieldsByTable.push({table: joinTable, fields: getTableFieldsAsAlphabeticalArray(joinTable)});
|
||||
|
||||
collapsedTables[joinTable.name] = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// no exposed joins - just the table (w/o its meta-data) //
|
||||
///////////////////////////////////////////////////////////
|
||||
fieldsByTable.push({fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
|
||||
}
|
||||
|
||||
setFieldsByTable(fieldsByTable);
|
||||
setCollapsedTables(collapsedTables);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -137,9 +161,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
function getTableFieldsAsAlphabeticalArray(table: QTableMetaData): QFieldMetaData[]
|
||||
{
|
||||
const fields: QFieldMetaData[] = [];
|
||||
const seenFields: Record<string, true> = {};
|
||||
|
||||
const fieldPusher = (field: QFieldMetaData) =>
|
||||
table.fields.forEach(field =>
|
||||
{
|
||||
let fullFieldName = field.name;
|
||||
if (table.name != tableMetaData.name)
|
||||
@@ -147,39 +169,12 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
fullFieldName = `${table.name}.${field.name}`;
|
||||
}
|
||||
|
||||
if(seenFields[fullFieldName])
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if(field instanceof QVirtualFieldMetaData)
|
||||
{
|
||||
if(includeVirtualFields == "none")
|
||||
{
|
||||
return;
|
||||
}
|
||||
else if (includeVirtualFields == "queryCriteria" && !field.isQueryCriteria)
|
||||
{
|
||||
return;
|
||||
}
|
||||
else if (includeVirtualFields == "querySelectable" && !field.isQuerySelectable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldNamesToHide && fieldNamesToHide.indexOf(fullFieldName) > -1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
seenFields[fullFieldName] = true;
|
||||
fields.push(field);
|
||||
}
|
||||
|
||||
table.fields.forEach(field => fieldPusher(field));
|
||||
table.virtualFields?.forEach(field => fieldPusher(field));
|
||||
|
||||
});
|
||||
fields.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return (fields);
|
||||
}
|
||||
@@ -236,7 +231,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
const {field, table} = getShownFieldAndTableByIndex(focusedIndex) ?? {};
|
||||
const {field, table} = getShownFieldAndTableByIndex(focusedIndex);
|
||||
if (field)
|
||||
{
|
||||
handleSelectedField(field, table ?? tableMetaData);
|
||||
@@ -488,41 +483,14 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the list of fields (and allowed virtual fields) for a given table.
|
||||
***************************************************************************/
|
||||
function getFieldsList(table: QTableMetaData): QFieldMetaData[]
|
||||
{
|
||||
const fieldsList = [...table.fields.values()];
|
||||
if (table.virtualFields && includeVirtualFields != "none")
|
||||
{
|
||||
for (let virtualField of table.virtualFields.values())
|
||||
{
|
||||
if (includeVirtualFields == "querySelectable" && virtualField.isQuerySelectable)
|
||||
{
|
||||
fieldsList.push(virtualField);
|
||||
}
|
||||
else if (includeVirtualFields == "queryCriteria" && virtualField.isQueryCriteria)
|
||||
{
|
||||
fieldsList.push(virtualField);
|
||||
}
|
||||
else if (includeVirtualFields == "all")
|
||||
{
|
||||
fieldsList.push(virtualField);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fieldsList;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Event handler for toggling a table in toggle mode
|
||||
*******************************************************************************/
|
||||
function handleTableToggle(event: React.ChangeEvent<HTMLInputElement>, table: QTableMetaData)
|
||||
{
|
||||
event.stopPropagation();
|
||||
const fieldsList = getFieldsList(table);
|
||||
|
||||
const fieldsList = [...table.fields.values()];
|
||||
for (let i = 0; i < fieldsList.length; i++)
|
||||
{
|
||||
const field = fieldsList[i];
|
||||
@@ -561,8 +529,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
*******************************************************************************/
|
||||
function getTableToggleState(table: QTableMetaData, isMainTable: boolean): { allOn: boolean, count: number }
|
||||
{
|
||||
const fieldsList = getFieldsList(table);
|
||||
|
||||
const fieldsList = [...table.fields.values()];
|
||||
let allOn = true;
|
||||
let count = 0;
|
||||
for (let i = 0; i < fieldsList.length; i++)
|
||||
@@ -638,7 +605,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
</Box>
|
||||
}
|
||||
<Box p={1} pt={0.5}>
|
||||
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}, autoComplete: "off"}} />
|
||||
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}}} />
|
||||
{
|
||||
searchText != "" && <IconButton sx={{position: "absolute", right: "0.5rem", top: "0.5rem"}} onClick={() =>
|
||||
{
|
||||
|
||||