Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f1bb1067 | ||
|
|
0d40488808 | ||
|
|
2ae916a61b | ||
|
|
6039fcff42 |
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,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
|
||||
@@ -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
+36
-54
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "qqq-frontend-material-dashboard",
|
||||
"version": "0.40.0-SNAPSHOT",
|
||||
"version": "0.41.0-SNAPSHOT",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "qqq-frontend-material-dashboard",
|
||||
"version": "0.40.0-SNAPSHOT",
|
||||
"version": "0.41.0-SNAPSHOT",
|
||||
"dependencies": {
|
||||
"@auth0/auth0-react": "1.10.2",
|
||||
"@emotion/react": "11.7.1",
|
||||
@@ -19,7 +19,7 @@
|
||||
"@mui/x-data-grid-pro": "5.17.23",
|
||||
"@mui/x-date-pickers": "7.1.1",
|
||||
"@mui/x-license-pro": "5.12.3",
|
||||
"@qrunio/qqq-frontend-core": "0.40.10-SNAPSHOT",
|
||||
"@qrunio/qqq-frontend-core": "0.40.11-SNAPSHOT",
|
||||
"@react-jvectormap/core": "1.0.1",
|
||||
"@react-jvectormap/unitedstates": "1.0.1",
|
||||
"@react-oauth/google": "0.2.8",
|
||||
@@ -168,7 +168,7 @@
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -178,7 +178,7 @@
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
@@ -209,7 +209,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/eslint-parser": {
|
||||
@@ -274,7 +274,7 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
@@ -384,7 +384,7 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
@@ -493,7 +493,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -518,7 +518,7 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
@@ -3259,7 +3259,7 @@
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -4049,9 +4049,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@qrunio/qqq-frontend-core": {
|
||||
"version": "0.40.10-SNAPSHOT",
|
||||
"resolved": "https://registry.npmjs.org/@qrunio/qqq-frontend-core/-/qqq-frontend-core-0.40.10-SNAPSHOT.tgz",
|
||||
"integrity": "sha512-yAP5J4IFOtaM+FK67n3kwpvRujEepV2D533rhyKQx+UIDIhmY6Go6bR+Iyvm1u/TltTNXSl8uD+Z669QGOcc0A==",
|
||||
"version": "0.40.11-SNAPSHOT",
|
||||
"resolved": "https://registry.npmjs.org/@qrunio/qqq-frontend-core/-/qqq-frontend-core-0.40.11-SNAPSHOT.tgz",
|
||||
"integrity": "sha512-rCRSLh5bYZ/G1VlBJC9CJRuG4fGkbFEKtqFMguUmV9VHRaYgdAJXWBAma9uGPDhzBOwElJLYV4S1ZkodobD/Eg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "0.28.1",
|
||||
@@ -5624,7 +5624,7 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@@ -5652,7 +5652,7 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -5719,7 +5719,7 @@
|
||||
"version": "4.17.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
@@ -5732,7 +5732,7 @@
|
||||
"version": "4.19.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -5802,7 +5802,7 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-proxy": {
|
||||
@@ -5874,7 +5874,7 @@
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
@@ -5925,7 +5925,7 @@
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ramda": {
|
||||
@@ -5941,7 +5941,7 @@
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
@@ -6049,7 +6049,7 @@
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@@ -7602,7 +7602,7 @@
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
@@ -7781,7 +7781,7 @@
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -7984,7 +7984,7 @@
|
||||
"version": "1.0.30001777",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
|
||||
"integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -9105,7 +9105,7 @@
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
@@ -9676,7 +9676,7 @@
|
||||
"version": "1.5.307",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||
"integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emittery": {
|
||||
@@ -11663,7 +11663,7 @@
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -14608,7 +14608,7 @@
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
@@ -15067,7 +15067,7 @@
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^3.0.2"
|
||||
@@ -16130,7 +16130,7 @@
|
||||
"version": "2.0.36",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
@@ -19956,7 +19956,7 @@
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -21871,7 +21871,7 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -22953,26 +22953,8 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-material-dashboard",
|
||||
"version": "0.40.0",
|
||||
"version": "0.41.0-SNAPSHOT",
|
||||
"homepage": ".",
|
||||
"description": "QQQ Default Dashboard",
|
||||
"dependencies": {
|
||||
@@ -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.0</revision>
|
||||
<qqq.version>0.40.0</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>
|
||||
|
||||
+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;
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -80,7 +80,6 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [focusedIndex, setFocusedIndex] = useState(null as number);
|
||||
|
||||
const [fieldsByTable, setFieldsByTable] = useState([] as TableWithFields[]);
|
||||
const [collapsedTables, setCollapsedTables] = useState({} as { [tableName: string]: boolean });
|
||||
|
||||
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
|
||||
@@ -123,39 +122,13 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////
|
||||
// init some stuff //
|
||||
/////////////////////
|
||||
if (fieldsByTable.length == 0)
|
||||
{
|
||||
collapsedTables[tableMetaData.name] = false;
|
||||
|
||||
if (availableExposedJoins?.length > 0)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
fieldsByTable.push({table: tableMetaData, fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
|
||||
|
||||
for (let i = 0; i < availableExposedJoins?.length; i++)
|
||||
{
|
||||
const joinTable = availableExposedJoins[i].joinTable;
|
||||
fieldsByTable.push({table: joinTable, fields: getTableFieldsAsAlphabeticalArray(joinTable)});
|
||||
|
||||
collapsedTables[joinTable.name] = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// no exposed joins - just the table (w/o its meta-data) //
|
||||
///////////////////////////////////////////////////////////
|
||||
fieldsByTable.push({fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
|
||||
}
|
||||
|
||||
setFieldsByTable(fieldsByTable);
|
||||
setCollapsedTables(collapsedTables);
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Derive fields from current options; cached groups retain joins after access changes. //
|
||||
// Keep only user-controlled collapsed state, which is independent of available fields. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
const fieldsByTable: TableWithFields[] = availableExposedJoins.length > 0
|
||||
? [tableMetaData, ...availableExposedJoins.map(join => join.joinTable)].map(table => ({table, fields: getTableFieldsAsAlphabeticalArray(table)}))
|
||||
: [{fields: getTableFieldsAsAlphabeticalArray(tableMetaData)}];
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -263,7 +236,7 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
const {field, table} = getShownFieldAndTableByIndex(focusedIndex);
|
||||
const {field, table} = getShownFieldAndTableByIndex(focusedIndex) ?? {};
|
||||
if (field)
|
||||
{
|
||||
handleSelectedField(field, table ?? tableMetaData);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -192,6 +192,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
|
||||
const [showSuccessfullyDeletedAlert, setShowSuccessfullyDeletedAlert] = useState(false);
|
||||
const [errorAlert, setErrorAlert] = useState(null as string);
|
||||
const [countError, setCountError] = useState(null as string);
|
||||
const [warningAlert, setWarningAlert] = useState(null as string);
|
||||
const [warningAlertList, setWarningAlertList] = useState([] as string[]);
|
||||
const [successAlert, setSuccessAlert] = useState(null as string);
|
||||
@@ -382,6 +383,9 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
/////////////////////////////////
|
||||
const [metaData, setMetaData] = useState(null as QInstance);
|
||||
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
|
||||
const readableQueryJoins = TableUtils.getReadableExposedJoins(tableMetaData, metaData);
|
||||
const omittedQueryJoinTables = [...(omitExposedJoins ?? []), ...(tableMetaData?.exposedJoins ?? [])
|
||||
.filter(join => !readableQueryJoins.includes(join)).map(join => join.joinTable.name)];
|
||||
const [tableLabel, setTableLabel] = useState("");
|
||||
const [tableProcesses, setTableProcesses] = useState([] as QProcessMetaData[]);
|
||||
const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]);
|
||||
@@ -456,7 +460,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
// state used to avoid showing results from an "old" query, that finishes loading after a newer one //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const [latestQueryId, setLatestQueryId] = useState(0);
|
||||
const [countResults, setCountResults] = useState({} as any);
|
||||
const [countResults, setCountResults] = useState({} as Record<number, {count?: number, distinctCount?: number, error?: string}>);
|
||||
const [receivedCountTimestamp, setReceivedCountTimestamp] = useState(new Date());
|
||||
const [queryResults, setQueryResults] = useState({} as any);
|
||||
const [latestQueryResults, setLatestQueryResults] = useState(null as QRecord[]);
|
||||
@@ -1043,6 +1047,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
// that a backend query cannot be made because of missing values for that expression //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
setWarningAlert(null);
|
||||
setCountError(null);
|
||||
for (var i = 0; i < queryFilter?.criteria?.length; i++)
|
||||
{
|
||||
for (var j = 0; j < queryFilter?.criteria[i]?.values?.length; j++)
|
||||
@@ -1083,7 +1088,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
|
||||
tablesToAdd?.forEach(t => visibleJoinTables.add(t));
|
||||
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables, metaData);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -1107,9 +1112,13 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
qControllerV1.count(tableName, apiVersion, filterForBackend, queryJoins, includeDistinct, tableVariant).then(([count, distinctCount]) =>
|
||||
{
|
||||
console.log(`Received count results for query ${thisQueryId}: ${count} ${distinctCount}`);
|
||||
countResults[thisQueryId] = [];
|
||||
countResults[thisQueryId].push(count);
|
||||
countResults[thisQueryId].push(distinctCount);
|
||||
countResults[thisQueryId] = {count, distinctCount};
|
||||
setCountResults(countResults);
|
||||
setReceivedCountTimestamp(new Date());
|
||||
}).catch(error =>
|
||||
{
|
||||
const message = error?.message ?? error?.response?.data?.error ?? "Unexpected error counting records";
|
||||
countResults[thisQueryId] = {error: `Cannot count records: ${message}`};
|
||||
setCountResults(countResults);
|
||||
setReceivedCountTimestamp(new Date());
|
||||
});
|
||||
@@ -1153,8 +1162,6 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
queryErrors[thisQueryId] = errorMessage;
|
||||
setQueryErrors(queryErrors);
|
||||
setReceivedQueryErrorTimestamp(new Date());
|
||||
|
||||
throw error;
|
||||
});
|
||||
})();
|
||||
};
|
||||
@@ -1177,7 +1184,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
///////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
if (countResults[latestQueryId] == null || countResults[latestQueryId].length == 0)
|
||||
if (countResults[latestQueryId] == null)
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// see same idea in displaying query results //
|
||||
@@ -1187,8 +1194,9 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
}
|
||||
try
|
||||
{
|
||||
setTotalRecords(countResults[latestQueryId][0]);
|
||||
setDistinctRecords(countResults[latestQueryId][1]);
|
||||
setTotalRecords(countResults[latestQueryId].count ?? null);
|
||||
setDistinctRecords(countResults[latestQueryId].distinctCount ?? null);
|
||||
setCountError(countResults[latestQueryId].error ?? null);
|
||||
delete countResults[latestQueryId];
|
||||
}
|
||||
catch (e)
|
||||
@@ -3118,7 +3126,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
idPrefix="columns"
|
||||
tableMetaData={tableMetaData}
|
||||
showTableHeaderEvenIfNoExposedJoins={true}
|
||||
omitExposedJoins={omitExposedJoins}
|
||||
omitExposedJoins={omittedQueryJoinTables}
|
||||
placeholder="Search Fields"
|
||||
buttonProps={{variant: buttonVariant, sx: columnMenuButtonStyles, ["data-button-state"]: buttonState, color: buttonColorName}}
|
||||
buttonChildren={<><Icon sx={{mr: "0.5rem"}}>view_week_outline</Icon> Columns ({view.queryColumns.getVisibleColumnCount()}) <Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon></>}
|
||||
@@ -3222,6 +3230,13 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
</Collapse>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
countError ? (
|
||||
<Collapse in={Boolean(countError)}>
|
||||
<Alert severity="error" sx={{mt: 1.5, mb: 0.5}} onClose={() => setCountError(null)}>{countError}</Alert>
|
||||
</Collapse>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
(tableLabel && showSuccessfullyDeletedAlert) ? (
|
||||
<Collapse in={Boolean(showSuccessfullyDeletedAlert)}>
|
||||
@@ -3278,7 +3293,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
setMode={doSetMode}
|
||||
savedViewsComponent={savedViewsComponent}
|
||||
columnMenuComponent={buildColumnMenu()}
|
||||
omitExposedJoins={omitExposedJoins}
|
||||
omitExposedJoins={omittedQueryJoinTables}
|
||||
useSavedViewsResult={useSavedViewsResult}
|
||||
viewOnChangeCallback={handleSavedViewChange}
|
||||
currentSavedView={currentSavedView}
|
||||
@@ -3309,7 +3324,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
queryFilter: queryFilter,
|
||||
updateFilter: doSetQueryFilter,
|
||||
allowVariables: allowVariables,
|
||||
omitExposedJoins: omitExposedJoins,
|
||||
omitExposedJoins: omittedQueryJoinTables,
|
||||
}
|
||||
}}
|
||||
localeText={{
|
||||
|
||||
@@ -635,7 +635,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
const visibleJoinTables = getVisibleJoinTables(tableMetaData);
|
||||
if (visibleJoinTables.size > 0)
|
||||
{
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables, metaData);
|
||||
}
|
||||
|
||||
/////////////////////
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProp
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const metaData = await qController.loadMetaData();
|
||||
const tableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTableMetaData(tableMetaData);
|
||||
|
||||
@@ -85,7 +86,7 @@ export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProp
|
||||
const visibleJoinTables = getVisibleJoinTables(tableMetaData);
|
||||
if (visibleJoinTables.size > 0)
|
||||
{
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables, metaData);
|
||||
}
|
||||
|
||||
const filter = new QQueryFilter(criteria, null, null, "AND", 0, 2);
|
||||
|
||||
@@ -30,6 +30,7 @@ import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
import {GridColDef, GridRowsProp, MuiEvent} from "@mui/x-data-grid-pro";
|
||||
import {GridColumnHeaderParams} from "@mui/x-data-grid/models/params/gridColumnHeaderParams";
|
||||
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React from "react";
|
||||
import {Link, NavigateFunction} from "react-router-dom";
|
||||
@@ -150,9 +151,8 @@ export default class DataGridUtils
|
||||
{
|
||||
if (tableMetaData.exposedJoins)
|
||||
{
|
||||
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
||||
for (const join of TableUtils.getReadableExposedJoins(tableMetaData, metaData))
|
||||
{
|
||||
const join = tableMetaData.exposedJoins[i];
|
||||
let joinTableName = join.joinTable.name;
|
||||
if (metaData.tables.has(joinTableName) && metaData.tables.get(joinTableName).readPermission && (includeExposedJoinTables === undefined || includeExposedJoinTables.indexOf(joinTableName) > -1))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,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";
|
||||
@@ -216,12 +218,33 @@ class TableUtils
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static getQueryJoins(tableMetaData: QTableMetaData, visibleJoinTables: Set<string>): QueryJoin[]
|
||||
public static getReadableExposedJoins(tableMetaData: QTableMetaData, metaData: QInstance): QExposedJoin[]
|
||||
{
|
||||
return (tableMetaData?.exposedJoins ?? []).filter(join =>
|
||||
{
|
||||
if (!metaData?.tables || !join.joinTable?.readPermission)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const tableNames = new Set([join.joinTable.name]);
|
||||
for (const edge of join.joinPath ?? [])
|
||||
{
|
||||
tableNames.add(edge.leftTable);
|
||||
tableNames.add(edge.rightTable);
|
||||
}
|
||||
return Array.from(tableNames).every(name => name == tableMetaData.name || metaData.tables.get(name)?.readPermission);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep full metadata for write controls; only automatic reads use this subset.
|
||||
*******************************************************************************/
|
||||
public static getQueryJoins(tableMetaData: QTableMetaData, visibleJoinTables: Set<string>, metaData: QInstance): QueryJoin[]
|
||||
{
|
||||
const queryJoins = [];
|
||||
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
||||
for (const join of this.getReadableExposedJoins(tableMetaData, metaData))
|
||||
{
|
||||
const join = tableMetaData.exposedJoins[i];
|
||||
if (visibleJoinTables.has(join.joinTable.name))
|
||||
{
|
||||
let joinName = null;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user