Compare commits
38
Commits
publish-e2a5f18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f1bb1067 | ||
|
|
0d40488808 | ||
|
|
2ae916a61b | ||
|
|
6039fcff42 | ||
|
|
47d3cb3fe1 | ||
|
|
149ff7ff79 | ||
|
|
2a404ea3c8 | ||
|
|
d31db14dd6 | ||
|
|
22a7866b7c | ||
|
|
23ed6f4a87 | ||
|
|
3400004d66 | ||
|
|
1b83a98a4b | ||
|
|
078955f529 | ||
|
|
b75dd26808 | ||
|
|
6dc44ee290 | ||
|
|
193f848a2c | ||
|
|
7c33fe7773 | ||
|
|
1445634a86 | ||
|
|
442cc85a93 | ||
|
|
372e87ae96 | ||
|
|
31e2bc712e | ||
|
|
90aa19bb44 | ||
|
|
7170852139 | ||
|
|
d109b9ca63 | ||
|
|
f2f0c02621 | ||
|
|
b745aa84a3 | ||
|
|
512d8cf85a | ||
|
|
44538c5289 | ||
|
|
0aec20513f | ||
|
|
451127a7d0 | ||
|
|
c557e116e3 | ||
|
|
18ab409b09 | ||
|
|
a284985a9c | ||
|
|
e9ceb2765c | ||
|
|
bd8f96d5ef | ||
|
|
2407504bfe | ||
|
|
88b9de82a4 | ||
|
|
01e01d2f2b |
No files matched your search
+14
-136
@@ -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\/.*/
|
||||
@@ -0,0 +1 @@
|
||||
* @KofTwentyTwo
|
||||
@@ -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:
|
||||
- "*"
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
profile: frontend-material
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
Generated
+537
-508
File diff suppressed because it is too large.
Load diff
+9
-4
@@ -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.9-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)/)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+16
-4
@@ -55,12 +55,12 @@ 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";
|
||||
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
|
||||
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useMemo, useState,} from "react";
|
||||
import React, {JSXElementConstructor, Key, ReactElement, ReactNode, useEffect, useMemo, useState,} from "react";
|
||||
import {useCookies} from "react-cookie";
|
||||
import {Navigate, Route, Routes, useLocation, useParams, useSearchParams,} from "react-router-dom";
|
||||
import {Md5} from "ts-md5/dist/md5";
|
||||
@@ -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();
|
||||
@@ -691,6 +694,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
}, [themeMetaData]);
|
||||
|
||||
const [pageHeader, setPageHeader] = useState("" as string | JSX.Element);
|
||||
const [pageHeaderRightContent, setPageHeaderRightContent] = useState(null as ReactNode);
|
||||
const [accentColor, setAccentColor] = useState("#0062FF");
|
||||
const [accentColorLight, setAccentColorLight] = useState("#C0D6F7");
|
||||
const [tableMetaData, setTableMetaData] = useState(null);
|
||||
@@ -707,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);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
@@ -801,6 +811,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
appRoutes && (
|
||||
<QContext.Provider value={{
|
||||
pageHeader: pageHeader,
|
||||
pageHeaderRightContent: pageHeaderRightContent,
|
||||
accentColor: accentColor,
|
||||
accentColorLight: accentColorLight,
|
||||
tableMetaData: tableMetaData,
|
||||
@@ -811,6 +822,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
helpHelpActive: helpHelpActive,
|
||||
userId: userId,
|
||||
setPageHeader: (header: string | JSX.Element) => setPageHeader(header),
|
||||
setPageHeaderRightContent: (content: ReactNode) => setPageHeaderRightContent(content),
|
||||
setAccentColor: (accentColor: string) => setAccentColor(accentColor),
|
||||
setAccentColorLight: (accentColorLight: string) => setAccentColorLight(accentColorLight),
|
||||
setTableMetaData: (tableMetaData: QTableMetaData) => setTableMetaData(tableMetaData),
|
||||
|
||||
+5
-2
@@ -22,13 +22,15 @@
|
||||
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 {createContext} from "react";
|
||||
import {AnalyticsModel} from "qqq/utils/analytics/AnalyticsUtils";
|
||||
import {createContext, ReactNode} from "react";
|
||||
|
||||
interface QContext
|
||||
{
|
||||
pageHeader: string | JSX.Element;
|
||||
setPageHeader?: (header: string | JSX.Element) => void;
|
||||
pageHeaderRightContent?: ReactNode;
|
||||
setPageHeaderRightContent?: (content: ReactNode) => void;
|
||||
|
||||
accentColor: string;
|
||||
setAccentColor?: (color: string) => void;
|
||||
@@ -69,6 +71,7 @@ interface QContext
|
||||
|
||||
const defaultState = {
|
||||
pageHeader: "",
|
||||
pageHeaderRightContent: null as ReactNode,
|
||||
accentColor: "#0062FF",
|
||||
accentColorLight: "#C0D6F7",
|
||||
dotMenuOpen: false,
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
|
||||
+71
@@ -39,6 +39,10 @@ public class MaterialDashboardInstanceMetaData implements QSupplementalInstanceM
|
||||
|
||||
private Integer queryScreenCopyFullQueryColumnValuesLimit = 100_000;
|
||||
|
||||
private MaterialDashboardTableMetaData.RecordViewActionsPlacement recordViewActionsPlacement = MaterialDashboardTableMetaData.RecordViewActionsPlacement.IN_IDENTITY_SECTION;
|
||||
|
||||
private WeekdayCriteriaSettings weekdayCriteriaSettings = new WeekdayCriteriaSettings();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -155,4 +159,71 @@ public class MaterialDashboardInstanceMetaData implements QSupplementalInstanceM
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardTableMetaData.RecordViewActionsPlacement getRecordViewActionsPlacement()
|
||||
{
|
||||
return (this.recordViewActionsPlacement);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public void setRecordViewActionsPlacement(MaterialDashboardTableMetaData.RecordViewActionsPlacement recordViewActionsPlacement)
|
||||
{
|
||||
this.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardInstanceMetaData withRecordViewActionsPlacement(MaterialDashboardTableMetaData.RecordViewActionsPlacement recordViewActionsPlacement)
|
||||
{
|
||||
this.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for weekdayCriteriaSettings
|
||||
* @see #withWeekdayCriteriaSettings(WeekdayCriteriaSettings)
|
||||
*******************************************************************************/
|
||||
public WeekdayCriteriaSettings getWeekdayCriteriaSettings()
|
||||
{
|
||||
return (this.weekdayCriteriaSettings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for weekdayCriteriaSettings
|
||||
* @see #withWeekdayCriteriaSettings(WeekdayCriteriaSettings)
|
||||
*******************************************************************************/
|
||||
public void setWeekdayCriteriaSettings(WeekdayCriteriaSettings weekdayCriteriaSettings)
|
||||
{
|
||||
this.weekdayCriteriaSettings = weekdayCriteriaSettings;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for weekdayCriteriaSettings
|
||||
*
|
||||
* @param weekdayCriteriaSettings
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardInstanceMetaData withWeekdayCriteriaSettings(WeekdayCriteriaSettings weekdayCriteriaSettings)
|
||||
{
|
||||
this.weekdayCriteriaSettings = weekdayCriteriaSettings;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+73
@@ -45,6 +45,12 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
*******************************************************************************/
|
||||
public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
{
|
||||
public enum RecordViewActionsPlacement
|
||||
{
|
||||
IN_IDENTITY_SECTION,
|
||||
INLINE_WITH_PAGE_TITLE
|
||||
}
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(MaterialDashboardTableMetaData.class);
|
||||
|
||||
public static final String TYPE = "materialDashboard";
|
||||
@@ -52,6 +58,8 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
private List<List<String>> gotoFieldNames;
|
||||
private List<String> defaultQuickFilterFieldNames;
|
||||
private List<FieldRule> fieldRules;
|
||||
private Boolean showRecordSidebar = true;
|
||||
private RecordViewActionsPlacement recordViewActionsPlacement = RecordViewActionsPlacement.IN_IDENTITY_SECTION;
|
||||
|
||||
private QCodeReference onLoadFormAdjuster = null;
|
||||
|
||||
@@ -262,6 +270,68 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for showRecordSidebar
|
||||
*******************************************************************************/
|
||||
public Boolean getShowRecordSidebar()
|
||||
{
|
||||
return (this.showRecordSidebar);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for showRecordSidebar
|
||||
*******************************************************************************/
|
||||
public void setShowRecordSidebar(Boolean showRecordSidebar)
|
||||
{
|
||||
this.showRecordSidebar = showRecordSidebar;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for showRecordSidebar
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardTableMetaData withShowRecordSidebar(Boolean showRecordSidebar)
|
||||
{
|
||||
this.showRecordSidebar = showRecordSidebar;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public RecordViewActionsPlacement getRecordViewActionsPlacement()
|
||||
{
|
||||
return (this.recordViewActionsPlacement);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public void setRecordViewActionsPlacement(RecordViewActionsPlacement recordViewActionsPlacement)
|
||||
{
|
||||
this.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardTableMetaData withRecordViewActionsPlacement(RecordViewActionsPlacement recordViewActionsPlacement)
|
||||
{
|
||||
this.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldRules
|
||||
*******************************************************************************/
|
||||
@@ -384,6 +454,9 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
clone.onLoadFormAdjuster = onLoadFormAdjuster.clone();
|
||||
}
|
||||
|
||||
clone.showRecordSidebar = showRecordSidebar;
|
||||
clone.recordViewActionsPlacement = recordViewActionsPlacement;
|
||||
|
||||
return abstractClone;
|
||||
}
|
||||
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Material dashboard instance metadata settings - to control weekday criteria
|
||||
* on date & date-time fields. e.g., day-is-any-of or day-is-non-eof.
|
||||
*
|
||||
* <p>These criteria are on by default - but this object can turn them off with
|
||||
* the {@code enabled} setting.</p>
|
||||
*
|
||||
* <p>Also - since how time zones should apply can be a hotly contested issue,
|
||||
* values for the parameters supported by QQQ's {@code WeekdayOfDateTimeFunction}
|
||||
* can be set as {@code dateTimeFieldFunctionArguments}</p>
|
||||
*
|
||||
* <p>e.g., to always use US/Central time:
|
||||
* <pre>
|
||||
new WeekdayCriteriaSettings().withDateTimeFieldFunctionArguments(
|
||||
Map.of(WeekdayOfDateTimeFunction.PARAM_TIME_ZONE_ID, "US/Central")));</pre></p>
|
||||
*
|
||||
* <p>or to use user/session time zone:
|
||||
* <pre>
|
||||
new WeekdayCriteriaSettings().withDateTimeFieldFunctionArguments(
|
||||
Map.of(WeekdayOfDateTimeFunction.PARAM_USE_SESSION_ZONE_ID, true)));</pre></p>
|
||||
*******************************************************************************/
|
||||
public class WeekdayCriteriaSettings implements Serializable, Cloneable
|
||||
{
|
||||
private Boolean enabled = true;
|
||||
private Map<String, Serializable> dateTimeFieldFunctionArguments = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for enabled
|
||||
* @see #withEnabled(Boolean)
|
||||
*******************************************************************************/
|
||||
public Boolean getEnabled()
|
||||
{
|
||||
return (this.enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for enabled
|
||||
* @see #withEnabled(Boolean)
|
||||
*******************************************************************************/
|
||||
public void setEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for enabled
|
||||
*
|
||||
* @param enabled
|
||||
* TODO document this property
|
||||
*
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public WeekdayCriteriaSettings withEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for dateTimeFieldFunctionArguments
|
||||
* @see #withDateTimeFieldFunctionArguments(Map)
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> getDateTimeFieldFunctionArguments()
|
||||
{
|
||||
return (this.dateTimeFieldFunctionArguments);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for dateTimeFieldFunctionArguments
|
||||
* @see #withDateTimeFieldFunctionArguments(Map)
|
||||
*******************************************************************************/
|
||||
public void setDateTimeFieldFunctionArguments(Map<String, Serializable> dateTimeFieldFunctionArguments)
|
||||
{
|
||||
this.dateTimeFieldFunctionArguments = dateTimeFieldFunctionArguments;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for dateTimeFieldFunctionArguments
|
||||
*
|
||||
* @param dateTimeFieldFunctionArguments
|
||||
* TODO document this property
|
||||
*
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public WeekdayCriteriaSettings withDateTimeFieldFunctionArguments(Map<String, Serializable> dateTimeFieldFunctionArguments)
|
||||
{
|
||||
this.dateTimeFieldFunctionArguments = dateTimeFieldFunctionArguments;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public WeekdayCriteriaSettings clone()
|
||||
{
|
||||
try
|
||||
{
|
||||
WeekdayCriteriaSettings clone = (WeekdayCriteriaSettings) super.clone();
|
||||
|
||||
if(dateTimeFieldFunctionArguments != null)
|
||||
{
|
||||
clone.dateTimeFieldFunctionArguments = new HashMap<>(dateTimeFieldFunctionArguments);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
catch(CloneNotSupportedException e)
|
||||
{
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 * //
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
@@ -65,7 +65,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
const route = useLocation().pathname.split("/").slice(1);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {pageHeader, setDotMenuOpen} = useContext(QContext);
|
||||
const {pageHeader, pageHeaderRightContent, setDotMenuOpen} = useContext(QContext);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -269,10 +269,11 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
</Toolbar>
|
||||
{
|
||||
pageHeader &&
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" flexWrap={{xs: "wrap", md: "nowrap"}} columnGap={2} rowGap={1}>
|
||||
<MDTypography pb="0.5rem" variant="h3" color={light ? "white" : "dark"}>
|
||||
{pageHeader}
|
||||
</MDTypography>
|
||||
{pageHeaderRightContent && <Box display="flex">{pageHeaderRightContent}</Box>}
|
||||
</Box>
|
||||
}
|
||||
</AppBar>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -47,7 +47,8 @@ interface FieldAutoCompleteProps
|
||||
autocompleteSlotProps?: any,
|
||||
hasError?: boolean,
|
||||
noOptionsText?: string,
|
||||
omitExposedJoins?: string[]
|
||||
omitExposedJoins?: string[],
|
||||
includeVirtualFields: "all" | "none" | "querySelectable" | "queryCriteria"
|
||||
}
|
||||
|
||||
FieldAutoComplete.defaultProps =
|
||||
@@ -63,11 +64,24 @@ FieldAutoComplete.defaultProps =
|
||||
autocompleteSlotProps: null,
|
||||
hasError: false,
|
||||
noOptionsText: "No options",
|
||||
includeVirtualFields: "none"
|
||||
};
|
||||
|
||||
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string)
|
||||
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string, includeVirtualFields: "all" | "none" | "querySelectable" | "queryCriteria" = "none")
|
||||
{
|
||||
const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
const availableFields = [...tableMetaData.fields.values()]
|
||||
if (includeVirtualFields != "none")
|
||||
{
|
||||
tableMetaData.virtualFields?.forEach(virtualField =>
|
||||
{
|
||||
if (includeVirtualFields == "all" || (includeVirtualFields == "querySelectable" && virtualField.isQuerySelectable) || (includeVirtualFields == "queryCriteria" && virtualField.isQueryCriteria))
|
||||
{
|
||||
availableFields.push(virtualField);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sortedFields = availableFields.sort((a, b) => a.label.localeCompare(b.label));
|
||||
for (let i = 0; i < sortedFields.length; i++)
|
||||
{
|
||||
const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name;
|
||||
@@ -90,12 +104,12 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a
|
||||
/*******************************************************************************
|
||||
** Component for rendering a list of field names from a table as an auto-complete.
|
||||
*******************************************************************************/
|
||||
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError, noOptionsText, omitExposedJoins}: FieldAutoCompleteProps): JSX.Element
|
||||
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError, noOptionsText, omitExposedJoins, includeVirtualFields}: FieldAutoCompleteProps): JSX.Element
|
||||
{
|
||||
const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null);
|
||||
|
||||
const fieldOptions: any[] = [];
|
||||
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName);
|
||||
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName, includeVirtualFields);
|
||||
let fieldsGroupBy = null;
|
||||
|
||||
const availableExposedJoins = useMemo(() =>
|
||||
|
||||
@@ -636,6 +636,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
fieldEndAdornment={<Box whiteSpace="nowrap"><Icon>arrow_upward</Icon><Icon>arrow_downward</Icon></Box>}
|
||||
handleAdornmentClick={handleSetSortArrowClick}
|
||||
omitExposedJoins={props.omitExposedJoins}
|
||||
includeVirtualFields="queryCriteria"
|
||||
/>);
|
||||
|
||||
const filterBuilderMouseEvents =
|
||||
@@ -692,6 +693,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
key={fieldName}
|
||||
allowVariables={props.allowVariables}
|
||||
fullFieldName={fieldName}
|
||||
metaData={metaData}
|
||||
tableMetaData={tableMetaData}
|
||||
updateCriteria={updateQuickCriteria}
|
||||
criteriaParam={getQuickCriteriaParam(fieldName)}
|
||||
@@ -712,6 +714,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
return (defaultQuickFilterFieldNameMap[fieldName] ? null : <QuickFilter
|
||||
key={fieldName}
|
||||
fullFieldName={fieldName}
|
||||
metaData={metaData}
|
||||
tableMetaData={tableMetaData}
|
||||
updateCriteria={updateQuickCriteria}
|
||||
criteriaParam={getQuickCriteriaParam(fieldName)}
|
||||
@@ -734,6 +737,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
isModeSelectOne={true}
|
||||
handleSelectedField={handleFieldListMenuSelection}
|
||||
omitExposedJoins={props.omitExposedJoins}
|
||||
includeVirtualFields="queryCriteria"
|
||||
/>
|
||||
}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {QVirtualFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QVirtualFieldMetaData";
|
||||
import {sanitizeId} from "qqq/utils/qqqIdUtils";
|
||||
import React, {useMemo, useState} from "react";
|
||||
|
||||
@@ -52,13 +53,15 @@ interface FieldListMenuProps
|
||||
handleToggleField?: (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => void,
|
||||
fieldEndAdornment?: JSX.Element,
|
||||
handleAdornmentClick?: (field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent<any>) => void,
|
||||
omitExposedJoins?: string[]
|
||||
omitExposedJoins?: string[],
|
||||
includeVirtualFields: "all" | "none" | "querySelectable" | "queryCriteria"
|
||||
}
|
||||
|
||||
FieldListMenu.defaultProps = {
|
||||
showTableHeaderEvenIfNoExposedJoins: false,
|
||||
isModeSelectOne: false,
|
||||
isModeToggle: false,
|
||||
includeVirtualFields: "none"
|
||||
};
|
||||
|
||||
interface TableWithFields
|
||||
@@ -71,13 +74,12 @@ interface TableWithFields
|
||||
** Component to render a list of fields from a table (and its join tables)
|
||||
** which can be interacted with...
|
||||
*******************************************************************************/
|
||||
export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick, omitExposedJoins}: FieldListMenuProps): JSX.Element
|
||||
export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick, omitExposedJoins, includeVirtualFields}: FieldListMenuProps): JSX.Element
|
||||
{
|
||||
const [menuAnchorElement, setMenuAnchorElement] = useState(null);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [focusedIndex, setFocusedIndex] = useState(null as number);
|
||||
|
||||
const [fieldsByTable, setFieldsByTable] = useState([] as TableWithFields[]);
|
||||
const [collapsedTables, setCollapsedTables] = useState({} as { [tableName: string]: boolean });
|
||||
|
||||
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
|
||||
@@ -120,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)}];
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -161,7 +137,9 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
function getTableFieldsAsAlphabeticalArray(table: QTableMetaData): QFieldMetaData[]
|
||||
{
|
||||
const fields: QFieldMetaData[] = [];
|
||||
table.fields.forEach(field =>
|
||||
const seenFields: Record<string, true> = {};
|
||||
|
||||
const fieldPusher = (field: QFieldMetaData) =>
|
||||
{
|
||||
let fullFieldName = field.name;
|
||||
if (table.name != tableMetaData.name)
|
||||
@@ -169,12 +147,39 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
fullFieldName = `${table.name}.${field.name}`;
|
||||
}
|
||||
|
||||
if(seenFields[fullFieldName])
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if(field instanceof QVirtualFieldMetaData)
|
||||
{
|
||||
if(includeVirtualFields == "none")
|
||||
{
|
||||
return;
|
||||
}
|
||||
else if (includeVirtualFields == "queryCriteria" && !field.isQueryCriteria)
|
||||
{
|
||||
return;
|
||||
}
|
||||
else if (includeVirtualFields == "querySelectable" && !field.isQuerySelectable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldNamesToHide && fieldNamesToHide.indexOf(fullFieldName) > -1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
seenFields[fullFieldName] = true;
|
||||
fields.push(field);
|
||||
});
|
||||
}
|
||||
|
||||
table.fields.forEach(field => fieldPusher(field));
|
||||
table.virtualFields?.forEach(field => fieldPusher(field));
|
||||
|
||||
fields.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return (fields);
|
||||
}
|
||||
@@ -231,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);
|
||||
@@ -483,14 +488,41 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Get the list of fields (and allowed virtual fields) for a given table.
|
||||
***************************************************************************/
|
||||
function getFieldsList(table: QTableMetaData): QFieldMetaData[]
|
||||
{
|
||||
const fieldsList = [...table.fields.values()];
|
||||
if (table.virtualFields && includeVirtualFields != "none")
|
||||
{
|
||||
for (let virtualField of table.virtualFields.values())
|
||||
{
|
||||
if (includeVirtualFields == "querySelectable" && virtualField.isQuerySelectable)
|
||||
{
|
||||
fieldsList.push(virtualField);
|
||||
}
|
||||
else if (includeVirtualFields == "queryCriteria" && virtualField.isQueryCriteria)
|
||||
{
|
||||
fieldsList.push(virtualField);
|
||||
}
|
||||
else if (includeVirtualFields == "all")
|
||||
{
|
||||
fieldsList.push(virtualField);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fieldsList;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Event handler for toggling a table in toggle mode
|
||||
*******************************************************************************/
|
||||
function handleTableToggle(event: React.ChangeEvent<HTMLInputElement>, table: QTableMetaData)
|
||||
{
|
||||
event.stopPropagation();
|
||||
|
||||
const fieldsList = [...table.fields.values()];
|
||||
const fieldsList = getFieldsList(table);
|
||||
for (let i = 0; i < fieldsList.length; i++)
|
||||
{
|
||||
const field = fieldsList[i];
|
||||
@@ -529,7 +561,8 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
*******************************************************************************/
|
||||
function getTableToggleState(table: QTableMetaData, isMainTable: boolean): { allOn: boolean, count: number }
|
||||
{
|
||||
const fieldsList = [...table.fields.values()];
|
||||
const fieldsList = getFieldsList(table);
|
||||
|
||||
let allOn = true;
|
||||
let count = 0;
|
||||
for (let i = 0; i < fieldsList.length; i++)
|
||||
@@ -605,7 +638,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
</Box>
|
||||
}
|
||||
<Box p={1} pt={0.5}>
|
||||
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}}} />
|
||||
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}, autoComplete: "off"}} />
|
||||
{
|
||||
searchText != "" && <IconButton sx={{position: "absolute", right: "0.5rem", top: "0.5rem"}} onClick={() =>
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import {QFieldType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {FieldFunction} from "@qrunio/qqq-frontend-core/lib/model/query/FieldFunction";
|
||||
import {QCriteriaOperator} from "@qrunio/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
||||
import {QFilterCriteria} from "@qrunio/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
@@ -82,14 +83,26 @@ export interface OperatorOption
|
||||
value: QCriteriaOperator;
|
||||
implicitValues?: any[];
|
||||
valueMode: ValueMode;
|
||||
fieldFunctionType?: string
|
||||
fieldFunctionArguments?: Record<string, any>
|
||||
}
|
||||
|
||||
export const getDefaultCriteriaValue = () => [""];
|
||||
|
||||
export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: string): OperatorOption[] =>
|
||||
export const getOperatorOptions = (metaData: QInstance, tableMetaData: QTableMetaData, fieldName: string): OperatorOption[] =>
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// read settings for weekday criteria from materialDashboard supplementalInstanceMetaData //
|
||||
// if the settings aren't there, assume we DO want these criteria available. //
|
||||
// and allow application to set fieldFunctionArguments for WeekdayOfDateTime functions - //
|
||||
// e.g., to set default time zone or to set preference to use session time zone, etc. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const weekdayCriteriaSettings = metaData.supplementalInstanceMetaData?.get("materialDashboard")?.weekdayCriteriaSettings;
|
||||
const weekdayCriteriaEnabled = weekdayCriteriaSettings ? weekdayCriteriaSettings.enabled : true;
|
||||
const weekdayCriteriaDateTimeFieldFunctionArguments: Record<string, any> | undefined = weekdayCriteriaSettings?.dateTimeFieldFunctionArguments;
|
||||
|
||||
const [field, fieldTable] = FilterUtils.getField(tableMetaData, fieldName);
|
||||
let operatorOptions = [];
|
||||
let operatorOptions: OperatorOption[] = [];
|
||||
if (field && fieldTable)
|
||||
{
|
||||
//////////////////////////////////////////////////////
|
||||
@@ -135,10 +148,11 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str
|
||||
operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE});
|
||||
operatorOptions.push({label: "is between", value: QCriteriaOperator.BETWEEN, valueMode: ValueMode.DOUBLE_DATE});
|
||||
operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN, valueMode: ValueMode.DOUBLE_DATE});
|
||||
//? operatorOptions.push({label: "is between", value: QCriteriaOperator.BETWEEN});
|
||||
//? operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN});
|
||||
//? operatorOptions.push({label: "is any of", value: QCriteriaOperator.IN});
|
||||
//? operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN});
|
||||
if(weekdayCriteriaEnabled)
|
||||
{
|
||||
operatorOptions.push({label: "day is any of", value: QCriteriaOperator.IN, valueMode: ValueMode.PVS_MULTI, fieldFunctionType: "WeekdayOfDate"});
|
||||
operatorOptions.push({label: "day is none of", value: QCriteriaOperator.NOT_IN, valueMode: ValueMode.PVS_MULTI, fieldFunctionType: "WeekdayOfDate"});
|
||||
}
|
||||
break;
|
||||
case QFieldType.DATE_TIME:
|
||||
operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME});
|
||||
@@ -151,8 +165,11 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str
|
||||
operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE});
|
||||
operatorOptions.push({label: "is between", value: QCriteriaOperator.BETWEEN, valueMode: ValueMode.DOUBLE_DATE_TIME});
|
||||
operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN, valueMode: ValueMode.DOUBLE_DATE_TIME});
|
||||
//? operatorOptions.push({label: "is between", value: QCriteriaOperator.BETWEEN});
|
||||
//? operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN});
|
||||
if(weekdayCriteriaEnabled)
|
||||
{
|
||||
operatorOptions.push({label: "day is any of", value: QCriteriaOperator.IN, valueMode: ValueMode.PVS_MULTI, fieldFunctionType: "WeekdayOfDateTime", fieldFunctionArguments: weekdayCriteriaDateTimeFieldFunctionArguments});
|
||||
operatorOptions.push({label: "day is none of", value: QCriteriaOperator.NOT_IN, valueMode: ValueMode.PVS_MULTI, fieldFunctionType: "WeekdayOfDateTime", fieldFunctionArguments: weekdayCriteriaDateTimeFieldFunctionArguments});
|
||||
}
|
||||
break;
|
||||
case QFieldType.BOOLEAN:
|
||||
operatorOptions.push({label: "equals yes", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.NONE, implicitValues: [true]});
|
||||
@@ -304,7 +321,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
defaultFieldValue = {field: field, table: fieldTable, fieldName: criteria.fieldName};
|
||||
}
|
||||
|
||||
operatorOptions = getOperatorOptions(tableMetaData, criteria.fieldName);
|
||||
operatorOptions = getOperatorOptions(metaData, tableMetaData, criteria.fieldName);
|
||||
|
||||
let newOperatorSelectedValue = operatorOptions.filter(option =>
|
||||
{
|
||||
@@ -357,7 +374,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// update the operator options, and the operator on this criteria //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
operatorOptions = getOperatorOptions(tableMetaData, criteria.fieldName);
|
||||
operatorOptions = getOperatorOptions(metaData, tableMetaData, criteria.fieldName);
|
||||
if (operatorOptions.length)
|
||||
{
|
||||
if (isFieldTypeDifferent(oldFieldName, criteria.fieldName))
|
||||
@@ -382,16 +399,17 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
/////////////////////////////////////////////
|
||||
const handleOperatorChange = (event: any, newValue: any, reason: string) =>
|
||||
{
|
||||
criteria.operator = newValue ? newValue.value : null;
|
||||
const newOperatorOption = newValue as OperatorOption;
|
||||
|
||||
if (newValue)
|
||||
if (newOperatorOption)
|
||||
{
|
||||
setOperatorSelectedValue(newValue);
|
||||
setOperatorInputValue(newValue.label);
|
||||
criteria.operator = newOperatorOption.value;
|
||||
setOperatorSelectedValue(newOperatorOption);
|
||||
setOperatorInputValue(newOperatorOption.label);
|
||||
|
||||
if (newValue.implicitValues)
|
||||
if (newOperatorOption.implicitValues)
|
||||
{
|
||||
criteria.values = newValue.implicitValues;
|
||||
criteria.values = newOperatorOption.implicitValues;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -403,17 +421,28 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
criteria.values = [];
|
||||
}
|
||||
|
||||
if (newValue.valueMode && !newValue.implicitValues)
|
||||
if (newOperatorOption.valueMode && !newOperatorOption.implicitValues)
|
||||
{
|
||||
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
|
||||
const requiredValueCount = getValueModeRequiredCount(newOperatorOption.valueMode);
|
||||
if (requiredValueCount != null && criteria.values.length > requiredValueCount)
|
||||
{
|
||||
criteria.values.splice(requiredValueCount);
|
||||
}
|
||||
}
|
||||
|
||||
if(newOperatorOption.fieldFunctionType)
|
||||
{
|
||||
criteria.fieldFunction = new FieldFunction(criteria.fieldName, newOperatorOption.fieldFunctionType, newOperatorOption.fieldFunctionArguments);
|
||||
}
|
||||
else
|
||||
{
|
||||
criteria.fieldFunction = undefined;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
criteria.operator = null;
|
||||
criteria.fieldFunction = undefined;
|
||||
setOperatorSelectedValue(null);
|
||||
setOperatorInputValue("");
|
||||
}
|
||||
@@ -489,7 +518,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
: <span />}
|
||||
</Box>
|
||||
<Box display="inline-block" width={250} className="fieldColumn">
|
||||
<FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange}
|
||||
<FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange} includeVirtualFields="queryCriteria"
|
||||
omitExposedJoins={omitExposedJoins} autocompleteSlotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QPossibleValue} from "@qrunio/qqq-frontend-core/lib/model/QPossibleValue";
|
||||
import {FilterVariableExpression} from "@qrunio/qqq-frontend-core/lib/model/query/FilterVariableExpression";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Box from "@mui/material/Box";
|
||||
@@ -39,6 +40,7 @@ import {EvaluatedExpression} from "qqq/components/query/EvaluatedExpression";
|
||||
import FilterCriteriaPaster from "qqq/components/query/FilterCriteriaPaster";
|
||||
import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow";
|
||||
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
|
||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React, {SyntheticEvent, useReducer, useState} from "react";
|
||||
import {flushSync} from "react-dom";
|
||||
@@ -407,10 +409,17 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
|
||||
initialValues = criteria.values;
|
||||
}
|
||||
}
|
||||
|
||||
let inlinePossibleValues: QPossibleValue[];
|
||||
if(operatorOption.fieldFunctionType === "WeekdayOfDate" || operatorOption.fieldFunctionType === "WeekdayOfDateTime")
|
||||
{
|
||||
inlinePossibleValues = FilterUtils.getWeekdayPossibleValues();
|
||||
}
|
||||
|
||||
return <Box display="flex" alignItems="flex-end" className="multiValue">
|
||||
<Box width={"100%"}>
|
||||
<DynamicSelect
|
||||
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
|
||||
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null, possibleValues: inlinePossibleValues}}
|
||||
overrideId={field.name + "-multi-" + criteria.id}
|
||||
key={field.name + "-multi-" + criteria.id + `-pasterIteration-${pasterIteration}`}
|
||||
isMultiple
|
||||
|
||||
@@ -27,13 +27,15 @@ import Menu from "@mui/material/Menu";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {FieldFunction} from "@qrunio/qqq-frontend-core/lib/model/query/FieldFunction";
|
||||
import {QCriteriaOperator} from "@qrunio/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
||||
import {QFilterCriteria} from "@qrunio/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
|
||||
import {getDefaultCriteriaValue, getOperatorOptions, getValueModeRequiredCount, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow";
|
||||
import {getDefaultCriteriaValue, getOperatorOptions, getValueModeRequiredCount, OperatorOption, validateCriteria, ValueMode} from "qqq/components/query/FilterCriteriaRow";
|
||||
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
|
||||
import XIcon from "qqq/components/query/XIcon";
|
||||
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
|
||||
@@ -45,6 +47,7 @@ export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
|
||||
|
||||
interface QuickFilterProps
|
||||
{
|
||||
metaData: QInstance;
|
||||
tableMetaData: QTableMetaData;
|
||||
fullFieldName: string;
|
||||
fieldMetaData: QFieldMetaData;
|
||||
@@ -170,9 +173,9 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q
|
||||
** Component to render a QuickFilter - that is - a button, with a Menu under it,
|
||||
** with Operator and Value controls.
|
||||
*******************************************************************************/
|
||||
export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField, queryScreenUsage, allowVariables}: QuickFilterProps): JSX.Element
|
||||
export default function QuickFilter({metaData, tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField, queryScreenUsage, allowVariables}: QuickFilterProps): JSX.Element
|
||||
{
|
||||
const operatorOptions = fieldMetaData ? getOperatorOptions(tableMetaData, fullFieldName) : [];
|
||||
const operatorOptions = fieldMetaData ? getOperatorOptions(metaData, tableMetaData, fullFieldName) : [];
|
||||
const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -326,16 +329,17 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
*******************************************************************************/
|
||||
const handleOperatorChange = (event: any, newValue: any, reason: string) =>
|
||||
{
|
||||
criteria.operator = newValue ? newValue.value : null;
|
||||
const newOperatorOption = newValue as OperatorOption;
|
||||
|
||||
if (newValue)
|
||||
if (newOperatorOption)
|
||||
{
|
||||
setOperatorSelectedValue(newValue);
|
||||
setOperatorInputValue(newValue.label);
|
||||
criteria.operator = newOperatorOption.value;
|
||||
setOperatorSelectedValue(newOperatorOption);
|
||||
setOperatorInputValue(newOperatorOption.label);
|
||||
|
||||
if (newValue.implicitValues)
|
||||
if (newOperatorOption.implicitValues)
|
||||
{
|
||||
criteria.values = newValue.implicitValues;
|
||||
criteria.values = newOperatorOption.implicitValues;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -347,17 +351,49 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
criteria.values = [];
|
||||
}
|
||||
|
||||
if (newValue.valueMode && !newValue.implicitValues)
|
||||
if (newOperatorOption.valueMode && !newOperatorOption.implicitValues)
|
||||
{
|
||||
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
|
||||
const requiredValueCount = getValueModeRequiredCount(newOperatorOption.valueMode);
|
||||
if (requiredValueCount != null && criteria.values.length > requiredValueCount)
|
||||
{
|
||||
criteria.values.splice(requiredValueCount);
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the operator option has a field function, then set it on the criteria object - else clear it //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(newOperatorOption.fieldFunctionType)
|
||||
{
|
||||
criteria.fieldFunction = new FieldFunction(criteria.fieldName, newOperatorOption.fieldFunctionType, newOperatorOption.fieldFunctionArguments);
|
||||
}
|
||||
else
|
||||
{
|
||||
criteria.fieldFunction = undefined;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// for dates & date-times - if moving from a PVS operator to a date (or date time) operator, reset the values array //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if((fieldMetaData?.type == "DATE" || fieldMetaData?.type == "DATE_TIME") && criteria.values.length > 0)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// at first we thought this code might fail if you move from pvs to none, then none to date... //
|
||||
// but presumably that goes through a splice again, w/ required value count 0, thus making it a non-issue //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const isPvs = (option: OperatorOption) => option?.valueMode == ValueMode.PVS_SINGLE || option?.valueMode == ValueMode.PVS_MULTI;
|
||||
const isDate = (option: OperatorOption) => option?.valueMode == ValueMode.SINGLE_DATE || option?.valueMode == ValueMode.DOUBLE_DATE || option?.valueMode == ValueMode.SINGLE_DATE_TIME || option?.valueMode == ValueMode.DOUBLE_DATE_TIME;
|
||||
|
||||
if((isPvs(operatorSelectedValue) && isDate(newOperatorOption)) || (isDate(operatorSelectedValue) && isPvs(newOperatorOption)))
|
||||
{
|
||||
criteria.values = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
criteria.operator = null;
|
||||
criteria.fieldFunction = undefined;
|
||||
setOperatorSelectedValue(null);
|
||||
setOperatorInputValue("");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
|
||||
|
||||
import {GridPinnedColumns} from "@mui/x-data-grid-pro";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {GridPinnedColumns} from "@mui/x-data-grid-pro";
|
||||
import DataGridUtils from "qqq/utils/DataGridUtils";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
|
||||
@@ -159,7 +159,16 @@ export default class QQueryColumns
|
||||
*******************************************************************************/
|
||||
private static getSortedFieldsFromTable(table: QTableMetaData)
|
||||
{
|
||||
const fields = [...table.fields.values()];
|
||||
const fields: QFieldMetaData[] = [];
|
||||
table.fields.forEach((field) => fields.push(field));
|
||||
table.virtualFields?.forEach((virtualField) =>
|
||||
{
|
||||
if (virtualField.isQuerySelectable)
|
||||
{
|
||||
fields.push(virtualField);
|
||||
}
|
||||
});
|
||||
|
||||
fields.sort((a: QFieldMetaData, b: QFieldMetaData) =>
|
||||
{
|
||||
return a.name.localeCompare(b.name);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ import {Select, SelectChangeEvent, Typography} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Card from "@mui/material/Card";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import React, {useContext, useState} from "react";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
import {useParams} from "react-router-dom";
|
||||
import QContext from "QContext";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
@@ -51,7 +51,6 @@ function TableDeveloperView({table}: Props): JSX.Element
|
||||
const [accessToken, setAccessToken] = useState(null as string);
|
||||
|
||||
const tableName = table.name;
|
||||
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
|
||||
const [noApis, setNoApis] = useState(null as boolean);
|
||||
const [tableMetaData, setTableMetaData] = useState(null);
|
||||
const [metaData, setMetaData] = useState(null as QInstance);
|
||||
@@ -62,11 +61,14 @@ function TableDeveloperView({table}: Props): JSX.Element
|
||||
|
||||
const {setPageHeader} = useContext(QContext);
|
||||
|
||||
(async () =>
|
||||
useEffect(() =>
|
||||
{
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
setAccessToken(accessToken);
|
||||
})();
|
||||
(async () =>
|
||||
{
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
setAccessToken(accessToken);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const LAST_API_NAME_LS_KEY = "qqq.tableDeveloperView.lastApiName";
|
||||
const LAST_API_VERSION_LS_KEY = "qqq.tableDeveloperView.lastApiVersion";
|
||||
@@ -95,10 +97,8 @@ function TableDeveloperView({table}: Props): JSX.Element
|
||||
}
|
||||
}
|
||||
|
||||
if (!asyncLoadInited)
|
||||
useEffect(() =>
|
||||
{
|
||||
setAsyncLoadInited(true);
|
||||
|
||||
(async () =>
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
@@ -161,7 +161,7 @@ function TableDeveloperView({table}: Props): JSX.Element
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
selectVersionAfterApiIsChanged(versionsJson);
|
||||
})();
|
||||
}
|
||||
}, [tableName]);
|
||||
|
||||
const selectApi = async (event: SelectChangeEvent) =>
|
||||
{
|
||||
|
||||
@@ -40,6 +40,7 @@ import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance"
|
||||
import {QProcessMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QTableVariant} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableVariant";
|
||||
import {QVirtualFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QVirtualFieldMetaData";
|
||||
import {QJobComplete} from "@qrunio/qqq-frontend-core/lib/model/processes/QJobComplete";
|
||||
import {QJobError} from "@qrunio/qqq-frontend-core/lib/model/processes/QJobError";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
@@ -73,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";
|
||||
@@ -191,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);
|
||||
@@ -381,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[]);
|
||||
@@ -455,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[]);
|
||||
@@ -1042,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++)
|
||||
@@ -1082,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);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -1106,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());
|
||||
});
|
||||
@@ -1152,8 +1162,6 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
queryErrors[thisQueryId] = errorMessage;
|
||||
setQueryErrors(queryErrors);
|
||||
setReceivedQueryErrorTimestamp(new Date());
|
||||
|
||||
throw error;
|
||||
});
|
||||
})();
|
||||
};
|
||||
@@ -1176,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 //
|
||||
@@ -1186,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)
|
||||
@@ -2310,25 +2319,39 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
const closeCopyMoreMenu = () => setCopyMoreMenu(null);
|
||||
*/
|
||||
|
||||
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, currentColumn.field);
|
||||
let isQueryCriteria = true;
|
||||
if(field instanceof QVirtualFieldMetaData)
|
||||
{
|
||||
if(!field.isQueryCriteria)
|
||||
{
|
||||
isQueryCriteria = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<GridColumnMenuContainer ref={ref} {...props}>
|
||||
<SortGridMenuItems onClick={hideMenu} column={currentColumn!} />
|
||||
|
||||
<MenuItem onClick={(e) =>
|
||||
{
|
||||
hideMenu(e);
|
||||
if (mode == "advanced")
|
||||
isQueryCriteria && <SortGridMenuItems onClick={hideMenu} column={currentColumn!} />
|
||||
}
|
||||
|
||||
{
|
||||
isQueryCriteria && <MenuItem onClick={(e) =>
|
||||
{
|
||||
handleColumnMenuAdvancedFilterSelection(currentColumn.field);
|
||||
}
|
||||
else
|
||||
{
|
||||
// @ts-ignore
|
||||
basicAndAdvancedQueryControlsRef.current.addField(currentColumn.field);
|
||||
}
|
||||
}}>
|
||||
Filter
|
||||
</MenuItem>
|
||||
hideMenu(e);
|
||||
if (mode == "advanced")
|
||||
{
|
||||
handleColumnMenuAdvancedFilterSelection(currentColumn.field);
|
||||
}
|
||||
else
|
||||
{
|
||||
// @ts-ignore
|
||||
basicAndAdvancedQueryControlsRef.current.addField(currentColumn.field);
|
||||
}
|
||||
}}>
|
||||
Filter
|
||||
</MenuItem>
|
||||
}
|
||||
|
||||
<HideGridColMenuItem onClick={hideMenu} column={currentColumn!} />
|
||||
|
||||
@@ -3103,13 +3126,14 @@ 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></>}
|
||||
isModeToggle={true}
|
||||
toggleStates={view.queryColumns.getVisibilityToggleStates()}
|
||||
handleToggleField={handleChangeOneColumnVisibility}
|
||||
includeVirtualFields="querySelectable"
|
||||
/>
|
||||
</Box>);
|
||||
};
|
||||
@@ -3206,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)}>
|
||||
@@ -3262,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}
|
||||
@@ -3293,7 +3324,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
queryFilter: queryFilter,
|
||||
updateFilter: doSetQueryFilter,
|
||||
allowVariables: allowVariables,
|
||||
omitExposedJoins: omitExposedJoins,
|
||||
omitExposedJoins: omittedQueryJoinTables,
|
||||
}
|
||||
}}
|
||||
localeText={{
|
||||
|
||||
@@ -72,7 +72,7 @@ import Client from "qqq/utils/qqq/Client";
|
||||
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
import React, {useContext, useEffect, useMemo, useState} from "react";
|
||||
import {useLocation, useNavigate, useParams} from "react-router-dom";
|
||||
|
||||
const qController = Client.getInstance();
|
||||
@@ -123,18 +123,10 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe
|
||||
{
|
||||
return <Grid container lg={12} key={key} display="flex" py={1} pr={2}>
|
||||
{
|
||||
fieldNames.map((fieldName: string) =>
|
||||
fieldNames.map((fieldName: string) =>
|
||||
{
|
||||
let [field, tableForField] = tableMetaData ? TableUtils.getFieldAndTable(tableMetaData, fieldName) : fieldMap ? [fieldMap[fieldName], null] : [null, null];
|
||||
|
||||
if(field == null)
|
||||
{
|
||||
if(tableMetaData?.virtualFields?.has(fieldName))
|
||||
{
|
||||
field = tableMetaData.virtualFields.get(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
if (field != null)
|
||||
{
|
||||
let label = field.label;
|
||||
@@ -146,7 +138,7 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe
|
||||
|
||||
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default", ...(styleOverrides?.label ?? {})}}>{label}:</Typography>;
|
||||
|
||||
if(field.hasAdornment(AdornmentType.WIDGET))
|
||||
if (field.hasAdornment(AdornmentType.WIDGET))
|
||||
{
|
||||
return (<Grid item key={fieldName} lg={gridColumns} flexDirection="column" pr={2}>
|
||||
<FieldValueAsWidget field={field} record={record} />
|
||||
@@ -188,7 +180,7 @@ export function getVisibleJoinTables(tableMetaData: QTableMetaData): Set<string>
|
||||
continue;
|
||||
}
|
||||
|
||||
section.fieldNames.forEach((fieldName) =>
|
||||
section.fieldNames.forEach((fieldName) =>
|
||||
{
|
||||
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
if (tableForField && tableForField.name != tableMetaData.name)
|
||||
@@ -250,7 +242,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
const openActionsMenu = (event: any) => setActionsMenuAnchorElement(event.currentTarget);
|
||||
const closeActionsMenu = () => setActionsMenuAnchorElement(null);
|
||||
|
||||
const {accentColor, setPageHeader, tableMetaData, setTableMetaData, tableProcesses, setTableProcesses, dotMenuOpen, keyboardHelpOpen, modalStack, helpHelpActive, recordAnalytics, userId: currentUserId} = useContext(QContext);
|
||||
const {accentColor, setPageHeader, setPageHeaderRightContent, tableMetaData, setTableMetaData, tableProcesses, setTableProcesses, dotMenuOpen, keyboardHelpOpen, modalStack, helpHelpActive, recordAnalytics, userId: currentUserId} = useContext(QContext);
|
||||
|
||||
const CREATE_CHILD_KEY = "createChild";
|
||||
|
||||
@@ -259,7 +251,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
tableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey));
|
||||
}
|
||||
|
||||
const reload = () =>
|
||||
const reload = () =>
|
||||
{
|
||||
setSuccessMessage(null);
|
||||
setNotFoundMessage(null);
|
||||
@@ -277,18 +269,18 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
///////////////////////
|
||||
// Keyboard handling //
|
||||
///////////////////////
|
||||
useEffect(() =>
|
||||
useEffect(() =>
|
||||
{
|
||||
if (tableMetaData == null)
|
||||
{
|
||||
(async () =>
|
||||
(async () =>
|
||||
{
|
||||
const tableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTableMetaData(tableMetaData);
|
||||
})();
|
||||
}
|
||||
|
||||
const down = (e: KeyboardEvent) =>
|
||||
const down = (e: KeyboardEvent) =>
|
||||
{
|
||||
const type = (e.target as any).type;
|
||||
const validType = (type !== "text" && type !== "textarea" && type !== "input" && type !== "search");
|
||||
@@ -324,19 +316,19 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", down);
|
||||
return () =>
|
||||
return () =>
|
||||
{
|
||||
document.removeEventListener("keydown", down);
|
||||
};
|
||||
}, [dotMenuOpen, keyboardHelpOpen, modalStack, showEditChildForm, showAudit, metaData, location]);
|
||||
|
||||
const gotoCreate = () =>
|
||||
const gotoCreate = () =>
|
||||
{
|
||||
const path = `${pathParts.slice(0, -1).join("/")}/create`;
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const gotoEdit = () =>
|
||||
const gotoEdit = () =>
|
||||
{
|
||||
const path = `${pathParts.slice(0, -1).join("/")}/${record.values.get(table.primaryKeyField)}/edit`;
|
||||
navigate(path);
|
||||
@@ -347,7 +339,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
// we'll stay in this component, but we'll need to reload all data for the new record. //
|
||||
// if, however, our url looks like a process, then open that process. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
useEffect(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -392,7 +384,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
const parts = hashParts[i].split("=");
|
||||
if (parts.length > 1 && parts[0] == "launchProcess")
|
||||
{
|
||||
(async () =>
|
||||
(async () =>
|
||||
{
|
||||
const processMetaData = await qController.loadProcessMetaData(parts[1]);
|
||||
setActiveModalProcess(processMetaData);
|
||||
@@ -407,7 +399,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (pathParts[pathParts.length - 4] === tableName && pathParts[pathParts.length - 2] == CREATE_CHILD_KEY)
|
||||
{
|
||||
(async () =>
|
||||
(async () =>
|
||||
{
|
||||
const childTable = await qController.loadTableMetaData(pathParts[pathParts.length - 1]);
|
||||
const childId: any = null; // todo - for editing a child, not just creating one.
|
||||
@@ -426,7 +418,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
const parts = hashParts[i].split("=");
|
||||
if (parts.length > 1 && parts[0] == CREATE_CHILD_KEY)
|
||||
{
|
||||
(async () =>
|
||||
(async () =>
|
||||
{
|
||||
const childTable = await qController.loadTableMetaData(parts[1]);
|
||||
const childId: any = null; // todo - for editing a child, not just creating one.
|
||||
@@ -514,7 +506,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// when the tableMetaData changes, grab the action menu out of it (or build a default one) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
useEffect(() =>
|
||||
{
|
||||
let actionMenu: QMenu = null;
|
||||
if (metaData && tableMetaData)
|
||||
@@ -533,7 +525,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
/*******************************************************************************
|
||||
** get an element (or empty) to use as help content for a section
|
||||
*******************************************************************************/
|
||||
const getSectionHelp = (section: QTableSection) =>
|
||||
const getSectionHelp = (section: QTableSection) =>
|
||||
{
|
||||
const helpRoles = ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
|
||||
const formattedHelpContent = <HelpContent helpContents={section.helpContents} roles={helpRoles} helpContentKey={`table:${tableName};section:${section.name}`} />;
|
||||
@@ -593,7 +585,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
{
|
||||
setAsyncLoadInited(true);
|
||||
|
||||
(async () =>
|
||||
(async () =>
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// load the full table meta-data (the one we took in is a partial) //
|
||||
@@ -643,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);
|
||||
}
|
||||
|
||||
/////////////////////
|
||||
@@ -669,7 +661,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
const historyPurge = (path: string) =>
|
||||
const historyPurge = (path: string) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -728,7 +720,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
{
|
||||
let section = tableSections[i];
|
||||
|
||||
if(section.alternatives?.has("RECORD_VIEW"))
|
||||
if (section.alternatives?.has("RECORD_VIEW"))
|
||||
{
|
||||
section = section.alternatives.get("RECORD_VIEW")
|
||||
}
|
||||
@@ -741,13 +733,13 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if a section has collapsible metaData that says it IS collapsible, then figure out what the initial open state should be //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(section.collapsible && section.collapsible.isCollapsible)
|
||||
if (section.collapsible && section.collapsible.isCollapsible)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// try to read a previous value from local storage. if it doesn't exist, use the value in the meta-data //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const lsValue = localStorage.getItem(makeCollapsibleSectionOpenStateLocalStorageKey(tableMetaData.name, section.name));
|
||||
if(lsValue != undefined)
|
||||
if (lsValue != undefined)
|
||||
{
|
||||
initialCollapsibleOpenStates[section.name] = (lsValue == "true");
|
||||
}
|
||||
@@ -831,33 +823,33 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
})();
|
||||
}
|
||||
|
||||
const handleClickDeleteButton = () =>
|
||||
const handleClickDeleteButton = () =>
|
||||
{
|
||||
setDeleteConfirmationOpen(true);
|
||||
setIsDeleteSubmitting(false);
|
||||
};
|
||||
|
||||
const handleDeleteConfirmClose = () =>
|
||||
const handleDeleteConfirmClose = () =>
|
||||
{
|
||||
setDeleteConfirmationOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = (event: { preventDefault: () => void }) =>
|
||||
const handleDelete = (event: { preventDefault: () => void }) =>
|
||||
{
|
||||
setIsDeleteSubmitting(true);
|
||||
event?.preventDefault();
|
||||
(async () =>
|
||||
(async () =>
|
||||
{
|
||||
recordAnalytics({category: "tableEvents", action: "delete", label: tableMetaData?.label + " / " + record?.recordLabel});
|
||||
|
||||
await qController.delete(tableName, id)
|
||||
.then(() =>
|
||||
.then(() =>
|
||||
{
|
||||
setIsDeleteSubmitting(false);
|
||||
const path = pathParts.slice(0, -1).join("/");
|
||||
navigate(path, {state: {deleteSuccess: true}});
|
||||
})
|
||||
.catch((error) =>
|
||||
.catch((error) =>
|
||||
{
|
||||
setIsDeleteSubmitting(false);
|
||||
setDeleteConfirmationOpen(false);
|
||||
@@ -892,18 +884,18 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
new: () => gotoCreate(),
|
||||
copy: () => navigate("copy"),
|
||||
edit: () => navigate("edit"),
|
||||
delete: (closeMenu?: () => void) =>
|
||||
delete: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
handleClickDeleteButton();
|
||||
},
|
||||
developerMode: () => navigate("dev"),
|
||||
audit: (closeMenu?: () => void) =>
|
||||
audit: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
navigate("#audit");
|
||||
},
|
||||
runProcess: (processName: string) =>
|
||||
runProcess: (processName: string) =>
|
||||
{
|
||||
const process = metaData?.processes?.get(processName);
|
||||
if (process)
|
||||
@@ -915,7 +907,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
console.log("No process found for name: [" + processName + "]");
|
||||
}
|
||||
},
|
||||
downloadFileFromField: (fieldName: string, closeMenu?: () => void) =>
|
||||
downloadFileFromField: (fieldName: string, closeMenu?: () => void) =>
|
||||
{
|
||||
////////////////////////////////////////////////////////////
|
||||
// todo can or should this share more with BlobComponent? //
|
||||
@@ -939,15 +931,15 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
|
||||
closeMenu?.();
|
||||
},
|
||||
getMetaData: () =>
|
||||
getMetaData: () =>
|
||||
{
|
||||
return metaData;
|
||||
},
|
||||
getTableProcesses: () =>
|
||||
getTableProcesses: () =>
|
||||
{
|
||||
return tableProcesses;
|
||||
},
|
||||
getGenericProcesses: () =>
|
||||
getGenericProcesses: () =>
|
||||
{
|
||||
return getGenericProcesses(metaData);
|
||||
}
|
||||
@@ -963,7 +955,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
/*******************************************************************************
|
||||
** function to open the sharing modal
|
||||
*******************************************************************************/
|
||||
const openShareModal = () =>
|
||||
const openShareModal = () =>
|
||||
{
|
||||
setShowShareModal(true);
|
||||
};
|
||||
@@ -972,7 +964,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
/*******************************************************************************
|
||||
** function to close the sharing modal
|
||||
*******************************************************************************/
|
||||
const closeShareModal = () =>
|
||||
const closeShareModal = () =>
|
||||
{
|
||||
setShowShareModal(false);
|
||||
};
|
||||
@@ -981,7 +973,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
/*******************************************************************************
|
||||
** render the share button (if allowed for table)
|
||||
*******************************************************************************/
|
||||
const renderShareButton = () =>
|
||||
const renderShareButton = () =>
|
||||
{
|
||||
if (tableMetaData && tableMetaData.shareableTableMetaData)
|
||||
{
|
||||
@@ -1021,13 +1013,13 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
};
|
||||
|
||||
|
||||
const openModalProcess = (process: QProcessMetaData = null) =>
|
||||
const openModalProcess = (process: QProcessMetaData = null) =>
|
||||
{
|
||||
navigate(process.name);
|
||||
closeActionsMenu();
|
||||
};
|
||||
|
||||
const closeModalProcess = (event: object, reason: string) =>
|
||||
const closeModalProcess = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown")
|
||||
{
|
||||
@@ -1061,7 +1053,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
setShowEditChildForm(showEditChildForm);
|
||||
}
|
||||
|
||||
const closeEditChildForm = (event: object, reason: string) =>
|
||||
const closeEditChildForm = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown")
|
||||
{
|
||||
@@ -1086,7 +1078,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
setShowEditChildForm(null);
|
||||
};
|
||||
|
||||
const closeAudit = (event: object, reason: string) =>
|
||||
const closeAudit = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick") // allowing esc here, as it's a non-destructive close || reason === "escapeKeyDown")
|
||||
{
|
||||
@@ -1124,6 +1116,58 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
|
||||
const tableNameForId = tableMetaData ? sanitizeId(tableMetaData.name) : "";
|
||||
|
||||
const materialDashboardTableMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
|
||||
const materialDashboardInstanceMetaData = metaData?.supplementalInstanceMetaData?.get("materialDashboard");
|
||||
const showRecordSidebar = materialDashboardTableMetaData?.showRecordSidebar !== false;
|
||||
const recordViewActionsPlacement = materialDashboardInstanceMetaData?.recordViewActionsPlacement ?? materialDashboardTableMetaData?.recordViewActionsPlacement ?? "IN_IDENTITY_SECTION";
|
||||
const showRecordViewActionsInlineWithPageTitle = recordViewActionsPlacement === "INLINE_WITH_PAGE_TITLE";
|
||||
|
||||
const renderRecordViewActions = (boxProps: any = {}) =>
|
||||
{
|
||||
return (
|
||||
<>
|
||||
<Box display="flex" {...boxProps}>
|
||||
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} />
|
||||
{renderShareButton()}
|
||||
{metaData && tableMetaData && <RecordViewAdditionalMenus tableMetaData={tableMetaData} record={record} actions={recordViewMenuActions} />}
|
||||
{metaData && tableMetaData && <QActionsMenuButton isOpen={actionsMenuAnchorElement} onClickHandler={openActionsMenu} />}
|
||||
</Box>
|
||||
<Menu
|
||||
anchorEl={actionsMenuAnchorElement}
|
||||
anchorOrigin={{vertical: "bottom", horizontal: "right",}}
|
||||
transformOrigin={{vertical: "top", horizontal: "right",}}
|
||||
open={Boolean(actionsMenuAnchorElement)}
|
||||
onClose={closeActionsMenu}
|
||||
keepMounted
|
||||
data-qqq-id="record-view-actions-menu"
|
||||
>
|
||||
{actionMenu?.items.map((item, index) => (<RecordViewMenuItem key={index} tableMetaData={tableMetaData} menuItem={item} record={record} actions={recordViewMenuActions} closeMenu={closeActionsMenu} itemsShown={itemsShownInActionsMenu} />))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const pageHeaderRecordViewActions = useMemo(() =>
|
||||
{
|
||||
if (!showRecordViewActionsInlineWithPageTitle)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box display="flex" pb="1rem" pr="0.5rem">
|
||||
{renderRecordViewActions()}
|
||||
</Box>
|
||||
);
|
||||
}, [showRecordViewActionsInlineWithPageTitle, metaData, tableMetaData, record, actionsMenuAnchorElement, actionMenu]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setPageHeaderRightContent?.(pageHeaderRecordViewActions);
|
||||
|
||||
return () => setPageHeaderRightContent?.(null);
|
||||
}, [setPageHeaderRightContent, pageHeaderRecordViewActions]);
|
||||
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box className="recordView" data-qqq-id={`record-view-${tableNameForId}`}>
|
||||
@@ -1138,7 +1182,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
<Box pb={3}>
|
||||
{
|
||||
successMessage ?
|
||||
<Alert color="success" sx={{mb: 3}} onClose={() =>
|
||||
<Alert color="success" sx={{mb: 3}} onClose={() =>
|
||||
{
|
||||
setSuccessMessage(null);
|
||||
}}>
|
||||
@@ -1148,7 +1192,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
}
|
||||
{
|
||||
warningMessage ?
|
||||
<Alert color="warning" sx={{mb: 3}} icon={<Icon>warning</Icon>} onClose={() =>
|
||||
<Alert color="warning" sx={{mb: 3}} icon={<Icon>warning</Icon>} onClose={() =>
|
||||
{
|
||||
setWarningMessage(null);
|
||||
}}>
|
||||
@@ -1158,7 +1202,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
}
|
||||
{
|
||||
errorMessage ?
|
||||
<Alert color="error" sx={{mb: 3}} onClose={() =>
|
||||
<Alert color="error" sx={{mb: 3}} onClose={() =>
|
||||
{
|
||||
setErrorMessage(null);
|
||||
}}>
|
||||
@@ -1168,10 +1212,10 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
}
|
||||
|
||||
<Grid container spacing={3} flexWrap={{md: "nowrap"}}>
|
||||
<Grid item xs={12} lg={3} className="recordSidebar">
|
||||
{showRecordSidebar && <Grid item xs={12} lg={3} className="recordSidebar">
|
||||
<QRecordSidebar tableSections={tableSections} />
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={9} className="recordWithSidebar">
|
||||
</Grid>}
|
||||
<Grid item xs={12} lg={showRecordSidebar ? 9 : 12} className={showRecordSidebar ? "recordWithSidebar" : ""}>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} mb={3}>
|
||||
@@ -1188,23 +1232,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
<Typography variant="h5" mb="0.5rem" data-qqq-id={`record-view-title-${tableNameForId}`}>
|
||||
{tableMetaData && record ? `Viewing ${tableMetaData?.label}: ${record?.recordLabel || ""}` : ""}
|
||||
</Typography>
|
||||
<Box display="flex" ml="auto">
|
||||
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} />
|
||||
{renderShareButton()}
|
||||
{metaData && tableMetaData && <RecordViewAdditionalMenus tableMetaData={tableMetaData} record={record} actions={recordViewMenuActions} />}
|
||||
{metaData && tableMetaData && <QActionsMenuButton isOpen={actionsMenuAnchorElement} onClickHandler={openActionsMenu} />}
|
||||
</Box>
|
||||
<Menu
|
||||
anchorEl={actionsMenuAnchorElement}
|
||||
anchorOrigin={{vertical: "bottom", horizontal: "right",}}
|
||||
transformOrigin={{vertical: "top", horizontal: "right",}}
|
||||
open={Boolean(actionsMenuAnchorElement)}
|
||||
onClose={closeActionsMenu}
|
||||
keepMounted
|
||||
data-qqq-id="record-view-actions-menu"
|
||||
>
|
||||
{actionMenu?.items.map((item, index) => (<RecordViewMenuItem key={index} tableMetaData={tableMetaData} menuItem={item} record={record} actions={recordViewMenuActions} closeMenu={closeActionsMenu} itemsShown={itemsShownInActionsMenu} />))}
|
||||
</Menu>
|
||||
{!showRecordViewActionsInlineWithPageTitle && renderRecordViewActions({ml: "auto"})}
|
||||
</Box>
|
||||
</Box>
|
||||
{t1Section && getSectionHelp(t1Section)}
|
||||
@@ -1213,7 +1241,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={3} pb={4}>
|
||||
{nonT1TableSections.length > 0 ? nonT1TableSections.map((section: QTableSection) =>
|
||||
{nonT1TableSections.length > 0 ? nonT1TableSections.map((section: QTableSection) =>
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// render all sections after the T1 section. //
|
||||
@@ -1226,7 +1254,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
if (section.widgetName)
|
||||
{
|
||||
const widgetMetaData = metaData.widgets.get(section.widgetName);
|
||||
if(section.collapsible)
|
||||
if (section.collapsible)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// if the section has collapsible meta-data, then put that meta-data in the //
|
||||
@@ -1269,14 +1297,14 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
</Typography>
|
||||
{
|
||||
section.collapsible?.isCollapsible &&
|
||||
<Box p="0.75rem 0.25rem 0">
|
||||
<IconButton onClick={() => toggleCollapsibleSectionOpenState(section.name)}>
|
||||
<Icon fontSize="large">{open ? "expand_less" : "expand_more"}</Icon>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box p="0.75rem 0.25rem 0">
|
||||
<IconButton onClick={() => toggleCollapsibleSectionOpenState(section.name)}>
|
||||
<Icon fontSize="large">{open ? "expand_less" : "expand_more"}</Icon>
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
<Box style={{display: open ? "block" : "none"}}>
|
||||
<Box style={{display: open ? "block" : "none"}}>
|
||||
{getSectionHelp(section)}
|
||||
<Box p={3} pt={0} flexDirection="column">
|
||||
{sectionFieldElements.get(section.name)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -86,7 +87,7 @@ export default class DataGridUtils
|
||||
return value;
|
||||
}
|
||||
|
||||
const fields = [...tableMetaData.fields.values()];
|
||||
const fields = [...tableMetaData.fields.values(), ...(tableMetaData.virtualFields?.values() ?? [])] as QFieldMetaData[];
|
||||
const rows = [] as any[];
|
||||
let rowIndex = 0;
|
||||
results.forEach((record: QRecord) =>
|
||||
@@ -107,7 +108,7 @@ export default class DataGridUtils
|
||||
|
||||
if (join?.joinTable?.fields?.values())
|
||||
{
|
||||
const fields = [...join.joinTable.fields.values()];
|
||||
const fields = [...join.joinTable.fields.values(), ...(join.joinTable.virtualFields?.values() ?? [])];
|
||||
fields.forEach((field) =>
|
||||
{
|
||||
let fieldName = join.joinTable.name + "." + field.name;
|
||||
@@ -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))
|
||||
{
|
||||
@@ -188,6 +188,23 @@ export default class DataGridUtils
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
if (columnSort === "bySection")
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// build set of virtual fields to use //
|
||||
////////////////////////////////////////
|
||||
const virtualFieldsToUse: Record<string, true> = {};
|
||||
(tableMetaData.virtualFields ?? []).forEach((virtualField) =>
|
||||
{
|
||||
if (virtualField.isQuerySelectable)
|
||||
{
|
||||
virtualFieldsToUse[virtualField.name] = true;
|
||||
}
|
||||
});
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// loop over sections, adding all fields from sections first, then, if there's //
|
||||
// an alternative section, add any virtual fields which are in our set one ones to use //
|
||||
// this doesn't quite put exactly where they'd be, but it's an okay first version. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
for (let i = 0; i < tableMetaData.sections.length; i++)
|
||||
{
|
||||
const section = tableMetaData.sections[i];
|
||||
@@ -200,7 +217,28 @@ export default class DataGridUtils
|
||||
{
|
||||
sortedKeys.push(section.fieldNames[j]);
|
||||
}
|
||||
|
||||
if (section.alternatives?.has("RECORD_VIEW"))
|
||||
{
|
||||
section.alternatives.get("RECORD_VIEW").fieldNames.forEach((fieldName) =>
|
||||
{
|
||||
if(virtualFieldsToUse[fieldName])
|
||||
{
|
||||
sortedKeys.push(fieldName);
|
||||
delete virtualFieldsToUse[fieldName];
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////
|
||||
// if there are any leftover virtuals, add them to the end. //
|
||||
//////////////////////////////////////////////////////////////
|
||||
for (let virtualFieldName in virtualFieldsToUse)
|
||||
{
|
||||
sortedKeys.push(virtualFieldName);
|
||||
}
|
||||
|
||||
}
|
||||
else // columnSort = "alphabetical"
|
||||
{
|
||||
@@ -208,15 +246,33 @@ export default class DataGridUtils
|
||||
// sort by labels... mmm //
|
||||
///////////////////////////
|
||||
sortedKeys.push(...tableMetaData.fields.keys());
|
||||
|
||||
if(tableMetaData.virtualFields)
|
||||
{
|
||||
for (let virtualField of tableMetaData.virtualFields.values())
|
||||
{
|
||||
if(virtualField.isQuerySelectable)
|
||||
{
|
||||
sortedKeys.push(virtualField.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortedKeys.sort((a: string, b: string): number =>
|
||||
{
|
||||
return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label));
|
||||
const fieldA = tableMetaData.fields.get(a) ?? tableMetaData.virtualFields?.get(a);
|
||||
const fieldB = tableMetaData.fields.get(b) ?? tableMetaData.virtualFields?.get(b);
|
||||
|
||||
const labelA = fieldA?.label ?? "";
|
||||
const labelB = fieldB?.label ?? "";
|
||||
|
||||
return (labelA.localeCompare(labelB));
|
||||
});
|
||||
}
|
||||
|
||||
sortedKeys.forEach((key) =>
|
||||
{
|
||||
const field = tableMetaData.fields.get(key);
|
||||
const field = tableMetaData.fields.get(key) ?? tableMetaData.virtualFields?.get(key);
|
||||
if (!field)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+28
-76
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFiel
|
||||
import {QFieldType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QPossibleValue} from "@qrunio/qqq-frontend-core/lib/model/QPossibleValue";
|
||||
import {FieldFunction} from "@qrunio/qqq-frontend-core/lib/model/query/FieldFunction";
|
||||
import {FilterVariableExpression} from "@qrunio/qqq-frontend-core/lib/model/query/FilterVariableExpression";
|
||||
import {NowExpression} from "@qrunio/qqq-frontend-core/lib/model/query/NowExpression";
|
||||
import {NowWithOffsetExpression} from "@qrunio/qqq-frontend-core/lib/model/query/NowWithOffsetExpression";
|
||||
@@ -155,7 +156,7 @@ class FilterUtils
|
||||
// DynamicFormFieldDefinition (dynamicField) and add possible value props to it. //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
const dynamicField = DynamicFormUtils.getDynamicField(field);
|
||||
DynamicFormUtils.addPossibleValuePropsToSingleField(dynamicField, field, tableMetaData.name, null, null);
|
||||
DynamicFormUtils.addPossibleValuePropsToSingleField(dynamicField, field, fieldTable.name, null, null);
|
||||
|
||||
const displayValuesMap = await getDisplayValues(dynamicField, values);
|
||||
|
||||
@@ -188,6 +189,8 @@ class FilterUtils
|
||||
|
||||
if (values && values.length)
|
||||
{
|
||||
const fieldFunctionName = criteria?.fieldFunction?.functionTypeIdentifierName;
|
||||
|
||||
for (let i = 0; i < values.length; i++)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
@@ -200,11 +203,21 @@ class FilterUtils
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////
|
||||
// make date-times work for the frontend //
|
||||
///////////////////////////////////////////
|
||||
if (field.type == QFieldType.DATE_TIME)
|
||||
if (fieldFunctionName == "WeekdayOfDate" || fieldFunctionName == "WeekdayOfDateTime")
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// for weekday-of functions, make PVS's out of the values (in case they're just the ints) //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(values[i] != null && values[i] != undefined && !values[i].label)
|
||||
{
|
||||
values[i] = this.getWeekdayPossibleValue(values[i]);
|
||||
}
|
||||
}
|
||||
else if (field.type == QFieldType.DATE_TIME)
|
||||
{
|
||||
///////////////////////////////////////////
|
||||
// make date-times work for the frontend //
|
||||
///////////////////////////////////////////
|
||||
values[i] = ValueUtils.formatDateTimeValueForForm(values[i]);
|
||||
}
|
||||
}
|
||||
@@ -224,6 +237,53 @@ class FilterUtils
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
public static getWeekdayPossibleValue(dayNo: number): QPossibleValue | null
|
||||
{
|
||||
return this.getWeekdayPossibleValues().filter(pv => pv.id == dayNo)[0] ?? null;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
public static getWeekdayPossibleValues(): QPossibleValue[]
|
||||
{
|
||||
const allDays: QPossibleValue[] = [
|
||||
new QPossibleValue({id: 1, label: "Monday"}),
|
||||
new QPossibleValue({id: 2, label: "Tuesday"}),
|
||||
new QPossibleValue({id: 3, label: "Wednesday"}),
|
||||
new QPossibleValue({id: 4, label: "Thursday"}),
|
||||
new QPossibleValue({id: 5, label: "Friday"}),
|
||||
new QPossibleValue({id: 6, label: "Saturday"}),
|
||||
new QPossibleValue({id: 7, label: "Sunday"}),
|
||||
];
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// determine the locale's first day of week to order the list accordingly //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
let firstDay = 7; // default to Sunday
|
||||
try
|
||||
{
|
||||
const locale = new Intl.Locale(navigator.language) as any;
|
||||
const weekInfo = typeof locale.getWeekInfo === "function" ? locale.getWeekInfo() : locale.weekInfo;
|
||||
if (weekInfo?.firstDay)
|
||||
{
|
||||
firstDay = weekInfo.firstDay;
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
// fall back to Sunday-first
|
||||
}
|
||||
|
||||
const startIndex = firstDay - 1;
|
||||
return [...allDays.slice(startIndex), ...allDays.slice(0, startIndex)];
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** given a table, and a field name (which may be prefixed with an exposed-join
|
||||
** table name (from the table) - return the corresponding field-meta-data, and
|
||||
@@ -246,7 +306,7 @@ class FilterUtils
|
||||
const joinTable = tableMetaData.exposedJoins[i].joinTable;
|
||||
if (joinTable.name == parts[0])
|
||||
{
|
||||
return ([joinTable.fields.get(parts[1]), joinTable]);
|
||||
return ([this.getFieldOrVirtualField(joinTable, parts[1]), joinTable]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,11 +316,35 @@ class FilterUtils
|
||||
}
|
||||
else
|
||||
{
|
||||
return ([tableMetaData.fields.get(fieldName), tableMetaData]);
|
||||
return ([this.getFieldOrVirtualField(tableMetaData, fieldName), tableMetaData]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
private static getFieldOrVirtualField(tableMetaData: QTableMetaData, fieldName: string): QFieldMetaData
|
||||
{
|
||||
if(!fieldName)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if(tableMetaData.fields.has(fieldName))
|
||||
{
|
||||
return tableMetaData.fields.get(fieldName);
|
||||
}
|
||||
|
||||
if(tableMetaData.virtualFields?.has(fieldName))
|
||||
{
|
||||
return tableMetaData.virtualFields.get(fieldName);
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -451,6 +535,10 @@ class FilterUtils
|
||||
{
|
||||
labels.push(value == true ? "yes" : "no");
|
||||
}
|
||||
else if (value && value.label)
|
||||
{
|
||||
labels.push(value.label);
|
||||
}
|
||||
else if (fieldMetaData?.type == QFieldType.DATE_TIME)
|
||||
{
|
||||
labels.push(ValueUtils.formatDateTime(value));
|
||||
@@ -459,10 +547,6 @@ class FilterUtils
|
||||
{
|
||||
labels.push(ValueUtils.formatDate(value));
|
||||
}
|
||||
else if (value && value.label)
|
||||
{
|
||||
labels.push(value.label);
|
||||
}
|
||||
else
|
||||
{
|
||||
labels.push(value);
|
||||
@@ -556,8 +640,16 @@ class FilterUtils
|
||||
case QCriteriaOperator.NOT_EQUALS_OR_IS_NULL:
|
||||
return ("does not equal");
|
||||
case QCriteriaOperator.IN:
|
||||
if (isDate || isDateTime)
|
||||
{
|
||||
return ("day is any of");
|
||||
}
|
||||
return ("is any of");
|
||||
case QCriteriaOperator.NOT_IN:
|
||||
if (isDate || isDateTime)
|
||||
{
|
||||
return ("day is none of");
|
||||
}
|
||||
return ("is none of");
|
||||
case QCriteriaOperator.STARTS_WITH:
|
||||
return ("starts with");
|
||||
@@ -720,7 +812,18 @@ class FilterUtils
|
||||
// else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName);
|
||||
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field)));
|
||||
const newCriteria = new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field));
|
||||
|
||||
if(criteria.fieldFunction)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// if there's a fieldFunction, clone it if it's an object (w/ a clone method), else //
|
||||
// Object.assign to make a copy of it - point being, so we don't modify the original //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
newCriteria.fieldFunction = criteria.fieldFunction.clone ? criteria.fieldFunction?.clone() : new FieldFunction(criteria.fieldFunction.fieldName, criteria.fieldFunction.functionTypeIdentifierName, criteria.fieldFunction.arguments);
|
||||
}
|
||||
|
||||
filterForBackend.criteria.push(newCriteria);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -152,19 +154,43 @@ class TableUtils
|
||||
const join = tableMetaData?.exposedJoins[i];
|
||||
if (join?.joinTable.name == nameParts[0])
|
||||
{
|
||||
return ([join.joinTable.fields.get(nameParts[1]), join.joinTable]);
|
||||
return ([this.getFieldOrVirtualField(join.joinTable, nameParts[1]), join.joinTable]);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return ([tableMetaData.fields.get(fieldName), tableMetaData]);
|
||||
return ([this.getFieldOrVirtualField(tableMetaData, fieldName), tableMetaData]);
|
||||
}
|
||||
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
private static getFieldOrVirtualField(tableMetaData: QTableMetaData, fieldName: string): QFieldMetaData
|
||||
{
|
||||
if(!fieldName)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if(tableMetaData.fields.has(fieldName))
|
||||
{
|
||||
return tableMetaData.fields.get(fieldName);
|
||||
}
|
||||
|
||||
if(tableMetaData.virtualFields?.has(fieldName))
|
||||
{
|
||||
return tableMetaData.virtualFields.get(fieldName);
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** for a field that might be from a join table, get its label - either the field's
|
||||
** label, if it's from "this" table - or the table's label: field's label, if it's
|
||||
@@ -192,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+54
-3
@@ -40,10 +40,62 @@ import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit test for MaterialDashboardTableMetaData
|
||||
** Unit test for MaterialDashboardTableMetaData
|
||||
*******************************************************************************/
|
||||
class MaterialDashboardTableMetaDataTest extends BaseTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testShowRecordSidebarDefaultsTrue()
|
||||
{
|
||||
MaterialDashboardTableMetaData tableMetaData = new MaterialDashboardTableMetaData();
|
||||
assertThat(tableMetaData.getShowRecordSidebar()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testShowRecordSidebarCloned()
|
||||
{
|
||||
MaterialDashboardTableMetaData tableMetaData = new MaterialDashboardTableMetaData().withShowRecordSidebar(false);
|
||||
MaterialDashboardTableMetaData clone = (MaterialDashboardTableMetaData) tableMetaData.clone();
|
||||
|
||||
assertThat(clone.getShowRecordSidebar()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRecordViewActionsPlacementDefaultsToInIdentitySection()
|
||||
{
|
||||
MaterialDashboardTableMetaData tableMetaData = new MaterialDashboardTableMetaData();
|
||||
assertThat(tableMetaData.getRecordViewActionsPlacement()).isEqualTo(MaterialDashboardTableMetaData.RecordViewActionsPlacement.IN_IDENTITY_SECTION);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRecordViewActionsPlacementCloned()
|
||||
{
|
||||
MaterialDashboardTableMetaData tableMetaData = new MaterialDashboardTableMetaData()
|
||||
.withRecordViewActionsPlacement(MaterialDashboardTableMetaData.RecordViewActionsPlacement.INLINE_WITH_PAGE_TITLE);
|
||||
MaterialDashboardTableMetaData clone = (MaterialDashboardTableMetaData) tableMetaData.clone();
|
||||
|
||||
assertThat(clone.getRecordViewActionsPlacement()).isEqualTo(MaterialDashboardTableMetaData.RecordViewActionsPlacement.INLINE_WITH_PAGE_TITLE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
@@ -119,7 +171,6 @@ class MaterialDashboardTableMetaDataTest extends BaseTest
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a little setup code on a qInstance; then validate it, and assert that it
|
||||
** failed validation with reasons that match the supplied vararg-reasons (but allow
|
||||
@@ -209,4 +260,4 @@ class MaterialDashboardTableMetaDataTest extends BaseTest
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// todo - end of methods copied from QInstanceValidatorTest... //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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/";
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -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), //
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.tests;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.common.DayOfWeekPossibleValueSourceMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
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.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.functions.FieldFunction;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.functions.implementations.StringLengthFunction;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.functions.implementations.WeekdayOfDateTimeFunction;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSectionAlternativeType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.lib.QBaseSeleniumWithQApplicationTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.metadata.MemoryBackendProducer;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.metadata.PeopleAppProducer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.Keys;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for field functions (e.g., "day is any of" filter on date-time fields)
|
||||
** and virtual fields appearing in the query data grid.
|
||||
*******************************************************************************/
|
||||
public class FieldFunctionsIT extends QBaseSeleniumWithQApplicationTest
|
||||
{
|
||||
private static final String TABLE_NAME = "fieldFunctionsTest";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected void customizeQInstance(QInstance qInstance) throws QException
|
||||
{
|
||||
qInstance.add(new DayOfWeekPossibleValueSourceMetaDataProducer().produce(qInstance));
|
||||
|
||||
QTableMetaData table = new QTableMetaData()
|
||||
.withName(TABLE_NAME)
|
||||
.withBackendName(MemoryBackendProducer.NAME)
|
||||
.withRecordLabelFormatAndFields("%s %s", "firstName", "lastName")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("firstName", QFieldType.STRING).withLabel("First Name"))
|
||||
.withField(new QFieldMetaData("lastName", QFieldType.STRING).withLabel("Last Name"))
|
||||
.withField(new QFieldMetaData("birthDate", QFieldType.DATE_TIME).withLabel("Birth Date"))
|
||||
.withSection(new QFieldSection("identity", new QIcon(), Tier.T1, List.of("id", "firstName", "lastName", "birthDate"))
|
||||
.withAlternative(QFieldSectionAlternativeType.RECORD_VIEW, (s) -> s.setFieldNames(List.of("id", "fullName", "firstName", "lastName", "birthDate"))))
|
||||
.withPrimaryKeyField("id");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// fullName - virtual field in the section alternative, so it appears after the section's //
|
||||
// regular fields but before any leftover virtuals //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
table.withVirtualField(new QVirtualFieldMetaData("fullName", QFieldType.STRING)
|
||||
.withLabel("Full Name")
|
||||
.withIsQuerySelectable(true));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// birthYear - virtual field NOT in any section alternative, so it appears at the end //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
table.withVirtualField(new QVirtualFieldMetaData("birthYear", QFieldType.STRING)
|
||||
.withLabel("Birth Year")
|
||||
.withIsQuerySelectable(true));
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// firstNameLength - queryable virtual field using StringLength field function on firstName. //
|
||||
// isQueryCriteria=true so it appears in the filter field picker. //
|
||||
// isQuerySelectable=true so it appears in the data grid (auto-computed by backend). //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
table.withVirtualField(new QVirtualFieldMetaData("firstNameLength", QFieldType.INTEGER)
|
||||
.withLabel("First Name Length")
|
||||
.withIsQueryCriteria(true)
|
||||
.withIsQuerySelectable(true)
|
||||
.withFieldFunction(new FieldFunction()
|
||||
.withFieldName("firstName")
|
||||
.withFunctionTypeIdentifier(StringLengthFunction.IDENTIFIER)));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// birthDayOfWeek - queryable virtual field using WeekdayOfDateTime field function on //
|
||||
// birthDate. Returns ISO-8601 day-of-week (Monday=1..Sunday=7). //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
Map<String, Serializable> weekdayArgs = new HashMap<>();
|
||||
weekdayArgs.put(WeekdayOfDateTimeFunction.PARAM_TIME_ZONE_ID, "UTC");
|
||||
|
||||
table.withVirtualField(new QVirtualFieldMetaData("birthDayOfWeek", QFieldType.INTEGER)
|
||||
.withLabel("Birth Day Of Week")
|
||||
.withPossibleValueSourceName(DayOfWeekPossibleValueSourceMetaDataProducer.NAME)
|
||||
.withIsQueryCriteria(true)
|
||||
.withIsQuerySelectable(true)
|
||||
.withFieldFunction(new FieldFunction()
|
||||
.withFieldName("birthDate")
|
||||
.withFunctionTypeIdentifier(WeekdayOfDateTimeFunction.IDENTIFIER)
|
||||
.withArguments(weekdayArgs)));
|
||||
|
||||
table.withVirtualField(new QVirtualFieldMetaData("virtuallyUseless", QFieldType.DECIMAL)
|
||||
.withLabel("Virtually Useless")
|
||||
.withIsQueryCriteria(false)
|
||||
.withIsQuerySelectable(false));
|
||||
|
||||
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(TestTableCustomizer.class));
|
||||
|
||||
qInstance.addTable(table);
|
||||
PeopleAppProducer.addTableToGreetingsApp(qInstance, table.getName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected void setupTestData() throws QException
|
||||
{
|
||||
new InsertAction().execute(new InsertInput(TABLE_NAME).withRecords(List.of(
|
||||
new QRecord().withValue("firstName", "Homer").withValue("lastName", "Simpson").withValue("birthDate", Instant.parse("2026-03-02T12:00:00Z")), // Monday
|
||||
new QRecord().withValue("firstName", "Marge").withValue("lastName", "Simpson").withValue("birthDate", Instant.parse("2026-03-03T12:00:00Z")), // Tuesday
|
||||
new QRecord().withValue("firstName", "Bart").withValue("lastName", "Simpson").withValue("birthDate", Instant.parse("2026-03-04T12:00:00Z")), // Wednesday
|
||||
new QRecord().withValue("firstName", "Lisa").withValue("lastName", "Simpson").withValue("birthDate", Instant.parse("2026-03-06T12:00:00Z")), // Friday
|
||||
new QRecord().withValue("firstName", "Maggie").withValue("lastName", "Simpson").withValue("birthDate", Instant.parse("2026-03-01T12:00:00Z")) // Sunday
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Populates the virtual fields on each record after query.
|
||||
***************************************************************************/
|
||||
public static class TestTableCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface queryInput, List<QRecord> records)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
String firstName = record.getValueString("firstName");
|
||||
String lastName = record.getValueString("lastName");
|
||||
record.setValue("fullName", (lastName != null ? lastName : "") + ", " + (firstName != null ? firstName : ""));
|
||||
|
||||
Instant birthDate = record.getValueInstant("birthDate");
|
||||
if(birthDate != null)
|
||||
{
|
||||
record.setValue("birthYear", String.valueOf(birthDate.atZone(ZoneId.of("UTC")).getYear()));
|
||||
}
|
||||
|
||||
record.setValue("virtuallyUseless", new BigDecimal("3.50"));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test the "day is any of" filter operator on a DATE_TIME field - verifying
|
||||
** that the weekday possible values appear and the filter activates correctly.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDayIsAnyOfFilterOnDateTimeField()
|
||||
{
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME, "Field Functions Test");
|
||||
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
|
||||
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 5, 5);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// set Birth Date filter to "day is any of" with Monday and Friday //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
queryScreenLib.setBasicFilterPossibleValues("Birth Date", "day is any of", List.of("Monday", "Friday"));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// assert the filter is active and shows the selected day(s) in the quick filter button //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("birthDate");
|
||||
qSeleniumLib.waitForSelectorContaining("button.filterActive", "Monday");
|
||||
|
||||
/////////////////////////////////////////
|
||||
// assert filter was applied correctly //
|
||||
/////////////////////////////////////////
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 2, 2);
|
||||
queryScreenLib.waitForDataGridCellContaining("Monday");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test that virtual fields with isQuerySelectable appear as columns in the
|
||||
** data grid with correct values and in the expected column ordering.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testVirtualFieldsAppearInDataGridWithCorrectValuesAndOrdering()
|
||||
{
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME, "Field Functions Test");
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 5, 5);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// verify virtual field column headers are present in the data grid //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
qSeleniumLib.waitForSelector(".MuiDataGrid-columnHeader[data-field='fullName']");
|
||||
qSeleniumLib.waitForSelector(".MuiDataGrid-columnHeader[data-field='birthYear']");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// verify virtual field values are correct in the grid cells //
|
||||
// fullName uses "lastName, firstName" format to be distinguishable from //
|
||||
// the record label which uses "firstName lastName" //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
qfmdSeleniumLib.waitForDataGridContent("Simpson, Homer");
|
||||
qfmdSeleniumLib.waitForDataGridContent("Simpson, Marge");
|
||||
qfmdSeleniumLib.waitForDataGridContent("2026");
|
||||
|
||||
////////////////////////////////////////////
|
||||
// verify column ordering is alphabetical //
|
||||
////////////////////////////////////////////
|
||||
List<WebElement> columnHeaders = driver.findElements(By.cssSelector(".MuiDataGrid-columnHeader[data-field]"));
|
||||
List<String> fieldNames = new ArrayList<>();
|
||||
for(WebElement header : columnHeaders)
|
||||
{
|
||||
fieldNames.add(header.getAttribute("data-field"));
|
||||
}
|
||||
|
||||
int firstNameIndex = fieldNames.indexOf("firstName");
|
||||
int birthDateIndex = fieldNames.indexOf("birthDate");
|
||||
int fullNameIndex = fieldNames.indexOf("fullName");
|
||||
int birthYearIndex = fieldNames.indexOf("birthYear");
|
||||
|
||||
assertTrue(firstNameIndex >= 0, "firstName column should be present");
|
||||
assertTrue(birthDateIndex >= 0, "birthDate column should be present");
|
||||
assertTrue(fullNameIndex >= 0, "fullName virtual column should be present");
|
||||
assertTrue(birthYearIndex >= 0, "birthYear virtual column should be present");
|
||||
assertTrue(fullNameIndex > birthDateIndex, "fullName should come after birthDate");
|
||||
assertTrue(birthYearIndex > birthDateIndex, "birthYear should come after birthDate");
|
||||
|
||||
assertThat(fieldNames.indexOf("virtuallyUseless")).isLessThan(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test that a queryable virtual field (firstNameLength, using StringLength
|
||||
** field function) appears in the filter field picker and correctly filters
|
||||
** records when used.
|
||||
** Homer=5, Marge=5, Bart=4, Lisa=4, Maggie=6
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFilterOnStringLengthVirtualField()
|
||||
{
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME, "Field Functions Test");
|
||||
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
|
||||
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 5, 5);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
// add First Name Length as a quick filter and set it to equals 5 (Homer and Marge) //
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
queryScreenLib.addBasicFilter("First Name Length");
|
||||
queryScreenLib.setBasicFilter("First Name Length", "equals", "5");
|
||||
|
||||
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstNameLength");
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 2, 2);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// verify the correct records are shown (Homer and Marge, both length 5) //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
qfmdSeleniumLib.waitForDataGridContent("Homer");
|
||||
qfmdSeleniumLib.waitForDataGridContent("Marge");
|
||||
qfmdSeleniumLib.waitForDataGridContentToNotExist("Bart");
|
||||
qfmdSeleniumLib.waitForDataGridContentToNotExist("Maggie");
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
// clear and re-filter for length 6 (only Maggie) //
|
||||
////////////////////////////////////////////////////
|
||||
queryScreenLib.clickQuickFilterClearIcon("firstNameLength");
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 5, 5);
|
||||
|
||||
queryScreenLib.setBasicFilter("First Name Length", "equals", "6");
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 1, 1);
|
||||
qfmdSeleniumLib.waitForDataGridContent("Maggie");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test that a queryable virtual field (birthDayOfWeek, using WeekdayOfDateTime
|
||||
** field function) appears in the filter field picker and correctly filters
|
||||
** records when used.
|
||||
** ISO-8601: Monday=1, Tuesday=2, Wednesday=3, Friday=5, Sunday=7
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFilterOnWeekdayOfDateTimeVirtualField()
|
||||
{
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME, "Field Functions Test");
|
||||
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
|
||||
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 5, 5);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// add Birth Day Of Week as a quick filter and set it to equals 1 (Monday = Homer) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
queryScreenLib.addBasicFilter("Birth Day Of Week");
|
||||
queryScreenLib.setBasicFilterPossibleValues("Birth Day Of Week", "is any of", List.of("Monday"));
|
||||
qSeleniumLib.waitForSelector(".filterValuesColumn INPUT").sendKeys(Keys.ESCAPE, Keys.ESCAPE);
|
||||
|
||||
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("birthDayOfWeek");
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 1, 1);
|
||||
qfmdSeleniumLib.waitForDataGridContent("Homer");
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// clear and re-filter for weekday 7 (Sunday = Maggie) //
|
||||
/////////////////////////////////////////////////////////
|
||||
queryScreenLib.clickQuickFilterClearIcon("birthDayOfWeek");
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 5, 5);
|
||||
|
||||
queryScreenLib.setBasicFilterPossibleValues("Birth Day Of Week", "is any of", List.of("Sunday"));
|
||||
qSeleniumLib.waitForSelector(".filterValuesColumn INPUT").sendKeys(Keys.ESCAPE, Keys.ESCAPE);
|
||||
|
||||
qfmdSeleniumLib.waitForQueryScreenPaginationValues(1, 1, 1);
|
||||
qfmdSeleniumLib.waitForDataGridContent("Maggie");
|
||||
}
|
||||
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type {AnalyticsPluginRegistryApi} from "qqq/utils/analytics/AnalyticsPluginRegistry";
|
||||
|
||||
declare global
|
||||
{
|
||||
interface Window
|
||||
{
|
||||
QQQAnalytics?: AnalyticsPluginRegistryApi;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
Reference in new issue
Block a user