Compare commits

...
Author SHA1 Message Date
james.maesandCodex 51f1bb1067 ci: avoid nonexistent Python source in main CodeQL scan
Preserve the reviewed one-file main CodeQL caller selecting Java/JavaScript/Actions present in the branch; scanner-only scope, no package publication or deployment.

Co-authored-by: Codex (GPT-6) <noreply@openai.com>
2026-10-30 16:02:21 -05:00
james.maesandCodex 0d40488808 ci: use central orb pipeline on main (#173)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 17:54:27 -05:00
2ae916a61b chore(release): publish Material 0.41.0 for QQQ 4.0 (#156)
* chore: bump version to 0.41.0-SNAPSHOT

* docs(README): note qqq-frontend-next as future direction

Adds a restrained banner pointing to qqq-frontend-next as the
planned successor (Next.js 15 + Tailwind 4 + shadcn/ui). This
repo remains supported until existing qqq apps complete
migration. Surface the direction without disrupting in-flight
work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add CODEOWNERS pointing to sole maintainer

Architecture audit pass 2026-05-21. Minimal CODEOWNERS file
making the implicit ownership explicit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: add CLAUDE.md pointer to QQQ knowledge-base dossier + hub

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(frontend): finalize QQQ 4 compatibility (#155)

Make the Material dashboard compatible with QQQ 4.0: migrate Java fixtures to Java 21/Javalin 7, preserve joined-read permissions, and fix disabled inputs and empty or denied widget rendering. Release CI also keeps package installation unattended.

Validation: 22 Java metadata tests, 48 JavaScript tests, typecheck/production build and the owned sample browser workflows pass. The tracked QQQ 4.0.0-RC.3 dependency is now public, with all 18 coordinates and 66 artifact signatures verified. Full feature CI1561 at a8d3c931 passes compilation, the production React build and all 121 Java/browser cases with zero failures, errors or skips. GitHub dependency submission and CodeQL also pass. The subsequent release/0.41.0 workflow publishes the dashboard RC for clean-cache first-party acceptance.

Refs QRun-IO/qqq#498, QRun-IO/qqq#537, QRun-IO/qqq#552, QRun-IO/qqq#558, QRun-IO/qqq#617. Deferred coverage and Medium/Low findings remain in QRun-IO/qqq#534.

* Bump version to 0.41.0-RC.1 [skip ci]

* chore(release): bind Material 0.41.0 to QQQ 4.0.0

Refs QRun-IO/qqq#631.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: James Maes <jmaes@dmdbrands.com>
Co-authored-by: CircleCI <ci@kingsrook.com>
2026-09-24 13:19:11 -05:00
james.maes 6039fcff42 Merge release/0.40.0 for v0.40.0 release 2026-03-29 17:58:05 -05:00
james.maes 47d3cb3fe1 chore: bump version to 0.40.0 2026-03-29 17:57:57 -05:00
CircleCI 149ff7ff79 Bump version to 0.40.0-RC.2 [skip ci] 2026-03-29 22:54:21 +00:00
james.maes 2a404ea3c8 chore: bump to 0.40.0-RC.1, pin qqq 0.40.0 2026-03-29 17:38:18 -05:00
james.maes d31db14dd6 chore: merge main into release branch (incorporate license + CHANGELOG) 2026-03-29 17:37:34 -05:00
james.maes 22a7866b7c fix(oauth2): prefer externalBaseUrl for OIDC authority (#419) 2026-03-27 15:07:02 -05:00
Tim Chamberlain 23ed6f4a87 chore: updated qqq-frontend-core version 2026-03-26 14:02:30 -05:00
Tim Chamberlain 3400004d66 Merge branch 'feature/analytics-updates' into develop 2026-03-25 14:15:00 -05:00
Tim Chamberlain 1b83a98a4b fix(analytics): preserve PostHog proxy asset path 2026-03-25 14:12:04 -05:00
Tim ChamberlainandDarin Kelkhoff 078955f529 feat: add analytics provider framework and PostHog support (#151)
* feat(analytics): add pluggable provider registry and posthog support

* updates to identification

* tweaks to posthog analytics provider

* fix: clean up analytics identity naming and harden PostHog host handling

* fix: add analyticsValues compatibility accessor

---------

Co-authored-by: Darin Kelkhoff <darin.kelkhoff@gmail.com>
2026-03-24 17:13:57 -05:00
Tim Chamberlain b75dd26808 fix: add analyticsValues compatibility accessor 2026-03-24 16:46:09 -05:00
Darin Kelkhoff 6dc44ee290 Merged develop into feature/analytics-updates 2026-03-24 15:02:43 -05:00
Darin Kelkhoff 193f848a2c Merge pull request #152 from QRun-IO/feature/additional-css-hooks
feat: add qqqId props to process buttons and CSS class hooks to app home
2026-03-24 14:59:49 -05:00
Tim Chamberlain 7c33fe7773 fix: clean up analytics identity naming and harden PostHog host handling 2026-03-24 12:58:22 -05:00
Darin KelkhoffandClaude Opus 4.6 1445634a86 feat: add qqqId props to process buttons and CSS class hooks to app home
Add qqqId prop to QCancelButton, QSubmitButton, and QAlternateButton
for test selector support via data-qqq-id attributes. Wire contextual
IDs through ProcessRun (next/submit, close/cancel/return, back).

Add className hooks to Home.tsx for appHome containers and big-icon
permission-denied boxes. Remove unused colors import from ProcessRun.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:29:41 -05:00
Tim Chamberlain 442cc85a93 tweaks to posthog analytics provider 2026-03-16 13:58:51 -05:00
Tim Chamberlain 372e87ae96 updates to identification 2026-03-14 22:34:06 -05:00
Darin Kelkhoff 31e2bc712e Merged develop into feature/analytics-updates 2026-03-09 14:08:08 -05:00
Tim Chamberlain d109b9ca63 feat(analytics): add pluggable provider registry and posthog support 2026-03-06 17:22:00 -06:00
james.maes c557e116e3 chore: migrate license from AGPL-3.0 to Apache-2.0 2026-01-05 10:11:38 -06:00
james.maes 18ab409b09 docs: add full release history to CHANGELOG 2025-12-29 08:09:25 -06:00
james.maes a284985a9c Merge develop into main for 0.35.0 release 2025-12-29 08:05:37 -06:00
james.maes e9ceb2765c ci: Add GitHub release configuration 2025-12-24 12:43:28 -06:00
GitHub Action bd8f96d5ef chore: bump version for next release 2025-08-30 15:31:28 +00:00
james.maes 2407504bfe fix - correct bad file merge for pom 2025-08-30 10:29:49 -05:00
james.maes 88b9de82a4 merge - release 0.25.0 2025-08-30 10:25:08 -05:00
CircleCI 01e01d2f2b Bump version to 0.25.0 [skip ci] 2025-08-28 21:21:39 +00:00
51 changed files with 1591 additions and 383 deletions

No files matched your search

+14 -136
View File
@@ -1,141 +1,19 @@
---
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
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
orbs:
qqq-orb: kingsrook/qqq-orb@0.6
executors:
playwright:
docker:
- image: mcr.microsoft.com/playwright:v1.58.2-jammy
jobs:
playwright_tests:
executor: playwright
steps:
- checkout
- restore_cache:
keys:
- npm-deps-{{ checksum "package-lock.json" }}
- npm-deps-
- run:
name: Install dependencies
command: npm ci --legacy-peer-deps
- save_cache:
key: npm-deps-{{ checksum "package-lock.json" }}
paths:
- node_modules
- run:
name: Run Playwright visual regression tests
command: npx playwright test
- store_artifacts:
path: playwright-report/
destination: playwright-report
- store_test_results:
path: test-results/
workflows:
test_only:
jobs:
- qqq-orb/mvn_frontend_test_only:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|main|release/.*|hotfix/.*)/
tags:
only: []
test_only_with_playwright:
when: << pipeline.parameters.run_playwright >>
jobs:
- playwright_tests:
filters:
branches:
ignore: /(develop|main|release/.*|hotfix/.*)/
tags:
only: []
- qqq-orb/mvn_frontend_test_only:
context: [qqq-maven-registry-credentials]
requires:
- playwright_tests
filters:
branches:
ignore: /(develop|main|release/.*|hotfix/.*)/
tags:
only: []
publish_snapshot:
jobs:
- qqq-orb/mvn_frontend_publish:
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only:
- develop
publish_snapshot_with_playwright:
when: << pipeline.parameters.run_playwright >>
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
View File
@@ -0,0 +1 @@
* @KofTwentyTwo
+38
View File
@@ -0,0 +1,38 @@
# GitHub Release Configuration
# Auto-generates release notes from PR labels
changelog:
exclude:
labels:
- ignore-for-release
- duplicate
- invalid
authors:
- dependabot
- dependabot[bot]
categories:
- title: Breaking Changes
labels:
- breaking-change
- breaking
- title: New Features
labels:
- enhancement
- feature
- title: Bug Fixes
labels:
- bug
- fix
- title: Performance Improvements
labels:
- performance
- title: Documentation
labels:
- documentation
- docs
- title: Dependencies
labels:
- dependencies
- title: Other Changes
labels:
- "*"
+23
View File
@@ -0,0 +1,23 @@
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
+3
View File
@@ -0,0 +1,3 @@
profile: frontend-material
orb_version: '0.8'
default_branch: develop
+1
View File
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.35.0] - 2025-12-29
### Changed
+13
View File
@@ -320,3 +320,16 @@ npm run e2e:all
Release candidates publish automatically on `release/*` branches via CircleCI `publish_release_candidate` workflow.
Snapshots publish on `develop` via `publish_snapshot` workflow.
## 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.
+4 -2
View File
@@ -2,10 +2,12 @@
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](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
**Status:** Stable; standard dashboard for QQQ 4.0.
## Why This Exists
@@ -82,7 +84,7 @@ mvn clean package -Pci
## Project Status
**Maturity:** Stable, production use
**Breaking changes:** Follows QQQ versioning
**Versioning:** Independently versioned; Material `0.41.0` accompanies QQQ `4.0.0`.
## Contributing
+94
View File
@@ -0,0 +1,94 @@
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([]);
});
+36 -54
View File
@@ -1,12 +1,12 @@
{
"name": "qqq-frontend-material-dashboard",
"version": "0.40.0-SNAPSHOT",
"version": "0.41.0-SNAPSHOT",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qqq-frontend-material-dashboard",
"version": "0.40.0-SNAPSHOT",
"version": "0.41.0-SNAPSHOT",
"dependencies": {
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
@@ -19,7 +19,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.10-SNAPSHOT",
"@qrunio/qqq-frontend-core": "0.40.11-SNAPSHOT",
"@react-jvectormap/core": "1.0.1",
"@react-jvectormap/unitedstates": "1.0.1",
"@react-oauth/google": "0.2.8",
@@ -168,7 +168,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -178,7 +178,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
@@ -209,7 +209,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@babel/eslint-parser": {
@@ -274,7 +274,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
@@ -384,7 +384,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
@@ -493,7 +493,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -518,7 +518,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
@@ -3259,7 +3259,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -4049,9 +4049,9 @@
}
},
"node_modules/@qrunio/qqq-frontend-core": {
"version": "0.40.10-SNAPSHOT",
"resolved": "https://registry.npmjs.org/@qrunio/qqq-frontend-core/-/qqq-frontend-core-0.40.10-SNAPSHOT.tgz",
"integrity": "sha512-yAP5J4IFOtaM+FK67n3kwpvRujEepV2D533rhyKQx+UIDIhmY6Go6bR+Iyvm1u/TltTNXSl8uD+Z669QGOcc0A==",
"version": "0.40.11-SNAPSHOT",
"resolved": "https://registry.npmjs.org/@qrunio/qqq-frontend-core/-/qqq-frontend-core-0.40.11-SNAPSHOT.tgz",
"integrity": "sha512-rCRSLh5bYZ/G1VlBJC9CJRuG4fGkbFEKtqFMguUmV9VHRaYgdAJXWBAma9uGPDhzBOwElJLYV4S1ZkodobD/Eg==",
"license": "ISC",
"dependencies": {
"axios": "0.28.1",
@@ -5624,7 +5624,7 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@@ -5652,7 +5652,7 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -5719,7 +5719,7 @@
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@@ -5732,7 +5732,7 @@
"version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -5802,7 +5802,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-proxy": {
@@ -5874,7 +5874,7 @@
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
@@ -5925,7 +5925,7 @@
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/ramda": {
@@ -5941,7 +5941,7 @@
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
@@ -6049,7 +6049,7 @@
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
@@ -6070,7 +6070,7 @@
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@@ -7602,7 +7602,7 @@
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -7781,7 +7781,7 @@
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -7984,7 +7984,7 @@
"version": "1.0.30001777",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
"integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -9105,7 +9105,7 @@
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
@@ -9676,7 +9676,7 @@
"version": "1.5.307",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
"integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
"devOptional": true,
"dev": true,
"license": "ISC"
},
"node_modules/emittery": {
@@ -11663,7 +11663,7 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -14608,7 +14608,7 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -15067,7 +15067,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"devOptional": true,
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
@@ -16130,7 +16130,7 @@
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
@@ -19956,7 +19956,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"devOptional": true,
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -21871,7 +21871,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -22953,26 +22953,8 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"devOptional": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
"license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",
+9 -4
View File
@@ -1,6 +1,6 @@
{
"name": "qqq-frontend-material-dashboard",
"version": "0.40.0-SNAPSHOT",
"version": "0.41.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.10-SNAPSHOT",
"@qrunio/qqq-frontend-core": "0.40.11-SNAPSHOT",
"@react-jvectormap/core": "1.0.1",
"@react-jvectormap/unitedstates": "1.0.1",
"@react-oauth/google": "0.2.8",
@@ -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",
@@ -127,5 +127,10 @@
"files": [
"lib",
"package.json"
]
],
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(@qrunio/qqq-frontend-core|@mui|@babel/runtime)/)"
]
}
}
+8 -4
View File
@@ -1,5 +1,8 @@
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,
@@ -20,10 +23,11 @@ export default defineConfig({
},
},
use: {
baseURL: 'https://localhost:3001',
baseURL: `https://localhost:${dashboardPort}`,
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 },
},
@@ -42,13 +46,13 @@ export default defineConfig({
webServer: [
{
command: `THEME_FIXTURE=${process.env.THEME_FIXTURE || 'withFullCustomTheme'} node e2e/fixture-server.js`,
url: 'http://localhost:8001/metaData',
url: `http://localhost:${fixturePort}/metaData`,
reuseExistingServer: true,
timeout: 60000, // 1 minute - Docker startup can be slow
},
{
command: 'HTTPS=true PORT=3001 REACT_APP_PROXY_LOCALHOST_PORT=8001 npm start',
url: 'https://localhost:3001',
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,
+3 -3
View File
@@ -58,8 +58,8 @@
</developers>
<properties>
<revision>0.40.1-SNAPSHOT</revision>
<qqq.version>0.40.0-SNAPSHOT</qqq.version>
<revision>0.41.0</revision>
<qqq.version>4.0.0</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>6.7.0</version>
<version>7.2.3</version>
<scope>test</scope>
</dependency>
<dependency>
+12 -3
View File
@@ -55,7 +55,7 @@ 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 GoogleAnalyticsUtils, {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import AnalyticsUtils, {AnalyticsModel} from "qqq/utils/analytics/AnalyticsUtils";
import {injectIslandVariables} from "qqq/utils/injectIslandVariables";
import {detectBasePath, resolveAssetUrl} from "qqq/utils/PathUtils";
import Client from "qqq/utils/qqq/Client";
@@ -92,6 +92,7 @@ export default function App({authenticationMetaData}: Props)
const {setupSession: auth0SetupSession, logout: auth0Logout} = useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
const {setupSession: oauth2SetupSession, logout: oauth2Logout} = useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext: authenticationMetaData.type === "OAUTH2"});
const {setupSession: anonymousSetupSession, logout: anonymousLogout} = useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
const [analyticsUtils] = useState(new AnalyticsUtils());
/////////////////////////////////////////////////////////
// tell the client how to do a logout if it sees a 401 //
@@ -147,6 +148,8 @@ export default function App({authenticationMetaData}: Props)
***************************************************************************/
function doLogout()
{
analyticsUtils.reset();
if (authenticationMetaData?.type === "AUTH_0")
{
auth0Logout();
@@ -708,14 +711,20 @@ export default function App({authenticationMetaData}: Props)
}, [loggedInUser]);
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
useEffect(() =>
{
if (isFullyAuthenticated)
{
analyticsUtils.initialize();
}
}, [isFullyAuthenticated, analyticsUtils]);
/*******************************************************************************
**
*******************************************************************************/
function recordAnalytics(model: AnalyticsModel)
{
googleAnalyticsUtils.recordAnalytics(model);
analyticsUtils.recordAnalytics(model);
}
///////////////////////////////////////////////////////////////////
+1 -1
View File
@@ -22,7 +22,7 @@
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/GoogleAnalyticsUtils";
import {AnalyticsModel} from "qqq/utils/analytics/AnalyticsUtils";
import {createContext, ReactNode} from "react";
interface QContext
@@ -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;
@@ -54,6 +54,7 @@ export default function useAnonymousAuthenticationModule({setIsFullyAuthenticate
try
{
const {values} = await qController.manageSession(null, null, null);
localStorage.setItem("sessionValues", JSON.stringify(values ?? {}));
if (values?.user)
{
setLoggedInUser(values.user);
@@ -74,6 +75,7 @@ export default function useAnonymousAuthenticationModule({setIsFullyAuthenticate
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("sessionValues");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
};
@@ -25,6 +25,7 @@ import HandleAuthorizationError from "HandleAuthorizationError";
import jwt_decode from "jwt-decode";
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
import {MaterialUIControllerProvider} from "qqq/context";
import AnalyticsUtils from "qqq/utils/analytics/AnalyticsUtils";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {useNavigate, useSearchParams} from "react-router-dom";
@@ -106,6 +107,40 @@ 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
);
};
/***************************************************************************
**
***************************************************************************/
@@ -115,26 +150,41 @@ 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 {uuid: values} = await qController.manageSession(accessToken, null);
const {values} = await qController.manageSession(accessToken, null);
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("sessionValues", JSON.stringify(values));
localStorage.setItem("sessionValues", JSON.stringify(values ?? {}));
loggedInUserForApp = values?.user ?? auth0User;
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(auth0User);
setLoggedInUser(loggedInUserForApp);
console.log("Token load complete.");
}
catch (e)
@@ -142,6 +192,7 @@ 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;
@@ -155,6 +206,7 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
const logout = () =>
{
localStorage.removeItem("accessToken");
localStorage.removeItem("sessionValues");
useAuth0Logout({returnTo: window.location.origin});
};
@@ -186,6 +186,7 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
// ** Exchange authorization code for session UUID via backend API * //
///////////////////////////////////////////////////////////////////////
const {uuid: newSessionUuid, values} = await qController.manageSession(null, null, manageSessionRequestBody);
localStorage.setItem("sessionValues", JSON.stringify(values ?? {}));
/////////////////////////////////////////////////////////
// ** Update parent component's authentication state * //
@@ -265,6 +266,7 @@ 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 * //
@@ -371,6 +373,7 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
// ** Clear authentication metadata stored in local storage by the QQQ client * //
//////////////////////////////////////////////////////////////////////////////////
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("sessionValues");
/////////////////////////////////////////////////////////////////////////////////////////////////////
// ** Remove session cookies from the browser * //
@@ -429,7 +432,7 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
////////////////////////////////////////////////////////////////////////////////////
// ** Extract the OAuth2 identity provider's base URL (authority) from metadata * //
////////////////////////////////////////////////////////////////////////////////////
const authority: string = authenticationMetaData.data.baseUrl;
const authority: string = authenticationMetaData.data.externalBaseUrl || authenticationMetaData.data.baseUrl;
//////////////////////////////////////////////////////////////////////////
// ** Extract the OAuth2 client ID for this application from metadata * //
+15 -8
View File
@@ -143,15 +143,16 @@ interface QCancelButtonProps
disabled: boolean;
label?: string;
iconName?: string;
qqqId?: string;
}
export function QCancelButton({
onClickHandler, disabled, label, iconName,
onClickHandler, disabled, label, iconName, qqqId
}: 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}>
<MDButton type="button" variant="outlined" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} onClick={onClickHandler} disabled={disabled} qqqId={qqqId}>
{label}
</MDButton>
</Box>
@@ -161,6 +162,7 @@ export function QCancelButton({
QCancelButton.defaultProps = {
label: "cancel",
iconName: "cancel",
qqqId: "cancel"
};
interface QSubmitButtonProps
@@ -168,13 +170,14 @@ interface QSubmitButtonProps
label?: string;
iconName?: string;
disabled: boolean;
qqqId?: string;
}
export function QSubmitButton({label, iconName, disabled}: QSubmitButtonProps): JSX.Element
export function QSubmitButton({label, iconName, disabled, qqqId}: 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}>
<MDButton type="submit" variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled} qqqId={qqqId}>
{label}
</MDButton>
</Box>
@@ -184,6 +187,7 @@ export function QSubmitButton({label, iconName, disabled}: QSubmitButtonProps):
QSubmitButton.defaultProps = {
label: "Submit",
iconName: "check",
qqqId: "submit"
};
interface QAlternateButtonProps
@@ -191,18 +195,21 @@ interface QAlternateButtonProps
label: string,
iconName?: string,
disabled: boolean,
onClick?: () => void
onClick?: () => void,
qqqId?: string;
}
export function QAlternateButton({label, iconName, disabled, onClick}: QAlternateButtonProps): JSX.Element
export function QAlternateButton({label, iconName, disabled, onClick, qqqId}: 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}>
<MDButton type="button" variant="gradient" color="secondary" size="small" fullWidth startIcon={iconName && <Icon>{iconName}</Icon>} onClick={onClick} disabled={disabled} qqqId={qqqId}>
{label}
</MDButton>
</Box>
);
}
QAlternateButton.defaultProps = {};
QAlternateButton.defaultProps = {
qqqId: "alternate"
};
@@ -0,0 +1,60 @@
/*
* 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();
}
});
+1 -1
View File
@@ -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} ownerState={{error, success, disabled}} />
<MDInputRoot {...rest} ref={ref} disabled={disabled} ownerState={{error, success, disabled}} />
));
// Declaring default props for MDInput
@@ -33,6 +33,7 @@ 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"
@@ -84,9 +85,8 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
if (props.tableMetaData.exposedJoins)
{
for (let i = 0; i < props.tableMetaData.exposedJoins.length; i++)
for (const exposedJoin of TableUtils.getReadableExposedJoins(props.tableMetaData, props.metaData))
{
const exposedJoin = props.tableMetaData.exposedJoins[i];
if (props.metaData.tables.has(exposedJoin.joinTable.name))
{
tables.push(exposedJoin.joinTable);
@@ -0,0 +1,82 @@
/*
* 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;
}
});
+8 -35
View File
@@ -80,7 +80,6 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
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});
@@ -123,39 +122,13 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
}
}
/////////////////////
// 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);
}
////////////////////////////////////////////////////////////////////////////////////////
// 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)}];
/*******************************************************************************
@@ -263,7 +236,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);
+5 -1
View File
@@ -90,7 +90,11 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
let widgetMetaDataList = [] as QWidgetMetaData[];
data?.childWidgetNameList.forEach((widgetName: string) =>
{
widgetMetaDataList.push(qInstance.widgets.get(widgetName));
const childWidget = qInstance.widgets.get(widgetName);
if (childWidget)
{
widgetMetaDataList.push(childWidget);
}
});
setWidgets(widgetMetaDataList);
}
@@ -142,7 +142,7 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
{
if (data)
{
data?.datasets.forEach((dataset: any, index: number) =>
data?.datasets?.forEach((dataset: any, index: number) =>
{
if (!dataset.backgroundColor)
{
@@ -165,7 +165,7 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
<Box>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
<Box width="100%" height="300px">
<Bar data={data} options={makeOptions(data)} getElementsAtEvent={handleClick} />
<Bar data={{...data, labels: data.labels ?? [], datasets: data.datasets ?? []}} options={makeOptions(data)} getElementsAtEvent={handleClick} />
</Box>
</Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} />;
@@ -62,7 +62,7 @@ describe("MaterialDashboardThemeMetaData", () =>
expect(theme.infoColor).toBe("#0000FF");
expect(theme.fontFamily).toBe("Roboto");
expect(theme.headerFontFamily).toBe("Open Sans");
expect(theme.borderRadius).toBe("8px");
expect(theme.borderRadiusGlobal).toBe("8px");
expect(theme.density).toBe("compact");
expect(theme.logoPath).toBe("/assets/logo.svg");
expect(theme.iconPath).toBe("/assets/icon.svg");
@@ -87,7 +87,7 @@ describe("MaterialDashboardThemeMetaData", () =>
expect(theme.infoColor).toBeUndefined();
expect(theme.fontFamily).toBeUndefined();
expect(theme.headerFontFamily).toBeUndefined();
expect(theme.borderRadius).toBeUndefined();
expect(theme.borderRadiusGlobal).toBeUndefined();
expect(theme.density).toBeUndefined();
expect(theme.logoPath).toBeUndefined();
expect(theme.iconPath).toBeUndefined();
+3 -3
View File
@@ -258,7 +258,7 @@ function AppHome({app}: Props): JSX.Element
<Box pl={0}>{getHelp(app, "header")}</Box>
</Box>
}
<Grid container spacing={3}>
<Grid container spacing={3} className="appHome">
<Grid item xs={12} lg={12}>
<Card sx={{overflow: "visible"}}>
<Box p={3} display="flex" alignItems="center" gap=".5rem">
@@ -313,7 +313,7 @@ function AppHome({app}: Props): JSX.Element
<Box pl={0}>{getHelp(app, "header")}</Box>
</Box>
}
<Box>
<Box className="appHome">
{app.widgets && app.widgets.length > 0 && (
<Box pb={app.sections ? 2.375 : 4} pt={"0.5rem"}>
<DashboardWidgets widgetMetaDataList={widgets} />
@@ -492,7 +492,7 @@ function AppHome({app}: Props): JSX.Element
/>
</Box>
</Link> :
<Box mb={3} title="You do not have permission to access this table">
<Box className="big-icon" mb={3} title="You do not have permission to access this table">
<MiniStatisticsCard
title={{fontWeight: "bold", text: table.label}}
count={count}
+10 -7
View File
@@ -50,7 +50,6 @@ import FormData from "form-data";
import {Form, Formik} from "formik";
import parse from "html-react-parser";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import linearGradient from "qqq/assets/theme/functions/linearGradient";
import {preferredColorNameInfoOrPrimary} from "qqq/assets/theme/functions/preferInfoColorToPrimaryColor";
import {QAlternateButton, QCancelButton, QSubmitButton} from "qqq/components/buttons/DefaultButtons";
@@ -74,7 +73,7 @@ import {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget"
import BaseLayout from "qqq/layouts/BaseLayout";
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery";
import {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import {AnalyticsModel} from "qqq/utils/analytics/AnalyticsUtils";
import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
@@ -620,8 +619,8 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
</MDTypography>
<Box component="div" py={3}>
<Grid container justifyContent="flex-end" spacing={3}>
{isModal ? <QCancelButton onClickHandler={() => handleCancelClicked(true)} disabled={false} label="Close" />
: !isWidget && <QCancelButton onClickHandler={() => handleCancelClicked(true)} disabled={false} />
{isModal ? <QCancelButton onClickHandler={() => handleCancelClicked(true)} disabled={false} label="Close" qqqId="close" />
: !isWidget && <QCancelButton onClickHandler={() => handleCancelClicked(true)} disabled={false} qqqId="cancel" />
}
</Grid>
</Box>
@@ -2179,18 +2178,21 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
let nextButtonLabel = "Next";
let nextButtonIcon = "arrow_forward";
let nextButtonQqqId = "next";
if (overrideOnLastStep !== null)
{
if (overrideOnLastStep)
{
nextButtonLabel = "Submit";
nextButtonIcon = "check";
nextButtonQqqId = "submit";
}
}
else if (onLastStep)
{
nextButtonLabel = "Submit";
nextButtonIcon = "check";
nextButtonQqqId = "submit";
}
const form = (
@@ -2266,6 +2268,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
onClickHandler={() => handleCancelClicked(true)}
label={isModal ? "Close" : "Return"}
iconName={isModal ? "cancel" : "arrow_back"}
qqqId={isModal ? "close" : "return"}
disabled={isSubmitting} />
}
{
@@ -2274,17 +2277,17 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
<Grid container justifyContent="flex-end" spacing={3}>
{
!isWidget && (
<QCancelButton onClickHandler={() => handleCancelClicked(false)} disabled={isSubmitting} />
<QCancelButton onClickHandler={() => handleCancelClicked(false)} disabled={isSubmitting} qqqId="cancel" />
)
}
{backStepName ? (
<QAlternateButton label="Back" onClick={handleBack} disabled={isSubmitting} iconName="arrow_back" />
<QAlternateButton label="Back" onClick={handleBack} disabled={isSubmitting} iconName="arrow_back" qqqId="back" />
) : (
<Box />
)}
<QSubmitButton label={nextButtonLabel} iconName={nextButtonIcon} disabled={isSubmitting} />
<QSubmitButton label={nextButtonLabel} iconName={nextButtonIcon} disabled={isSubmitting} qqqId={nextButtonQqqId} />
</Grid>
</Box>
)
+29 -14
View File
@@ -74,7 +74,7 @@ import RecordQueryView from "qqq/models/query/RecordQueryView";
import ProcessRun from "qqq/pages/processes/ProcessRun";
import ColumnStats from "qqq/pages/records/query/ColumnStats";
import DataGridUtils from "qqq/utils/DataGridUtils";
import {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import {AnalyticsModel} from "qqq/utils/analytics/AnalyticsUtils";
import Client from "qqq/utils/qqq/Client";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
@@ -192,6 +192,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
const [showSuccessfullyDeletedAlert, setShowSuccessfullyDeletedAlert] = useState(false);
const [errorAlert, setErrorAlert] = useState(null as string);
const [countError, setCountError] = useState(null as string);
const [warningAlert, setWarningAlert] = useState(null as string);
const [warningAlertList, setWarningAlertList] = useState([] as string[]);
const [successAlert, setSuccessAlert] = useState(null as string);
@@ -382,6 +383,9 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
/////////////////////////////////
const [metaData, setMetaData] = useState(null as QInstance);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const readableQueryJoins = TableUtils.getReadableExposedJoins(tableMetaData, metaData);
const omittedQueryJoinTables = [...(omitExposedJoins ?? []), ...(tableMetaData?.exposedJoins ?? [])
.filter(join => !readableQueryJoins.includes(join)).map(join => join.joinTable.name)];
const [tableLabel, setTableLabel] = useState("");
const [tableProcesses, setTableProcesses] = useState([] as QProcessMetaData[]);
const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]);
@@ -456,7 +460,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
// state used to avoid showing results from an "old" query, that finishes loading after a newer one //
//////////////////////////////////////////////////////////////////////////////////////////////////////
const [latestQueryId, setLatestQueryId] = useState(0);
const [countResults, setCountResults] = useState({} as any);
const [countResults, setCountResults] = useState({} as Record<number, {count?: number, distinctCount?: number, error?: string}>);
const [receivedCountTimestamp, setReceivedCountTimestamp] = useState(new Date());
const [queryResults, setQueryResults] = useState({} as any);
const [latestQueryResults, setLatestQueryResults] = useState(null as QRecord[]);
@@ -1043,6 +1047,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
// that a backend query cannot be made because of missing values for that expression //
/////////////////////////////////////////////////////////////////////////////////////////////////
setWarningAlert(null);
setCountError(null);
for (var i = 0; i < queryFilter?.criteria?.length; i++)
{
for (var j = 0; j < queryFilter?.criteria[i]?.values?.length; j++)
@@ -1083,7 +1088,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
tablesToAdd?.forEach(t => visibleJoinTables.add(t));
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables, metaData);
}
//////////////////////////////////////////////////////////////////////////////////////////////////
@@ -1107,9 +1112,13 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
qControllerV1.count(tableName, apiVersion, filterForBackend, queryJoins, includeDistinct, tableVariant).then(([count, distinctCount]) =>
{
console.log(`Received count results for query ${thisQueryId}: ${count} ${distinctCount}`);
countResults[thisQueryId] = [];
countResults[thisQueryId].push(count);
countResults[thisQueryId].push(distinctCount);
countResults[thisQueryId] = {count, distinctCount};
setCountResults(countResults);
setReceivedCountTimestamp(new Date());
}).catch(error =>
{
const message = error?.message ?? error?.response?.data?.error ?? "Unexpected error counting records";
countResults[thisQueryId] = {error: `Cannot count records: ${message}`};
setCountResults(countResults);
setReceivedCountTimestamp(new Date());
});
@@ -1153,8 +1162,6 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
queryErrors[thisQueryId] = errorMessage;
setQueryErrors(queryErrors);
setReceivedQueryErrorTimestamp(new Date());
throw error;
});
})();
};
@@ -1177,7 +1184,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
///////////////////////////
useEffect(() =>
{
if (countResults[latestQueryId] == null || countResults[latestQueryId].length == 0)
if (countResults[latestQueryId] == null)
{
///////////////////////////////////////////////
// see same idea in displaying query results //
@@ -1187,8 +1194,9 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
}
try
{
setTotalRecords(countResults[latestQueryId][0]);
setDistinctRecords(countResults[latestQueryId][1]);
setTotalRecords(countResults[latestQueryId].count ?? null);
setDistinctRecords(countResults[latestQueryId].distinctCount ?? null);
setCountError(countResults[latestQueryId].error ?? null);
delete countResults[latestQueryId];
}
catch (e)
@@ -3118,7 +3126,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
idPrefix="columns"
tableMetaData={tableMetaData}
showTableHeaderEvenIfNoExposedJoins={true}
omitExposedJoins={omitExposedJoins}
omitExposedJoins={omittedQueryJoinTables}
placeholder="Search Fields"
buttonProps={{variant: buttonVariant, sx: columnMenuButtonStyles, ["data-button-state"]: buttonState, color: buttonColorName}}
buttonChildren={<><Icon sx={{mr: "0.5rem"}}>view_week_outline</Icon> Columns ({view.queryColumns.getVisibleColumnCount()}) <Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon></>}
@@ -3222,6 +3230,13 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
</Collapse>
) : null
}
{
countError ? (
<Collapse in={Boolean(countError)}>
<Alert severity="error" sx={{mt: 1.5, mb: 0.5}} onClose={() => setCountError(null)}>{countError}</Alert>
</Collapse>
) : null
}
{
(tableLabel && showSuccessfullyDeletedAlert) ? (
<Collapse in={Boolean(showSuccessfullyDeletedAlert)}>
@@ -3278,7 +3293,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
setMode={doSetMode}
savedViewsComponent={savedViewsComponent}
columnMenuComponent={buildColumnMenu()}
omitExposedJoins={omitExposedJoins}
omitExposedJoins={omittedQueryJoinTables}
useSavedViewsResult={useSavedViewsResult}
viewOnChangeCallback={handleSavedViewChange}
currentSavedView={currentSavedView}
@@ -3309,7 +3324,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
queryFilter: queryFilter,
updateFilter: doSetQueryFilter,
allowVariables: allowVariables,
omitExposedJoins: omitExposedJoins,
omitExposedJoins: omittedQueryJoinTables,
}
}}
localeText={{
+1 -1
View File
@@ -635,7 +635,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
const visibleJoinTables = getVisibleJoinTables(tableMetaData);
if (visibleJoinTables.size > 0)
{
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables, metaData);
}
/////////////////////
@@ -66,6 +66,7 @@ export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProp
(async () =>
{
const metaData = await qController.loadMetaData();
const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
@@ -85,7 +86,7 @@ export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProp
const visibleJoinTables = getVisibleJoinTables(tableMetaData);
if (visibleJoinTables.size > 0)
{
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables, metaData);
}
const filter = new QQueryFilter(criteria, null, null, "AND", 0, 2);
+2 -2
View File
@@ -30,6 +30,7 @@ import Tooltip from "@mui/material/Tooltip/Tooltip";
import {GridColDef, GridRowsProp, MuiEvent} from "@mui/x-data-grid-pro";
import {GridColumnHeaderParams} from "@mui/x-data-grid/models/params/gridColumnHeaderParams";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React from "react";
import {Link, NavigateFunction} from "react-router-dom";
@@ -150,9 +151,8 @@ export default class DataGridUtils
{
if (tableMetaData.exposedJoins)
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
for (const join of TableUtils.getReadableExposedJoins(tableMetaData, metaData))
{
const join = tableMetaData.exposedJoins[i];
let joinTableName = join.joinTable.name;
if (metaData.tables.has(joinTableName) && metaData.tables.get(joinTableName).readPermission && (includeExposedJoinTables === undefined || includeExposedJoinTables.indexOf(joinTableName) > -1))
{
@@ -0,0 +1,120 @@
/*
* 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 AnalyticsProviderInterface from "qqq/utils/analytics/AnalyticsProviderInterface";
export type AnalyticsProviderFactory = () => AnalyticsProviderInterface;
export interface AnalyticsPluginRegistryApi
{
register(name: string, providerFactory: AnalyticsProviderFactory): void;
unregister(name: string): void;
has(name: string): boolean;
create(name: string): AnalyticsProviderInterface | null;
list(): string[];
}
const providerFactories: Map<string, AnalyticsProviderFactory> = new Map<string, AnalyticsProviderFactory>();
/*******************************************************************************
**
*******************************************************************************/
function normalizeName(name: string): string
{
return (name ?? "").trim().toLowerCase();
}
/*******************************************************************************
**
*******************************************************************************/
const registryApi: AnalyticsPluginRegistryApi = {
register(name: string, providerFactory: AnalyticsProviderFactory): void
{
const normalizedName = normalizeName(name);
if(!normalizedName)
{
throw new Error("Analytics provider name must be non-empty.");
}
if(typeof providerFactory !== "function")
{
throw new Error(`Analytics provider [${normalizedName}] must be registered with a factory function.`);
}
providerFactories.set(normalizedName, providerFactory);
},
unregister(name: string): void
{
providerFactories.delete(normalizeName(name));
},
has(name: string): boolean
{
return providerFactories.has(normalizeName(name));
},
create(name: string): AnalyticsProviderInterface | null
{
const factory = providerFactories.get(normalizeName(name));
if(!factory)
{
return null;
}
return factory();
},
list(): string[]
{
return Array.from(providerFactories.keys()).sort();
}
};
/*******************************************************************************
** Ensure registry is available globally for overlay plugin scripts.
*******************************************************************************/
export function ensureAnalyticsPluginRegistry(): AnalyticsPluginRegistryApi
{
const win = window as any;
if(!win.QQQAnalytics)
{
win.QQQAnalytics = registryApi;
}
return win.QQQAnalytics as AnalyticsPluginRegistryApi;
}
/*******************************************************************************
**
*******************************************************************************/
export function getAnalyticsPluginRegistry(): AnalyticsPluginRegistryApi
{
return ensureAnalyticsPluginRegistry();
}
@@ -0,0 +1,31 @@
/*
* 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 {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
import {AnalyticsModel} from "qqq/utils/analytics/AnalyticsTypes";
export default interface AnalyticsProviderInterface
{
initialize(metaData: QInstance, sessionValues: {[key: string]: any} | null): void;
record(model: AnalyticsModel): void;
reset(): void;
}
+36
View File
@@ -0,0 +1,36 @@
/*
* 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/>.
*/
export interface PageView
{
location: Location;
title: string;
}
export interface UserEvent
{
action: string;
category: string;
label?: string;
}
export type AnalyticsModel = PageView | UserEvent;
+292
View File
@@ -0,0 +1,292 @@
/*
* 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 {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
import AnalyticsProviderInterface from "qqq/utils/analytics/AnalyticsProviderInterface";
import {getAnalyticsPluginRegistry} from "qqq/utils/analytics/AnalyticsPluginRegistry";
import {AnalyticsModel} from "qqq/utils/analytics/AnalyticsTypes";
import {registerBuiltInAnalyticsProviders} from "qqq/utils/analytics/BuiltInAnalyticsProviders";
import Client from "qqq/utils/qqq/Client";
export type {AnalyticsModel, PageView, UserEvent} from "qqq/utils/analytics/AnalyticsTypes";
const qController = Client.getInstance();
const DEFAULT_ANALYTICS_PROVIDERS: string[] = ["google", "posthog"];
const pluginScriptLoadPromises: Map<string, Promise<void>> = new Map<string, Promise<void>>();
/*******************************************************************************
** Facade over analytics providers.
*******************************************************************************/
export default class AnalyticsUtils
{
private metaData: QInstance | null = null;
private setupPromise: Promise<void> | null = null;
private providers: AnalyticsProviderInterface[] = [];
/*******************************************************************************
**
*******************************************************************************/
constructor()
{
}
/*******************************************************************************
** Prefer the generic analyticsValues key, but keep googleAnalyticsValues as a
** deprecated fallback while app backends migrate their session payloads.
*******************************************************************************/
public static getAnalyticsIdentityValues = (sessionValues: {[key: string]: any} | null): {[key: string]: any} =>
{
return sessionValues?.analyticsValues ?? sessionValues?.googleAnalyticsValues ?? {};
}
/*******************************************************************************
**
*******************************************************************************/
private setup = async (): Promise<void> =>
{
this.metaData = await qController.loadMetaData();
registerBuiltInAnalyticsProviders();
await this.loadConfiguredPluginScripts();
this.providers = this.buildConfiguredProviders();
let sessionValues: {[key: string]: any} = null;
try
{
sessionValues = JSON.parse(localStorage.getItem("sessionValues"));
}
catch(e)
{
console.log("Error reading session values from localStorage: " + e);
}
for(let i = 0; i < this.providers.length; i++)
{
try
{
this.providers[i].initialize(this.metaData, sessionValues);
}
catch(e)
{
console.error(`Error initializing analytics provider #${i + 1}`, e);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private loadConfiguredPluginScripts = async (): Promise<void> =>
{
const environmentValues = this.metaData?.environmentValues;
const configuredPluginScripts = environmentValues?.get("ANALYTICS_PLUGIN_SCRIPTS") || environmentValues?.get("ANALYTICS_PLUGIN_SCRIPT_URLS");
const urls = this.parseList(configuredPluginScripts);
if(urls.length == 0)
{
return;
}
await Promise.all(urls.map((url) => this.loadPluginScript(url)));
}
/*******************************************************************************
**
*******************************************************************************/
private loadPluginScript = async (url: string): Promise<void> =>
{
const resolvedUrl = new URL(url, window.location.origin).toString();
const existingPromise = pluginScriptLoadPromises.get(resolvedUrl);
if(existingPromise)
{
await existingPromise;
return;
}
const loadPromise = new Promise<void>((resolve, reject) =>
{
const script = document.createElement("script");
script.async = true;
script.crossOrigin = "anonymous";
script.type = "text/javascript";
script.src = resolvedUrl;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load analytics plugin script: ${resolvedUrl}`));
document.head.appendChild(script);
});
pluginScriptLoadPromises.set(resolvedUrl, loadPromise);
try
{
await loadPromise;
}
catch(e)
{
console.error(e);
}
}
/*******************************************************************************
**
*******************************************************************************/
private buildConfiguredProviders = (): AnalyticsProviderInterface[] =>
{
const registry = getAnalyticsPluginRegistry();
const configuredProviderNames = this.parseList(this.metaData?.environmentValues?.get("ANALYTICS_PROVIDERS"));
const providerNames = configuredProviderNames.length > 0 ? configuredProviderNames : DEFAULT_ANALYTICS_PROVIDERS;
const uniqueProviderNames = Array.from(new Set(providerNames.map((name) => name.toLowerCase())));
const configuredProviders: AnalyticsProviderInterface[] = [];
for(let i = 0; i < uniqueProviderNames.length; i++)
{
const providerName = uniqueProviderNames[i];
const provider = registry.create(providerName);
if(!provider)
{
console.warn(`Configured analytics provider [${providerName}] was not found in QQQAnalytics registry.`);
continue;
}
if(!this.isValidProvider(provider))
{
console.warn(`Configured analytics provider [${providerName}] does not implement required methods.`);
continue;
}
configuredProviders.push(provider);
}
if(configuredProviders.length == 0)
{
console.warn("No analytics providers were activated.");
}
return configuredProviders;
}
/*******************************************************************************
**
*******************************************************************************/
private isValidProvider = (provider: AnalyticsProviderInterface): boolean =>
{
return typeof provider.initialize === "function"
&& typeof provider.record === "function"
&& typeof provider.reset === "function";
}
/*******************************************************************************
**
*******************************************************************************/
private parseList = (csv: string | null | undefined): string[] =>
{
if(!csv)
{
return [];
}
return csv
.split(/[,\n\r;]/g)
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
/*******************************************************************************
**
*******************************************************************************/
private ensureSetup = async (): Promise<void> =>
{
if(this.metaData != null)
{
return;
}
if(this.setupPromise == null)
{
this.setupPromise = this.setup();
}
await this.setupPromise;
}
/*******************************************************************************
**
*******************************************************************************/
public initialize = () =>
{
(async () =>
{
await this.ensureSetup();
})();
}
/*******************************************************************************
**
*******************************************************************************/
public recordAnalytics = (model: AnalyticsModel) =>
{
(async () =>
{
await this.ensureSetup();
for(let i = 0; i < this.providers.length; i++)
{
try
{
this.providers[i].record(model);
}
catch(e)
{
console.error(`Error recording analytics for provider #${i + 1}`, e);
}
}
})();
}
/*******************************************************************************
**
*******************************************************************************/
public reset = () =>
{
for(let i = 0; i < this.providers.length; i++)
{
try
{
this.providers[i].reset();
}
catch(e)
{
console.error(`Error resetting analytics provider #${i + 1}`, e);
}
}
}
}
@@ -0,0 +1,44 @@
/*
* 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 {getAnalyticsPluginRegistry} from "qqq/utils/analytics/AnalyticsPluginRegistry";
import GoogleAnalyticsProvider from "qqq/utils/analytics/GoogleAnalyticsProvider";
import PostHogAnalyticsProvider from "qqq/utils/analytics/PostHogAnalyticsProvider";
let hasRegisteredBuiltIns: boolean = false;
/*******************************************************************************
**
*******************************************************************************/
export function registerBuiltInAnalyticsProviders(): void
{
if(hasRegisteredBuiltIns)
{
return;
}
const registry = getAnalyticsPluginRegistry();
registry.register("google", () => new GoogleAnalyticsProvider());
registry.register("posthog", () => new PostHogAnalyticsProvider());
hasRegisteredBuiltIns = true;
}
@@ -20,48 +20,48 @@
*/
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
import Client from "qqq/utils/qqq/Client";
import AnalyticsProviderInterface from "qqq/utils/analytics/AnalyticsProviderInterface";
import AnalyticsUtils from "qqq/utils/analytics/AnalyticsUtils";
import {AnalyticsModel, PageView, UserEvent} from "qqq/utils/analytics/AnalyticsTypes";
import ReactGA from "react-ga4";
export interface PageView
export default class GoogleAnalyticsProvider implements AnalyticsProviderInterface
{
location: Location;
title: string;
}
export interface UserEvent
{
action: string;
category: string;
label?: string;
}
export type AnalyticsModel = PageView | UserEvent;
const qController = Client.getInstance();
/*******************************************************************************
** Utilities for working with Google Analytics (through react-ga4)^
*******************************************************************************/
export default class GoogleAnalyticsUtils
{
private metaData: QInstance = null;
private active: boolean = false;
/*******************************************************************************
**
*******************************************************************************/
constructor()
public initialize = (metaData: QInstance, sessionValues: {[key: string]: any} | null): void =>
{
if(metaData.environmentValues?.get("GOOGLE_ANALYTICS_ENABLED") == "true" && metaData.environmentValues?.get("GOOGLE_ANALYTICS_TRACKING_ID"))
{
this.active = true;
ReactGA.initialize(metaData.environmentValues.get("GOOGLE_ANALYTICS_TRACKING_ID"),
{
gaOptions: {},
gtagOptions: {}
});
const analyticsIdentityValues = AnalyticsUtils.getAnalyticsIdentityValues(sessionValues);
if(Object.keys(analyticsIdentityValues).length > 0)
{
ReactGA.gtag("set", "user_properties", analyticsIdentityValues);
}
}
else
{
this.active = false;
}
}
/*******************************************************************************
**
*******************************************************************************/
private send = (model: AnalyticsModel) =>
public record = (model: AnalyticsModel): void =>
{
if(!this.active)
{
@@ -76,7 +76,7 @@ export default class GoogleAnalyticsUtils
else if(model.hasOwnProperty("action") || model.hasOwnProperty("category") || model.hasOwnProperty("label"))
{
const userEvent = model as UserEvent;
ReactGA.event({action: userEvent.action, category: userEvent.category, label: userEvent.label})
ReactGA.event({action: userEvent.action, category: userEvent.category, label: userEvent.label});
}
else
{
@@ -88,56 +88,8 @@ export default class GoogleAnalyticsUtils
/*******************************************************************************
**
*******************************************************************************/
private setup = async (): Promise<void> =>
public reset = (): void =>
{
this.metaData = await qController.loadMetaData();
let sessionValues: {[key: string]: any} = null;
try
{
sessionValues = JSON.parse(localStorage.getItem("sessionValues"));
}
catch(e)
{
console.log("Error reading session values from localStorage: " + e);
}
if (this.metaData.environmentValues?.get("GOOGLE_ANALYTICS_ENABLED") == "true" && this.metaData.environmentValues?.get("GOOGLE_ANALYTICS_TRACKING_ID"))
{
this.active = true;
if(sessionValues && sessionValues["googleAnalyticsValues"])
{
ReactGA.gtag("set", "user_properties", sessionValues["googleAnalyticsValues"]);
}
ReactGA.initialize(this.metaData.environmentValues.get("GOOGLE_ANALYTICS_TRACKING_ID"),
{
gaOptions: {},
gtagOptions: {}
});
}
else
{
this.active = false;
}
// no-op for GA in current implementation
}
/*******************************************************************************
**
*******************************************************************************/
public recordAnalytics = (model: AnalyticsModel) =>
{
if(this.metaData == null)
{
(async () =>
{
await this.setup();
})()
}
this.send(model);
}
}
@@ -0,0 +1,43 @@
/*
* 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 {describe, expect, it} from "@jest/globals";
import {buildPostHogScriptUrl} from "./PostHogAnalyticsProvider";
describe("buildPostHogScriptUrl", () =>
{
it("should rewrite direct PostHog hosts to the asset host", () =>
{
expect(buildPostHogScriptUrl("https://us.i.posthog.com", "https://app.example.com")).toBe("https://us-assets.i.posthog.com/static/array.js");
});
it("should preserve same-origin proxy prefixes", () =>
{
expect(buildPostHogScriptUrl("/_posthog", "https://app.example.com")).toBe("https://app.example.com/_posthog/static/array.js");
});
it("should preserve an existing script asset path", () =>
{
expect(buildPostHogScriptUrl("https://eu.i.posthog.com/static/array.js?x=1", "https://app.example.com")).toBe("https://eu-assets.i.posthog.com/static/array.js");
});
});
@@ -0,0 +1,293 @@
/*
* 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 {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
import AnalyticsProviderInterface from "qqq/utils/analytics/AnalyticsProviderInterface";
import AnalyticsUtils from "qqq/utils/analytics/AnalyticsUtils";
import {AnalyticsModel, PageView, UserEvent} from "qqq/utils/analytics/AnalyticsTypes";
type PostHogType = {
__QQQ_POSTHOG_INITIALIZED?: boolean;
__SV?: number;
_i?: any[];
people?: any[];
push?: (...items: any[]) => number;
init?: (token: string, config: {[key: string]: any}, name?: string) => void;
identify?: (distinctId: string, properties?: {[key: string]: any}) => void;
capture?: (event: string, properties?: {[key: string]: any}) => void;
reset?: () => void;
[name: string]: any;
};
export function buildPostHogScriptUrl(apiHost: string, origin: string): string
{
const scriptUrl = new URL(apiHost, origin);
const normalizedPathname = scriptUrl.pathname.replace(/\/+$/, "");
if(scriptUrl.hostname.toLowerCase() == "i.posthog.com")
{
scriptUrl.hostname = "assets.i.posthog.com";
}
else if(scriptUrl.hostname.toLowerCase().endsWith(".i.posthog.com"))
{
scriptUrl.hostname = scriptUrl.hostname.replace(/\.i\.posthog\.com$/i, "-assets.i.posthog.com");
}
scriptUrl.hash = "";
scriptUrl.search = "";
if(normalizedPathname.toLowerCase().endsWith("/static/array.js"))
{
scriptUrl.pathname = normalizedPathname;
}
else if(normalizedPathname && normalizedPathname != "/")
{
scriptUrl.pathname = `${normalizedPathname}/static/array.js`;
}
else
{
scriptUrl.pathname = "/static/array.js";
}
return (scriptUrl.toString());
}
export default class PostHogAnalyticsProvider implements AnalyticsProviderInterface
{
private active: boolean = false;
/*******************************************************************************
**
*******************************************************************************/
public initialize = (metaData: QInstance, sessionValues: {[key: string]: any} | null): void =>
{
const postHogEnabled = metaData.environmentValues?.get("POSTHOG_ENABLED") == "true";
const postHogApiKey = metaData.environmentValues?.get("POSTHOG_API_KEY") || metaData.environmentValues?.get("POSTHOG_PROJECT_API_KEY");
const postHogHost = metaData.environmentValues?.get("POSTHOG_HOST") || "https://us.i.posthog.com";
if(postHogEnabled && postHogApiKey)
{
this.active = true;
this.initializePostHog(postHogApiKey, postHogHost);
this.identify(sessionValues);
}
else
{
this.active = false;
}
}
/*******************************************************************************
**
*******************************************************************************/
public record = (model: AnalyticsModel): void =>
{
if(!this.active)
{
return;
}
if(model.hasOwnProperty("location"))
{
const pageView = model as PageView;
this.getPostHog()?.capture?.("$pageview", {
path: pageView.location.pathname,
search: pageView.location.search,
title: pageView.title
});
}
else if(model.hasOwnProperty("action") || model.hasOwnProperty("category") || model.hasOwnProperty("label"))
{
const userEvent = model as UserEvent;
this.getPostHog()?.capture?.(userEvent.action, {
category: userEvent.category,
label: userEvent.label
});
}
else
{
console.log("Unrecognizable analytics model", model);
}
}
/*******************************************************************************
**
*******************************************************************************/
public reset = (): void =>
{
this.getPostHog()?.reset?.();
}
/*******************************************************************************
**
*******************************************************************************/
private initializePostHog = (projectApiKey: string, apiHost: string): void =>
{
const win = window as any;
const scriptUrl = this.getPostHogScriptUrl(apiHost);
win.posthog = win.posthog || [];
const existingPostHog = this.getPostHog();
if(existingPostHog && existingPostHog.__QQQ_POSTHOG_INITIALIZED)
{
return;
}
(function(document: Document, posthog: PostHogType)
{
if(posthog.__SV)
{
return;
}
posthog.__SV = 1;
posthog._i = posthog._i || [];
posthog.people = posthog.people || [];
const createStub = (target: any, method: string) =>
{
let queueTarget = target;
let queueMethod = method;
const splitMethod = method.split(".");
if(splitMethod.length == 2)
{
queueTarget = target[splitMethod[0]] = target[splitMethod[0]] || [];
queueMethod = splitMethod[1];
}
queueTarget[queueMethod] = (...args: any[]) =>
{
queueTarget.push([queueMethod, ...args]);
};
};
const methods = "capture identify alias group register register_once unregister unregister_once set_config reset opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing start_session_recording stop_session_recording setPersonPropertiesForFlags onFeatureFlags".split(" ");
methods.forEach((method) => createStub(posthog, method));
posthog.init = (token: string, config: {[key: string]: any}, name?: string) =>
{
const target = name ? (posthog[name] = posthog[name] || []) : posthog;
target.people = target.people || [];
methods.forEach((method) => createStub(target, method));
posthog._i?.push([token, config, name || "posthog"]);
};
const script = document.createElement("script");
script.id = "qqq-posthog-js";
script.async = true;
script.crossOrigin = "anonymous";
script.type = "text/javascript";
script.src = scriptUrl;
const firstScript = document.getElementsByTagName("script")[0];
if(firstScript && firstScript.parentNode)
{
firstScript.parentNode.insertBefore(script, firstScript);
}
else
{
document.head.appendChild(script);
}
})(document, win.posthog);
const postHog = this.getPostHog();
postHog?.init?.(projectApiKey, {
api_host: apiHost,
person_profiles: "identified_only",
capture_pageview: false,
session_idle_timeout_seconds: 5 * 60
});
if(postHog)
{
postHog.__QQQ_POSTHOG_INITIALIZED = true;
}
}
/*******************************************************************************
**
*******************************************************************************/
private identify = (sessionValues: {[key: string]: any} | null): void =>
{
if(!this.active)
{
return;
}
const analyticsIdentityValues = AnalyticsUtils.getAnalyticsIdentityValues(sessionValues) as {[key: string]: any};
const userId = sessionValues?.user?.id || sessionValues?.user?.userId;
const email = analyticsIdentityValues["user_email"] || sessionValues?.user?.email || sessionValues?.user?.idReference;
const name = analyticsIdentityValues["name"] || sessionValues?.user?.name || sessionValues?.user?.fullName;
const clientName = analyticsIdentityValues["client_name"];
const clientId = analyticsIdentityValues["client_id"];
const distinctId = userId || email;
if(!distinctId)
{
return;
}
const properties: {[key: string]: any} = {};
if(userId)
{
properties.user_id = userId;
}
if(email)
{
properties.email = email;
}
if(name)
{
properties.name = name;
}
if(clientName)
{
properties.client = clientName;
properties.client_name = clientName;
}
if(clientId)
{
properties.client_id = clientId;
}
this.getPostHog()?.identify?.(String(distinctId), properties);
}
/*******************************************************************************
**
*******************************************************************************/
private getPostHog = (): PostHogType | null =>
{
return (window as any).posthog || null;
}
/*******************************************************************************
**
*******************************************************************************/
private getPostHogScriptUrl = (apiHost: string): string =>
{
return (buildPostHogScriptUrl(apiHost, window.location.origin));
}
}
+106
View File
@@ -0,0 +1,106 @@
/*
* 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 {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
import TableUtils from "./TableUtils";
/*******************************************************************************
** Full relationship metadata remains available for writes; automatic reads use
** only paths the metadata permits. The HTTP endpoint still enforces access.
*******************************************************************************/
describe("automatic joined read permissions", () =>
{
/*******************************************************************************
** Existing model constructors and a reversed two-hop exposed path.
*******************************************************************************/
function fixture()
{
const metaData = new QInstance({tables: Object.fromEntries(["person", "pet", "toy", "owner"].map(name => [name,
{name, label: name, readPermission: true, insertPermission: true, fields: {id: {name: "id", type: "INTEGER"}}, exposedJoins: []}]))});
const table = metaData.tables.get("person");
table.exposedJoins = [
{label: "Pets", isMany: true, joinTable: metaData.tables.get("pet"), joinPath: [{name: "personPet", type: "ONE_TO_MANY", leftTable: "person", rightTable: "pet"}]},
{label: "Toys", isMany: true, joinTable: metaData.tables.get("toy"), joinPath: [
{name: "petPerson", type: "MANY_TO_ONE", leftTable: "pet", rightTable: "person"},
{name: "toyPet", type: "MANY_TO_ONE", leftTable: "toy", rightTable: "pet"}]},
{label: "Owner", isMany: false, joinTable: metaData.tables.get("owner"), joinPath: [{name: "personOwner", type: "MANY_TO_ONE", leftTable: "person", rightTable: "owner"}]}
];
return {metaData, table};
}
it("omits unreadable target and intermediate reads while retaining authorized writes and metadata", () =>
{
const {metaData, table} = fixture();
metaData.tables.get("pet").readPermission = false;
const originalJoins = table.exposedJoins;
const originalPath = table.exposedJoins[1].joinPath;
const visible = new Set(["pet", "toy", "owner"]);
const joins = TableUtils.getQueryJoins(table, visible, metaData);
expect(joins.map(join => join.joinTable)).toEqual(["owner"]);
expect(joins[0]).toMatchObject({select: true, type: "LEFT", joinName: "personOwner"});
expect(table.exposedJoins).toBe(originalJoins);
expect(table.exposedJoins).toHaveLength(3);
expect(table.exposedJoins[1].joinPath).toBe(originalPath);
expect(table.exposedJoins[0].joinTable.insertPermission).toBe(true);
expect(Array.from(visible)).toEqual(["pet", "toy", "owner"]);
});
it("restores the same reversed path when the intermediate becomes readable", () =>
{
const {metaData, table} = fixture();
const visible = new Set(["toy"]);
metaData.tables.get("pet").readPermission = false;
expect(TableUtils.getQueryJoins(table, visible, metaData)).toEqual([]);
metaData.tables.get("pet").readPermission = true;
metaData.tables.get("pet").isHidden = true;
expect(TableUtils.getQueryJoins(table, visible, metaData).map(join => join.joinTable)).toEqual(["toy"]);
metaData.tables.delete("pet");
expect(TableUtils.getQueryJoins(table, visible, metaData)).toEqual([]);
});
it("requires separate embedded and canonical target permission", () =>
{
const {metaData, table} = fixture();
const canonical = metaData.tables.get("pet");
const embedded = new QTableMetaData({name: "pet", readPermission: false, insertPermission: true});
table.exposedJoins[0].joinTable = embedded;
const visible = new Set(["pet"]);
expect(TableUtils.getQueryJoins(table, visible, metaData)).toEqual([]);
embedded.readPermission = true;
canonical.readPermission = false;
expect(TableUtils.getQueryJoins(table, visible, metaData)).toEqual([]);
canonical.readPermission = true;
expect(TableUtils.getQueryJoins(table, visible, metaData).map(join => join.joinTable)).toEqual(["pet"]);
metaData.tables.delete("pet");
expect(TableUtils.getQueryJoins(table, visible, metaData)).toEqual([]);
expect(embedded.insertPermission).toBe(true);
});
it("waits for global metadata and never requires a second base-table grant", () =>
{
const {metaData, table} = fixture();
const visible = new Set(["pet"]);
expect(TableUtils.getQueryJoins(table, visible, null)).toEqual([]);
metaData.tables.delete("person");
expect(TableUtils.getQueryJoins(table, visible, metaData).map(join => join.joinTable)).toEqual(["pet"]);
});
});
+26 -3
View File
@@ -19,6 +19,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QExposedJoin} from "@qrunio/qqq-frontend-core/lib/model/metaData/QExposedJoin";
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableSection} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableSection";
@@ -216,12 +218,33 @@ class TableUtils
/*******************************************************************************
**
*******************************************************************************/
public static getQueryJoins(tableMetaData: QTableMetaData, visibleJoinTables: Set<string>): QueryJoin[]
public static getReadableExposedJoins(tableMetaData: QTableMetaData, metaData: QInstance): QExposedJoin[]
{
return (tableMetaData?.exposedJoins ?? []).filter(join =>
{
if (!metaData?.tables || !join.joinTable?.readPermission)
{
return false;
}
const tableNames = new Set([join.joinTable.name]);
for (const edge of join.joinPath ?? [])
{
tableNames.add(edge.leftTable);
tableNames.add(edge.rightTable);
}
return Array.from(tableNames).every(name => name == tableMetaData.name || metaData.tables.get(name)?.readPermission);
});
}
/*******************************************************************************
** Keep full metadata for write controls; only automatic reads use this subset.
*******************************************************************************/
public static getQueryJoins(tableMetaData: QTableMetaData, visibleJoinTables: Set<string>, metaData: QInstance): QueryJoin[]
{
const queryJoins = [];
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
for (const join of this.getReadableExposedJoins(tableMetaData, metaData))
{
const join = tableMetaData.exposedJoins[i];
if (visibleJoinTables.has(join.joinTable.name))
{
let joinName = null;
+2
View File
@@ -59,6 +59,8 @@ module.exports = function (app)
app.use("/api*", getRequestHandler());
app.use("/*api", getRequestHandler());
app.use("/qqq/*", getRequestHandler());
app.use("/_posthog", getRequestHandler());
app.use("/_posthog/*", getRequestHandler());
app.use("/dynamic-qfmd-components/*", getRequestHandler());
app.use("/material-dashboard-backend/*", getRequestHandler());
@@ -50,7 +50,7 @@ public class TestUtils
QInstance qInstance = new QInstance();
qInstance.addBackend(defineBackend());
qInstance.addTable(defineTablePerson());
qInstance.setAuthentication(defineAuthentication());
qInstance.withInstanceDefaultAuthentication(defineAuthentication());
return (qInstance);
}
@@ -66,7 +66,7 @@ public class QSeleniumLib
public final WebDriver driver;
private long WAIT_SECONDS = 10;
private String BASE_URL = "https://localhost:3001";
private String BASE_URL = System.getProperty("qqq.test.baseUrl", "https://localhost:3001");
private boolean SCREENSHOTS_ENABLED = true;
private String SCREENSHOTS_PATH = "/tmp/QSeleniumScreenshots/";
@@ -35,7 +35,7 @@ import javax.net.ssl.SSLSession;
*/
public class ServerHealthChecker
{
private static final String DEFAULT_BASE_URL = "https://localhost:3001";
private static final String DEFAULT_BASE_URL = System.getProperty("qqq.test.baseUrl", "https://localhost:3001");
private static final int DEFAULT_TIMEOUT_SECONDS = 60; // Increased for CI
private static final int DEFAULT_RETRY_INTERVAL_MS = 2000; // Increased for CI
@@ -158,4 +158,4 @@ public class ServerHealthChecker
{
waitForServerReady(DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS);
}
}
}
@@ -124,7 +124,7 @@ public class QSeleniumJavalin
{
javalin = Javalin.create(config ->
{
config.router.apiBuilder(
config.routes.apiBuilder(
() ->
{
for(Map.Entry<String, String> routeToFile : CollectionUtils.nonNullMap(routesToFiles).entrySet())
@@ -143,14 +143,13 @@ public class QSeleniumJavalin
}
);
}).start(8001);
config.routes.before(new CapturingHandler(this));
javalin.before(new CapturingHandler(this));
javalin.error(404, context -> {
LOG.warn("Returning 404 for [" + context.method() + " " + context.path() + "]");
pathsThat404ed.add(context.path());
});
config.routes.error(404, context -> {
LOG.warn("Returning 404 for [" + context.method() + " " + context.path() + "]");
pathsThat404ed.add(context.path());
});
}).start(Integer.getInteger("qqq.test.backendPort", 8001));
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// to accept "large" access tokens in Authorization: Bearer <token> headers (e.g., with 100s of permissions), //
@@ -55,7 +55,7 @@ public class TestApplicationServer
public void start() throws QException
{
javalinServer = new QApplicationJavalinServer(testApplication)
.withPort(8001)
.withPort(Integer.getInteger("qqq.test.backendPort", 8001))
.withServeFrontendMaterialDashboard(false)
.withServeLegacyUnversionedMiddlewareAPI(true);
+12
View File
@@ -0,0 +1,12 @@
import type {AnalyticsPluginRegistryApi} from "qqq/utils/analytics/AnalyticsPluginRegistry";
declare global
{
interface Window
{
QQQAnalytics?: AnalyticsPluginRegistryApi;
}
}
export {};