Compare commits
31
Commits
No files matched your search
+136
-14
@@ -1,19 +1,141 @@
|
||||
---
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /.*/
|
||||
run_playwright: << pipeline.parameters.run_playwright >>
|
||||
|
||||
parameters:
|
||||
run_playwright:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6
|
||||
|
||||
executors:
|
||||
playwright:
|
||||
docker:
|
||||
- image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
|
||||
jobs:
|
||||
playwright_tests:
|
||||
executor: playwright
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- npm-deps-{{ checksum "package-lock.json" }}
|
||||
- npm-deps-
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: npm ci --legacy-peer-deps
|
||||
- save_cache:
|
||||
key: npm-deps-{{ checksum "package-lock.json" }}
|
||||
paths:
|
||||
- node_modules
|
||||
- run:
|
||||
name: Run Playwright visual regression tests
|
||||
command: npx playwright test
|
||||
- store_artifacts:
|
||||
path: playwright-report/
|
||||
destination: playwright-report
|
||||
- store_test_results:
|
||||
path: test-results/
|
||||
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_test_only:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release/.*|hotfix/.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
test_only_with_playwright:
|
||||
when: << pipeline.parameters.run_playwright >>
|
||||
jobs:
|
||||
- playwright_tests:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release/.*|hotfix/.*)/
|
||||
tags:
|
||||
only: []
|
||||
- qqq-orb/mvn_frontend_test_only:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
requires:
|
||||
- playwright_tests
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release/.*|hotfix/.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
|
||||
publish_snapshot_with_playwright:
|
||||
when: << pipeline.parameters.run_playwright >>
|
||||
jobs:
|
||||
- playwright_tests:
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
requires:
|
||||
- playwright_tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
|
||||
publish_feature:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: feature
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- feature/.*
|
||||
tags:
|
||||
only: [/publish.*/]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /hotfix\/.*/
|
||||
@@ -1 +0,0 @@
|
||||
* @KofTwentyTwo
|
||||
@@ -1,38 +0,0 @@
|
||||
# GitHub Release Configuration
|
||||
# Auto-generates release notes from PR labels
|
||||
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
- duplicate
|
||||
- invalid
|
||||
authors:
|
||||
- dependabot
|
||||
- dependabot[bot]
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
- breaking-change
|
||||
- breaking
|
||||
- title: New Features
|
||||
labels:
|
||||
- enhancement
|
||||
- feature
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- bug
|
||||
- fix
|
||||
- title: Performance Improvements
|
||||
labels:
|
||||
- performance
|
||||
- title: Documentation
|
||||
labels:
|
||||
- documentation
|
||||
- docs
|
||||
- title: Dependencies
|
||||
labels:
|
||||
- dependencies
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
||||
@@ -1,23 +0,0 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,3 +0,0 @@
|
||||
profile: frontend-material
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
@@ -7,7 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [0.35.0] - 2025-12-29
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -320,16 +320,3 @@ 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,12 +2,10 @@
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
> **QQQ 4.0 dashboard:** Material Dashboard `0.41.0` is the standard dashboard for QQQ 4.0. [qqq-frontend-next](https://github.com/QRun-IO/qqq-frontend-next) is a compatible preview. Widget parity remains incomplete and is deferred in [#550](https://github.com/QRun-IO/qqq/issues/550).
|
||||
|
||||
React admin dashboard for QQQ applications.
|
||||
|
||||
**For:** Teams using QQQ who want a ready-to-use admin UI without writing frontend code
|
||||
**Status:** Stable; standard dashboard for QQQ 4.0.
|
||||
**Status:** Stable
|
||||
|
||||
## Why This Exists
|
||||
|
||||
@@ -84,7 +82,7 @@ mvn clean package -Pci
|
||||
## Project Status
|
||||
|
||||
**Maturity:** Stable, production use
|
||||
**Versioning:** Independently versioned; Material `0.41.0` accompanies QQQ `4.0.0`.
|
||||
**Breaking changes:** Follows QQQ versioning
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import {test, expect, Page} from "@playwright/test";
|
||||
import {readFileSync} from "fs";
|
||||
import path from "path";
|
||||
|
||||
/** First-party HTTP fixtures exercise browser request and error behavior; backend authorization is tested separately. */
|
||||
async function configure(page: Page, bridgeReadable = false, denyCount = false, denyQuery = false)
|
||||
{
|
||||
const fixture = (name: string) => JSON.parse(readFileSync(path.resolve(__dirname, "../../src/test/resources/fixtures", name), "utf8"));
|
||||
const global = fixture("metaData/index.json");
|
||||
const person = fixture("qqq/v1/metaData/table/person.json");
|
||||
const toy = {name: "toy", label: "Toy", primaryKeyField: "id", readPermission: true, insertPermission: true,
|
||||
fields: {name: {name: "name", label: "Toy name", type: "STRING"}}};
|
||||
global.tables.pet = {name: "pet", label: "Pet", readPermission: bridgeReadable, insertPermission: true};
|
||||
global.tables.toy = {...toy};
|
||||
person.exposedJoins = [{label: "Toys", isMany: true, joinTable: toy, joinPath: [
|
||||
{name: "personPet", leftTable: "person", rightTable: "pet", type: "ONE_TO_MANY"},
|
||||
{name: "toyPet", leftTable: "toy", rightTable: "pet", type: "MANY_TO_ONE"}
|
||||
]}];
|
||||
const queryInputs: any[] = [];
|
||||
const countInputs: any[] = [];
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.route(/\/metaData\/?(?:\?.*)?$/, route => route.fulfill({json: global}));
|
||||
await page.route("**/metaData/table/person*", route => route.fulfill({json: person}));
|
||||
await page.route("**/qqq/v1/table/person/query*", async route =>
|
||||
{
|
||||
queryInputs.push(route.request().postDataJSON());
|
||||
if (denyQuery) await route.fulfill({status: 403, json: {error: "Permission denied."}});
|
||||
else
|
||||
{
|
||||
// Complete the successful Query after the denied Count is already displayed.
|
||||
if (denyCount) await page.getByRole("alert").filter({hasText: "Cannot count records:"}).waitFor();
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
await page.route("**/qqq/v1/table/person/count*", async route =>
|
||||
{
|
||||
countInputs.push(route.request().postDataJSON());
|
||||
if (denyCount) await route.fulfill({status: 403, json: {error: "Permission denied."}});
|
||||
else await route.continue();
|
||||
});
|
||||
return {queryInputs, countInputs, errors};
|
||||
}
|
||||
|
||||
test("restricted bridge stays out of automatic requests and field choices", async ({page}) =>
|
||||
{
|
||||
const {queryInputs, countInputs, errors} = await configure(page);
|
||||
await page.goto("/peopleApp/greetingsApp/person");
|
||||
await expect.poll(() => queryInputs.length).toBeGreaterThan(0);
|
||||
await expect.poll(() => countInputs.length).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("cell", {name: "Test", exact: true})).toBeVisible();
|
||||
expect(queryInputs.every(input => !input.joins?.some((join: any) => join.joinTable === "toy"))).toBe(true);
|
||||
expect(countInputs.every(input => !input.joins?.some((join: any) => join.joinTable === "toy"))).toBe(true);
|
||||
await page.locator('[data-qqq-id="button-columns"]').click();
|
||||
await expect(page.locator(".fieldListMenuBody-columns")).toBeVisible();
|
||||
await expect(page.locator(".fieldListMenuBody-columns")).not.toContainText("Toy name");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("readable bridge restores query choices and selected joins", async ({page}) =>
|
||||
{
|
||||
const {queryInputs, errors} = await configure(page, true);
|
||||
await page.goto("/peopleApp/greetingsApp/person");
|
||||
await expect.poll(() => queryInputs.length).toBeGreaterThan(0);
|
||||
await page.locator('[data-qqq-id="button-columns"]').click();
|
||||
await page.locator(".fieldListMenuBody-columns").getByText("Toy name", {exact: true}).click();
|
||||
await expect.poll(() => queryInputs.some(input => input.joins?.some((join: any) => join.joinTable === "toy"))).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("denied Count remains visible after successful record loading", async ({page}) =>
|
||||
{
|
||||
const {queryInputs, countInputs, errors} = await configure(page, false, true);
|
||||
await page.goto("/peopleApp/greetingsApp/person");
|
||||
await expect.poll(() => queryInputs.length).toBeGreaterThan(0);
|
||||
await expect.poll(() => countInputs.length).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("cell", {name: "Test", exact: true})).toBeVisible();
|
||||
await expect(page.getByRole("alert").filter({hasText: "Cannot count records:"})).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("saved denied criteria and ordering are retained and show an error", async ({page}) =>
|
||||
{
|
||||
const {queryInputs, countInputs, errors} = await configure(page, false, true, true);
|
||||
const filter = {criteria: [{fieldName: "toy.name", operator: "EQUALS", values: ["restricted"]}], orderBys: [{fieldName: "toy.name", isAscending: true}]};
|
||||
await page.goto("/peopleApp/greetingsApp/person?filter=" + encodeURIComponent(JSON.stringify(filter)));
|
||||
await expect.poll(() => queryInputs.length).toBeGreaterThan(0);
|
||||
await expect.poll(() => countInputs.length).toBeGreaterThan(0);
|
||||
expect(queryInputs[0].filter.criteria).toEqual(filter.criteria);
|
||||
expect(queryInputs[0].filter.orderBys).toEqual(filter.orderBys);
|
||||
expect(countInputs[0].filter.criteria).toEqual(filter.criteria);
|
||||
await expect(page.getByRole("alert").filter({hasText: "Permission denied."}).first()).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
Generated
+54
-36
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "qqq-frontend-material-dashboard",
|
||||
"version": "0.41.0-SNAPSHOT",
|
||||
"version": "0.40.0-SNAPSHOT",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "qqq-frontend-material-dashboard",
|
||||
"version": "0.41.0-SNAPSHOT",
|
||||
"version": "0.40.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.11-SNAPSHOT",
|
||||
"@qrunio/qqq-frontend-core": "0.40.10-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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -4049,9 +4049,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@qrunio/qqq-frontend-core": {
|
||||
"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==",
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": 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==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -22953,9 +22953,27 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
|
||||
+3
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-material-dashboard",
|
||||
"version": "0.41.0-SNAPSHOT",
|
||||
"version": "0.40.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,10 +127,5 @@
|
||||
"files": [
|
||||
"lib",
|
||||
"package.json"
|
||||
],
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(@qrunio/qqq-frontend-core|@mui|@babel/runtime)/)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const fixturePort = process.env.FIXTURE_PORT || '8001';
|
||||
const dashboardPort = process.env.DASHBOARD_PORT || '3001';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e/tests',
|
||||
fullyParallel: false,
|
||||
@@ -23,11 +20,10 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL: `https://localhost:${dashboardPort}`,
|
||||
baseURL: 'https://localhost:3001',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
channel: process.env.PLAYWRIGHT_CHANNEL,
|
||||
// Consistent viewport for visual regression
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
@@ -46,13 +42,13 @@ export default defineConfig({
|
||||
webServer: [
|
||||
{
|
||||
command: `THEME_FIXTURE=${process.env.THEME_FIXTURE || 'withFullCustomTheme'} node e2e/fixture-server.js`,
|
||||
url: `http://localhost:${fixturePort}/metaData`,
|
||||
url: 'http://localhost:8001/metaData',
|
||||
reuseExistingServer: true,
|
||||
timeout: 60000, // 1 minute - Docker startup can be slow
|
||||
},
|
||||
{
|
||||
command: `HTTPS=true PORT=${dashboardPort} REACT_APP_PROXY_LOCALHOST_PORT=${fixturePort} npm start`,
|
||||
url: `https://localhost:${dashboardPort}`,
|
||||
command: 'HTTPS=true PORT=3001 REACT_APP_PROXY_LOCALHOST_PORT=8001 npm start',
|
||||
url: 'https://localhost:3001',
|
||||
reuseExistingServer: true,
|
||||
timeout: 300000, // 5 minutes - React compilation in Docker takes time
|
||||
ignoreHTTPSErrors: true,
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>0.41.0</revision>
|
||||
<qqq.version>4.0.0</qqq.version>
|
||||
<revision>0.40.1-SNAPSHOT</revision>
|
||||
<qqq.version>0.40.0-SNAPSHOT</qqq.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
@@ -152,7 +152,7 @@
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>7.2.3</version>
|
||||
<version>6.7.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
+9
-21
@@ -47,12 +47,10 @@ import AppHome from "qqq/pages/apps/Home";
|
||||
import NoApps from "qqq/pages/apps/NoApps";
|
||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||
import ReportRun from "qqq/pages/processes/ReportRun";
|
||||
import EntityCreate from "qqq/pages/records/create/RecordCreate";
|
||||
import RecordScreen from "qqq/pages/records/RecordScreen";
|
||||
import TableDeveloperView from "qqq/pages/records/developer/TableDeveloperView";
|
||||
import EntityEdit from "qqq/pages/records/edit/RecordEdit";
|
||||
import RecordQuery from "qqq/pages/records/query/RecordQuery";
|
||||
import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView";
|
||||
import RecordView from "qqq/pages/records/view/RecordView";
|
||||
import RecordViewByUniqueKey from "qqq/pages/records/view/RecordViewByUniqueKey";
|
||||
import {createDynamicTheme} from "qqq/utils/createDynamicTheme";
|
||||
import AnalyticsUtils, {AnalyticsModel} from "qqq/utils/analytics/AnalyticsUtils";
|
||||
@@ -258,7 +256,6 @@ export default function App({authenticationMetaData}: Props)
|
||||
}
|
||||
|
||||
let foundFirstApp = false;
|
||||
|
||||
function addAppToAppRoutesList(metaData: QInstance, app: QAppTreeNode, routeList: any[], parentPath: string, depth: number)
|
||||
{
|
||||
const path = `${parentPath}/${app.name}`;
|
||||
@@ -294,6 +291,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
else if (app.type === QAppNodeType.TABLE)
|
||||
{
|
||||
const table = metaData.tables.get(app.name);
|
||||
|
||||
routeList.push({
|
||||
name: `${app.label}`,
|
||||
key: app.name,
|
||||
@@ -312,7 +310,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
name: `${app.label} Create`,
|
||||
key: `${app.name}.create`,
|
||||
route: `${path}/create`,
|
||||
component: <EntityCreate table={table} />,
|
||||
component: <RecordScreen table={table} mode="create" />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
@@ -322,21 +320,11 @@ export default function App({authenticationMetaData}: Props)
|
||||
component: <TableDeveloperView table={table} />,
|
||||
});
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this is the path to open a modal-form when viewing a record, to create a different (child) record //
|
||||
// it can also be done with a hash like: #/createChild=:childTableName //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
routeList.push({
|
||||
key: `${app.name}.createChild`,
|
||||
route: `${path}/:id/createChild/:childTableName`,
|
||||
component: <RecordView table={table} />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
name: `${app.label} View`,
|
||||
key: `${app.name}.view`,
|
||||
route: `${path}/:id`,
|
||||
component: <RecordView table={table} />,
|
||||
component: <RecordScreen table={table} mode="view" />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
@@ -350,14 +338,14 @@ export default function App({authenticationMetaData}: Props)
|
||||
name: `${app.label}`,
|
||||
key: `${app.name}.edit`,
|
||||
route: `${path}/:id/edit`,
|
||||
component: <EntityEdit table={table} isCopy={false} />,
|
||||
component: <RecordScreen table={table} mode="edit" />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
name: `${app.label}`,
|
||||
key: `${app.name}.copy`,
|
||||
route: `${path}/:id/copy`,
|
||||
component: <EntityEdit table={table} isCopy={true} />,
|
||||
component: <RecordScreen table={table} mode="create" isCopy={true} />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
@@ -385,7 +373,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
name: process.label,
|
||||
key: `${app.name}/${process.name}`,
|
||||
route: `${path}/:id/${process.name}`,
|
||||
component: <RecordView table={table} key={`${table.name}-${process.name}`} launchProcess={process} />,
|
||||
component: <RecordScreen table={table} key={`${table.name}-${process.name}`} mode="view" launchProcess={process} />,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -411,7 +399,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
name: process.label,
|
||||
key: `${app.name}/${process.name}`,
|
||||
route: `${path}/:id/${process.name}`,
|
||||
component: <RecordView table={table} launchProcess={process} />,
|
||||
component: <RecordScreen table={table} mode="view" launchProcess={process} />,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -437,7 +425,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
name: process.label,
|
||||
key: `${app.name}/${process.name}`,
|
||||
route: `${path}/:id/${process.name}`,
|
||||
component: <RecordView table={table} launchProcess={process} />,
|
||||
component: <RecordScreen table={table} mode="view" launchProcess={process} />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
|
||||
|
||||
+1
@@ -160,6 +160,7 @@ public class MaterialDashboardInstanceMetaData implements QSupplementalInstanceM
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
|
||||
+1
@@ -418,6 +418,7 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
|
||||
@@ -432,7 +432,7 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Extract the OAuth2 identity provider's base URL (authority) from metadata * //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
const authority: string = authenticationMetaData.data.externalBaseUrl || authenticationMetaData.data.baseUrl;
|
||||
const authority: string = authenticationMetaData.data.baseUrl;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// ** Extract the OAuth2 client ID for this application from metadata * //
|
||||
|
||||
@@ -97,15 +97,17 @@ export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): J
|
||||
);
|
||||
}
|
||||
|
||||
export function QEditButton(): JSX.Element
|
||||
export function QEditButton({onClickHandler}: {onClickHandler?: () => void}): JSX.Element
|
||||
{
|
||||
const button = (
|
||||
<MDButton variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>} onClick={onClickHandler}>
|
||||
Edit
|
||||
</MDButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<Link to="edit">
|
||||
<MDButton variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>}>
|
||||
Edit
|
||||
</MDButton>
|
||||
</Link>
|
||||
{onClickHandler ? button : <Link to="edit">{button}</Link>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,11 @@ function BooleanFieldSwitch({name, label, value, isDisabled, onChangeCallback}:
|
||||
const classNullSwitch = (value === null || value == undefined || `${value}` == "") ? "nullSwitch" : "";
|
||||
|
||||
return (
|
||||
<Box bgcolor={isDisabled ? colors.grey[200] : ""}>
|
||||
<Box bgcolor={isDisabled ? colors.grey[200] : ""} sx={{
|
||||
".compactForm & .MuiInputLabel-root": {display: "none"},
|
||||
".compactForm & .MuiStack-root": {height: "26px"},
|
||||
".compactForm & .MuiTypography-root": {fontSize: "0.875rem"},
|
||||
}}>
|
||||
<InputLabel shrink={true}>{label}</InputLabel>
|
||||
<Stack direction="row" spacing={1} alignItems="center" height="37px">
|
||||
<Typography
|
||||
|
||||
@@ -363,7 +363,29 @@ function QDynamicFormField({
|
||||
else
|
||||
{
|
||||
return (
|
||||
<Box mb={1.5}>
|
||||
<Box sx={{
|
||||
mb: 1.5,
|
||||
".compactForm &": {mb: 0},
|
||||
".compactForm & .MuiInputBase-root": {
|
||||
height: "26px",
|
||||
borderRadius: "3px",
|
||||
},
|
||||
".compactForm & .MuiInputBase-input": {
|
||||
padding: "3px 6px",
|
||||
fontSize: "0.875rem",
|
||||
height: "18px",
|
||||
boxSizing: "content-box",
|
||||
},
|
||||
".compactForm & .MuiOutlinedInput-notchedOutline": {
|
||||
borderRadius: "3px",
|
||||
},
|
||||
".compactForm & .MuiInputLabel-root": {
|
||||
display: "none",
|
||||
},
|
||||
".compactForm & .fieldErrorMessage": {
|
||||
display: "none",
|
||||
},
|
||||
}}>
|
||||
{field}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -92,7 +92,26 @@ export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
|
||||
},
|
||||
"& .Mui-disabled .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: inputBorderColor
|
||||
}
|
||||
},
|
||||
".compactForm &": {
|
||||
margin: 0,
|
||||
},
|
||||
".compactForm & .MuiInputBase-root": {
|
||||
padding: "0 6px",
|
||||
},
|
||||
".compactForm & .MuiOutlinedInput-root": {
|
||||
borderRadius: "3px",
|
||||
minHeight: "26px",
|
||||
},
|
||||
".compactForm & .MuiOutlinedInput-root .MuiAutocomplete-input": {
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
".compactForm & .MuiAutocomplete-tag": {
|
||||
padding: "0 0.25rem",
|
||||
},
|
||||
".compactForm & .MuiFormControl-root": {
|
||||
margin: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -61,12 +61,34 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
const [openMenu, setOpenMenu] = useState<any>(false);
|
||||
const [history, setHistory] = useState<any>([] as HistoryEntry[]);
|
||||
const [autocompleteValue, setAutocompleteValue] = useState<any>(null);
|
||||
const fullPath = useLocation().pathname;
|
||||
const route = useLocation().pathname.split("/").slice(1);
|
||||
const locationPath = useLocation().pathname;
|
||||
const [fullPath, setFullPath] = useState(locationPath);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {pageHeader, pageHeaderRightContent, setDotMenuOpen} = useContext(QContext);
|
||||
|
||||
// Sync fullPath with React Router location changes
|
||||
useEffect(() =>
|
||||
{
|
||||
setFullPath(locationPath);
|
||||
}, [locationPath]);
|
||||
|
||||
// Listen for manual pushState/replaceState URL changes (e.g., entering/leaving edit mode)
|
||||
// and popstate (e.g., history.back() from cancel in edit mode)
|
||||
useEffect(() =>
|
||||
{
|
||||
const handleUrlChanged = () => setFullPath(window.location.pathname);
|
||||
window.addEventListener("urlchanged", handleUrlChanged);
|
||||
window.addEventListener("popstate", handleUrlChanged);
|
||||
return () =>
|
||||
{
|
||||
window.removeEventListener("urlchanged", handleUrlChanged);
|
||||
window.removeEventListener("popstate", handleUrlChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const route = fullPath.split("/").slice(1);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
// Setting the navbar type
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {expect, it} from "@jest/globals";
|
||||
import {ThemeProvider} from "@mui/material/styles";
|
||||
import React from "react";
|
||||
import {createRoot} from "react-dom/client";
|
||||
import {act} from "react-dom/test-utils";
|
||||
import theme from "qqq/components/legacy/Theme";
|
||||
import MDInput from "./index";
|
||||
|
||||
/*******************************************************************************
|
||||
** A disabled field must receive native input semantics as well as styling.
|
||||
*******************************************************************************/
|
||||
it("updates native disabled state when the form metadata changes", () =>
|
||||
{
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const render = (disabled: boolean) => act(() => root.render(<ThemeProvider theme={theme}>
|
||||
<MDInput name="createDate" type="datetime-local" disabled={disabled} />
|
||||
</ThemeProvider>));
|
||||
try
|
||||
{
|
||||
render(true);
|
||||
expect(host.querySelector("input").disabled).toBe(true);
|
||||
host.querySelector("input").focus();
|
||||
expect(document.activeElement).not.toBe(host.querySelector("input"));
|
||||
render(false);
|
||||
expect(host.querySelector("input").disabled).toBe(false);
|
||||
host.querySelector("input").focus();
|
||||
expect(document.activeElement).toBe(host.querySelector("input"));
|
||||
render(true);
|
||||
expect(host.querySelector("input").disabled).toBe(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
@@ -31,7 +31,7 @@ interface Props extends Omit<OutlinedTextFieldProps | StandardTextFieldProps, "v
|
||||
}
|
||||
|
||||
const MDInput: FC<Props | any> = forwardRef(({error, success, disabled, ...rest}, ref) => (
|
||||
<MDInputRoot {...rest} ref={ref} disabled={disabled} ownerState={{error, success, disabled}} />
|
||||
<MDInputRoot {...rest} ref={ref} ownerState={{error, success, disabled}} />
|
||||
));
|
||||
|
||||
// Declaring default props for MDInput
|
||||
|
||||
@@ -35,7 +35,7 @@ import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
|
||||
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import {ProcessSummaryLine} from "qqq/models/processes/ProcessSummaryLine";
|
||||
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
|
||||
import {renderSectionOfFields} from "qqq/utils/qqq/ViewUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
|
||||
@@ -33,7 +33,6 @@ import TextField from "@mui/material/TextField";
|
||||
import {GridColDef, GridSlotsComponentsProps, useGridApiContext, useGridSelector} from "@mui/x-data-grid-pro";
|
||||
import {GridColumnsPanelProps} from "@mui/x-data-grid/components/panel/GridColumnsPanel";
|
||||
import {gridColumnDefinitionsSelector, gridColumnVisibilityModelSelector} from "@mui/x-data-grid/hooks/features/columns/gridColumnsSelector";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import React, {createRef, forwardRef, useEffect, useReducer, useRef, useState} from "react";
|
||||
|
||||
declare module "@mui/x-data-grid"
|
||||
@@ -85,8 +84,9 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
|
||||
|
||||
if (props.tableMetaData.exposedJoins)
|
||||
{
|
||||
for (const exposedJoin of TableUtils.getReadableExposedJoins(props.tableMetaData, props.metaData))
|
||||
for (let i = 0; i < props.tableMetaData.exposedJoins.length; i++)
|
||||
{
|
||||
const exposedJoin = props.tableMetaData.exposedJoins[i];
|
||||
if (props.metaData.tables.has(exposedJoin.joinTable.name))
|
||||
{
|
||||
tables.push(exposedJoin.joinTable);
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2024. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {expect, it, jest} from "@jest/globals";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import React from "react";
|
||||
import {createRoot} from "react-dom/client";
|
||||
import {act} from "react-dom/test-utils";
|
||||
import FieldListMenu from "./FieldListMenu";
|
||||
|
||||
/*******************************************************************************
|
||||
** Revoked and restored query choices must follow current metadata without
|
||||
** changing the relationship descriptor used by authorized creation controls.
|
||||
*******************************************************************************/
|
||||
it("updates joined field choices when omitted paths change", () =>
|
||||
{
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
jest.useFakeTimers();
|
||||
const scrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
const table = new QTableMetaData({name: "person", label: "Person", fields: {id: {name: "id", label: "Person ID", type: "INTEGER"}}, exposedJoins: [
|
||||
{label: "Pets", isMany: true, joinPath: [], joinTable: {name: "pet", label: "Pet", readPermission: false, insertPermission: true,
|
||||
fields: {name: {name: "name", label: "Pet name", type: "STRING"}}}}
|
||||
]});
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const selected = jest.fn();
|
||||
const render = (omit: string[]) => act(() => root.render(<FieldListMenu idPrefix="live-fields" tableMetaData={table}
|
||||
buttonProps={{}} buttonChildren="Choose fields" isModeSelectOne={true} handleSelectedField={selected} omitExposedJoins={omit} />));
|
||||
try
|
||||
{
|
||||
render([]);
|
||||
expect(document.querySelector(".fieldListMenuBody-live-fields").textContent).toContain("Pet name");
|
||||
act(() => (document.querySelector("[data-qqq-id=\"button-live-fields\"]") as HTMLElement).click());
|
||||
act(() => jest.runOnlyPendingTimers());
|
||||
act(() =>
|
||||
{
|
||||
document.querySelector("input").dispatchEvent(new KeyboardEvent("keydown", {key: "End", bubbles: true}));
|
||||
});
|
||||
act(() => jest.runOnlyPendingTimers());
|
||||
render(["pet"]);
|
||||
act(() =>
|
||||
{
|
||||
document.querySelector("input").dispatchEvent(new KeyboardEvent("keydown", {key: "Enter", bubbles: true}));
|
||||
});
|
||||
act(() => jest.runOnlyPendingTimers());
|
||||
expect(selected).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".fieldListMenuBody-live-fields").textContent).not.toContain("Pet name");
|
||||
expect(document.querySelector(".fieldListMenuBody-live-fields").textContent).toContain("Person ID");
|
||||
render([]);
|
||||
expect(document.querySelector(".fieldListMenuBody-live-fields").textContent).toContain("Pet name");
|
||||
expect(table.exposedJoins).toHaveLength(1);
|
||||
expect(table.exposedJoins[0].joinTable.insertPermission).toBe(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
jest.useRealTimers();
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
delete (globalThis as any).IS_REACT_ACT_ENVIRONMENT;
|
||||
}
|
||||
});
|
||||
@@ -80,6 +80,7 @@ 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});
|
||||
@@ -122,13 +123,39 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Derive fields from current options; cached groups retain joins after access changes. //
|
||||
// Keep only user-controlled collapsed state, which is independent of available fields. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
const fieldsByTable: TableWithFields[] = availableExposedJoins.length > 0
|
||||
? [tableMetaData, ...availableExposedJoins.map(join => join.joinTable)].map(table => ({table, fields: getTableFieldsAsAlphabeticalArray(table)}))
|
||||
: [{fields: getTableFieldsAsAlphabeticalArray(tableMetaData)}];
|
||||
/////////////////////
|
||||
// init some stuff //
|
||||
/////////////////////
|
||||
if (fieldsByTable.length == 0)
|
||||
{
|
||||
collapsedTables[tableMetaData.name] = false;
|
||||
|
||||
if (availableExposedJoins?.length > 0)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
fieldsByTable.push({table: tableMetaData, fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
|
||||
|
||||
for (let i = 0; i < availableExposedJoins?.length; i++)
|
||||
{
|
||||
const joinTable = availableExposedJoins[i].joinTable;
|
||||
fieldsByTable.push({table: joinTable, fields: getTableFieldsAsAlphabeticalArray(joinTable)});
|
||||
|
||||
collapsedTables[joinTable.name] = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// no exposed joins - just the table (w/o its meta-data) //
|
||||
///////////////////////////////////////////////////////////
|
||||
fieldsByTable.push({fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
|
||||
}
|
||||
|
||||
setFieldsByTable(fieldsByTable);
|
||||
setCollapsedTables(collapsedTables);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -236,7 +263,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);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function FieldValueAsWidget({field, record}: Props): JSX.Element
|
||||
|
||||
const skeletonWrapperRef = useRef();
|
||||
|
||||
const widgetData = record.values.get(field.name);
|
||||
const widgetData = record?.values?.get(field.name);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
||||
@@ -26,15 +26,36 @@ import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import {Capability} from "@qrunio/qqq-frontend-core/lib/model/metaData/Capability";
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QMenu} from "@qrunio/qqq-frontend-core/lib/model/metaData/QMenu";
|
||||
import {QMenuItem} from "@qrunio/qqq-frontend-core/lib/model/metaData/QMenuItem";
|
||||
import {QProcessMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import {RecordViewMenuActions} from "qqq/pages/records/view/RecordView";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// define an object that is passed into the components of RecordViewMenu.tsx for getting data from //
|
||||
// this component, and for making callbacks into this component to perform actions from menus. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
export interface RecordViewMenuActions
|
||||
{
|
||||
new: (closeMenu?: () => void) => void;
|
||||
copy: (closeMenu?: () => void) => void;
|
||||
edit: () => void;
|
||||
developerMode: (closeMenu?: () => void) => void;
|
||||
audit: (closeMenu?: () => void) => void;
|
||||
delete: (closeMenu?: () => void) => void;
|
||||
runProcess: (processName: string) => void;
|
||||
downloadFileFromField: (fieldName: string, closeMenu?: () => void) => void;
|
||||
|
||||
getMetaData: () => QInstance;
|
||||
getTableProcesses: () => QProcessMetaData[];
|
||||
getGenericProcesses: () => QProcessMetaData[];
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////
|
||||
// component for the multiple additional menus //
|
||||
/////////////////////////////////////////////////
|
||||
@@ -191,13 +212,13 @@ export function RecordViewMenuItem({tableMetaData: table, menuItem, record, acti
|
||||
case "NEW":
|
||||
{
|
||||
return table?.capabilities.has(Capability.TABLE_INSERT) && table?.insertPermission
|
||||
? itemsShown.addItem(menuItem) && <MenuItem onClick={() => actions.new()} data-qqq-id="menu-item-new">{content(menuItem, "add", "New")}</MenuItem>
|
||||
? itemsShown.addItem(menuItem) && <MenuItem onClick={() => actions.new(closeMenu)} data-qqq-id="menu-item-new">{content(menuItem, "add", "New")}</MenuItem>
|
||||
: <></>;
|
||||
}
|
||||
case "COPY":
|
||||
{
|
||||
return table?.capabilities.has(Capability.TABLE_INSERT) && table?.insertPermission
|
||||
? <MenuItem onClick={() => actions.copy()} data-qqq-id="menu-item-copy">{content(menuItem, "copy", "Copy")}</MenuItem>
|
||||
? <MenuItem onClick={() => actions.copy(closeMenu)} data-qqq-id="menu-item-copy">{content(menuItem, "copy", "Copy")}</MenuItem>
|
||||
: <></>;
|
||||
}
|
||||
case "EDIT":
|
||||
@@ -214,7 +235,7 @@ export function RecordViewMenuItem({tableMetaData: table, menuItem, record, acti
|
||||
}
|
||||
case "DEVELOPER_MODE":
|
||||
{
|
||||
return <MenuItem onClick={() => actions.developerMode()}>{content(menuItem, "code", "Developer Mode")}</MenuItem>;
|
||||
return <MenuItem onClick={() => actions.developerMode(closeMenu)}>{content(menuItem, "code", "Developer Mode")}</MenuItem>;
|
||||
}
|
||||
case "AUDIT":
|
||||
{
|
||||
|
||||
@@ -24,12 +24,11 @@ import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {Alert, Skeleton} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import parse from "html-react-parser";
|
||||
import QContext from "QContext";
|
||||
import EntityForm from "qqq/components/forms/EntityForm";
|
||||
import RecordScreenModal from "qqq/pages/records/RecordScreenModal";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import TabPanel from "qqq/components/misc/TabPanel";
|
||||
import BarChart from "qqq/components/widgets/charts/barchart/BarChart";
|
||||
@@ -84,6 +83,7 @@ interface Props
|
||||
initialWidgetDataList: any[];
|
||||
values?: { [key: string]: any };
|
||||
screen: WidgetScreenType;
|
||||
addSubValidations?: (name: string, validations: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
DashboardWidgets.defaultProps = {
|
||||
@@ -102,7 +102,7 @@ DashboardWidgets.defaultProps = {
|
||||
screen: "dashboard",
|
||||
};
|
||||
|
||||
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, omitPaddingOnWidget, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels, actionCallback, initialWidgetDataList, values, screen}: Props): JSX.Element
|
||||
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, omitPaddingOnWidget, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels, actionCallback, initialWidgetDataList, values, screen, addSubValidations}: Props): JSX.Element
|
||||
{
|
||||
const [widgetData, setWidgetData] = useState(initialWidgetDataList == null ? [] as any[] : initialWidgetDataList);
|
||||
const [errorsLoading, setErrorsLoading] = useState([] as any[]);
|
||||
@@ -150,7 +150,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
// sure that we keep the dep the same as before for all use-cases (screens) other than "recordView" //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const widgetMetaDataListJSONString = useMemo(() => JSON.stringify(widgetMetaDataList), [widgetMetaDataList]);
|
||||
const loadWidgetsUseEffectDeps = screen == "recordView" ? [widgetMetaDataListJSONString] : [widgetMetaDataList];
|
||||
const loadWidgetsUseEffectDeps = (screen == "recordView" || screen == "recordEdit") ? [widgetMetaDataListJSONString] : [widgetMetaDataList];
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -861,7 +861,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
{
|
||||
widgetMetaData.type === "filterAndColumnsSetup" && (
|
||||
widgetData && widgetData[i] &&
|
||||
<FilterAndColumnsSetupWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
<FilterAndColumnsSetupWidget isEditable={screen === "recordEdit"} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={Object.keys(values).length > 0 ? values : convertQRecordValuesFromMapToObject(record)} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
{
|
||||
if (actionCallback)
|
||||
{
|
||||
@@ -873,22 +873,26 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
{
|
||||
widgetMetaData.type === "pivotTableSetup" && (
|
||||
widgetData && widgetData[i] && widgetData[i].queryParams &&
|
||||
<PivotTableSetupWidget isEditable={false} widgetMetaData={widgetMetaData} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
|
||||
<PivotTableSetupWidget isEditable={screen === "recordEdit"} widgetMetaData={widgetMetaData} recordValues={Object.keys(values).length > 0 ? values : convertQRecordValuesFromMapToObject(record)} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
{
|
||||
if (actionCallback)
|
||||
{
|
||||
actionCallback(values);
|
||||
}
|
||||
}} />
|
||||
)
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "rowBuilder" && (
|
||||
(widgetData && widgetData[i]) ?
|
||||
<RowBuilderWidget widgetMetaData={widgetMetaData} widgetData={widgetData[i]} screen={screen} onSaveCallback={rowBuilderOnSaveCallback} /> :
|
||||
<RowBuilderWidget widgetMetaData={widgetMetaData} widgetData={widgetData[i]} screen={screen} onSaveCallback={rowBuilderOnSaveCallback} addSubValidations={addSubValidations} /> :
|
||||
<NotLoaded widgetMetaData={widgetMetaData} widgetIndex={i} />
|
||||
)
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "cronUI" && (
|
||||
(widgetData && widgetData[i]) ?
|
||||
<CronUIWidget screen={screen} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={convertQRecordValuesFromMapToObject(record)} recordDisplayValueMap={record.displayValues} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
<CronUIWidget screen={screen} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={Object.keys(values).length > 0 ? values : convertQRecordValuesFromMapToObject(record)} recordDisplayValueMap={record?.displayValues} addSubValidations={addSubValidations} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
{
|
||||
if (actionCallback)
|
||||
{
|
||||
@@ -900,7 +904,13 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
{
|
||||
widgetMetaData.type === "dynamicForm" && (
|
||||
widgetData && widgetData[i] &&
|
||||
<DynamicFormWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} record={record} recordValues={convertQRecordValuesFromMapToObject(record)} />
|
||||
<DynamicFormWidget isEditable={screen === "recordEdit"} mode={screen === "recordEdit" ? "edit" : "view"} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} record={record} recordValues={Object.keys(values).length > 0 ? values : convertQRecordValuesFromMapToObject(record)} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
{
|
||||
if (actionCallback)
|
||||
{
|
||||
actionCallback(values);
|
||||
}
|
||||
}} />
|
||||
)
|
||||
}
|
||||
{
|
||||
@@ -1004,19 +1014,17 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
}
|
||||
{
|
||||
showEditChildForm &&
|
||||
<Modal open={showEditChildForm as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
|
||||
<div className="modalEditForm">
|
||||
<EntityForm
|
||||
isModal={true}
|
||||
closeModalHandler={closeEditChildForm}
|
||||
table={showEditChildForm.table}
|
||||
defaultValues={showEditChildForm.defaultValues}
|
||||
disabledFields={showEditChildForm.disabledFields}
|
||||
onSubmitCallback={submitEditChildForm}
|
||||
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<RecordScreenModal
|
||||
open={true}
|
||||
onClose={closeEditChildForm}
|
||||
tableName={showEditChildForm.table?.name}
|
||||
defaultValues={showEditChildForm.defaultValues}
|
||||
disabledFields={showEditChildForm.disabledFields}
|
||||
onSubmitCallback={submitEditChildForm}
|
||||
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table?.label}`}
|
||||
saveButtonLabel="OK"
|
||||
saveButtonIcon="check"
|
||||
/>
|
||||
}
|
||||
</>
|
||||
) : null
|
||||
|
||||
@@ -90,11 +90,7 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
|
||||
let widgetMetaDataList = [] as QWidgetMetaData[];
|
||||
data?.childWidgetNameList.forEach((widgetName: string) =>
|
||||
{
|
||||
const childWidget = qInstance.widgets.get(widgetName);
|
||||
if (childWidget)
|
||||
{
|
||||
widgetMetaDataList.push(childWidget);
|
||||
}
|
||||
widgetMetaDataList.push(qInstance.widgets.get(widgetName));
|
||||
});
|
||||
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, labels: data.labels ?? [], datasets: data.datasets ?? []}} options={makeOptions(data)} getElementsAtEvent={handleClick} />
|
||||
<Bar data={data} options={makeOptions(data)} getElementsAtEvent={handleClick} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} />;
|
||||
|
||||
@@ -45,7 +45,7 @@ import {WidgetScreenType} from "qqq/components/widgets/DashboardWidgets";
|
||||
import Widget from "qqq/components/widgets/Widget";
|
||||
import {WidgetUtils} from "qqq/components/widgets/WidgetUtils";
|
||||
import {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
|
||||
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
|
||||
import {renderSectionOfFields} from "qqq/utils/qqq/ViewUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import usePossibleValueLabels from "qqq/utils/usePossibleValueLabels";
|
||||
import {useContext, useEffect, useRef, useState} from "react";
|
||||
@@ -661,7 +661,7 @@ export default function CronUIWidget({widgetMetaData, widgetData, screen, record
|
||||
|
||||
setTimeout(() =>
|
||||
{
|
||||
const leftOffset = -9;
|
||||
const leftOffset = -11;
|
||||
let left = leftOffset + (document.getElementById("tapeMeasure")?.clientWidth) - input.scrollLeft;
|
||||
if (left < leftOffset)
|
||||
{
|
||||
@@ -827,7 +827,7 @@ export default function CronUIWidget({widgetMetaData, widgetData, screen, record
|
||||
additionalCallbacks={{onTextSelect: advancedTextSelect, onBlur: advancedTextBlur}} />
|
||||
{
|
||||
(doAdvancedCaratPositionTooltip && advancedCaretPosition) && <Box sx={{
|
||||
transition: "left .1s ease", left: `${caretTipLeft}px`, position: "absolute", top: "89px", width: "66px", lineHeight: "1.2", py: "0.25rem", textAlign: "center", fontSize: "1rem", background: "lightyellow", display: "inline-block", padding: "0.125rem", border: "1px solid rosybrown", borderRadius: "0.5rem",
|
||||
transition: "left .1s ease", left: `${caretTipLeft}px`, position: "absolute", top: "89px", width: "70px", lineHeight: "1.2", py: "0.25rem", textAlign: "center", fontSize: "1rem", background: "lightyellow", display: "inline-block", padding: "0.125rem", border: "1px solid rosybrown", borderRadius: "0.5rem",
|
||||
"&::after": {content: "\"\"", position: "absolute", left: "50%", top: "-10px", transform: "translateX(-50%)", width: "0%", height: "0%", borderLeft: "7px solid transparent", borderRight: "7px solid transparent", borderBottom: "10px solid lightyellow"},
|
||||
"&::before": {content: "\"\"", position: "absolute", left: "50%", top: "-12px", transform: "translateX(-50%)", width: "0%", height: "0%", borderLeft: "8px solid transparent", borderRight: "8px solid transparent", borderBottom: "12px solid rosybrown"},
|
||||
}}>{advancedCaretPosition}</Box>
|
||||
@@ -861,6 +861,12 @@ export default function CronUIWidget({widgetMetaData, widgetData, screen, record
|
||||
{cronDescription ? <Box>{cronDescription}</Box> : <Box color={colors.error.main}>{cronDescriptionError}</Box>}
|
||||
{loadingDescription ? <LinearProgress color="info" /> : <Box height="0.375rem"></Box>}
|
||||
</Box>
|
||||
{
|
||||
cronExpressionField?.isRequired &&
|
||||
<Typography component="div" className="fieldErrorMessage" variant="caption" color="error" fontWeight="regular">
|
||||
<ErrorMessage name={cronExpressionFieldName} render={msg => <span data-field-error={cronExpressionFieldName}>{msg}</span>} />
|
||||
</Typography>
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -897,7 +903,7 @@ interface SubFormProps
|
||||
displayText: string,
|
||||
selectedValues?: string[],
|
||||
setSelectedValues?: (value: (string[])) => void,
|
||||
preOpen?: () => void
|
||||
preOpen?: () => void,
|
||||
}
|
||||
|
||||
|
||||
@@ -910,6 +916,8 @@ function SubForm({which, option, setOption, displayText, selectedValues, setSele
|
||||
{
|
||||
const [popupAnchor, setPopupAnchor] = useState(null);
|
||||
const inputRef = useRef();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isCompact = containerRef.current?.closest(".compactForm") != null;
|
||||
|
||||
const singularLabel = which == "day" ? "Day" : which == "hour" ? "Hour" : "Minute";
|
||||
const pluralLabel = which == "day" ? "Days" : which == "hour" ? "Hours" : "Minutes";
|
||||
@@ -1047,6 +1055,8 @@ function SubForm({which, option, setOption, displayText, selectedValues, setSele
|
||||
"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"},
|
||||
"& .MuiOutlinedInput-input": {cursor: "pointer", fontSize: "1rem", padding: "0.5rem"},
|
||||
"& .MuiIcon-root": {fontSize: "1.5rem !important", fontFamily: "'Material Icons'", color: "rgba(0, 0, 0, 0.54)"},
|
||||
".compactForm & .MuiOutlinedInput-root": {borderRadius: "3px", height: "26px", minHeight: "26px"},
|
||||
".compactForm & .MuiOutlinedInput-input": {fontSize: "0.875rem", padding: "3px 6px"},
|
||||
};
|
||||
|
||||
//@ts-ignore inputRef.current
|
||||
@@ -1058,7 +1068,7 @@ function SubForm({which, option, setOption, displayText, selectedValues, setSele
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box ref={containerRef}>
|
||||
<TextField id={which} inputRef={inputRef} variant="outlined" value={displayText} fullWidth sx={textFieldSx} InputProps={{readOnly: true, endAdornment: (<Icon>{popupAnchor ? "arrow_drop_up" : "arrow_drop_down"}</Icon>)}} onClick={doOpen} style={{marginBottom: "12px"}}></TextField>
|
||||
<Popover
|
||||
anchorEl={popupAnchor}
|
||||
@@ -1066,7 +1076,7 @@ function SubForm({which, option, setOption, displayText, selectedValues, setSele
|
||||
transformOrigin={{vertical: "top", horizontal: "left"}}
|
||||
open={Boolean(popupAnchor)}
|
||||
onClose={() => doClose()}
|
||||
PaperProps={{style: {width: menuWidth, minWidth: 240, backgroundColor: "white", border: "1px solid lightgray", borderRadius: "0.75rem"}}}
|
||||
PaperProps={{className: isCompact ? "compactForm" : "", style: {width: menuWidth, minWidth: 240, backgroundColor: "white", border: "1px solid lightgray", borderRadius: isCompact ? "3px" : "0.75rem"}}}
|
||||
>
|
||||
<Box sx={boxSx}>
|
||||
<RadioGroup value={option} onChange={handleSelectRadioOpton} sx={{px: 2}}>
|
||||
|
||||
@@ -24,13 +24,14 @@ import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFiel
|
||||
import {QWidgetMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import {FormikContextType, useFormikContext} from "formik";
|
||||
import QDynamicForm from "qqq/components/forms/DynamicForm";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import DynamicFormUtils, {DynamicFormFieldDefinition} from "qqq/components/forms/DynamicFormUtils";
|
||||
import Widget from "qqq/components/widgets/Widget";
|
||||
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
|
||||
import RecordScreenField from "qqq/pages/records/RecordScreenField";
|
||||
import RecordScreenContext, {RecordScreenMode} from "qqq/pages/records/RecordScreenContext";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -44,6 +45,7 @@ interface DynamicFormWidgetProps
|
||||
record: QRecord;
|
||||
recordValues: { [name: string]: any };
|
||||
onSaveCallback?: (values: { [name: string]: any }) => void;
|
||||
mode?: RecordScreenMode;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,8 +61,9 @@ DynamicFormWidget.defaultProps = {
|
||||
** Component to display a dynamic form - e.g., on a record edit or view screen,
|
||||
** or even within a process.
|
||||
*******************************************************************************/
|
||||
export default function DynamicFormWidget({isEditable, widgetMetaData, widgetData, record, recordValues, onSaveCallback}: DynamicFormWidgetProps): JSX.Element
|
||||
export default function DynamicFormWidget({isEditable, widgetMetaData, widgetData, record, recordValues, onSaveCallback, mode}: DynamicFormWidgetProps): JSX.Element
|
||||
{
|
||||
const {onEditIconClick} = useContext(RecordScreenContext);
|
||||
const [fields, setFields] = useState([] as QFieldMetaData[]);
|
||||
|
||||
const [effectiveIsEditable, setEffectiveIsEditable] = useState(isEditable);
|
||||
@@ -169,85 +172,112 @@ export default function DynamicFormWidget({isEditable, widgetMetaData, widgetDat
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Build a record with values for the dynamic form fields — used by
|
||||
** RecordScreenField to display values in both view and edit modes.
|
||||
*******************************************************************************/
|
||||
function renderEditForm()
|
||||
function buildFakeRecord(): QRecord
|
||||
{
|
||||
const formikProps = useFormikContext();
|
||||
if(!fields || !fields.length)
|
||||
const fakeRecord = new QRecord(widgetData?.recordOfFieldValues ?? {});
|
||||
const mergedDynamicFormValuesIntoFieldName = widgetData?.mergedDynamicFormValuesIntoFieldName;
|
||||
|
||||
if (mergedDynamicFormValuesIntoFieldName && recordValues[mergedDynamicFormValuesIntoFieldName])
|
||||
{
|
||||
return (
|
||||
<Box>
|
||||
<Box fontSize="1rem">{widgetData && widgetData.noFieldsMessage}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const formData: any = {};
|
||||
formData.values = formikProps.values;
|
||||
formData.touched = formikProps.touched;
|
||||
formData.errors = formikProps.errors;
|
||||
formData.formFields = {};
|
||||
|
||||
// todo - merge the formValidations object with formik's - maybe in the useEffect where we build it
|
||||
// setValidations(Yup.object().shape(formValidations));
|
||||
// formikProps.validationSchema.
|
||||
|
||||
for (let key of Object.keys(dynamicFormFields))
|
||||
{
|
||||
const dynamicFormField = dynamicFormFields[key];
|
||||
formData.formFields[dynamicFormField.name] = dynamicFormField;
|
||||
|
||||
const initialValue = getInitialValue(dynamicFormField.name);
|
||||
if(initialValue != null)
|
||||
let mergedValues = recordValues[mergedDynamicFormValuesIntoFieldName];
|
||||
if (typeof mergedValues === "string")
|
||||
{
|
||||
console.log(`@dk trying to set an initial value [${dynamicFormField.name}] to [${initialValue}]`);
|
||||
// @ts-ignore some any
|
||||
formikProps.initialValues[dynamicFormField.name] = initialValue;
|
||||
try
|
||||
{
|
||||
mergedValues = JSON.parse(mergedValues);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
if (mergedValues && typeof mergedValues === "object")
|
||||
{
|
||||
for (let i = 0; i < fields?.length; i++)
|
||||
{
|
||||
const fieldName = fields[i].name;
|
||||
if (mergedValues[fieldName] !== undefined)
|
||||
{
|
||||
fakeRecord.values.set(fieldName, mergedValues[fieldName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(formData.values)
|
||||
{
|
||||
checkForFormValueChanges(formikProps);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<QDynamicForm formData={formData} record={record} />
|
||||
</Box>
|
||||
);
|
||||
return fakeRecord;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Wrapper that tracks Formik value changes and merges them into the parent
|
||||
** field via onSaveCallback (for edit mode only).
|
||||
*******************************************************************************/
|
||||
function renderViewForm()
|
||||
function FormValueTracker(): JSX.Element
|
||||
{
|
||||
const fieldNames: string[] = [];
|
||||
const fieldMap: {[name: string]: QFieldMetaData} = {};
|
||||
const fakeRecord = new QRecord(widgetData.recordOfFieldValues ?? {});
|
||||
const formikProps = useFormikContext();
|
||||
|
||||
const mergedDynamicFormValuesIntoFieldName = widgetData.mergedDynamicFormValuesIntoFieldName;
|
||||
|
||||
for (let i = 0; i < fields?.length; i++)
|
||||
// set initial values for dynamic fields in Formik
|
||||
if (dynamicFormFields)
|
||||
{
|
||||
const fieldName = fields[i].name;
|
||||
fieldNames.push(fieldName);
|
||||
fieldMap[fieldName] = fields[i];
|
||||
|
||||
if(mergedDynamicFormValuesIntoFieldName && recordValues[mergedDynamicFormValuesIntoFieldName])
|
||||
for (let key of Object.keys(dynamicFormFields))
|
||||
{
|
||||
fakeRecord.values.set(fieldName, recordValues[mergedDynamicFormValuesIntoFieldName][fieldName]);
|
||||
const initialValue = getInitialValue(dynamicFormFields[key].name);
|
||||
if (initialValue != null && (formikProps.values as any)[dynamicFormFields[key].name] === undefined)
|
||||
{
|
||||
// @ts-ignore
|
||||
formikProps.initialValues[dynamicFormFields[key].name] = initialValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const section = renderSectionOfFields(`dynamicFormWidget:${widgetMetaData.name}`, fieldNames, null, false, fakeRecord, fieldMap);
|
||||
if (formikProps.values)
|
||||
{
|
||||
checkForFormValueChanges(formikProps);
|
||||
}
|
||||
|
||||
return (<Box>
|
||||
{section}
|
||||
</Box>);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render the fields using RecordScreenField for consistent layout.
|
||||
*******************************************************************************/
|
||||
function renderFields(): JSX.Element
|
||||
{
|
||||
const effectiveMode: RecordScreenMode = mode ?? (effectiveIsEditable ? "edit" : "view");
|
||||
const fakeRecord = buildFakeRecord();
|
||||
|
||||
if (!fields || !fields.length)
|
||||
{
|
||||
return (
|
||||
<Box fontSize="1rem">{widgetData && widgetData.noFieldsMessage}</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid container display="flex" spacing={0}>
|
||||
{effectiveIsEditable && <FormValueTracker />}
|
||||
{fields.map((field) =>
|
||||
{
|
||||
const formFieldDef: DynamicFormFieldDefinition = dynamicFormFields?.[field.name];
|
||||
return (
|
||||
<Grid item key={field.name} xs={12} pt={0}>
|
||||
<RecordScreenField
|
||||
field={field}
|
||||
fieldName={field.name}
|
||||
mode={effectiveMode}
|
||||
record={fakeRecord}
|
||||
formFieldDef={formFieldDef}
|
||||
onEditIconClick={onEditIconClick}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -255,11 +285,7 @@ export default function DynamicFormWidget({isEditable, widgetMetaData, widgetDat
|
||||
// render //
|
||||
////////////
|
||||
return (<Widget widgetMetaData={widgetMetaData}>
|
||||
{
|
||||
<React.Fragment>
|
||||
{effectiveIsEditable ? renderEditForm() : renderViewForm()}
|
||||
</React.Fragment>
|
||||
}
|
||||
{renderFields()}
|
||||
</Widget>);
|
||||
}
|
||||
|
||||
@@ -221,8 +221,16 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
renderCell: ((params: GridRenderCellParams) =>
|
||||
{
|
||||
return <Box>
|
||||
{allowRecordEdit && <IconButton onClick={() => editRecordCallback(params.row.__rowIndex)}><Icon>edit</Icon></IconButton>}
|
||||
{allowRecordDelete && <IconButton onClick={() => deleteRecordCallback(params.row.__rowIndex)}><Icon>delete</Icon></IconButton>}
|
||||
{allowRecordEdit && <IconButton onClick={(e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
editRecordCallback(params.row.__rowIndex);
|
||||
}}><Icon>edit</Icon></IconButton>}
|
||||
{allowRecordDelete && <IconButton onClick={(e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
deleteRecordCallback(params.row.__rowIndex);
|
||||
}}><Icon>delete</Icon></IconButton>}
|
||||
</Box>;
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import EntityForm from "qqq/components/forms/EntityForm";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useReducer, useState} from "react";
|
||||
|
||||
|
||||
////////////////////////////////
|
||||
// structure of expected data //
|
||||
////////////////////////////////
|
||||
export interface ModalEditFormData
|
||||
{
|
||||
tableName: string;
|
||||
defaultValues?: { [key: string]: string };
|
||||
disabledFields?: { [key: string]: boolean } | string[];
|
||||
overrideHeading?: string;
|
||||
onSubmitCallback?: (values: any, tableName: String) => void;
|
||||
initialShowModalValue?: boolean;
|
||||
}
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
function ModalEditForm({tableName, defaultValues, disabledFields, overrideHeading, onSubmitCallback, initialShowModalValue}: ModalEditFormData,): JSX.Element
|
||||
{
|
||||
const [showModal, setShowModal] = useState(initialShowModalValue);
|
||||
const [table, setTable] = useState(null as QTableMetaData);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!tableName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const tableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTable(tableMetaData);
|
||||
forceUpdate();
|
||||
})();
|
||||
}, [tableName]);
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const closeEditChildForm = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
setShowModal(null);
|
||||
};
|
||||
|
||||
return (
|
||||
table && showModal &&
|
||||
<Modal open={showModal as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
|
||||
<div className="modalEditForm">
|
||||
<EntityForm
|
||||
isModal={true}
|
||||
closeModalHandler={closeEditChildForm}
|
||||
table={table}
|
||||
defaultValues={defaultValues}
|
||||
disabledFields={disabledFields}
|
||||
onSubmitCallback={onSubmitCallback}
|
||||
overrideHeading={overrideHeading}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModalEditForm;
|
||||
@@ -62,7 +62,7 @@ describe("MaterialDashboardThemeMetaData", () =>
|
||||
expect(theme.infoColor).toBe("#0000FF");
|
||||
expect(theme.fontFamily).toBe("Roboto");
|
||||
expect(theme.headerFontFamily).toBe("Open Sans");
|
||||
expect(theme.borderRadiusGlobal).toBe("8px");
|
||||
expect(theme.borderRadius).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.borderRadiusGlobal).toBeUndefined();
|
||||
expect(theme.borderRadius).toBeUndefined();
|
||||
expect(theme.density).toBeUndefined();
|
||||
expect(theme.logoPath).toBeUndefined();
|
||||
expect(theme.iconPath).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,966 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import {Typography} from "@mui/material";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import {Capability} from "@qrunio/qqq-frontend-core/lib/model/metaData/Capability";
|
||||
import {QProcessMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QMenu} from "@qrunio/qqq-frontend-core/lib/model/metaData/QMenu";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import QContext from "QContext";
|
||||
import {preferredColorNameInfoOrPrimary} from "qqq/assets/theme/functions/preferInfoColorToPrimaryColor";
|
||||
import {QActionsMenuButton, QCancelButton, QDeleteButton, QEditButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import MDButton from "qqq/components/legacy/MDButton";
|
||||
import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog";
|
||||
import {RecordViewAdditionalMenus, RecordViewMenuItem, ItemsShownInMenu, RecordViewMenuActions} from "qqq/components/view/RecordViewMenus";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||
import RecordScreenBody from "qqq/pages/records/RecordScreenBody";
|
||||
import {RecordScreenMode} from "qqq/pages/records/RecordScreenContext";
|
||||
import RecordScreenSection from "qqq/pages/records/RecordScreenSection";
|
||||
import {useRecordScreen} from "qqq/pages/records/useRecordScreen";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import {QFieldType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {sanitizeId} from "qqq/utils/qqqIdUtils";
|
||||
import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react";
|
||||
import {useLocation, useNavigate, useParams} from "react-router-dom";
|
||||
import RecordScreenModal from "qqq/pages/records/RecordScreenModal";
|
||||
import AuditBody from "qqq/components/audits/AuditBody";
|
||||
import ShareModal from "qqq/components/sharing/ShareModal";
|
||||
|
||||
|
||||
interface Props
|
||||
{
|
||||
table: QTableMetaData;
|
||||
mode?: RecordScreenMode;
|
||||
isCopy?: boolean;
|
||||
launchProcess?: QProcessMetaData;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Unified Record Screen: view, edit, and create modes in one component.
|
||||
** Fields stay in the same grid positions across mode transitions.
|
||||
***************************************************************************/
|
||||
export default function RecordScreen({table, mode: propMode, isCopy, launchProcess}: Props): JSX.Element
|
||||
{
|
||||
const {id} = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {accentColor, dotMenuOpen, keyboardHelpOpen, modalStack, tableProcesses, setPageHeader, setPageHeaderRightContent} = useContext(QContext);
|
||||
|
||||
// scroll correction: stores field name + Y position before mode switch
|
||||
const scrollCorrectionRef = useRef<{fieldName: string; yBefore: number} | null>(null);
|
||||
|
||||
const screen = useRecordScreen(table.name, id, propMode, isCopy, {scrollCorrectionRef});
|
||||
const {
|
||||
mode, setMode, record, tableMetaData, metaData,
|
||||
t1Section, formFieldsBySection,
|
||||
loading, notFoundMessage, notAllowedError,
|
||||
saveRecord, deleteRecord,
|
||||
allTableProcesses, tableVariant,
|
||||
setSuccessMessage, setWarningMessage, setErrorMessage,
|
||||
showEditChildForm: hookShowEditChildForm, setShowEditChildForm: hookSetShowEditChildForm,
|
||||
submitEditChildForm, widgetReloadCounters, widgetReloadAdditionalParams, reloadWidget,
|
||||
} = screen;
|
||||
|
||||
// If the user navigates via a React Router <Link> while in pushState-edit-mode,
|
||||
// React Router processes a navigation (location.key changes) but propMode stays "view"
|
||||
// because React Router never knew about /edit. Detect this and exit edit mode.
|
||||
useEffect(() =>
|
||||
{
|
||||
if (mode === "edit" && propMode === "view" && !location.pathname.endsWith("/edit"))
|
||||
{
|
||||
setMode("view");
|
||||
}
|
||||
}, [location.key]);
|
||||
|
||||
// Listen for browser back/forward (popstate) to exit edit mode when URL no longer ends with /edit
|
||||
// (covers history.back() from cancel, or user pressing browser back button)
|
||||
useEffect(() =>
|
||||
{
|
||||
const handlePopState = () =>
|
||||
{
|
||||
if (!window.location.pathname.endsWith("/edit") && mode === "edit" && propMode === "view")
|
||||
{
|
||||
setMode("view");
|
||||
}
|
||||
};
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [mode, propMode, setMode]);
|
||||
|
||||
// local UI state
|
||||
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
|
||||
const [isDeleteSubmitting, setIsDeleteSubmitting] = useState(false);
|
||||
const [actionsMenuAnchorElement, setActionsMenuAnchorElement] = useState(null);
|
||||
const [activeModalProcess, setActiveModalProcess] = useState<QProcessMetaData>(null);
|
||||
// showEditChildForm comes from hook for edit-mode, or local for view-mode hash navigation.
|
||||
// Use hook's state as the canonical source; alias for convenience.
|
||||
const showEditChildForm = hookShowEditChildForm;
|
||||
const setShowEditChildForm = hookSetShowEditChildForm;
|
||||
const [showAudit, setShowAudit] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [actionMenu, setActionMenu] = useState(null);
|
||||
const enterEditModeRef = useRef<(fieldName?: string) => void>(null);
|
||||
|
||||
useLayoutEffect(() =>
|
||||
{
|
||||
if (scrollCorrectionRef.current)
|
||||
{
|
||||
const {fieldName: corrFieldName, yBefore} = scrollCorrectionRef.current;
|
||||
const fieldElAfter = document.querySelector(`[data-field-name='${corrFieldName}']`) ?? document.getElementById(corrFieldName);
|
||||
if (fieldElAfter)
|
||||
{
|
||||
const yAfter = fieldElAfter.getBoundingClientRect().top;
|
||||
const drift = yAfter - yBefore;
|
||||
if (Math.abs(drift) > 1)
|
||||
{
|
||||
// ensure smooth scrolling is off for instant correction, then restore
|
||||
const htmlEl = document.documentElement;
|
||||
htmlEl.style.scrollBehavior = "auto";
|
||||
window.scrollBy(0, drift);
|
||||
htmlEl.style.scrollBehavior = "smooth";
|
||||
}
|
||||
}
|
||||
scrollCorrectionRef.current = null;
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
// ref to the formik submit function for Ctrl+S
|
||||
const formikSubmitRef = useRef<() => void>(() =>
|
||||
{
|
||||
});
|
||||
|
||||
const openActionsMenu = (event: any) => setActionsMenuAnchorElement(event.currentTarget);
|
||||
const closeActionsMenu = () => setActionsMenuAnchorElement(null);
|
||||
|
||||
const pathParts = location.pathname.replace(/\/+$/, "").split("/");
|
||||
const tableNameForId = tableMetaData ? sanitizeId(tableMetaData.name) : "";
|
||||
const isEditing = mode === "edit" || mode === "create";
|
||||
|
||||
// keep h3 page header in sync with mode
|
||||
useEffect(() =>
|
||||
{
|
||||
if (mode === "edit" && tableMetaData && record)
|
||||
{
|
||||
setPageHeader(`Edit ${tableMetaData.label}: ${record.recordLabel ?? id}`);
|
||||
}
|
||||
else if (mode === "view" && record)
|
||||
{
|
||||
setPageHeader(record.recordLabel ?? id);
|
||||
}
|
||||
}, [mode, tableMetaData, record]);
|
||||
|
||||
/////////////////////////
|
||||
// Handle cancel click //
|
||||
/////////////////////////
|
||||
const handleCancelClicked = () =>
|
||||
{
|
||||
if (isCopy)
|
||||
{
|
||||
navigate(location.pathname.replace(/\/copy$/, ""), {replace: true});
|
||||
}
|
||||
else if (id)
|
||||
{
|
||||
// capture scroll position before switching back to view mode
|
||||
// look for both field elements and section wrappers as scroll anchors
|
||||
const scrollAnchors = Array.from(document.querySelectorAll("[data-field-name], .form-section-wrapper"));
|
||||
for (const el of scrollAnchors)
|
||||
{
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top >= 0 && rect.top < window.innerHeight)
|
||||
{
|
||||
const fieldName = el.getAttribute("data-field-name") ?? el.getAttribute("id");
|
||||
if (fieldName)
|
||||
{
|
||||
scrollCorrectionRef.current = {fieldName, yBefore: rect.top};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// disable smooth scrolling before mode switch to prevent animated drift
|
||||
document.documentElement.style.scrollBehavior = "auto";
|
||||
|
||||
setMode("view");
|
||||
if (propMode === "view")
|
||||
{
|
||||
// edit was entered via pushState — pop the entry cleanly
|
||||
window.history.back();
|
||||
}
|
||||
else
|
||||
{
|
||||
// direct navigation to /edit URL — replace in place
|
||||
window.history.replaceState(null, "", location.pathname.replace(/\/edit$/, ""));
|
||||
window.dispatchEvent(new Event("urlchanged"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
navigate(location.pathname.replace(/\/create$/, ""), {replace: true});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
///////////////////////
|
||||
// Keyboard handling //
|
||||
///////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
const down = (e: KeyboardEvent) =>
|
||||
{
|
||||
const type = (e.target as any).type;
|
||||
const validType = (type !== "text" && type !== "textarea" && type !== "input" && type !== "search");
|
||||
|
||||
// Ctrl+S / Cmd+S to save in edit mode (works even when focused on inputs)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s" && isEditing)
|
||||
{
|
||||
e.preventDefault();
|
||||
formikSubmitRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (validType && !dotMenuOpen && !keyboardHelpOpen && !showAudit && !showEditChildForm && (!modalStack || modalStack.length === 0))
|
||||
{
|
||||
if (mode === "view")
|
||||
{
|
||||
if (!e.metaKey && !e.ctrlKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission)
|
||||
{
|
||||
e.preventDefault();
|
||||
navigate(pathParts.slice(0, -1).join("/") + "/create");
|
||||
}
|
||||
else if (!e.metaKey && !e.ctrlKey && e.key === "e" && table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission)
|
||||
{
|
||||
e.preventDefault();
|
||||
enterEditModeRef.current?.();
|
||||
}
|
||||
else if (!e.metaKey && !e.ctrlKey && e.key === "c" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission)
|
||||
{
|
||||
e.preventDefault();
|
||||
navigate("copy");
|
||||
}
|
||||
else if (!e.metaKey && !e.ctrlKey && e.key === "d" && table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission)
|
||||
{
|
||||
e.preventDefault();
|
||||
setDeleteConfirmationOpen(true);
|
||||
}
|
||||
else if (!e.metaKey && !e.ctrlKey && e.key === "a" && metaData?.tables?.has("audit"))
|
||||
{
|
||||
e.preventDefault();
|
||||
navigate("#audit");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", down);
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, [mode, isEditing, dotMenuOpen, keyboardHelpOpen, modalStack, showEditChildForm, showAudit, metaData, location, deleteConfirmationOpen]);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** process URL hash for audit/createChild/process modals
|
||||
***************************************************************************/
|
||||
const qController = Client.getInstance();
|
||||
const CREATE_CHILD_KEY = "createChild";
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const hashParts = location.hash.split("/");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// look for a createChild specification in the hash //
|
||||
// e.g., person/42#/createChild=address/defaultValues={...}/disabledFields={} //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
let foundCreateChild = false;
|
||||
for (let i = 0; i < hashParts.length; i++)
|
||||
{
|
||||
const parts = hashParts[i].split("=");
|
||||
if (parts.length > 1 && parts[0] === CREATE_CHILD_KEY)
|
||||
{
|
||||
foundCreateChild = true;
|
||||
const childTableName = parts[1];
|
||||
|
||||
// parse optional defaultValues and disabledFields from subsequent hash parts
|
||||
let defaultValues: any = null;
|
||||
let disabledFields: any = null;
|
||||
for (let j = i + 1; j < hashParts.length; j++)
|
||||
{
|
||||
const subParts = hashParts[j].split("=");
|
||||
if (subParts.length > 1)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (subParts[0] === "defaultValues")
|
||||
{
|
||||
defaultValues = JSON.parse(decodeURIComponent(subParts.slice(1).join("=")));
|
||||
}
|
||||
else if (subParts[0] === "disabledFields")
|
||||
{
|
||||
disabledFields = JSON.parse(decodeURIComponent(subParts.slice(1).join("=")));
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.warn("Error parsing hash param", subParts[0], e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const childTable = await qController.loadTableMetaData(childTableName);
|
||||
setShowEditChildForm({table: childTable, id: null, defaultValues, disabledFields});
|
||||
})();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundCreateChild)
|
||||
{
|
||||
// close any open child form if hash no longer has createChild
|
||||
if (showEditChildForm) setShowEditChildForm(null);
|
||||
}
|
||||
|
||||
if (hashParts[0] === "#audit")
|
||||
{
|
||||
setShowAudit(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
setShowAudit(false);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// look for a launchProcess specification in the hash //
|
||||
// e.g., person/42#launchProcess=bulkInsert //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
for (let i = 0; i < hashParts.length; i++)
|
||||
{
|
||||
const parts = hashParts[i].split("=");
|
||||
if (parts.length > 1 && parts[0].replace("#", "") === "launchProcess")
|
||||
{
|
||||
const processName = parts[1];
|
||||
(async () =>
|
||||
{
|
||||
const processMetaData = await qController.loadProcessMetaData(processName);
|
||||
setActiveModalProcess(processMetaData);
|
||||
})();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [location.hash]);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** process URL path for modal processes
|
||||
***************************************************************************/
|
||||
useEffect(() =>
|
||||
{
|
||||
if (mode !== "view") return;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// modal process: e.g., person/42/someProcess //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (allTableProcesses.length > 0 && pathParts.length >= 3)
|
||||
{
|
||||
if (pathParts[pathParts.length - 3] === table.name)
|
||||
{
|
||||
const processName = pathParts[pathParts.length - 1];
|
||||
const processList = allTableProcesses.filter(p => p.name.endsWith(processName));
|
||||
if (processList.length > 0)
|
||||
{
|
||||
setActiveModalProcess(processList[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [location.pathname, allTableProcesses]);
|
||||
|
||||
|
||||
/////////////////
|
||||
// Delete flow //
|
||||
/////////////////
|
||||
const handleDelete = async () =>
|
||||
{
|
||||
setIsDeleteSubmitting(true);
|
||||
try
|
||||
{
|
||||
await deleteRecord();
|
||||
}
|
||||
catch (error: any)
|
||||
{
|
||||
setIsDeleteSubmitting(false);
|
||||
setDeleteConfirmationOpen(false);
|
||||
if (error.message?.toLowerCase().startsWith("warning"))
|
||||
{
|
||||
const path = pathParts.slice(0, -1).join("/");
|
||||
navigate(path, {state: {deleteSuccess: true, warning: error.message}});
|
||||
}
|
||||
else
|
||||
{
|
||||
setErrorMessage(error.message);
|
||||
HtmlUtils.autoScroll(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
///////////////////////
|
||||
// Actions menu data //
|
||||
///////////////////////
|
||||
const recordViewMenuActions: RecordViewMenuActions = {
|
||||
new: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
navigate(pathParts.slice(0, -1).join("/") + "/create");
|
||||
},
|
||||
copy: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
navigate("copy");
|
||||
},
|
||||
edit: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
enterEditMode();
|
||||
},
|
||||
delete: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
setDeleteConfirmationOpen(true);
|
||||
setIsDeleteSubmitting(false);
|
||||
},
|
||||
developerMode: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
navigate("dev");
|
||||
},
|
||||
audit: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
navigate("#audit");
|
||||
},
|
||||
runProcess: (processName: string) =>
|
||||
{
|
||||
const process = metaData?.processes?.get(processName);
|
||||
if (process)
|
||||
{
|
||||
navigate(process.name);
|
||||
closeActionsMenu();
|
||||
}
|
||||
},
|
||||
downloadFileFromField: (fieldName: string, closeMenu?: () => void) =>
|
||||
{
|
||||
const fieldValue = record?.values?.get(fieldName);
|
||||
if (fieldName && fieldValue)
|
||||
{
|
||||
const field = tableMetaData.fields.get(fieldName);
|
||||
const url = ValueUtils.getUrlFromBlobOrFileDownloadField(fieldValue, tableVariant, field, record, fieldName);
|
||||
|
||||
if (field.type == QFieldType.BLOB)
|
||||
{
|
||||
const fileName = record?.displayValues?.get(fieldName) ?? fieldName;
|
||||
HtmlUtils.downloadUrlViaIFrame(field, url, fileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
window.open(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (closeMenu)
|
||||
{
|
||||
closeMenu();
|
||||
}
|
||||
},
|
||||
getMetaData: () => metaData,
|
||||
getTableProcesses: () => tableProcesses,
|
||||
getGenericProcesses: () => [],
|
||||
};
|
||||
|
||||
const itemsShownInActionsMenu = new ItemsShownInMenu();
|
||||
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Actions placement & sidebar metadata //
|
||||
///////////////////////////////////////////
|
||||
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} />
|
||||
{tableMetaData?.shareableTableMetaData && (
|
||||
<Box mr={2}>
|
||||
<MDButton type="button" color={preferredColorNameInfoOrPrimary()} size="small" onClick={() => setShowShareModal(true)} fullWidth startIcon={<Icon>group_add</Icon>}>
|
||||
Share
|
||||
</MDButton>
|
||||
</Box>
|
||||
)}
|
||||
{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: any, index: number) => (
|
||||
<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" sx={{visibility: mode === "view" ? "visible" : "hidden"}}>
|
||||
{renderRecordViewActions()}
|
||||
</Box>
|
||||
);
|
||||
}, [showRecordViewActionsInlineWithPageTitle, mode, metaData, tableMetaData, record, actionsMenuAnchorElement, actionMenu]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setPageHeaderRightContent?.(pageHeaderRecordViewActions);
|
||||
|
||||
return () => setPageHeaderRightContent?.(null);
|
||||
}, [setPageHeaderRightContent, pageHeaderRecordViewActions]);
|
||||
|
||||
/////////////////////
|
||||
// Modal callbacks //
|
||||
/////////////////////
|
||||
const closeModalProcess = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown") return;
|
||||
|
||||
if (location.hash)
|
||||
{
|
||||
navigate(location.pathname);
|
||||
}
|
||||
else
|
||||
{
|
||||
const newPath = location.pathname.split("/");
|
||||
newPath.pop();
|
||||
navigate(newPath.join("/"));
|
||||
}
|
||||
setActiveModalProcess(null);
|
||||
};
|
||||
|
||||
const closeAudit = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick") return;
|
||||
setShowAudit(false);
|
||||
if (location.hash) navigate(location.pathname);
|
||||
};
|
||||
|
||||
const closeEditChildForm = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown") return;
|
||||
setShowEditChildForm(null);
|
||||
if (location.hash) navigate(location.pathname);
|
||||
|
||||
// trigger widget reload when a view-mode child record was created or updated (server-persisted)
|
||||
if ((reason === "recordCreated" || reason === "recordUpdated") && mode === "view")
|
||||
{
|
||||
reloadWidget(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// when the tableMetaData changes, grab the action menu out of it (or build a default one) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
let menu: QMenu = null;
|
||||
if (metaData && tableMetaData)
|
||||
{
|
||||
menu = (tableMetaData.menus ?? []).find(m => m.slot == "VIEW_SCREEN_ACTIONS");
|
||||
|
||||
if (!menu)
|
||||
{
|
||||
menu = new QMenu(
|
||||
{
|
||||
label: "Actions",
|
||||
icon: {name: "game"},
|
||||
slot: "VIEW_SCREEN_ACTIONS",
|
||||
items: [
|
||||
{
|
||||
itemType: "SUB_LIST", values: {
|
||||
items: [
|
||||
{itemType: "BUILT_IN", values: {option: "NEW"}},
|
||||
{itemType: "BUILT_IN", values: {option: "COPY"}},
|
||||
{itemType: "BUILT_IN", values: {option: "EDIT"}},
|
||||
{itemType: "BUILT_IN", values: {option: "DELETE"}}
|
||||
]
|
||||
}
|
||||
},
|
||||
{itemType: "DIVIDER"},
|
||||
{itemType: "BUILT_IN", values: {option: "THIS_TABLE_PROCESS_LIST"}},
|
||||
{itemType: "DIVIDER"},
|
||||
{
|
||||
itemType: "SUB_LIST", values: {
|
||||
items: [
|
||||
{itemType: "BUILT_IN", values: {option: "ALL_TABLES_PROCESS_LIST"}},
|
||||
{itemType: "BUILT_IN", values: {option: "DEVELOPER_MODE"}},
|
||||
{itemType: "BUILT_IN", values: {option: "AUDIT"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
setActionMenu(menu);
|
||||
}, [metaData, tableMetaData]);
|
||||
|
||||
|
||||
/////////////////////////
|
||||
// Handle mode switch //
|
||||
/////////////////////////
|
||||
const enterEditMode = useCallback((fieldName?: string) =>
|
||||
{
|
||||
if (table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission)
|
||||
{
|
||||
setSuccessMessage(null);
|
||||
setWarningMessage(null);
|
||||
setErrorMessage(null);
|
||||
closeActionsMenu();
|
||||
|
||||
// capture the clicked field's screen position before mode switch
|
||||
if (fieldName)
|
||||
{
|
||||
const fieldEl = document.querySelector(`[data-field-name='${fieldName}']`);
|
||||
if (fieldEl)
|
||||
{
|
||||
scrollCorrectionRef.current = {fieldName, yBefore: fieldEl.getBoundingClientRect().top};
|
||||
}
|
||||
}
|
||||
|
||||
setMode("edit");
|
||||
window.history.pushState(null, "", location.pathname.replace(/\/(edit)?\/?$/, "") + "/edit");
|
||||
window.dispatchEvent(new Event("urlchanged"));
|
||||
|
||||
if (fieldName)
|
||||
{
|
||||
// focus the input after React renders
|
||||
setTimeout(() =>
|
||||
{
|
||||
const input = document.querySelector(`input[name='${fieldName}']`) as HTMLInputElement;
|
||||
if (input)
|
||||
{
|
||||
input.focus();
|
||||
}
|
||||
else
|
||||
{
|
||||
setTimeout(() =>
|
||||
{
|
||||
const retryInput = document.querySelector(`input[name='${fieldName}']`) as HTMLInputElement;
|
||||
retryInput?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}, [table, setMode, location.pathname]);
|
||||
|
||||
enterEditModeRef.current = enterEditMode;
|
||||
|
||||
/////////////////
|
||||
// RENDER //
|
||||
/////////////////
|
||||
if (loading)
|
||||
{
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="300px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (notFoundMessage)
|
||||
{
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box mb={3}>
|
||||
<Alert color="error" sx={{mb: 3}} icon={<Icon>warning</Icon>}>{notFoundMessage}</Alert>
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (notAllowedError && !isEditing)
|
||||
{
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box mb={3}>
|
||||
<Alert severity="error">{notAllowedError}</Alert>
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const handleFormikSubmit = async (values: any, actions: any) =>
|
||||
{
|
||||
actions.setSubmitting(true);
|
||||
try
|
||||
{
|
||||
await saveRecord(values);
|
||||
}
|
||||
catch (error: any)
|
||||
{
|
||||
if (error.message?.toLowerCase().startsWith("warning"))
|
||||
{
|
||||
const path = location.pathname.replace(/\/edit$/, "");
|
||||
navigate(path, {state: {updateSuccess: true, warning: error.message}});
|
||||
}
|
||||
else
|
||||
{
|
||||
setErrorMessage(error.message);
|
||||
HtmlUtils.autoScroll(0);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
actions.setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
////////////////
|
||||
// Main body //
|
||||
////////////////
|
||||
const renderT1Section = (formikProps?: any) =>
|
||||
{
|
||||
const titleText = mode === "view"
|
||||
? (tableMetaData && record ? `Viewing ${tableMetaData?.label}: ${record?.recordLabel || ""}` : "")
|
||||
: mode === "create"
|
||||
? (isCopy ? `Copy ${tableMetaData?.label}: ${record?.recordLabel || ""}` : `Creating New ${tableMetaData?.label}`)
|
||||
: `Edit ${tableMetaData?.label}: ${record?.recordLabel || ""}`;
|
||||
|
||||
return (
|
||||
<Card id={t1Section?.name} className="recordScreenFieldSection" sx={{scrollMarginTop: "100px", minHeight: "88px", overflow: "visible"}} data-qqq-id={`record-screen-header-${tableNameForId}`}>
|
||||
<Box display="flex" p={3} pb={1}>
|
||||
<Box mr={1.5}>
|
||||
<Avatar sx={{bgcolor: accentColor}}>
|
||||
<Icon>{tableMetaData?.iconName}</Icon>
|
||||
</Avatar>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between" width="100%" alignItems="flex-start" flexWrap={{xs: "wrap", md: "nowrap"}}>
|
||||
<Typography variant="h5" mb="0.5rem" data-qqq-id={`record-screen-title-${tableNameForId}`}>
|
||||
{titleText}
|
||||
</Typography>
|
||||
{!showRecordViewActionsInlineWithPageTitle && (
|
||||
<Box sx={{ml: "auto", visibility: mode === "view" ? "visible" : "hidden"}}>
|
||||
{renderRecordViewActions()}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{t1Section && (
|
||||
<Box p={0} pt={1}>
|
||||
<RecordScreenSection
|
||||
section={t1Section}
|
||||
mode={mode}
|
||||
record={record}
|
||||
formFieldsBySection={formFieldsBySection}
|
||||
tableVariant={tableVariant}
|
||||
onEditIconClick={enterEditMode}
|
||||
isT1={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const renderViewModeBottomBar = () =>
|
||||
{
|
||||
if (mode !== "view" || !tableMetaData || !record) return null;
|
||||
|
||||
const canDelete = table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission;
|
||||
const canEdit = table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission;
|
||||
|
||||
if (!canDelete && !canEdit) return null;
|
||||
|
||||
return (
|
||||
<Box component="div" p={3} className="stickyBottomButtonBar" data-qqq-id={`record-screen-button-bar-${tableNameForId}`}>
|
||||
<Grid container justifyContent="flex-end" spacing={3}>
|
||||
{canDelete && <QDeleteButton onClickHandler={() =>
|
||||
{
|
||||
setDeleteConfirmationOpen(true);
|
||||
setIsDeleteSubmitting(false);
|
||||
}} />}
|
||||
{canEdit && <QEditButton onClickHandler={() => enterEditMode()} />}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEditModeBottomBar = (isSubmitting: boolean) => (
|
||||
<Box component="div" p={3} className="stickyBottomButtonBar" data-qqq-id={`record-screen-button-bar-${tableNameForId}`}>
|
||||
<Grid container justifyContent="flex-end" spacing={3}>
|
||||
<QCancelButton onClickHandler={handleCancelClicked} disabled={isSubmitting} />
|
||||
<QSaveButton disabled={isSubmitting || !!notAllowedError} label="Save" iconName="save" />
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderDeleteDialog = () => (
|
||||
<Dialog open={deleteConfirmationOpen} onClose={() => setDeleteConfirmationOpen(false)}>
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>Are you sure you want to delete this record?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteConfirmationOpen(false)}>No</Button>
|
||||
<Button onClick={handleDelete} autoFocus disabled={isDeleteSubmitting}>Yes</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const renderModals = () => (
|
||||
<>
|
||||
{activeModalProcess && (
|
||||
<Modal open={true} onClose={closeModalProcess}>
|
||||
<div className="modalProcess">
|
||||
<ProcessRun process={activeModalProcess} isModal={true} table={tableMetaData} recordIds={[id]} closeModalHandler={closeModalProcess} />
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showEditChildForm && (
|
||||
<RecordScreenModal
|
||||
open={true}
|
||||
onClose={closeEditChildForm}
|
||||
tableName={showEditChildForm.table?.name}
|
||||
recordId={showEditChildForm.id}
|
||||
defaultValues={showEditChildForm.defaultValues}
|
||||
disabledFields={showEditChildForm.disabledFields}
|
||||
onSubmitCallback={showEditChildForm.widgetName ? submitEditChildForm : null}
|
||||
overrideHeading={showEditChildForm.widgetName ? `${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table?.label}` : null}
|
||||
saveButtonLabel={showEditChildForm.widgetName ? "OK" : "Save"}
|
||||
saveButtonIcon={showEditChildForm.widgetName ? "check" : "save"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAudit && tableMetaData && record && (
|
||||
<Modal open={showAudit} onClose={closeAudit}>
|
||||
<div className="audit">
|
||||
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
|
||||
<Card sx={{my: 5, mx: "auto", pb: 0, maxWidth: "1024px"}}>
|
||||
<Box component="div">
|
||||
<AuditBody recordId={id} record={record} tableMetaData={tableMetaData} />
|
||||
<Box p={3} display="flex" flexDirection="row" justifyContent="flex-end">
|
||||
<QCancelButton label="Close" onClickHandler={() => closeAudit(null, null)} disabled={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showShareModal && tableMetaData && record && (
|
||||
<ShareModal open={showShareModal} onClose={() => setShowShareModal(false)} tableMetaData={tableMetaData} record={record} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
const renderBottomBar = (isSubmitting: boolean): JSX.Element => (
|
||||
<>
|
||||
{mode === "view" && renderViewModeBottomBar()}
|
||||
{isEditing && renderEditModeBottomBar(isSubmitting)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
/////////////////////////////////////
|
||||
// MAIN RENDER - Formik wraps all //
|
||||
/////////////////////////////////////
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box className={`recordScreen${mode === "view" ? " recordView" : " recordEdit"}${mode === "create" ? " recordCreate" : ""}${isEditing ? " compactForm" : ""}`} data-qqq-id={`record-screen-${tableNameForId}`}>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Box mb={3}>
|
||||
<Box pb={3}>
|
||||
<RecordScreenBody
|
||||
screen={screen}
|
||||
onSubmit={handleFormikSubmit}
|
||||
formikSubmitRef={formikSubmitRef}
|
||||
renderT1Card={renderT1Section}
|
||||
renderBottomBar={renderBottomBar}
|
||||
showSidebar={showRecordSidebar}
|
||||
widgetReloadCounters={widgetReloadCounters}
|
||||
widgetReloadAdditionalParams={widgetReloadAdditionalParams}
|
||||
enterEditMode={enterEditMode}
|
||||
/>
|
||||
|
||||
{renderDeleteDialog()}
|
||||
{renderModals()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import {Alert} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import {QTableSection} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableSection";
|
||||
import {Form, Formik, useFormikContext} from "formik";
|
||||
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
|
||||
import RecordScreenContext from "qqq/pages/records/RecordScreenContext";
|
||||
import RecordScreenSection from "qqq/pages/records/RecordScreenSection";
|
||||
import {UseRecordScreenResult} from "qqq/pages/records/useRecordScreen";
|
||||
import {sanitizeId} from "qqq/utils/qqqIdUtils";
|
||||
import React, {useCallback, useEffect, useRef, useMemo} from "react";
|
||||
import * as Yup from "yup";
|
||||
|
||||
|
||||
interface RecordScreenBodyProps
|
||||
{
|
||||
screen: UseRecordScreenResult;
|
||||
formId?: string;
|
||||
onSubmit: (values: any, actions: any) => Promise<void>;
|
||||
formikSubmitRef?: React.MutableRefObject<() => void>;
|
||||
renderT1Card?: () => JSX.Element;
|
||||
renderBottomBar?: (isSubmitting: boolean) => JSX.Element;
|
||||
showSidebar?: boolean;
|
||||
widgetReloadCounters?: Record<string, number>;
|
||||
widgetReloadAdditionalParams?: Record<string, { [key: string]: any }>;
|
||||
enterEditMode?: (fieldName?: string) => void;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Shared Formik-wrapped body used by both full-page RecordScreen and
|
||||
** RecordScreenModal. Handles context, value tracking, sections, alerts.
|
||||
***************************************************************************/
|
||||
export default function RecordScreenBody({screen, formId, onSubmit, formikSubmitRef, renderT1Card, renderBottomBar, showSidebar = true, widgetReloadCounters = {}, widgetReloadAdditionalParams = {}, enterEditMode}: RecordScreenBodyProps): JSX.Element
|
||||
{
|
||||
const {
|
||||
mode, record, tableMetaData, metaData,
|
||||
tableSections, t1Section, nonT1Sections,
|
||||
formFieldsBySection, initialValues, formValidations,
|
||||
fieldRules,
|
||||
errorMessage, successMessage, warningMessage, notAllowedError,
|
||||
handleFieldChange, handleFieldBlur, reloadWidget,
|
||||
collapsibleSectionOpenStates, toggleCollapsibleSectionOpenState, openCollapsedSectionsWithErrorFields,
|
||||
setSuccessMessage, setWarningMessage, setErrorMessage,
|
||||
setFieldValueRef, formValuesRef,
|
||||
childListWidgetData,
|
||||
openAddChildRecord, openEditChildRecord, deleteChildRecord,
|
||||
tableVariant,
|
||||
sectionVisibility,
|
||||
addSubValidations,
|
||||
} = screen;
|
||||
|
||||
const isEditing = mode === "edit" || mode === "create";
|
||||
const tableNameForId = tableMetaData ? sanitizeId(tableMetaData.name) : "";
|
||||
|
||||
// formik JSON ref for change detection
|
||||
const formValuesJSONRef = useRef<string>("");
|
||||
|
||||
// Stable wrapper for setFieldValue
|
||||
const stableSetFieldValue = useCallback((name: string, value: any, shouldValidate?: boolean) =>
|
||||
{
|
||||
setFieldValueRef.current?.(name, value, shouldValidate);
|
||||
}, []);
|
||||
|
||||
const isFormDisabled = !!notAllowedError;
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
mode,
|
||||
tableMetaData,
|
||||
metaData,
|
||||
record,
|
||||
setFieldValue: stableSetFieldValue,
|
||||
handleFieldBlur,
|
||||
reloadWidget,
|
||||
fieldRules,
|
||||
isFormDisabled,
|
||||
addSubValidations,
|
||||
childListWidgetData,
|
||||
openAddChildRecord,
|
||||
openEditChildRecord,
|
||||
deleteChildRecord,
|
||||
onEditIconClick: enterEditMode,
|
||||
}), [mode, tableMetaData, metaData, record, stableSetFieldValue, reloadWidget, fieldRules, isFormDisabled, addSubValidations, childListWidgetData, enterEditMode]);
|
||||
|
||||
|
||||
//////////////////////////////////////////////
|
||||
// Formik value-change tracker component //
|
||||
//////////////////////////////////////////////
|
||||
const FormikValueTracker = (): JSX.Element =>
|
||||
{
|
||||
const {values, setFieldValue} = useFormikContext<any>();
|
||||
|
||||
setFieldValueRef.current = setFieldValue;
|
||||
|
||||
if (values)
|
||||
{
|
||||
const newJSON = JSON.stringify(values);
|
||||
if (formValuesJSONRef.current !== newJSON)
|
||||
{
|
||||
const valueChangesToMake: { [fieldName: string]: any } = {};
|
||||
|
||||
// detect field value changes by comparing against the ref (which tracks
|
||||
// actual runtime values). The dirty check is not used because Formik
|
||||
// considers the form "not dirty" when values match initialValues — but
|
||||
// field rules need to fire when a value changes from e.g. 'b' back to 'a'
|
||||
// (the initial value), since the intermediate state 'b' may have triggered
|
||||
// side effects (like widget reloads) that need to be reversed.
|
||||
const hasRefValues = Object.keys(formValuesRef.current).length > 0;
|
||||
for (let fieldName in values)
|
||||
{
|
||||
if (hasRefValues && formValuesRef.current[fieldName] !== values[fieldName])
|
||||
{
|
||||
handleFieldChange(fieldName, formValuesRef.current[fieldName], values[fieldName], valueChangesToMake);
|
||||
}
|
||||
formValuesRef.current[fieldName] = values[fieldName];
|
||||
}
|
||||
|
||||
for (let fieldName in valueChangesToMake)
|
||||
{
|
||||
formValuesRef.current[fieldName] = valueChangesToMake[fieldName];
|
||||
setFieldValue(fieldName, valueChangesToMake[fieldName], false);
|
||||
}
|
||||
|
||||
formValuesJSONRef.current = JSON.stringify(values);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
const renderAlerts = () => (
|
||||
<>
|
||||
{successMessage && (
|
||||
<Alert color="success" sx={{mb: 3, zIndex: 99, position: "sticky", top: "12px"}} onClose={() => setSuccessMessage(null)}>
|
||||
{successMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{warningMessage && (
|
||||
<Alert color="warning" sx={{mb: 3}} icon={<Icon>warning</Icon>} onClose={() => setWarningMessage(null)}>
|
||||
{warningMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert color="error" sx={{mb: 3}} onClose={() => setErrorMessage(null)}>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{notAllowedError && isEditing && (
|
||||
<Alert severity="error" sx={{mb: 3}}>
|
||||
{notAllowedError}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderNonT1Sections = (includeT1?: boolean) =>
|
||||
{
|
||||
const sectionsToRender = includeT1 && t1Section ? [t1Section, ...nonT1Sections] : nonT1Sections;
|
||||
return (
|
||||
<Grid container spacing={3} pb={4}>
|
||||
{sectionsToRender.map((section: QTableSection) =>
|
||||
{
|
||||
const open = collapsibleSectionOpenStates[section.name];
|
||||
const gridCols = section.widgetName
|
||||
? (metaData?.widgets?.get(section.widgetName)?.gridColumns ?? 12)
|
||||
: (section.gridColumns ?? 12);
|
||||
|
||||
const isSectionVisible = sectionVisibility[section.name] !== false;
|
||||
|
||||
return (
|
||||
<Grid
|
||||
id={section.name}
|
||||
key={section.name}
|
||||
item
|
||||
lg={gridCols}
|
||||
xs={12}
|
||||
className={`form-section-wrapper ${isSectionVisible ? "is-visible" : "is-hidden"}`}
|
||||
sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}
|
||||
>
|
||||
<Box width="100%" height={open !== false ? "100%" : "auto"}>
|
||||
<RecordScreenSection
|
||||
section={section}
|
||||
mode={mode}
|
||||
record={record}
|
||||
formFieldsBySection={formFieldsBySection}
|
||||
isOpen={open}
|
||||
onToggleCollapse={() => toggleCollapsibleSectionOpenState(section.name)}
|
||||
tableVariant={tableVariant}
|
||||
onEditIconClick={enterEditMode}
|
||||
widgetReloadCounters={widgetReloadCounters}
|
||||
widgetReloadAdditionalParams={widgetReloadAdditionalParams}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object().shape(formValidations);
|
||||
|
||||
const effectiveFormId = formId || `record-screen-form-${tableNameForId}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderAlerts()}
|
||||
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={onSubmit}
|
||||
enableReinitialize
|
||||
>
|
||||
{({isSubmitting, setFieldValue, submitForm}) =>
|
||||
{
|
||||
setFieldValueRef.current = setFieldValue;
|
||||
if (formikSubmitRef)
|
||||
{
|
||||
formikSubmitRef.current = submitForm;
|
||||
}
|
||||
|
||||
const FormWrapper = isEditing ? Form : "div";
|
||||
|
||||
return (
|
||||
<RecordScreenContext.Provider value={contextValue}>
|
||||
<FormWrapper id={effectiveFormId} autoComplete="off">
|
||||
<FormikValueTracker />
|
||||
{isEditing && <ScrollToFirstError openCollapsedSectionsWithErrorFields={openCollapsedSectionsWithErrorFields} />}
|
||||
|
||||
{showSidebar ? (
|
||||
<Grid container spacing={3} flexWrap={{md: "nowrap"}}>
|
||||
<Grid item xs={12} lg={3} className="recordSidebar">
|
||||
<QRecordSidebar tableSections={tableSections} sectionVisibility={sectionVisibility} />
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={9} className="recordWithSidebar">
|
||||
{renderT1Card && (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} mb={3}>
|
||||
{renderT1Card()}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{renderNonT1Sections()}
|
||||
|
||||
{renderBottomBar?.(isSubmitting)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<>
|
||||
{renderT1Card && <Box mb={3}>{renderT1Card()}</Box>}
|
||||
{renderNonT1Sections(!renderT1Card)}
|
||||
{renderBottomBar?.(isSubmitting)}
|
||||
</>
|
||||
)}
|
||||
</FormWrapper>
|
||||
</RecordScreenContext.Provider>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Scroll to the first validation error on submit.
|
||||
***************************************************************************/
|
||||
function ScrollToFirstError({openCollapsedSectionsWithErrorFields}: {openCollapsedSectionsWithErrorFields: (errorFieldNames: string[]) => void}): JSX.Element
|
||||
{
|
||||
const {submitCount, isValid, errors} = useFormikContext<any>();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (submitCount === 0 || isValid) return;
|
||||
|
||||
const errorFieldNames = Object.keys(errors);
|
||||
if (errorFieldNames.length > 0)
|
||||
{
|
||||
openCollapsedSectionsWithErrorFields(errorFieldNames);
|
||||
}
|
||||
|
||||
setTimeout(() =>
|
||||
{
|
||||
const firstError = document.querySelector("[data-field-error]");
|
||||
if (firstError)
|
||||
{
|
||||
firstError.scrollIntoView({block: "center"});
|
||||
}
|
||||
}, 150);
|
||||
}, [submitCount, isValid]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import {FieldRule} from "qqq/models/fields/FieldRules";
|
||||
import {createContext} from "react";
|
||||
|
||||
export type RecordScreenMode = "view" | "edit" | "create";
|
||||
|
||||
export interface RecordScreenContextValue
|
||||
{
|
||||
mode: RecordScreenMode;
|
||||
tableMetaData: QTableMetaData;
|
||||
metaData: QInstance;
|
||||
record: QRecord;
|
||||
setFieldValue: (name: string, value: any, shouldValidate?: boolean) => void;
|
||||
handleFieldBlur: (fieldName: string, value: any) => void;
|
||||
reloadWidget: (widgetName: string, additionalParams?: { [key: string]: any }) => void;
|
||||
fieldRules: FieldRule[];
|
||||
isFormDisabled: boolean;
|
||||
|
||||
// widget sub-validations
|
||||
addSubValidations: (name: string, validations: Record<string, any>) => void;
|
||||
|
||||
// enter edit mode callback (for pencil icons in view mode)
|
||||
onEditIconClick?: (fieldName?: string) => void;
|
||||
|
||||
// child record management (edit mode)
|
||||
childListWidgetData: { [name: string]: ChildRecordListData };
|
||||
openAddChildRecord: (widgetName: string, widgetData: ChildRecordListData) => void;
|
||||
openEditChildRecord: (widgetName: string, widgetData: ChildRecordListData, rowIndex: number) => void;
|
||||
deleteChildRecord: (widgetName: string, rowIndex: number) => void;
|
||||
}
|
||||
|
||||
const RecordScreenContext = createContext<RecordScreenContextValue>({
|
||||
mode: "view",
|
||||
tableMetaData: null,
|
||||
metaData: null,
|
||||
record: null,
|
||||
setFieldValue: () =>
|
||||
{
|
||||
},
|
||||
handleFieldBlur: () =>
|
||||
{
|
||||
},
|
||||
reloadWidget: () =>
|
||||
{
|
||||
},
|
||||
fieldRules: [],
|
||||
isFormDisabled: false,
|
||||
addSubValidations: () =>
|
||||
{
|
||||
},
|
||||
childListWidgetData: {},
|
||||
openAddChildRecord: () =>
|
||||
{
|
||||
},
|
||||
openEditChildRecord: () =>
|
||||
{
|
||||
},
|
||||
deleteChildRecord: () =>
|
||||
{
|
||||
},
|
||||
});
|
||||
|
||||
export default RecordScreenContext;
|
||||
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import OutlinedInput from "@mui/material/OutlinedInput";
|
||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {AdornmentType} from "@qrunio/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QTableVariant} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableVariant";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {Field, useFormikContext} from "formik";
|
||||
import {flushSync} from "react-dom";
|
||||
import DynamicFormUtils, {DynamicFormFieldDefinition} from "qqq/components/forms/DynamicFormUtils";
|
||||
import DynamicFormFieldAsWidget from "qqq/components/forms/DynamicFormFieldAsWidget";
|
||||
import DynamicSelect from "qqq/components/forms/DynamicSelect";
|
||||
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
|
||||
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
|
||||
import {FieldValueAsWidget} from "qqq/components/view/FieldValueAsWidget";
|
||||
import QContext from "QContext";
|
||||
import RecordScreenContext, {RecordScreenMode} from "qqq/pages/records/RecordScreenContext";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React, {useCallback, useContext, useRef, useState, useEffect} from "react";
|
||||
|
||||
// target height for all input controls: 26px
|
||||
const INPUT_HEIGHT = "26px";
|
||||
const INPUT_FONT_SIZE = "0.875rem"; // 14px
|
||||
const ERROR_FONT_SIZE = "0.75rem"; // 12px
|
||||
|
||||
// ghost border for view mode values to match edit-mode sizing
|
||||
const viewValueSx = {
|
||||
padding: "3px 6px",
|
||||
border: "1px solid transparent",
|
||||
borderRadius: "3px",
|
||||
fontSize: INPUT_FONT_SIZE,
|
||||
transition: "border-color 150ms",
|
||||
display: "inline-block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap" as const,
|
||||
maxWidth: "100%",
|
||||
lineHeight: "18px",
|
||||
boxSizing: "border-box" as const,
|
||||
height: INPUT_HEIGHT,
|
||||
// move chips up 3px so they fit in the box height
|
||||
"& .MuiChip-root": {
|
||||
position: "relative",
|
||||
top: "-3px"
|
||||
}
|
||||
};
|
||||
|
||||
// non-editable field styling during edit mode
|
||||
// TODO: these colors are hardcoded — consider integrating with the theme system later
|
||||
const nonEditableFieldSx = {
|
||||
...viewValueSx,
|
||||
color: "#616161",
|
||||
backgroundColor: "#F5F5F5",
|
||||
border: "1px solid #E0E0E0",
|
||||
maxWidth: {xs: "100%", lg: "286px"},
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
|
||||
const rowSx = {
|
||||
py: "3px",
|
||||
minHeight: "36px",
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}
|
||||
|
||||
|
||||
interface RecordScreenFieldProps
|
||||
{
|
||||
field: QFieldMetaData;
|
||||
fieldName: string;
|
||||
mode: RecordScreenMode;
|
||||
record?: QRecord;
|
||||
formFieldDef?: DynamicFormFieldDefinition;
|
||||
tableMetaData?: QTableMetaData;
|
||||
tableVariant?: QTableVariant;
|
||||
onEditIconClick?: (fieldName?: string) => void;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A single field that renders in either view or edit mode.
|
||||
** In view mode: label on left, display value on right (with ghost border).
|
||||
** In edit mode: label on left, input control on right.
|
||||
***************************************************************************/
|
||||
export default function RecordScreenField({field, fieldName, mode, record, formFieldDef, tableMetaData, tableVariant, onEditIconClick}: RecordScreenFieldProps): JSX.Element
|
||||
{
|
||||
const {setFieldValue, handleFieldBlur} = useContext(RecordScreenContext);
|
||||
const {helpHelpActive} = useContext(QContext);
|
||||
const {values: formikValues} = useFormikContext<any>();
|
||||
const isEditing = mode === "edit" || mode === "create";
|
||||
const isEditable = field.isEditable && isEditing && (formFieldDef?.isEditable !== false);
|
||||
const [showCopied, setShowCopied] = useState(false);
|
||||
|
||||
const handleCopyValue = useCallback((e: React.MouseEvent) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
// use getDisplayValue for proper formatting (e.g., date-times), but fall back
|
||||
// to plain display/raw values since getDisplayValue can return JSX for some types
|
||||
let formattedValue = record ? ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant) : null;
|
||||
if (formattedValue != null && typeof formattedValue === "object")
|
||||
{
|
||||
// getDisplayValue returned JSX — fall back to display value or raw value
|
||||
formattedValue = record?.displayValues?.get(fieldName) ?? record?.values?.get(fieldName);
|
||||
}
|
||||
const textToCopy = formattedValue != null && formattedValue !== "" ? String(formattedValue) : "";
|
||||
if (textToCopy)
|
||||
{
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setShowCopied(true);
|
||||
setTimeout(() => setShowCopied(false), 1500);
|
||||
}
|
||||
}, [record, field, fieldName, tableVariant]);
|
||||
const label = field.label;
|
||||
|
||||
const helpRoles = isEditing
|
||||
? [(mode === "create" ? "INSERT_SCREEN" : "EDIT_SCREEN"), "WRITE_SCREENS", "ALL_SCREENS"]
|
||||
: ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
|
||||
const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles);
|
||||
const helpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={isEditing ? undefined : label} helpContentKey={`table:${tableMetaData?.name};field:${fieldName}`} />;
|
||||
|
||||
// widget-adorned fields render specially
|
||||
if (field.hasAdornment(AdornmentType.WIDGET))
|
||||
{
|
||||
if (isEditing)
|
||||
{
|
||||
return (
|
||||
<>
|
||||
<DynamicFormFieldAsWidget
|
||||
name={fieldName}
|
||||
fieldMetaData={field}
|
||||
setValueCallback={(name: string, value: any) => setFieldValue(name, value)}
|
||||
otherValues={formikValues}
|
||||
/>
|
||||
<AnimatedFieldError fieldName={fieldName} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <FieldValueAsWidget field={field} record={record} />;
|
||||
}
|
||||
|
||||
const labelElement = (
|
||||
<Typography
|
||||
className="fieldLabel"
|
||||
variant="button"
|
||||
textTransform="none"
|
||||
fontWeight="bold"
|
||||
pr={1}
|
||||
color="rgb(52, 71, 103)"
|
||||
{...(isEditable ? {component: "label", htmlFor: fieldName} : {})}
|
||||
sx={{
|
||||
cursor: isEditable ? "pointer" : "default",
|
||||
flexShrink: 0,
|
||||
fontSize: INPUT_FONT_SIZE,
|
||||
lineHeight: 1.2,
|
||||
maxWidth: {xs: "174px", lg: "none"},
|
||||
}}
|
||||
>
|
||||
{label}{isEditing && field.isRequired && <span style={{marginLeft: "1px"}}>*</span>}:
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const wrappedLabel = showHelp && helpContent && !isEditing
|
||||
? <Tooltip title={helpContent}>{labelElement}</Tooltip>
|
||||
: labelElement;
|
||||
|
||||
/////////////////////////
|
||||
// VIEW MODE RENDERING //
|
||||
/////////////////////////
|
||||
if (!isEditable)
|
||||
{
|
||||
const valueSx = isEditing ? nonEditableFieldSx : viewValueSx;
|
||||
// in edit mode, show plain text (no links) for non-editable fields
|
||||
let displayValue: any;
|
||||
if (isEditing)
|
||||
{
|
||||
// for PV fields, use the initialDisplayValue from the form field def
|
||||
// (which was looked up during form data setup); otherwise use
|
||||
// ValueUtils.getDisplayValue for proper formatting (e.g., date-times),
|
||||
// falling back to plain display/raw values if it returns JSX (links, chips, etc.)
|
||||
displayValue = formFieldDef?.possibleValueProps?.initialDisplayValue;
|
||||
if (displayValue == null)
|
||||
{
|
||||
displayValue = record ? ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant) : null;
|
||||
if (displayValue != null && typeof displayValue === "object")
|
||||
{
|
||||
displayValue = record?.displayValues?.get(fieldName) ?? record?.values?.get(fieldName);
|
||||
}
|
||||
}
|
||||
displayValue = displayValue ?? formikValues?.[fieldName] ?? "";
|
||||
}
|
||||
else
|
||||
{
|
||||
displayValue = record ? ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant) : "";
|
||||
}
|
||||
const rawValue = record?.values?.get(fieldName) ?? formikValues?.[fieldName];
|
||||
const hasValue = rawValue != null && String(rawValue) !== "";
|
||||
|
||||
return (
|
||||
<Box sx={rowSx} data-field-name={fieldName}>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
"&:hover .field-action-icons": {opacity: 1},
|
||||
}}
|
||||
>
|
||||
{wrappedLabel}
|
||||
<Typography
|
||||
variant="button"
|
||||
textTransform="none"
|
||||
fontWeight="regular"
|
||||
color={isEditing ? "#616161" : "rgb(123, 128, 154)"}
|
||||
sx={valueSx}
|
||||
>
|
||||
{displayValue}
|
||||
</Typography>
|
||||
{mode === "view" && (
|
||||
<Box className="field-action-icons" sx={{opacity: showCopied ? 1 : 0, transition: "opacity 150ms", display: "flex", alignItems: "center", flexShrink: 0}}>
|
||||
{hasValue && (
|
||||
<Tooltip title={showCopied ? "Copied" : "Copy value"} open={showCopied || undefined} placement="top" enterDelay={500} enterNextDelay={300}>
|
||||
<IconButton size="small" onClick={handleCopyValue} sx={{ml: 0.5}}>
|
||||
<Icon fontSize="small">content_copy</Icon>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{field.isEditable && !fieldName.includes(".") && onEditIconClick && (
|
||||
<Tooltip title="Edit" placement="top" enterDelay={500} enterNextDelay={300}>
|
||||
<IconButton size="small" onClick={() => onEditIconClick(fieldName)} sx={{ml: 0.25}}>
|
||||
<Icon fontSize="small">edit</Icon>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/////////////////////////
|
||||
// EDIT MODE RENDERING //
|
||||
/////////////////////////
|
||||
return (
|
||||
<Box data-field-name={fieldName}>
|
||||
<Box sx={rowSx}>
|
||||
{wrappedLabel}
|
||||
<Box sx={{maxWidth: {xs: "none", lg: "286px"}, flex: 1, minWidth: 0, lineHeight: 1}}>
|
||||
{renderEditControl(field, fieldName, formFieldDef, formikValues, setFieldValue, handleFieldBlur)}
|
||||
</Box>
|
||||
</Box>
|
||||
{showHelp && <Box color="#757575" fontSize="0.875rem" mt="-0.25rem" pl="2rem">{helpContent}</Box>}
|
||||
<AnimatedFieldError fieldName={fieldName} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Animated error message that slides open/closed.
|
||||
** Stays mounted so the close animation can play when the error clears.
|
||||
***************************************************************************/
|
||||
function AnimatedFieldError({fieldName}: {fieldName: string}): JSX.Element
|
||||
{
|
||||
const {getFieldMeta, submitCount} = useFormikContext<any>();
|
||||
const meta = getFieldMeta(fieldName);
|
||||
const error = (meta.touched || submitCount > 0) && meta.error ? meta.error : undefined;
|
||||
const [displayedError, setDisplayedError] = useState<string | undefined>(undefined);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (error)
|
||||
{
|
||||
// Show the new error text immediately so we can measure it
|
||||
setDisplayedError(error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep displayed text during close animation, clear after transition
|
||||
const timer = setTimeout(() => setDisplayedError(undefined), 200);
|
||||
setHeight(0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Measure after displayedError updates the DOM
|
||||
useEffect(() =>
|
||||
{
|
||||
if (displayedError && contentRef.current)
|
||||
{
|
||||
setHeight(contentRef.current.scrollHeight);
|
||||
}
|
||||
}, [displayedError]);
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
height: `${height}px`,
|
||||
overflow: "hidden",
|
||||
transition: "height 200ms ease-in-out",
|
||||
fontSize: ERROR_FONT_SIZE,
|
||||
paddingBottom: displayedError ? "2px" : "0",
|
||||
}}>
|
||||
<Box ref={contentRef}>
|
||||
{displayedError && <Typography variant="caption" color="error" data-field-error={fieldName}>{displayedError}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** render the appropriate edit control for a field
|
||||
***************************************************************************/
|
||||
function renderEditControl(field: QFieldMetaData, fieldName: string, formFieldDef: DynamicFormFieldDefinition, formikValues?: any, setFieldValue?: (name: string, value: any) => void, handleFieldBlur?: (fieldName: string, value: any) => void): JSX.Element
|
||||
{
|
||||
if (!formFieldDef)
|
||||
{
|
||||
return <Typography variant="body2" color="text.secondary">Field not configured</Typography>;
|
||||
}
|
||||
|
||||
// possible value / dropdown
|
||||
if (formFieldDef.possibleValueProps)
|
||||
{
|
||||
return (
|
||||
<DynamicSelect
|
||||
fieldPossibleValueProps={formFieldDef.possibleValueProps}
|
||||
name={fieldName}
|
||||
isEditable={formFieldDef.isEditable}
|
||||
fieldLabel=""
|
||||
useCase="form"
|
||||
inForm={false}
|
||||
initialValue={formikValues?.[fieldName]}
|
||||
onChange={(value: any) => setFieldValue(fieldName, value ? value.id : null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// boolean / checkbox
|
||||
if (formFieldDef.type === "checkbox")
|
||||
{
|
||||
return <BooleanFieldSwitch name={fieldName} label="" value={formikValues?.[fieldName] ?? false} isDisabled={!formFieldDef.isEditable} />;
|
||||
}
|
||||
|
||||
// default: text/number/datetime/etc via Formik Field
|
||||
const isToUpperCase = DynamicFormUtils.isToUpperCase(field);
|
||||
const isToLowerCase = DynamicFormUtils.isToLowerCase(field);
|
||||
|
||||
let caseChangeOnChange: any = {};
|
||||
if (isToUpperCase || isToLowerCase)
|
||||
{
|
||||
caseChangeOnChange.onChange = (e: any) =>
|
||||
{
|
||||
const beforeStart = e.target.selectionStart;
|
||||
const beforeEnd = e.target.selectionEnd;
|
||||
|
||||
flushSync(() =>
|
||||
{
|
||||
let newValue = e.currentTarget.value;
|
||||
if (isToUpperCase)
|
||||
{
|
||||
newValue = newValue.toUpperCase();
|
||||
}
|
||||
if (isToLowerCase)
|
||||
{
|
||||
newValue = newValue.toLowerCase();
|
||||
}
|
||||
setFieldValue(fieldName, newValue);
|
||||
});
|
||||
|
||||
const input = document.getElementById(fieldName) as HTMLInputElement;
|
||||
if (input)
|
||||
{
|
||||
input.setSelectionRange(beforeStart, beforeEnd);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
name={fieldName}
|
||||
id={fieldName}
|
||||
type={formFieldDef.type}
|
||||
as={OutlinedInput}
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{
|
||||
".compactForm &": {
|
||||
height: INPUT_HEIGHT,
|
||||
},
|
||||
".compactForm & .MuiOutlinedInput-input": {
|
||||
padding: "3px 6px",
|
||||
fontSize: INPUT_FONT_SIZE,
|
||||
height: "18px",
|
||||
boxSizing: "content-box",
|
||||
},
|
||||
".compactForm & .MuiOutlinedInput-notchedOutline": {
|
||||
borderRadius: "3px",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
id: fieldName,
|
||||
style: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
{
|
||||
if (e.key === "Enter")
|
||||
{
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) =>
|
||||
{
|
||||
handleFieldBlur?.(fieldName, e.target.value);
|
||||
}}
|
||||
{...caseChangeOnChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Card from "@mui/material/Card";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import RecordScreenBody from "qqq/pages/records/RecordScreenBody";
|
||||
import {useRecordScreen, UseRecordScreenOptions} from "qqq/pages/records/useRecordScreen";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
|
||||
|
||||
interface RecordScreenModalProps
|
||||
{
|
||||
open: boolean;
|
||||
onClose: (event: object, reason: string) => void;
|
||||
tableName: string;
|
||||
recordId?: string;
|
||||
defaultValues?: { [key: string]: any };
|
||||
disabledFields?: { [key: string]: boolean } | string[];
|
||||
overrideTableMetaData?: QTableMetaData;
|
||||
onSubmitCallback?: (values: any, tableName: string) => void;
|
||||
overrideHeading?: string;
|
||||
saveButtonLabel?: string;
|
||||
saveButtonIcon?: string;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Modal wrapper that renders RecordScreenBody inside a MUI Modal + Card.
|
||||
** Used for child record create/edit modals.
|
||||
***************************************************************************/
|
||||
export default function RecordScreenModal({
|
||||
open,
|
||||
onClose,
|
||||
tableName,
|
||||
recordId,
|
||||
defaultValues,
|
||||
disabledFields,
|
||||
overrideTableMetaData,
|
||||
onSubmitCallback,
|
||||
overrideHeading,
|
||||
saveButtonLabel = "Save",
|
||||
saveButtonIcon = "save",
|
||||
}: RecordScreenModalProps): JSX.Element
|
||||
{
|
||||
const formikSubmitRef = useRef<() => void>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [isSticking, setIsSticking] = useState(false);
|
||||
|
||||
const options: UseRecordScreenOptions = {
|
||||
skipNavigation: true,
|
||||
defaultValues,
|
||||
disabledFields,
|
||||
overrideTableMetaData,
|
||||
onSubmitCallback,
|
||||
};
|
||||
|
||||
const initialMode = recordId ? "edit" : "create";
|
||||
const screen = useRecordScreen(tableName, recordId, initialMode, false, options);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const sentinel = sentinelRef.current;
|
||||
const root = scrollContainerRef.current;
|
||||
if (!sentinel || !root) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsSticking(!entry.isIntersecting),
|
||||
{root, threshold: 0}
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [screen.loading]);
|
||||
|
||||
const heading = overrideHeading
|
||||
?? (recordId
|
||||
? `Editing ${screen.tableMetaData?.label ?? ""}`
|
||||
: `Creating New ${screen.tableMetaData?.label ?? ""}`);
|
||||
|
||||
const handleModalClose = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown")
|
||||
{
|
||||
return;
|
||||
}
|
||||
onClose(event, reason);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) =>
|
||||
{
|
||||
await screen.saveRecord(values);
|
||||
onClose(null, recordId ? "recordUpdated" : "recordCreated");
|
||||
};
|
||||
|
||||
const handleCancel = () =>
|
||||
{
|
||||
onClose(null, "cancelled");
|
||||
};
|
||||
|
||||
const renderBottomBar = (isSubmitting: boolean) => (
|
||||
<>
|
||||
<Box component="div" p={3} className={`stickyBottomButtonBar modalBottomButtonBar${isSticking ? " sticking" : ""}`}>
|
||||
<Grid container justifyContent="flex-end" spacing={3}>
|
||||
<QCancelButton onClickHandler={handleCancel} disabled={isSubmitting} />
|
||||
<QSaveButton disabled={isSubmitting} label={saveButtonLabel} iconName={saveButtonIcon} />
|
||||
</Grid>
|
||||
</Box>
|
||||
<div ref={sentinelRef} style={{height: "1px"}} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleModalClose}>
|
||||
<Box ref={scrollContainerRef} className="modalEditForm compactForm" sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%", "& form > .MuiGrid-container": {pb: "16px"}}}>
|
||||
<Card sx={{my: 5, mx: "auto", p: 4, pb: 0, maxWidth: "1024px"}}>
|
||||
<Box mb={2}>
|
||||
<Typography variant="h5" fontWeight={500}>
|
||||
{heading}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{!screen.loading && (
|
||||
<RecordScreenBody
|
||||
screen={screen}
|
||||
formId="record-screen-modal-form"
|
||||
onSubmit={handleSubmit}
|
||||
formikSubmitRef={formikSubmitRef}
|
||||
renderBottomBar={renderBottomBar}
|
||||
showSidebar={false}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Card from "@mui/material/Card";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {QTableSection} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableSection";
|
||||
import {QTableVariant} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableVariant";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {DynamicFormFieldDefinition} from "qqq/components/forms/DynamicFormUtils";
|
||||
import HelpContent from "qqq/components/misc/HelpContent";
|
||||
import DashboardWidgets from "qqq/components/widgets/DashboardWidgets";
|
||||
import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import RecordScreenContext, {RecordScreenMode} from "qqq/pages/records/RecordScreenContext";
|
||||
import RecordScreenField from "qqq/pages/records/RecordScreenField";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import {useFormikContext} from "formik";
|
||||
import React, {useContext, memo} from "react";
|
||||
|
||||
|
||||
interface RecordScreenSectionProps
|
||||
{
|
||||
section: QTableSection;
|
||||
mode: RecordScreenMode;
|
||||
record?: QRecord;
|
||||
formFieldsBySection?: Map<string, DynamicFormFieldDefinition[]>;
|
||||
isOpen?: boolean;
|
||||
isT1?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onEditIconClick?: (fieldName?: string) => void;
|
||||
tableVariant?: QTableVariant;
|
||||
widgetReloadCounters?: Record<string, number>;
|
||||
widgetReloadAdditionalParams?: Record<string, { [key: string]: any }>;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Wrapper that reads Formik values and passes them to DashboardWidgets.
|
||||
** This is NOT memoized, so it re-renders when Formik values change —
|
||||
** which is needed for widgets like FilterAndColumnsSetup that depend on
|
||||
** form field values (e.g., tableName).
|
||||
***************************************************************************/
|
||||
function FormikAwareDashboardWidgets({sectionName, mode, recordId, tableMetaDataName, widgetMetaDataList, record, primaryKeyField, widgetReloadCounter, childUrlParams, setFieldValue, addSubValidations}: {
|
||||
sectionName: string;
|
||||
mode: string;
|
||||
recordId: any;
|
||||
tableMetaDataName: string;
|
||||
widgetMetaDataList: any[];
|
||||
record: QRecord;
|
||||
primaryKeyField: string;
|
||||
widgetReloadCounter: number;
|
||||
childUrlParams?: string;
|
||||
setFieldValue: (name: string, value: any) => void;
|
||||
addSubValidations?: (name: string, validations: Record<string, any>) => void;
|
||||
}): JSX.Element
|
||||
{
|
||||
const {values: formikValues} = useFormikContext<any>();
|
||||
|
||||
return (
|
||||
<DashboardWidgets
|
||||
key={`${sectionName}-${mode}-${recordId ?? "new"}-${widgetReloadCounter}`}
|
||||
tableName={tableMetaDataName}
|
||||
widgetMetaDataList={widgetMetaDataList}
|
||||
record={record}
|
||||
entityPrimaryKey={record?.values?.get(primaryKeyField)}
|
||||
omitWrappingGridContainer={true}
|
||||
screen="recordEdit"
|
||||
values={formikValues ?? {}}
|
||||
childUrlParams={childUrlParams}
|
||||
addSubValidations={addSubValidations}
|
||||
actionCallback={(data: any) =>
|
||||
{
|
||||
if (data && typeof data === "object")
|
||||
{
|
||||
for (const [key, value] of Object.entries(data))
|
||||
{
|
||||
setFieldValue(key, value);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Renders a single section (card) on the RecordScreen.
|
||||
** Handles both field-based and widget-based sections.
|
||||
***************************************************************************/
|
||||
function RecordScreenSection({section, mode, record, formFieldsBySection, isOpen = true, isT1 = false, onToggleCollapse, onEditIconClick, tableVariant, widgetReloadCounters = {}, widgetReloadAdditionalParams = {}}: RecordScreenSectionProps): JSX.Element
|
||||
{
|
||||
const {tableMetaData, metaData, childListWidgetData, openAddChildRecord, openEditChildRecord, deleteChildRecord, setFieldValue, isFormDisabled, addSubValidations} = useContext(RecordScreenContext);
|
||||
|
||||
if (!section || section.isHidden)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCollapsible = !isT1 && section.collapsible?.isCollapsible;
|
||||
const moreHeaderProps = isCollapsible ? {sx: {cursor: "pointer"}, onClick: onToggleCollapse} : {};
|
||||
|
||||
// widget section
|
||||
if (section.widgetName && metaData)
|
||||
{
|
||||
const widgetMetaData = metaData.widgets.get(section.widgetName);
|
||||
if (!widgetMetaData)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (section.collapsible)
|
||||
{
|
||||
widgetMetaData.collapsible = section.collapsible;
|
||||
}
|
||||
|
||||
// per-widget reload counter (specific + global "__all__" counter)
|
||||
const widgetReloadCounter = (widgetReloadCounters[section.widgetName] ?? 0) + (widgetReloadCounters["__all__"] ?? 0);
|
||||
|
||||
// build childUrlParams from any reload-triggered additional params for this widget
|
||||
const reloadParams = widgetReloadAdditionalParams[section.widgetName];
|
||||
const childUrlParams = reloadParams
|
||||
? Object.entries(reloadParams).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&")
|
||||
: undefined;
|
||||
|
||||
// In edit mode, managed-association childRecordList widgets render directly
|
||||
// as RecordGridWidget with in-memory CRUD callbacks (like EntityForm does).
|
||||
const isManagedChildRecordList = mode !== "view"
|
||||
&& widgetMetaData.type === "childRecordList"
|
||||
&& widgetMetaData.defaultValues?.has("manageAssociationName");
|
||||
|
||||
if (isManagedChildRecordList)
|
||||
{
|
||||
if (!childListWidgetData[section.widgetName])
|
||||
{
|
||||
// data is loading — show a placeholder instead of DashboardWidgets
|
||||
return (
|
||||
<Box width="100%" flexGrow={1} alignItems="stretch" display="flex" justifyContent="center" p={3}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const widgetData = childListWidgetData[section.widgetName];
|
||||
widgetData.viewAllLink = null;
|
||||
if (!isFormDisabled)
|
||||
{
|
||||
widgetData.canAddChildRecord = true;
|
||||
}
|
||||
widgetMetaData.showExportButton = false;
|
||||
|
||||
return (
|
||||
<Box width="100%" flexGrow={1} alignItems="stretch" sx={{"& .widget.inCard": {padding: "24px"}}}>
|
||||
<RecordGridWidget
|
||||
widgetMetaData={widgetMetaData}
|
||||
data={widgetData}
|
||||
disableRowClick
|
||||
allowRecordEdit={!isFormDisabled}
|
||||
allowRecordDelete={!isFormDisabled}
|
||||
addNewRecordCallback={() => openAddChildRecord(section.widgetName, widgetData)}
|
||||
editRecordCallback={(rowIndex) => openEditChildRecord(section.widgetName, widgetData, rowIndex)}
|
||||
deleteRecordCallback={(rowIndex) => deleteChildRecord(section.widgetName, rowIndex)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isEditing = mode === "edit" || mode === "create";
|
||||
|
||||
return (
|
||||
<Box width="100%" height="100%" flexGrow={1} alignItems="stretch" sx={{"& .widget.inCard": {padding: "24px"}}}>
|
||||
{isEditing ? (
|
||||
<FormikAwareDashboardWidgets
|
||||
sectionName={section.name}
|
||||
mode={mode}
|
||||
recordId={record?.values?.get("id")}
|
||||
tableMetaDataName={tableMetaData.name}
|
||||
widgetMetaDataList={[widgetMetaData]}
|
||||
record={record}
|
||||
primaryKeyField={tableMetaData.primaryKeyField}
|
||||
widgetReloadCounter={widgetReloadCounter}
|
||||
childUrlParams={childUrlParams}
|
||||
setFieldValue={setFieldValue}
|
||||
addSubValidations={addSubValidations}
|
||||
/>
|
||||
) : (
|
||||
<DashboardWidgets
|
||||
key={`${section.name}-${mode}-${record?.values?.get("id") ?? "new"}-${widgetReloadCounter}`}
|
||||
tableName={tableMetaData.name}
|
||||
widgetMetaDataList={[widgetMetaData]}
|
||||
record={record}
|
||||
entityPrimaryKey={record?.values?.get(tableMetaData.primaryKeyField)}
|
||||
omitWrappingGridContainer={true}
|
||||
childUrlParams={childUrlParams}
|
||||
screen="recordView"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// field section
|
||||
if (!section.fieldNames || section.fieldNames.length === 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const helpRoles = (mode === "edit" || mode === "create")
|
||||
? [(mode === "create" ? "INSERT_SCREEN" : "EDIT_SCREEN"), "WRITE_SCREENS", "ALL_SCREENS"]
|
||||
: ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
|
||||
const sectionHelp = <HelpContent helpContents={section.helpContents} roles={helpRoles} helpContentKey={`table:${tableMetaData?.name};section:${section.name}`} />;
|
||||
|
||||
const sectionFormFields = formFieldsBySection?.get(section.name);
|
||||
|
||||
// build a lookup for form field defs by field name
|
||||
const formFieldDefMap: { [key: string]: DynamicFormFieldDefinition } = {};
|
||||
if (sectionFormFields)
|
||||
{
|
||||
for (let ff of sectionFormFields)
|
||||
{
|
||||
formFieldDefMap[ff.name] = ff;
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsContent = (
|
||||
<>
|
||||
{sectionHelp && (
|
||||
<Box px="1.5rem" pb="0.5rem" fontSize="0.875rem">
|
||||
{sectionHelp}
|
||||
</Box>
|
||||
)}
|
||||
<Box p={3} pt={0} flexDirection="column">
|
||||
<Grid container display="flex" spacing={0}>
|
||||
{section.fieldNames.map((fieldName: string) =>
|
||||
{
|
||||
let [field] = tableMetaData ? TableUtils.getFieldAndTable(tableMetaData, fieldName) : [null, null];
|
||||
|
||||
if (field == null && tableMetaData?.virtualFields?.has(fieldName))
|
||||
{
|
||||
field = tableMetaData.virtualFields.get(fieldName);
|
||||
}
|
||||
|
||||
if (field == null || field.isHidden)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// skip join fields in edit/create mode — they aren't editable from this table
|
||||
if ((mode === "edit" || mode === "create") && fieldName.includes("."))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const gridColumns = (field.gridColumns && field.gridColumns > 0) ? field.gridColumns : 12;
|
||||
|
||||
return (
|
||||
<Grid item key={fieldName} lg={gridColumns} xs={12} pt={0}>
|
||||
<RecordScreenField
|
||||
field={field}
|
||||
fieldName={fieldName}
|
||||
mode={mode}
|
||||
record={record}
|
||||
formFieldDef={formFieldDefMap[fieldName]}
|
||||
tableMetaData={tableMetaData}
|
||||
tableVariant={tableVariant}
|
||||
onEditIconClick={onEditIconClick}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
// T1 sections render without a Card wrapper (parent already provides one)
|
||||
if (isT1)
|
||||
{
|
||||
return fieldsContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card id={section.name} className="recordScreenFieldSection" sx={{overflow: "visible", scrollMarginTop: "100px", height: isOpen ? "100%" : "auto"}}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" pb={isOpen ? 0 : 1.5} {...moreHeaderProps}>
|
||||
<Typography variant="h6" p={3} pb={1}>
|
||||
{section.label}
|
||||
</Typography>
|
||||
{isCollapsible && (
|
||||
<Box p="0.75rem 0.25rem 0">
|
||||
<IconButton onClick={(e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
onToggleCollapse?.();
|
||||
}}>
|
||||
<Icon fontSize="large">{isOpen ? "expand_less" : "expand_more"}</Icon>
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box style={{display: isOpen ? "block" : "none"}}>
|
||||
{fieldsContent}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Memoized export — prevents widget sections from re-rendering on every
|
||||
** Formik keystroke. Only re-renders when meaningful props change.
|
||||
** Callback props (onToggleCollapse, onEditIconClick) are excluded from
|
||||
** comparison since they're recreated each render but functionally stable.
|
||||
***************************************************************************/
|
||||
export default memo(RecordScreenSection, (prev, next) =>
|
||||
{
|
||||
return (
|
||||
prev.section === next.section
|
||||
&& prev.mode === next.mode
|
||||
&& prev.record === next.record
|
||||
&& prev.formFieldsBySection === next.formFieldsBySection
|
||||
&& prev.isOpen === next.isOpen
|
||||
&& prev.isT1 === next.isT1
|
||||
&& prev.tableVariant === next.tableVariant
|
||||
&& prev.widgetReloadCounters === next.widgetReloadCounters
|
||||
&& prev.widgetReloadAdditionalParams === next.widgetReloadAdditionalParams
|
||||
// Note: childListWidgetData changes are detected via context, not props
|
||||
);
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import EntityForm from "qqq/components/forms/EntityForm";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
|
||||
interface Props
|
||||
{
|
||||
table?: QTableMetaData;
|
||||
}
|
||||
|
||||
function EntityCreate({table}: Props): JSX.Element
|
||||
{
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box mb={3}>
|
||||
<EntityForm table={table} />
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
EntityCreate.defaultProps = {
|
||||
table: null,
|
||||
};
|
||||
|
||||
export default EntityCreate;
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import {useParams} from "react-router-dom";
|
||||
import EntityForm from "qqq/components/forms/EntityForm";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
|
||||
interface Props
|
||||
{
|
||||
table?: QTableMetaData;
|
||||
isCopy?: boolean
|
||||
}
|
||||
|
||||
EntityEdit.defaultProps = {
|
||||
table: null,
|
||||
isCopy: false
|
||||
};
|
||||
|
||||
function EntityEdit({table, isCopy}: Props): JSX.Element
|
||||
{
|
||||
const {id} = useParams();
|
||||
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box mb={3}>
|
||||
<EntityForm table={table} id={id} isCopy={isCopy} />
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityEdit;
|
||||
@@ -192,7 +192,6 @@ 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);
|
||||
@@ -383,9 +382,6 @@ 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[]);
|
||||
@@ -460,7 +456,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 Record<number, {count?: number, distinctCount?: number, error?: string}>);
|
||||
const [countResults, setCountResults] = useState({} as any);
|
||||
const [receivedCountTimestamp, setReceivedCountTimestamp] = useState(new Date());
|
||||
const [queryResults, setQueryResults] = useState({} as any);
|
||||
const [latestQueryResults, setLatestQueryResults] = useState(null as QRecord[]);
|
||||
@@ -1047,7 +1043,6 @@ 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++)
|
||||
@@ -1088,7 +1083,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
|
||||
tablesToAdd?.forEach(t => visibleJoinTables.add(t));
|
||||
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables, metaData);
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -1112,13 +1107,9 @@ 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] = {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}`};
|
||||
countResults[thisQueryId] = [];
|
||||
countResults[thisQueryId].push(count);
|
||||
countResults[thisQueryId].push(distinctCount);
|
||||
setCountResults(countResults);
|
||||
setReceivedCountTimestamp(new Date());
|
||||
});
|
||||
@@ -1162,6 +1153,8 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
queryErrors[thisQueryId] = errorMessage;
|
||||
setQueryErrors(queryErrors);
|
||||
setReceivedQueryErrorTimestamp(new Date());
|
||||
|
||||
throw error;
|
||||
});
|
||||
})();
|
||||
};
|
||||
@@ -1184,7 +1177,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
///////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
if (countResults[latestQueryId] == null)
|
||||
if (countResults[latestQueryId] == null || countResults[latestQueryId].length == 0)
|
||||
{
|
||||
///////////////////////////////////////////////
|
||||
// see same idea in displaying query results //
|
||||
@@ -1194,9 +1187,8 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
}
|
||||
try
|
||||
{
|
||||
setTotalRecords(countResults[latestQueryId].count ?? null);
|
||||
setDistinctRecords(countResults[latestQueryId].distinctCount ?? null);
|
||||
setCountError(countResults[latestQueryId].error ?? null);
|
||||
setTotalRecords(countResults[latestQueryId][0]);
|
||||
setDistinctRecords(countResults[latestQueryId][1]);
|
||||
delete countResults[latestQueryId];
|
||||
}
|
||||
catch (e)
|
||||
@@ -3126,7 +3118,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
idPrefix="columns"
|
||||
tableMetaData={tableMetaData}
|
||||
showTableHeaderEvenIfNoExposedJoins={true}
|
||||
omitExposedJoins={omittedQueryJoinTables}
|
||||
omitExposedJoins={omitExposedJoins}
|
||||
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></>}
|
||||
@@ -3230,13 +3222,6 @@ 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)}>
|
||||
@@ -3293,7 +3278,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
setMode={doSetMode}
|
||||
savedViewsComponent={savedViewsComponent}
|
||||
columnMenuComponent={buildColumnMenu()}
|
||||
omitExposedJoins={omittedQueryJoinTables}
|
||||
omitExposedJoins={omitExposedJoins}
|
||||
useSavedViewsResult={useSavedViewsResult}
|
||||
viewOnChangeCallback={handleSavedViewChange}
|
||||
currentSavedView={currentSavedView}
|
||||
@@ -3324,7 +3309,7 @@ const RecordQueryInner = forwardRef(({table, apiVersion, usage, isModal, isPrevi
|
||||
queryFilter: queryFilter,
|
||||
updateFilter: doSetQueryFilter,
|
||||
allowVariables: allowVariables,
|
||||
omitExposedJoins: omittedQueryJoinTables,
|
||||
omitExposedJoins: omitExposedJoins,
|
||||
}
|
||||
}}
|
||||
localeText={{
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -29,28 +29,27 @@ import {QueryJoin} from "@qrunio/qqq-frontend-core/lib/model/query/QueryJoin";
|
||||
import {Alert, Box} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
import RecordView, {getVisibleJoinTables} from "qqq/pages/records/view/RecordView";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
import {useLocation, useNavigate, useSearchParams} from "react-router-dom";
|
||||
|
||||
interface RecordViewByUniqueKeyProps
|
||||
{
|
||||
table: QTableMetaData;
|
||||
}
|
||||
|
||||
RecordViewByUniqueKey.defaultProps = {};
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
/***************************************************************************
|
||||
** Wrapper around RecordView, that reads a unique key from the query string,
|
||||
** looks for a record matching that key, and shows that record.
|
||||
** Wrapper that reads a unique key from the query string, looks for a
|
||||
** matching record, and redirects to the standard view URL.
|
||||
***************************************************************************/
|
||||
export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProps): JSX.Element
|
||||
{
|
||||
const tableName = table.name;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
|
||||
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
|
||||
@@ -66,7 +65,6 @@ export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProp
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const metaData = await qController.loadMetaData();
|
||||
const tableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTableMetaData(tableMetaData);
|
||||
|
||||
@@ -83,10 +81,10 @@ export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProp
|
||||
}
|
||||
|
||||
let queryJoins: QueryJoin[] = null;
|
||||
const visibleJoinTables = getVisibleJoinTables(tableMetaData);
|
||||
const visibleJoinTables = TableUtils.getVisibleJoinTables(tableMetaData);
|
||||
if (visibleJoinTables.size > 0)
|
||||
{
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables, metaData);
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
|
||||
}
|
||||
|
||||
const filter = new QQueryFilter(criteria, null, null, "AND", 0, 2);
|
||||
@@ -137,13 +135,28 @@ export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProp
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
// redirect to the standard view URL once we have the record ID
|
||||
useEffect(() =>
|
||||
{
|
||||
if (doneLoading && record)
|
||||
{
|
||||
const recordId = record.values.get(tableMetaData?.primaryKeyField);
|
||||
if (recordId)
|
||||
{
|
||||
const basePath = location.pathname.replace(/\/key$/, "");
|
||||
navigate(`${basePath}/${recordId}`, {replace: true});
|
||||
}
|
||||
}
|
||||
}, [doneLoading, record]);
|
||||
|
||||
if (!doneLoading)
|
||||
{
|
||||
return (<div>Loading...</div>);
|
||||
}
|
||||
else if (record)
|
||||
{
|
||||
return (<RecordView table={table} record={record} />);
|
||||
// will redirect via the useEffect above
|
||||
return (<div>Loading...</div>);
|
||||
}
|
||||
else if (errorMessage)
|
||||
{
|
||||
|
||||
@@ -733,7 +733,8 @@ The .widgetIsCollapsed is a 2-level deep descendant of .dashboardWidgetContainer
|
||||
(and we don't want to find deeper levels, due to parent widgets maybe having an
|
||||
inner child collapsed, which is why the '> * >' is used in the :has()).
|
||||
Limit this to the record-view screen, until it's found to be wanted elsewhere. */
|
||||
.recordView .dashboardWidgetContainer:has(> * > .widgetIsCollapsed)
|
||||
.recordView .dashboardWidgetContainer:has(> * > .widgetIsCollapsed),
|
||||
.recordScreen .dashboardWidgetContainer:has(> * > .widgetIsCollapsed)
|
||||
{
|
||||
height: auto;
|
||||
}
|
||||
@@ -894,8 +895,24 @@ Limit this to the record-view screen, until it's found to be wanted elsewhere. *
|
||||
|
||||
.modalBottomButtonBar
|
||||
{
|
||||
padding-bottom: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
padding-bottom: 1.5rem !important;
|
||||
padding-right: 2rem !important;
|
||||
padding-left: 2rem !important;
|
||||
margin-left: -2rem !important;
|
||||
margin-right: -2rem !important;
|
||||
margin-bottom: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
background: #ffffff;
|
||||
transition: box-shadow 200ms ease;
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
padding-top: 1.25rem !important;
|
||||
padding-bottom: 1.5rem !important;
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
.modalBottomButtonBar.sticking
|
||||
{
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.stickyBottomButtonBar>.MuiGrid-container
|
||||
@@ -1137,3 +1154,23 @@ Limit this to the record-view screen, until it's found to be wanted elsewhere. *
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Container query: section cards become size containers so fields
|
||||
can adapt to narrow sections (e.g., gridColumns=6) */
|
||||
.recordScreenFieldSection
|
||||
{
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* When section is narrow, labels shrink to min-content to avoid
|
||||
a gap from wrapping, and long words break to stay within bounds */
|
||||
@container (max-width: 400px)
|
||||
{
|
||||
.recordScreenFieldSection .fieldLabel
|
||||
{
|
||||
flex-basis: min-content;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 130px;
|
||||
max-width: 40%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ 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";
|
||||
@@ -151,8 +150,9 @@ export default class DataGridUtils
|
||||
{
|
||||
if (tableMetaData.exposedJoins)
|
||||
{
|
||||
for (const join of TableUtils.getReadableExposedJoins(tableMetaData, metaData))
|
||||
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
||||
{
|
||||
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))
|
||||
{
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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,8 +19,6 @@
|
||||
* 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";
|
||||
@@ -102,7 +100,6 @@ class TableUtils
|
||||
}
|
||||
}
|
||||
|
||||
console.log("allowedSections length: " + allowedSections.length);
|
||||
return (allowedSections);
|
||||
}
|
||||
|
||||
@@ -218,33 +215,12 @@ class TableUtils
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
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[]
|
||||
public static getQueryJoins(tableMetaData: QTableMetaData, visibleJoinTables: Set<string>): QueryJoin[]
|
||||
{
|
||||
const queryJoins = [];
|
||||
for (const join of this.getReadableExposedJoins(tableMetaData, metaData))
|
||||
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
||||
{
|
||||
const join = tableMetaData.exposedJoins[i];
|
||||
if (visibleJoinTables.has(join.joinTable.name))
|
||||
{
|
||||
let joinName = null;
|
||||
@@ -264,6 +240,37 @@ class TableUtils
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For a table, get the set of join table names that are visible in any
|
||||
** non-hidden section's field list.
|
||||
*******************************************************************************/
|
||||
public static getVisibleJoinTables(tableMetaData: QTableMetaData): Set<string>
|
||||
{
|
||||
const visibleJoinTables = new Set<string>();
|
||||
|
||||
for (let i = 0; i < tableMetaData?.sections.length; i++)
|
||||
{
|
||||
const section = tableMetaData?.sections[i];
|
||||
if (section.isHidden || !section.fieldNames || !section.fieldNames.length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
section.fieldNames.forEach((fieldName) =>
|
||||
{
|
||||
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
if (tableForField && tableForField.name != tableMetaData.name)
|
||||
{
|
||||
visibleJoinTables.add(tableForField.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (visibleJoinTables);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default TableUtils;
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import {Typography} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
import {SxProps} from "@mui/system";
|
||||
import {AdornmentType} from "@qrunio/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QTableVariant} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableVariant";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
|
||||
import {FieldValueAsWidget} from "qqq/components/view/FieldValueAsWidget";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React from "react";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render a grid of view-mode fields (label + display value).
|
||||
** Extracted from RecordView for reuse by CronUIWidget, DynamicFormWidget, ValidationReview.
|
||||
*******************************************************************************/
|
||||
export function renderSectionOfFields(key: string, fieldNames: string[], tableMetaData: QTableMetaData, helpHelpActive: boolean, record: QRecord, fieldMap?: { [name: string]: QFieldMetaData }, styleOverrides?: { label?: SxProps, value?: SxProps }, tableVariant?: QTableVariant)
|
||||
{
|
||||
return <Grid container lg={12} key={key} display="flex" py={1} pr={2}>
|
||||
{
|
||||
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;
|
||||
let gridColumns = (field.gridColumns && field.gridColumns > 0) ? field.gridColumns : 12;
|
||||
|
||||
const helpRoles = ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
|
||||
const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles);
|
||||
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={label} helpContentKey={`table:${tableMetaData?.name};field:${fieldName}`} />;
|
||||
|
||||
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))
|
||||
{
|
||||
return (<Grid item key={fieldName} lg={gridColumns} flexDirection="column" pr={2}>
|
||||
<FieldValueAsWidget field={field} record={record} />
|
||||
</Grid>);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid item key={fieldName} lg={gridColumns} flexDirection="column" pr={2}>
|
||||
<>
|
||||
{
|
||||
showHelp && formattedHelpContent ? <Tooltip title={formattedHelpContent}>{labelElement}</Tooltip> : labelElement
|
||||
}
|
||||
<div style={{display: "inline-block", width: 0}}> </div>
|
||||
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)" sx={{...(styleOverrides?.value ?? {})}}>
|
||||
{ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant)}
|
||||
</Typography>
|
||||
</>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
</Grid>;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ public class TestUtils
|
||||
QInstance qInstance = new QInstance();
|
||||
qInstance.addBackend(defineBackend());
|
||||
qInstance.addTable(defineTablePerson());
|
||||
qInstance.withInstanceDefaultAuthentication(defineAuthentication());
|
||||
qInstance.setAuthentication(defineAuthentication());
|
||||
return (qInstance);
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -159,7 +159,25 @@ public class QFMDSeleniumLib
|
||||
***************************************************************************/
|
||||
public void waitForViewScreenFieldValue(String fieldLabel, String value)
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiGrid-item", fieldLabel + ": " + value);
|
||||
// RecordScreenField renders label and value as separate block elements (getText returns "Label:\nValue")
|
||||
// while renderSectionOfFields (used by widgets in view mode) renders inline (getText returns "Label: Value")
|
||||
// Try both separators — space and newline.
|
||||
// Catch StaleElementReferenceException in the predicate (page may re-render between findElements and getText).
|
||||
String withSpace = fieldLabel + ": " + value;
|
||||
String withNewline = fieldLabel + ":\n" + value;
|
||||
qSeleniumLib.waitForSelectorAllSatisfyingPredicate(".MuiGrid-item",
|
||||
elements ->
|
||||
{
|
||||
try
|
||||
{
|
||||
return elements.stream().anyMatch(e -> e.getText().contains(withSpace) || e.getText().contains(withNewline));
|
||||
}
|
||||
catch(org.openqa.selenium.StaleElementReferenceException e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"containing [" + fieldLabel + ": " + value + "]");
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +186,11 @@ public class QFMDSeleniumLib
|
||||
***************************************************************************/
|
||||
public void waitForViewScreenFieldValueToNotExist(String fieldLabel, String value)
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist(".MuiGrid-item", fieldLabel + ": " + value);
|
||||
String withSpace = fieldLabel + ": " + value;
|
||||
String withNewline = fieldLabel + ":\n" + value;
|
||||
qSeleniumLib.waitForSelectorAllSatisfyingPredicate(".MuiGrid-item",
|
||||
elements -> elements.stream().noneMatch(e -> e.getText().contains(withSpace) || e.getText().contains(withNewline)),
|
||||
"not containing [" + fieldLabel + ": " + value + "]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ public class QSeleniumLib
|
||||
public final WebDriver driver;
|
||||
|
||||
private long WAIT_SECONDS = 10;
|
||||
private String BASE_URL = System.getProperty("qqq.test.baseUrl", "https://localhost:3001");
|
||||
private String BASE_URL = "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 = System.getProperty("qqq.test.baseUrl", "https://localhost:3001");
|
||||
private static final String DEFAULT_BASE_URL = "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -124,7 +124,7 @@ public class QSeleniumJavalin
|
||||
{
|
||||
javalin = Javalin.create(config ->
|
||||
{
|
||||
config.routes.apiBuilder(
|
||||
config.router.apiBuilder(
|
||||
() ->
|
||||
{
|
||||
for(Map.Entry<String, String> routeToFile : CollectionUtils.nonNullMap(routesToFiles).entrySet())
|
||||
@@ -143,13 +143,14 @@ public class QSeleniumJavalin
|
||||
}
|
||||
);
|
||||
|
||||
config.routes.before(new CapturingHandler(this));
|
||||
}).start(8001);
|
||||
|
||||
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));
|
||||
javalin.before(new CapturingHandler(this));
|
||||
|
||||
javalin.error(404, context -> {
|
||||
LOG.warn("Returning 404 for [" + context.method() + " " + context.path() + "]");
|
||||
pathsThat404ed.add(context.path());
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// to accept "large" access tokens in Authorization: Bearer <token> headers (e.g., with 100s of permissions), //
|
||||
|
||||
+8
-3
@@ -22,6 +22,7 @@
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib;
|
||||
@@ -31,6 +32,8 @@ import org.junit.jupiter.api.Test;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.Keys;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.openqa.selenium.support.ui.ExpectedConditions;
|
||||
import org.openqa.selenium.support.ui.WebDriverWait;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
@@ -114,9 +117,11 @@ public class SavedReportIT extends QBaseSeleniumTest
|
||||
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "First Name");
|
||||
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "contains");
|
||||
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "Darin");
|
||||
List<WebElement> columns = qSeleniumLib.waitForSelectorContaining("h5", "Columns")
|
||||
.findElement(QSeleniumLib.PARENT)
|
||||
.findElements(By.cssSelector("DIV"));
|
||||
// Use exact text match for <h5>Columns</h5> inside the widget
|
||||
// (not the section header "Filters and Columns" which also matches "contains Columns")
|
||||
WebElement columnsH5 = new WebDriverWait(qSeleniumLib.driver, Duration.ofSeconds(10))
|
||||
.until(ExpectedConditions.presenceOfElementLocated(By.xpath("//h5[text()='Columns']")));
|
||||
List<WebElement> columns = columnsH5.findElement(QSeleniumLib.PARENT).findElements(By.cssSelector("DIV"));
|
||||
|
||||
assertThat(columns)
|
||||
.hasSizeGreaterThanOrEqualTo(5) // at least this many
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ public class TestApplicationServer
|
||||
public void start() throws QException
|
||||
{
|
||||
javalinServer = new QApplicationJavalinServer(testApplication)
|
||||
.withPort(Integer.getInteger("qqq.test.backendPort", 8001))
|
||||
.withPort(8001)
|
||||
.withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(true);
|
||||
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Simple backend step that echoes the input as a greeting. //
|
||||
// Used by the PersonGreeterProcess to test process widgets on RecordScreen. //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
public class PersonGreeterBackendStep implements BackendStep
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String greetingName = input.getValueString("greetingName");
|
||||
output.addValue("showResults", true);
|
||||
output.addValue("greetingResult", "Hello, " + greetingName + "!");
|
||||
}
|
||||
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.metadata;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.processes.NoCodeWidgetFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.nocode.WidgetHtmlLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Meta Data Producer for PersonGreeterProcess — a simple process used to test
|
||||
** process-type widgets on the RecordScreen.
|
||||
*******************************************************************************/
|
||||
public class PersonGreeterProcessProducer extends MetaDataProducer<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "personGreeterProcess";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Person Greeter")
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// Step 1: Frontend — input form + output display (NoCode widget) //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
.withStep(new QFrontendStepMetaData()
|
||||
.withName("enterInputs")
|
||||
.withLabel("Person Greeter")
|
||||
.withComponent(new QFrontendComponentMetaData()
|
||||
.withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("greetingName", QFieldType.STRING)
|
||||
.withIsRequired(true))
|
||||
.withComponent(new NoCodeWidgetFrontendComponentMetaData()
|
||||
.withOutput(new WidgetHtmlLine()
|
||||
.withCondition(new QFilterCriteria("showResults", QCriteriaOperator.EQUALS, true))
|
||||
.withVelocityTemplate("<b>Greeting:</b> $greetingResult"))
|
||||
)
|
||||
)
|
||||
|
||||
////////////////////////////////////////
|
||||
// Step 2: Backend — runs the greeting //
|
||||
////////////////////////////////////////
|
||||
.withStep(new QBackendStepMetaData()
|
||||
.withName("runGreeter")
|
||||
.withCode(new QCodeReference(PersonGreeterBackendStep.class))
|
||||
.withInputData(new QFunctionInputMetaData()
|
||||
.withFieldList(List.of(
|
||||
new QFieldMetaData("greetingName", QFieldType.STRING)
|
||||
)))
|
||||
)
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// Step 3: Frontend — empty preview step (required by process widget pattern) //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
.withStep(new QFrontendStepMetaData()
|
||||
.withName("preview"));
|
||||
}
|
||||
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ProcessWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Meta Data Producer for PersonGreeterWidget — a process-type widget that
|
||||
** renders the PersonGreeterProcess inline on the record view screen.
|
||||
*******************************************************************************/
|
||||
public class PersonGreeterWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "PersonGreeterWidget";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withType(WidgetType.PROCESS.getType())
|
||||
.withIsCard(true)
|
||||
.withName(NAME)
|
||||
.withLabel("Person Greeter")
|
||||
.withGridColumns(12)
|
||||
.withDefaultValue(ProcessWidgetRenderer.WIDGET_PROCESS_NAME, PersonGreeterProcessProducer.NAME)
|
||||
.withCodeReference(new QCodeReference(ProcessWidgetRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
+10
-7
@@ -37,9 +37,11 @@ import com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.met
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.metadata.PetTableProducer;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -144,7 +146,6 @@ public class ChildRecordListWidgetIT extends QBaseSeleniumWithQApplicationTest
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist(".MuiDataGrid-rowCount", "of");
|
||||
|
||||
qSeleniumLib.waitForSelector("[aria-label=\"Export\"] button").click();
|
||||
qSeleniumLib.waitForever();
|
||||
|
||||
////////////////////////////
|
||||
// validate view all link //
|
||||
@@ -256,15 +257,17 @@ public class ChildRecordListWidgetIT extends QBaseSeleniumWithQApplicationTest
|
||||
qSeleniumLib.waitForSelectorContaining("button", "add new").click();
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Creating New Pet");
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
// make sure parent foreign key input is disabled //
|
||||
////////////////////////////////////////////////////
|
||||
assertFalse(qSeleniumLib.waitForSelector("input#ownerPersonId").isEnabled());
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// make sure parent foreign key field is present but not an input //
|
||||
// (RecordScreen renders non-editable fields as plain text, not inputs) //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
WebElement ownerField = qSeleniumLib.waitForSelector("[data-field-name='ownerPersonId']");
|
||||
assertTrue(ownerField.findElements(By.tagName("input")).isEmpty());
|
||||
|
||||
qfmdSeleniumLib.inputTextField("name", "Stompy");
|
||||
qfmdSeleniumLib.inputDynamicSelectOption("speciesId", "Elephant");
|
||||
|
||||
qSeleniumLib.waitForSelectorContaining(".entityForm button", "Save").click();
|
||||
qSeleniumLib.waitForSelectorContaining(".modalEditForm button", "Save").click();
|
||||
|
||||
qSeleniumLib.waitForSelectorContaining(WIDGET_CELL, "Stompy");
|
||||
qSeleniumLib.waitForSelectorContaining(WIDGET_CELL, "Elephant");
|
||||
|
||||
+24
-7
@@ -213,20 +213,20 @@ public class FormAdjusterIT extends QBaseSeleniumWithQApplicationTest
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist("h6", "Data");
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist("h6", "Filter");
|
||||
assertSidebar(List.of("Identity"));
|
||||
assertSidebar(List.of("Identity", "Dates"));
|
||||
|
||||
qfmdSeleniumLib.inputTextField("name", "filter");
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Creating New").click(); // blur the text field to trigger the on-change
|
||||
qfmdSeleniumLib.blurByClickingBlankSpace();
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist("h6", "Data");
|
||||
qSeleniumLib.waitForSelectorContaining("h6", "Filter");
|
||||
assertSidebar(List.of("Identity", "Filter"));
|
||||
assertSidebar(List.of("Identity", "Filter", "Dates"));
|
||||
|
||||
qfmdSeleniumLib.inputTextField("name", " type");
|
||||
qfmdSeleniumLib.blurByClickingBlankSpace();
|
||||
qSeleniumLib.waitForSelectorContaining("h6", "Data");
|
||||
qSeleniumLib.waitForSelectorContaining("h6", "Filter");
|
||||
assertSidebar(List.of("Identity", "Data", "Filter"));
|
||||
assertSidebar(List.of("Identity", "Data", "Filter", "Dates"));
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// put a value in the 'type' field. then make it go away //
|
||||
@@ -254,14 +254,31 @@ public class FormAdjusterIT extends QBaseSeleniumWithQApplicationTest
|
||||
@Test
|
||||
void testDisablingForm()
|
||||
{
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME + "/" + MAY_NOT_EDIT_WITH_CUSTOM_MESSAGE_ID + "/edit",
|
||||
"Edit Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_CUSTOM_MESSAGE_ID);
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME + "/" + MAY_NOT_EDIT_WITH_CUSTOM_MESSAGE_ID + "/edit", "" + MAY_NOT_EDIT_WITH_CUSTOM_MESSAGE_ID);
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Edit Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_CUSTOM_MESSAGE_ID);
|
||||
qfmdSeleniumLib.waitForAlert("No soup for you.");
|
||||
assertThatThrownBy(() -> qfmdSeleniumLib.inputTextField("size", "XL"));
|
||||
qSeleniumLib.waitForSelectorContaining("button[disabled]", "save");
|
||||
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME + "/" + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID + "/edit",
|
||||
"Edit Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID);
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME + "/" + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID + "/edit", "" + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID);
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Edit Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID);
|
||||
qfmdSeleniumLib.waitForAlert("You are not allowed to edit this record");
|
||||
qSeleniumLib.waitForSelectorContaining("button[disabled]", "save");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// cancel from disabled edit mode — alert should disappear and view fields should show //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
qSeleniumLib.waitForSelectorContaining("button", "Cancel").click();
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Viewing Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID);
|
||||
qfmdSeleniumLib.waitForAlertToNotExist("You are not allowed to edit this record");
|
||||
qfmdSeleniumLib.waitForViewScreenFieldValue("Name", MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_NAME);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// from view screen, enter edit mode via instant-edit — form adjuster should still //
|
||||
// disable the form (on-load adjuster must run when entering edit via pushState too) //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
qSeleniumLib.waitForSelectorContaining("button", "Edit").click();
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Edit Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID);
|
||||
qfmdSeleniumLib.waitForAlert("You are not allowed to edit this record");
|
||||
qSeleniumLib.waitForSelectorContaining("button[disabled]", "save");
|
||||
}
|
||||
|
||||
+2297
File diff suppressed because it is too large.
Load diff
@@ -1 +1 @@
|
||||
{"type":"pivotTableSetup"}
|
||||
{"type":"pivotTableSetup","queryParams":{}}
|
||||
Reference in new issue
Block a user