Compare commits
403
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489946dd8a | ||
|
|
ef9e3d5f5b | ||
|
|
bb6d0ddad4 | ||
|
|
b72e0f6ff1 | ||
|
|
d1c25885e5 | ||
|
|
df48647286 | ||
|
|
9eff022cd2 | ||
|
|
a1cd14ae51 | ||
|
|
064e86d072 | ||
|
|
161c40faa7 | ||
|
|
92c6b464c8 | ||
|
|
5b881c388e | ||
|
|
8ed21256c8 | ||
|
|
ca96f092aa | ||
|
|
759581b340 | ||
|
|
e743f2da60 | ||
|
|
f807698137 | ||
|
|
6cf716bcea | ||
|
|
519e36110e | ||
|
|
3e8ddc7473 | ||
|
|
2be6e1e5db | ||
|
|
a636a728ef | ||
|
|
fb64010c4b | ||
|
|
b470e21360 | ||
|
|
bff73c92e0 | ||
|
|
cc95052c2d | ||
|
|
8bf24f1445 | ||
|
|
26274fa064 | ||
|
|
6f6506c3b3 | ||
|
|
ecedf8a904 | ||
|
|
e06fcf55a8 | ||
|
|
64453032a9 | ||
|
|
7bafcd9ce4 | ||
|
|
fd253c75bc | ||
|
|
559ea0e8a0 | ||
|
|
c0815358a6 | ||
|
|
5fa26a2307 | ||
|
|
3f2ba9c0fd | ||
|
|
e5247b2188 | ||
|
|
0d7c79606b | ||
|
|
bdd3623653 | ||
|
|
36fd4ea276 | ||
|
|
7b3bb064d9 | ||
|
|
258ef72122 | ||
|
|
f84a6cad8c | ||
|
|
24d58db74a | ||
|
|
a1751940ce | ||
|
|
f97bdef45a | ||
|
|
301203e747 | ||
|
|
e3c1c152ec | ||
|
|
e36d8556c9 | ||
|
|
b829e2864b | ||
|
|
245adaee46 | ||
|
|
f67eb11dcb | ||
|
|
d7a8abb95c | ||
|
|
aa3876f121 | ||
|
|
e8d737b379 | ||
|
|
0dfb2c526c | ||
|
|
281d2abdb9 | ||
|
|
3b42186c0b | ||
|
|
43077b9d6c | ||
|
|
e96bdbc2e7 | ||
|
|
1c5cd13f77 | ||
|
|
02f77eb91f | ||
|
|
d4fa735c69 | ||
|
|
fd9cb03334 | ||
|
|
ba016d4d0b | ||
|
|
5eb404a5a5 | ||
|
|
8944c21c71 | ||
|
|
cfc3053b2a | ||
|
|
c4df149994 | ||
|
|
1e0ecc5b50 | ||
|
|
e418660d5e | ||
|
|
a49707d3d6 | ||
|
|
e369190c78 | ||
|
|
3e515e2428 | ||
|
|
3a183ec96b | ||
|
|
4127caf3bb | ||
|
|
7b2e56704b | ||
|
|
2162380d88 | ||
|
|
dd423866a5 | ||
|
|
2b6a8d10b5 | ||
|
|
b5cf09f8a9 | ||
|
|
14148778b1 | ||
|
|
110728c9b9 | ||
|
|
e44b9430a4 | ||
|
|
c180cadd9e | ||
|
|
165dc5ab4c | ||
|
|
6751393cf2 | ||
|
|
3ea2c379cb | ||
|
|
100f7028a6 | ||
|
|
f935d0c9d2 | ||
|
|
f7fcb526ba | ||
|
|
e55fbe2eaf | ||
|
|
9ddf2888f9 | ||
|
|
d595750a35 | ||
|
|
3dbf49d828 | ||
|
|
3dbdd7ad09 | ||
|
|
4982520ded | ||
|
|
d93a7479c5 | ||
|
|
df868eab83 | ||
|
|
c6dbfbd3af | ||
|
|
ccf2117a95 | ||
|
|
ba41ffe075 | ||
|
|
c6b9528ff9 | ||
|
|
5a10ebbce3 | ||
|
|
b6852a2d3e | ||
|
|
0c5948d0a5 | ||
|
|
a44cba0761 | ||
|
|
f412dca3bf | ||
|
|
f150104bb9 | ||
|
|
f1baa4b52b | ||
|
|
b6ab102201 | ||
|
|
aa1d4fde51 | ||
|
|
c95ae1e10d | ||
|
|
7283b31844 | ||
|
|
12192dcf76 | ||
|
|
814849863a | ||
|
|
270ab0f1ee | ||
|
|
d573a44051 | ||
|
|
98ab125735 | ||
|
|
eb27249182 | ||
|
|
50e79b9c2e | ||
|
|
8a0ead9112 | ||
|
|
11ad2bf296 | ||
|
|
7760f9015d | ||
|
|
c74b688868 | ||
|
|
dc4bcbd6f0 | ||
|
|
8d3efb03e6 | ||
|
|
094397b5e8 | ||
|
|
e8468c439a | ||
|
|
58bcc8f215 | ||
|
|
af4edbeb7d | ||
|
|
49815d05fc | ||
|
|
2fe892d3f9 | ||
|
|
cc1cb77ebe | ||
|
|
ecb71ab34c | ||
|
|
d6c05bcce5 | ||
|
|
3bead04106 | ||
|
|
083f3b0cd4 | ||
|
|
70280f0484 | ||
|
|
6bea9c6537 | ||
|
|
73bc86db60 | ||
|
|
93f5a20d33 | ||
|
|
3db529a46b | ||
|
|
23b8e1d92a | ||
|
|
98e09086b5 | ||
|
|
87860d459d | ||
|
|
cd57109802 | ||
|
|
8df5c95165 | ||
|
|
99e46cbf6c | ||
|
|
363f0d6797 | ||
|
|
dd44590401 | ||
|
|
127b0ae7a2 | ||
|
|
4896adee50 | ||
|
|
cc38c44405 | ||
|
|
247e4dadce | ||
|
|
4e83f5e516 | ||
|
|
e1edab746d | ||
|
|
dcd459a8d0 | ||
|
|
f1016c3505 | ||
|
|
1486d9014d | ||
|
|
47f1fadd96 | ||
|
|
6863430087 | ||
|
|
98d7f49de5 | ||
|
|
67d8f0bb30 | ||
|
|
dfdadfb8dc | ||
|
|
54ae073925 | ||
|
|
69843a18b1 | ||
|
|
6a28b1c446 | ||
|
|
6f42b836b9 | ||
|
|
1d7c453ab8 | ||
|
|
70a9c9a44c | ||
|
|
71c7ff9dc6 | ||
|
|
6683304217 | ||
|
|
c390fc1e50 | ||
|
|
fb804619c2 | ||
|
|
9dc1a08f8b | ||
|
|
d060f7d39a | ||
|
|
6bdf22211d | ||
|
|
442d1a6aac | ||
|
|
0e04b45550 | ||
|
|
b1025df8b2 | ||
|
|
48829e34e1 | ||
|
|
d1b81d1cd1 | ||
|
|
e59b6dda44 | ||
|
|
bdcc9fa52a | ||
|
|
d6b8473c65 | ||
|
|
2f9e67c8f1 | ||
|
|
9f5d43435f | ||
|
|
3c885faeff | ||
|
|
b08c466d4a | ||
|
|
3c3ef422f9 | ||
|
|
c62c55c431 | ||
|
|
ebe92475e9 | ||
|
|
21979f513b | ||
|
|
05865e1542 | ||
|
|
1aa5d14851 | ||
|
|
626b5846b6 | ||
|
|
ee3bf5ccad | ||
|
|
3c132b1cf8 | ||
|
|
284d0f23a4 | ||
|
|
3e0df2c0ee | ||
|
|
d4866da0cd | ||
|
|
b302b1d217 | ||
|
|
c728f58c85 | ||
|
|
4d4642342a | ||
|
|
60daa4f4d5 | ||
|
|
d9ce51c223 | ||
|
|
7d495aad88 | ||
|
|
8a2567bab9 | ||
|
|
97d31f5279 | ||
|
|
559b9fee4e | ||
|
|
89a8bcc9eb | ||
|
|
e1fc2dcd5f | ||
|
|
85a1817fbc | ||
|
|
8c5fa6b066 | ||
|
|
fb812b7c74 | ||
|
|
ab4fc2f656 | ||
|
|
df17dfc346 | ||
|
|
b50439d2c8 | ||
|
|
e141ba6f2e | ||
|
|
3e3ef3a947 | ||
|
|
ed83806574 | ||
|
|
008977a2a2 | ||
|
|
441e3f0aff | ||
|
|
250da8af33 | ||
|
|
b5e8e235fa | ||
|
|
b1c6e8b09e | ||
|
|
c8dd87e488 | ||
|
|
d2240f648b | ||
|
|
175aefe6fe | ||
|
|
6aacc94043 | ||
|
|
2317b17c4f | ||
|
|
4ec306d250 | ||
|
|
2cfe69c51f | ||
|
|
82c46a1855 | ||
|
|
bb19e16591 | ||
|
|
12686647d0 | ||
|
|
63de379569 | ||
|
|
f534b5b8b8 | ||
|
|
fb8d7f4e66 | ||
|
|
91fa1c2bf9 | ||
|
|
b606fb3837 | ||
|
|
bc51a9c3e2 | ||
|
|
e63242bad3 | ||
|
|
5e9537bdbe | ||
|
|
46e974f7f2 | ||
|
|
03f25470ff | ||
|
|
9f9df40b72 | ||
|
|
9255e684dd | ||
|
|
f88baa93eb | ||
|
|
ec834298d6 | ||
|
|
dc16ac1c97 | ||
|
|
233c1595e5 | ||
|
|
6468f4b325 | ||
|
|
060f716f71 | ||
|
|
543a67dbb9 | ||
|
|
a792a9464e | ||
|
|
5b5a884dda | ||
|
|
02779e7ccf | ||
|
|
2e283db382 | ||
|
|
85f0179a1e | ||
|
|
ba2eaf7b38 | ||
|
|
69ea5140b8 | ||
|
|
f969a7cb76 | ||
|
|
50e35b38b1 | ||
|
|
19185759c3 | ||
|
|
6705f79850 | ||
|
|
6e707da818 | ||
|
|
4558fc6ef5 | ||
|
|
f204bcf7c5 | ||
|
|
e319b62c7f | ||
|
|
a2757da6a5 | ||
|
|
39fb04d2af | ||
|
|
1e68eefb83 | ||
|
|
0de18f2a34 | ||
|
|
b0aeba2ae6 | ||
|
|
a588d3b1c6 | ||
|
|
5c2bf9e7b4 | ||
|
|
6a004d6cdb | ||
|
|
7c7b9a3dbf | ||
|
|
92a71bc62f | ||
|
|
2c65826a91 | ||
|
|
86dcc90e1d | ||
|
|
90fd03ae46 | ||
|
|
1dce760934 | ||
|
|
ff4683af1f | ||
|
|
ab4be1d5af | ||
|
|
0d7e76df6c | ||
|
|
d41f5f8339 | ||
|
|
f0f09a8ff1 | ||
|
|
4d30eb3060 | ||
|
|
d4a675e952 | ||
|
|
633c97b710 | ||
|
|
c70ef3dae8 | ||
|
|
6f15356b51 | ||
|
|
0bf33a01f9 | ||
|
|
0bca8e9361 | ||
|
|
6b90894425 | ||
|
|
248040a99f | ||
|
|
80ac2a304a | ||
|
|
b82b25156e | ||
|
|
69b46570cb | ||
|
|
3da656c01f | ||
|
|
1da0f4f1de | ||
|
|
ce947bc0f7 | ||
|
|
0a42b9d4f0 | ||
|
|
5ab906bcfe | ||
|
|
c1ea7081f1 | ||
|
|
020e174110 | ||
|
|
68c1f897af | ||
|
|
7d6b083ae2 | ||
|
|
3d4f0ba24b | ||
|
|
6fc11bb0ba | ||
|
|
78c788812a | ||
|
|
cb36f59090 | ||
|
|
96bdcf1874 | ||
|
|
07d116d9ba | ||
|
|
5c69ae666c | ||
|
|
5bdc3a6cd0 | ||
|
|
2e5aba6c16 | ||
|
|
185775ca4d | ||
|
|
cbcb3b505e | ||
|
|
ce91f68088 | ||
|
|
81da1a4627 | ||
|
|
bb06e2743a | ||
|
|
b279a04b43 | ||
|
|
1f2e57d688 | ||
|
|
52bb7ba411 | ||
|
|
34c6f650b5 | ||
|
|
d792c23035 | ||
|
|
e3d30633f1 | ||
|
|
a6ee682671 | ||
|
|
c62252075f | ||
|
|
debc6f3ebf | ||
|
|
679375ba63 | ||
|
|
fb10dad803 | ||
|
|
c9a618c7f6 | ||
|
|
f654208769 | ||
|
|
3dacab8d60 | ||
|
|
13ce684d23 | ||
|
|
b67eea7d87 | ||
|
|
8ae3b95105 | ||
|
|
5a309e5628 | ||
|
|
67e1e78817 | ||
|
|
214b6b8af4 | ||
|
|
8ec0ce5455 | ||
|
|
07cb6fd323 | ||
|
|
3bb8451671 | ||
|
|
6076c4ddfd | ||
|
|
44a8810260 | ||
|
|
c69a4b8203 | ||
|
|
7db4f34ddd | ||
|
|
71dc3f3f65 | ||
|
|
ce22db2f89 | ||
|
|
aacb239164 | ||
|
|
219458ec63 | ||
|
|
59fdc72455 | ||
|
|
5c3ddb7dec | ||
|
|
d65c1fb5d8 | ||
|
|
19a63d6956 | ||
|
|
40f5b55307 | ||
|
|
7320b19fbb | ||
|
|
3f8a3e7e4d | ||
|
|
3ef2d64327 | ||
|
|
d793c23861 | ||
|
|
d0201d96e1 | ||
|
|
7b66ece466 | ||
|
|
02c163899a | ||
|
|
8fafe16a95 | ||
|
|
722c8d3bcf | ||
|
|
85acb612c9 | ||
|
|
74c634414a | ||
|
|
f8368b030c | ||
|
|
dda4ea4f4b | ||
|
|
0c3a6ac278 | ||
|
|
85a8bd2d0a | ||
|
|
4b64c46c57 | ||
|
|
6db003026b | ||
|
|
65b347b794 | ||
|
|
1626648dda | ||
|
|
f503c008ec | ||
|
|
8a16010977 | ||
|
|
ab530121ca | ||
|
|
9b5d9f1290 | ||
|
|
ee9cd5a5f6 | ||
|
|
45be12c728 | ||
|
|
169bd4ee7e | ||
|
|
85056b121b | ||
|
|
b90b5217ca | ||
|
|
911ba1da21 | ||
|
|
bfa9b1d182 | ||
|
|
cce73fcb0b | ||
|
|
f2b41532d4 | ||
|
|
3fc4e37c12 | ||
|
|
451af347f7 | ||
|
|
1630fbacda | ||
|
|
2220e6f86e | ||
|
|
a66ffa753d | ||
|
|
b07d65aaca | ||
|
|
78fc2c50d0 | ||
|
|
8a6eef9907 |
No files matched your search
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ -z "$CIRCLE_BRANCH" ] && [ -z "$CIRCLE_TAG" ]; then
|
||||
echo "Error: env vars CIRCLE_BRANCH and CIRCLE_TAG were not set."
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
if [ "$CIRCLE_BRANCH" == "dev" ] || [ "$CIRCLE_BRANCH" == "staging" ] || [ "$CIRCLE_BRANCH" == "main" ]; then
|
||||
echo "On a primary branch [$CIRCLE_BRANCH] - will not edit the pom version.";
|
||||
exit 0;
|
||||
fi
|
||||
|
||||
if [ -n "$CIRCLE_BRANCH" ]; then
|
||||
SLUG=$(echo $CIRCLE_BRANCH | sed 's/[^a-zA-Z0-9]/-/g')
|
||||
else
|
||||
SLUG=$(echo $CIRCLE_TAG | sed 's/^snapshot-//g')
|
||||
fi
|
||||
|
||||
POM=$(dirname $0)/../pom.xml
|
||||
|
||||
echo "Updating $POM <revision> to: $SLUG-SNAPSHOT"
|
||||
sed -i "s/<revision>.*/<revision>$SLUG-SNAPSHOT<\/revision>/" $POM
|
||||
git diff $POM
|
||||
+96
-111
@@ -1,130 +1,115 @@
|
||||
---
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
node: circleci/node@5.1.0
|
||||
browser-tools: circleci/browser-tools@1.4.7
|
||||
qqq-orb: kingsrook/qqq-orb@dev:snapshot
|
||||
|
||||
executors:
|
||||
java17:
|
||||
playwright:
|
||||
docker:
|
||||
- image: 'cimg/openjdk:17.0.9'
|
||||
|
||||
commands:
|
||||
install_java17:
|
||||
steps:
|
||||
- run:
|
||||
name: Install Java 17
|
||||
command: |
|
||||
sudo apt-get update
|
||||
sudo apt install -y openjdk-17-jdk
|
||||
sudo rm /etc/alternatives/java
|
||||
sudo ln -s /usr/lib/jvm/java-17-openjdk-amd64/bin/java /etc/alternatives/java
|
||||
|
||||
install_npm:
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '16.13'
|
||||
- node/install-packages
|
||||
|
||||
mvn_verify:
|
||||
steps:
|
||||
- browser-tools/install-chrome
|
||||
- browser-tools/install-chromedriver
|
||||
- run:
|
||||
name: install dockerize
|
||||
command: wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
|
||||
environment:
|
||||
DOCKERIZE_VERSION: v0.3.0
|
||||
- run:
|
||||
name: Install Browser dependencies
|
||||
command: |
|
||||
sudo apt update
|
||||
sudo apt install -y libnss3-dev libgdk-pixbuf2.0-dev libgtk-3-dev libxss-dev
|
||||
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-dependencies-{{ checksum "pom.xml" }}
|
||||
- run:
|
||||
name: Run react app and mvn verify
|
||||
command: |
|
||||
echo "HTTPS=true" >> ./.env
|
||||
npm run build
|
||||
export REACT_APP_PROXY_LOCALHOST_PORT=8001; export PORT=3001; npm run start &
|
||||
dockerize -wait tcp://localhost:3001 -timeout 3m
|
||||
export QQQ_SELENIUM_HEADLESS=true; mvn -s .circleci/mvn-settings.xml verify
|
||||
- run:
|
||||
name: Save test results
|
||||
command: |
|
||||
mkdir -p ~/test-results/junit/
|
||||
find . -type f -regex ".*/target/surefire-reports/.*xml" -exec cp {} ~/test-results/junit/ \;
|
||||
when: always
|
||||
- store_test_results:
|
||||
path: ~/test-results
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/.m2
|
||||
key: v1-dependencies-{{ checksum "pom.xml" }}
|
||||
- store_artifacts:
|
||||
path: /tmp/QSeleniumScreenshots
|
||||
|
||||
mvn_deploy:
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Adjust pom version
|
||||
command: |
|
||||
.circleci/adjust-pom-version.sh
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-dependencies-{{ checksum "pom.xml" }}
|
||||
- run:
|
||||
name: Run NPM Build
|
||||
command: |
|
||||
npm run build
|
||||
- run:
|
||||
name: Build Maven Jar and Deploy
|
||||
command: |
|
||||
rm -rf src/main/resources/material-dashboard
|
||||
mkdir -p src/main/resources/material-dashboard
|
||||
cp -r build/* src/main/resources/material-dashboard
|
||||
mvn -s .circleci/mvn-settings.xml deploy -DskipTests
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/.m2
|
||||
key: v1-dependencies-{{ checksum "pom.xml" }}
|
||||
- image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
|
||||
jobs:
|
||||
mvn_test:
|
||||
executor: java17
|
||||
playwright_tests:
|
||||
executor: playwright
|
||||
steps:
|
||||
- install_npm
|
||||
- mvn_verify
|
||||
|
||||
mvn_deploy:
|
||||
executor: java17
|
||||
steps:
|
||||
- install_npm
|
||||
- mvn_verify
|
||||
- mvn_deploy
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- npm-deps-{{ checksum "package-lock.json" }}
|
||||
- npm-deps-
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: npm ci --legacy-peer-deps
|
||||
- save_cache:
|
||||
key: npm-deps-{{ checksum "package-lock.json" }}
|
||||
paths:
|
||||
- node_modules
|
||||
- run:
|
||||
name: Build React app
|
||||
command: npm run build
|
||||
- run:
|
||||
name: Run Playwright e2e tests
|
||||
command: npx playwright test
|
||||
- store_artifacts:
|
||||
path: playwright-report/
|
||||
destination: playwright-report
|
||||
- store_test_results:
|
||||
path: test-results/
|
||||
|
||||
workflows:
|
||||
test:
|
||||
test_only:
|
||||
jobs:
|
||||
- mvn_test:
|
||||
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
|
||||
- playwright_tests:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(main|dev|integration.*)/
|
||||
ignore: /(develop|main|release/.*|hotfix/.*)/
|
||||
tags:
|
||||
ignore: /(version|snapshot)-.*/
|
||||
deploy:
|
||||
jobs:
|
||||
- mvn_deploy:
|
||||
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
|
||||
only: []
|
||||
- qqq-orb/mvn_frontend_test_only:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
only: /(main|dev|integration.*)/
|
||||
ignore: /(develop|main|release/.*|hotfix/.*)/
|
||||
tags:
|
||||
only: /(version|snapshot)-.*/
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- playwright_tests:
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
requires:
|
||||
- playwright_tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- develop
|
||||
|
||||
publish_feature:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: feature
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- feature/.*
|
||||
tags:
|
||||
only: [/publish.*/]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_frontend_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /hotfix\/.*/
|
||||
@@ -1,9 +0,0 @@
|
||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||
<servers>
|
||||
<server>
|
||||
<id>github-qqq-maven-registry</id>
|
||||
<username>${env.QQQ_MAVEN_REGISTRY_USERNAME}</username>
|
||||
<password>${env.QQQ_MAVEN_REGISTRY_PASSWORD}</password>
|
||||
</server>
|
||||
</servers>
|
||||
</settings>
|
||||
@@ -0,0 +1,60 @@
|
||||
# Claude Context File - QQQ Frontend Material Dashboard
|
||||
# This file helps Claude Code quickly orient itself when starting a new session
|
||||
|
||||
## Project Overview
|
||||
- **Name:** QQQ Frontend Material Dashboard
|
||||
- **Type:** React 18 + TypeScript hybrid Maven project
|
||||
- **Purpose:** Default UI for QQQ low-code application framework
|
||||
- **Version:** 0.28.3-SNAPSHOT
|
||||
- **Core Dependency:** @qrunio/qqq-frontend-core
|
||||
|
||||
## Technology Stack
|
||||
- React 18.0.0, TypeScript 4.9.4
|
||||
- Material-UI (MUI) v5.11.1
|
||||
- Formik + Yup for forms
|
||||
- React Router v6
|
||||
- Maven + frontend-maven-plugin for builds
|
||||
- Node.js 18.17.0 (via Maven)
|
||||
|
||||
## Key Directories
|
||||
- `src/qqq/pages/` - Page components (RecordQuery, RecordView, etc.)
|
||||
- `src/qqq/components/` - Reusable components
|
||||
- `src/qqq/utils/qqq/Client.ts` - API client singleton
|
||||
- `src/qqq/authorization/` - Pluggable auth modules
|
||||
- `src/qqq/assets/theme*/` - Light/dark theming
|
||||
|
||||
## Code Style (ESLint enforced)
|
||||
- 3-space indentation
|
||||
- Double quotes
|
||||
- Allman brace style (opening brace on new line)
|
||||
- Functional components with hooks
|
||||
- React Context for state (no Redux)
|
||||
|
||||
## Development Commands
|
||||
```bash
|
||||
npm run install:legacy # Install with --legacy-peer-deps
|
||||
npm start # Dev server on port 3000
|
||||
npm run build # Production build
|
||||
mvn clean package # Full Maven build with embedded frontend
|
||||
```
|
||||
|
||||
## Important Patterns
|
||||
1. **Metadata-driven UI** - Components render from QQQ field/table metadata
|
||||
2. **Path-agnostic SPA** - Detects base path from script location (PathUtils.ts)
|
||||
3. **Dynamic components** - useDynamicComponents() loads from qqq-frontend-core
|
||||
4. **Modal stack** - QContext tracks open modals with push/pop
|
||||
5. **API client** - Always use Client.getInstance(), never hardcode paths
|
||||
|
||||
## Recent Work (Dec 2024)
|
||||
- Row Builder widget with drag & drop
|
||||
- Field As Widget (adornmentType=widget)
|
||||
- Multiple SPA support (path-agnostic routing)
|
||||
|
||||
## Related Resources
|
||||
- Backend: /Users/james.maes/Git.Local/QRun-IO/qqq/
|
||||
- User AI config: ~/.ai/ (Nix-managed)
|
||||
- QQQ CODE_STYLE.md for Java conventions
|
||||
|
||||
## CI/CD
|
||||
- CircleCI with kingsrook/qqq-orb
|
||||
- develop -> snapshot, release/* -> RC, tags -> production
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
contact@kingsrook.com. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
|
||||
---
|
||||
|
||||
**Note**: This repository is a frontend component of the QQQ framework.
|
||||
For the complete community guidelines and enforcement, see the main QQQ repository:
|
||||
https://github.com/Kingsrook/qqq
|
||||
@@ -0,0 +1,165 @@
|
||||
# Contributing to QQQ Frontend Material Dashboard
|
||||
|
||||
Thank you for your interest in contributing to the QQQ Frontend Material Dashboard! This repository contains the **frontend dashboard application** that runs on top of the QQQ framework.
|
||||
|
||||
## 🎯 What This Repository Is
|
||||
|
||||
This repository is a **frontend component** of the QQQ framework that provides:
|
||||
- React-based user interface components
|
||||
- Material-UI theming and styling
|
||||
- Client-side routing and state management
|
||||
- Authentication UI components
|
||||
- Data visualization and form components
|
||||
|
||||
## 🚫 What This Repository Is NOT
|
||||
|
||||
This repository does NOT contain:
|
||||
- The QQQ server or backend
|
||||
- Core framework functionality
|
||||
- Business logic or process engine
|
||||
- Database operations or API endpoints
|
||||
|
||||
## 🔄 Contribution Types
|
||||
|
||||
### ✅ Frontend-Only Changes (This Repository)
|
||||
|
||||
- **UI/UX improvements** to existing components
|
||||
- **New React components** for the dashboard
|
||||
- **Styling and theming** updates
|
||||
- **Frontend performance** optimizations
|
||||
- **Client-side bug fixes**
|
||||
- **Documentation updates** for frontend code
|
||||
|
||||
### ❌ Backend/Core Changes (Main QQQ Repository)
|
||||
|
||||
- **Server-side functionality**
|
||||
- **Core framework features**
|
||||
- **Database schema changes**
|
||||
- **API endpoint modifications**
|
||||
- **Business logic changes**
|
||||
- **Process engine updates**
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: LTS version (18.x or higher)
|
||||
- **npm**: 8.x or higher
|
||||
- **Git**: For version control
|
||||
- **QQQ Server**: Running instance for testing
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Fork this repository**
|
||||
2. **Clone your fork**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/qqq-frontend-material-dashboard.git
|
||||
cd qqq-frontend-material-dashboard
|
||||
```
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
4. **Create a feature branch**
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
## 📝 Development Guidelines
|
||||
|
||||
### Code Style
|
||||
|
||||
- **TypeScript**: Use strict typing
|
||||
- **ESLint**: Follow the configured rules
|
||||
- **Prettier**: Maintain consistent formatting
|
||||
- **React**: Use functional components with hooks
|
||||
- **Material-UI**: Follow MUI design patterns
|
||||
|
||||
### Component Structure
|
||||
|
||||
```typescript
|
||||
// Example component structure
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ExampleComponent({ title, children }: Props): JSX.Element {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- **Unit tests**: Test individual components
|
||||
- **Integration tests**: Test component interactions
|
||||
- **Browser testing**: Test in multiple browsers
|
||||
- **Responsive testing**: Test on different screen sizes
|
||||
|
||||
## 🔄 Pull Request Process
|
||||
|
||||
1. **Ensure your changes are frontend-only**
|
||||
2. **Follow the existing code patterns**
|
||||
3. **Add tests for new functionality**
|
||||
4. **Update documentation if needed**
|
||||
5. **Test thoroughly in the browser**
|
||||
6. **Submit a pull request**
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Changes are frontend-focused only
|
||||
- [ ] Code follows existing patterns
|
||||
- [ ] Tests are included and passing
|
||||
- [ ] Documentation is updated
|
||||
- [ ] No breaking changes introduced
|
||||
- [ ] Cross-browser compatibility verified
|
||||
|
||||
## 🚨 For Major Changes
|
||||
|
||||
If your contribution involves:
|
||||
- **Core framework functionality**
|
||||
- **Backend integration changes**
|
||||
- **Major architectural decisions**
|
||||
- **Breaking changes to the QQQ API**
|
||||
|
||||
Please submit to the **main QQQ repository** instead:
|
||||
👉 **[https://github.com/Kingsrook/qqq](https://github.com/Kingsrook/qqq)**
|
||||
|
||||
## 🐛 Reporting Issues
|
||||
|
||||
**All issues should be reported to the main QQQ repository:**
|
||||
👉 **[https://github.com/Kingsrook/qqq/issues](https://github.com/Kingsrook/qqq/issues)**
|
||||
|
||||
When reporting frontend-specific issues, include:
|
||||
- Browser and version
|
||||
- Operating system
|
||||
- Steps to reproduce
|
||||
- Screenshots if applicable
|
||||
- Console errors
|
||||
- QQQ server version
|
||||
|
||||
## 💬 Getting Help
|
||||
|
||||
- **Documentation**: [QQQ Wiki](https://github.com/Kingsrook/qqq.wiki)
|
||||
- **Discussions**: [QQQ Discussions](https://github.com/Kingsrook/qqq/discussions)
|
||||
- **Main Repository**: [https://github.com/Kingsrook/qqq](https://github.com/Kingsrook/qqq)
|
||||
|
||||
## 📄 License
|
||||
|
||||
By contributing to this project, you agree that your contributions will be licensed under the same license as the project (GNU Affero General Public License v3.0).
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
Thank you for contributing to the QQQ Frontend Material Dashboard! Your contributions help make the QQQ framework's user interface better for everyone.
|
||||
|
||||
---
|
||||
|
||||
**Remember**: This is a frontend component. For core QQQ functionality, use the main repository: https://github.com/Kingsrook/qqq
|
||||
@@ -0,0 +1,24 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [Kingsrook]
|
||||
# Patreon is also supported, but requires a custom URL
|
||||
# patreon: Kingsrook
|
||||
# Open Collective is also supported, but requires a custom URL
|
||||
# open_collective: Kingsrook
|
||||
# Ko-fi is also supported, but requires a custom URL
|
||||
# ko_fi: Kingsrook
|
||||
# Tidelift is also supported, but requires a custom URL
|
||||
# tidelift: Kingsrook
|
||||
# Community Bridge is also supported, but requires a custom URL
|
||||
# community_bridge: Kingsrook
|
||||
# Liberapay is also supported, but requires a custom URL
|
||||
# liberapay: Kingsrook
|
||||
# IssueHunt is also supported, but requires a custom URL
|
||||
# issuehunt: Kingsrook
|
||||
# Otechie is also supported, but requires a custom URL
|
||||
# otechie: Kingsrook
|
||||
# Custom URLs are also supported, but require a custom URL
|
||||
# custom: https://kingsrook.com/support
|
||||
|
||||
# Note: This repository is a frontend component of the QQQ framework.
|
||||
# For main project funding and support, visit: https://github.com/Kingsrook/qqq
|
||||
@@ -0,0 +1,37 @@
|
||||
# ⚠️ Issues Should Go to Main QQQ Repository
|
||||
|
||||
This repository is a **frontend component** of the QQQ framework. All issues, bug reports, feature requests, and support questions should be submitted to the **main QQQ repository**.
|
||||
|
||||
## 🎯 Where to Submit Issues
|
||||
|
||||
**Please submit your issue here instead:**
|
||||
👉 **[https://github.com/Kingsrook/qqq/issues](https://github.com/Kingsrook/qqq/issues)**
|
||||
|
||||
## 🔍 Why This Repository Exists
|
||||
|
||||
This repository contains only the **frontend dashboard application** that runs on top of QQQ. It does not contain:
|
||||
- The QQQ server or backend
|
||||
- Core framework functionality
|
||||
- Business logic or process engine
|
||||
- Database or API services
|
||||
|
||||
## 📋 What to Include in Your Issue
|
||||
|
||||
When submitting to the main QQQ repository, please include:
|
||||
|
||||
1. **Clear description** of the problem or feature request
|
||||
2. **Steps to reproduce** (for bugs)
|
||||
3. **Expected vs. actual behavior**
|
||||
4. **Environment details** (OS, browser, QQQ version)
|
||||
5. **Screenshots or logs** if applicable
|
||||
6. **Frontend-specific details** if the issue relates to the UI
|
||||
|
||||
## 🚀 Getting Help
|
||||
|
||||
- **Documentation**: [QQQ Wiki](https://github.com/Kingsrook/qqq.wiki)
|
||||
- **Discussions**: [QQQ Discussions](https://github.com/Kingsrook/qqq/discussions)
|
||||
- **Main Repository**: [https://github.com/Kingsrook/qqq](https://github.com/Kingsrook/qqq)
|
||||
|
||||
---
|
||||
|
||||
**Thank you for understanding! This helps us maintain a single source of truth for all QQQ-related issues.**
|
||||
@@ -0,0 +1,83 @@
|
||||
# 🎨 Frontend Component Pull Request
|
||||
|
||||
## 📝 Description
|
||||
|
||||
**What does this PR do?**
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
**What type of change is this?**
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] UI/UX improvement
|
||||
- [ ] Documentation update
|
||||
- [ ] Code refactoring
|
||||
- [ ] Performance improvement
|
||||
- [ ] Other (please describe)
|
||||
|
||||
**Does this PR introduce a breaking change?**
|
||||
- [ ] Yes
|
||||
- [ ] No
|
||||
|
||||
**If yes, please describe the breaking change:**
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
**How have you tested this PR?**
|
||||
- [ ] Manual testing in browser
|
||||
- [ ] Unit tests
|
||||
- [ ] Integration tests
|
||||
- [ ] Cross-browser testing
|
||||
- [ ] Mobile responsiveness testing
|
||||
|
||||
**Test environment:**
|
||||
- OS:
|
||||
- Browser:
|
||||
- QQQ version:
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<!-- If applicable, add screenshots to help explain your changes -->
|
||||
|
||||
## 🔗 Related Issues
|
||||
|
||||
<!-- Link to any related issues in the main QQQ repository -->
|
||||
|
||||
## 📋 Pull Request Checklist
|
||||
|
||||
- [ ] I have read the [contributing guidelines](https://github.com/Kingsrook/qqq.wiki)
|
||||
- [ ] My changes are focused on frontend/UI components only
|
||||
- [ ] I have tested my changes in the browser
|
||||
- [ ] My changes follow the existing code style and patterns
|
||||
- [ ] I have updated documentation if necessary
|
||||
- [ ] My changes do not break existing functionality
|
||||
|
||||
## Reminders
|
||||
This repository contains the **frontend dashboard application** for the QQQ framework. Please ensure your changes are appropriate for a frontend component.
|
||||
|
||||
### 🎯 What This Repository Is
|
||||
|
||||
This repository contains:
|
||||
- React components and UI logic
|
||||
- Material-UI theming and styling
|
||||
- Frontend routing and state management
|
||||
- Client-side authentication components
|
||||
- Data visualization and form components
|
||||
|
||||
### 🚫 What This Repository Is NOT
|
||||
|
||||
This repository does NOT contain:
|
||||
- QQQ server or backend logic
|
||||
- Core framework functionality
|
||||
- Business logic or process engine
|
||||
- Database operations or API endpoints
|
||||
|
||||
### 🔄 For Major Changes
|
||||
|
||||
If your changes involve:
|
||||
- **Core framework functionality**
|
||||
- **Backend integration changes**
|
||||
- **Major architectural decisions**
|
||||
- **Breaking changes to the QQQ API**
|
||||
|
||||
**Remember**: This is a frontend component. For core QQQ functionality, please consider submitting to the **main QQQ repository** instead:
|
||||
👉 **[https://github.com/Kingsrook/qqq](https://github.com/Kingsrook/qqq)**
|
||||
@@ -1,18 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
src/main/resources/material-dashboard/
|
||||
|
||||
**/.idea*
|
||||
*.iml
|
||||
.yalc*
|
||||
yalc.lock
|
||||
.env
|
||||
/certs
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/node/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# production
|
||||
/build
|
||||
@@ -22,6 +28,7 @@ yalc.lock
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.claude/
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to QQQ Frontend Material Dashboard will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.35.0] - 2025-12-29
|
||||
|
||||
### Changed
|
||||
- Upgraded to Java 21
|
||||
- Updated qqq-orb to 0.5.0 for Java 21 CI support
|
||||
- Updated qqq.version dependency to 0.35.0
|
||||
- Updated license to AGPL-3.0 with QRun-IO copyright
|
||||
|
||||
### Added
|
||||
- Row builder widget with drag & drop reordering support
|
||||
- Field-as-widget adornment support for custom field UI rendering
|
||||
- Path-agnostic SPA support for multiple hosting paths
|
||||
- OAuth2 authentication flow with dynamic base path support
|
||||
- Pluggable themes design documentation
|
||||
|
||||
### Fixed
|
||||
- Boolean equals-false filter being lost when saved as a view
|
||||
- Null pointer when setting up grid column models without table path
|
||||
- Query operator labels showing inaccurate 'or equals' text
|
||||
- Record sidebar min-height to prevent shrinking on short screens
|
||||
|
||||
## [0.25.0] - 2025-05-20
|
||||
|
||||
### Added
|
||||
- OAuth2 authentication module
|
||||
- Banner support for system-wide notifications
|
||||
- Query-param default process values as JSON object
|
||||
- Min/max input record validation alerts for processes
|
||||
|
||||
### Changed
|
||||
- Refactored authentication handling to pass metadata into App
|
||||
- Improved goto dialog behavior for empty string fields
|
||||
|
||||
### Fixed
|
||||
- Goto fields quick bug fix
|
||||
- Full-width file upload adornment for lg-size fields
|
||||
|
||||
## [0.24.0] - 2025-03-06
|
||||
|
||||
### Added
|
||||
- SFTP and headless bulk load support
|
||||
- Dynamic URL support for blob downloads
|
||||
- Field column width support on view and edit forms
|
||||
- Default values for new child records from parent fields
|
||||
- Filter JSON field improvements with preview
|
||||
|
||||
### Changed
|
||||
- Removed textTransform="capitalize" from page headers
|
||||
- Added LONG to types that get numeric operators
|
||||
|
||||
### Fixed
|
||||
- Blob download URLs when not using dynamic URL
|
||||
- Child record dashboard widget force reload on removal
|
||||
|
||||
## [0.23.0] - 2024-11-22
|
||||
|
||||
### Added
|
||||
- Mobile-first UI/UX improvements
|
||||
- Ad-hoc composite/block widgets
|
||||
- Non-linear process flow support
|
||||
- Child record widget data display
|
||||
- Generate labels POC
|
||||
|
||||
### Changed
|
||||
- Updated block widget rendering for Android app compatibility
|
||||
- Added proxy for /qqq/* versioned middleware
|
||||
- Improved next/submit handling for validation screens
|
||||
|
||||
### Fixed
|
||||
- Filter-as-widget dropping possible-value fields with defaultFilter
|
||||
- Non-blob file downloads in value utils
|
||||
|
||||
## [0.22.0] - 2024-09-04
|
||||
|
||||
### Added
|
||||
- Query filter widget preview functionality
|
||||
- Timezone conversion for date formatting
|
||||
|
||||
### Changed
|
||||
- Default operator for DATE fields changed to equals
|
||||
- Added tabular-nums font variant for counting numbers
|
||||
|
||||
### Fixed
|
||||
- Possible value filter bug with use-case handling
|
||||
- Date query bugs with timezone conversion
|
||||
|
||||
## [0.21.0] - 2024-08-23
|
||||
|
||||
- Initial tracked release
|
||||
@@ -0,0 +1,208 @@
|
||||
# CI/CD Integration with GitHub Actions Library
|
||||
|
||||
## Overview
|
||||
|
||||
The qqq-frontend-material-dashboard project has been successfully integrated with the Kingsrook GitHub Actions Library to provide standardized CI/CD workflows for building, testing, and publishing.
|
||||
|
||||
## Project Type
|
||||
|
||||
This project is configured as an **integrated Maven+NPM project** where:
|
||||
- Maven handles the overall build lifecycle and Java components
|
||||
- The frontend-maven-plugin builds the React frontend and packages it into the Maven JAR
|
||||
- NPM components are not published separately - they are embedded as resources in the Maven JAR
|
||||
|
||||
## Workflow Configuration
|
||||
|
||||
### Current Setup
|
||||
|
||||
The project uses the following workflow configuration in `.github/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
name: 'CI/CD Pipeline'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop, 'release/*', 'hotfix/*', 'feature/*' ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: '🚀 Build, Test, and Publish Release (main, rc, hotfix, or snapshot)'
|
||||
if: |
|
||||
github.ref_name == 'main' ||
|
||||
github.ref_name == 'develop' ||
|
||||
startsWith(github.ref_name, 'release/') ||
|
||||
startsWith(github.ref_name, 'hotfix/')
|
||||
uses: Kingsrook/github-actions-library/.github/workflows/reusable-gitflow-publish@develop
|
||||
with:
|
||||
project-type: 'maven'
|
||||
java-version: '17'
|
||||
maven-args: '-B -q -P ci package'
|
||||
maven-working-directory: '.'
|
||||
secrets:
|
||||
GPG_PRIVATE_KEY_B64: ${{ secrets.GPG_PRIVATE_KEY_B64 }}
|
||||
GPG_KEYNAME: ${{ secrets.GPG_KEYNAME }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
|
||||
CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
test:
|
||||
name: '🧪 Build and Test Only'
|
||||
if: |
|
||||
!(
|
||||
github.ref_name == 'main' ||
|
||||
github.ref_name == 'develop' ||
|
||||
startsWith(github.ref_name, 'release/') ||
|
||||
startsWith(github.ref_name, 'hotfix/')
|
||||
)
|
||||
uses: Kingsrook/github-actions-library/.github/workflows/reusable-gitflow-test@develop
|
||||
with:
|
||||
project-type: 'maven'
|
||||
java-version: '17'
|
||||
maven-args: '-B -q -P ci package'
|
||||
maven-working-directory: '.'
|
||||
```
|
||||
|
||||
### Key Configuration Details
|
||||
|
||||
1. **Project Type**: `maven` - This is correct for an integrated project where Maven builds everything
|
||||
2. **Maven Arguments**: `-B -q -P ci package` - Uses the CI profile and runs the package phase to trigger frontend build
|
||||
3. **Branch Reference**: `@develop` - Uses the latest version of the GitHub Actions library
|
||||
4. **Java Version**: `17` - Matches the project's Java version requirement
|
||||
|
||||
## Maven Configuration
|
||||
|
||||
### CI Profile
|
||||
|
||||
The project includes a CI profile in `pom.xml` that optimizes the build for automated environments:
|
||||
|
||||
```xml
|
||||
<profile>
|
||||
<!-- CI/CD profile - optimized for automated builds -->
|
||||
<id>ci</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<nodeVersion>v18.17.0</nodeVersion>
|
||||
<npmVersion>9.6.7</npmVersion>
|
||||
<workingDirectory>.</workingDirectory>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
```
|
||||
|
||||
### Frontend Maven Plugin
|
||||
|
||||
The main build configuration includes the frontend-maven-plugin with the following executions:
|
||||
|
||||
1. **install-node-and-npm**: Installs Node.js v18.17.0 and NPM 9.6.7
|
||||
2. **npm-install**: Runs `npm install --legacy-peer-deps` to handle peer dependency conflicts
|
||||
3. **npm-build**: Runs `npm run build` to create the production React build
|
||||
4. **copy-frontend-build**: Copies the built frontend files to Maven resources
|
||||
|
||||
## Build Process
|
||||
|
||||
### Local Testing
|
||||
|
||||
To test the build locally:
|
||||
|
||||
```bash
|
||||
# Set up Java 17
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 17)
|
||||
|
||||
# Run the same command as the CI workflow
|
||||
mvn clean compile test-compile -B -P ci package
|
||||
```
|
||||
|
||||
### CI Workflow Steps
|
||||
|
||||
1. **Environment Validation**: Validates required secrets and configurations
|
||||
2. **Version Management**: Uses calculate-version.sh to determine next version
|
||||
3. **Build and Test**:
|
||||
- Installs Node.js and NPM
|
||||
- Runs `npm install --legacy-peer-deps`
|
||||
- Runs `npm run build` to create production build
|
||||
- Copies frontend files to Maven resources
|
||||
- Compiles Java code
|
||||
- Runs tests
|
||||
4. **Publishing**: Publishes to Maven Central (for publishing branches)
|
||||
5. **Git Operations**: Commits version changes (for publishing branches)
|
||||
|
||||
## Required Secrets
|
||||
|
||||
The following secrets must be configured in the GitHub repository:
|
||||
|
||||
### For Publishing Workflows
|
||||
- `GPG_PRIVATE_KEY_B64` - Base64 encoded GPG private key for artifact signing
|
||||
- `GPG_KEYNAME` - GPG key identifier (email or key ID)
|
||||
- `GPG_PASSPHRASE` - GPG key passphrase
|
||||
- `CENTRAL_USERNAME` - Sonatype OSSRH username for Maven Central
|
||||
- `CENTRAL_PASSWORD` - Sonatype OSSRH password for Maven Central
|
||||
- `NPM_TOKEN` - NPM authentication token (required even though NPM isn't published separately)
|
||||
|
||||
### For Testing Workflows
|
||||
- No additional secrets required beyond the standard `GITHUB_TOKEN`
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
The workflow follows GitFlow conventions:
|
||||
|
||||
- **Feature branches** (`feature/*`): Run test workflow only
|
||||
- **Develop branch** (`develop`): Run publish workflow, creates SNAPSHOT versions
|
||||
- **Release branches** (`release/*`): Run publish workflow, creates RC versions
|
||||
- **Main branch** (`main`): Run publish workflow, creates stable releases
|
||||
- **Hotfix branches** (`hotfix/*`): Run publish workflow, creates patch versions
|
||||
|
||||
## Version Management
|
||||
|
||||
The workflow uses the `calculate-version.sh` script from the GitHub Actions library to:
|
||||
- Determine the current version from `pom.xml`
|
||||
- Calculate the next version based on branch type
|
||||
- Update version files automatically
|
||||
- Handle SNAPSHOT, RC, and stable version formats
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Frontend Build Failures**: Ensure the CI profile is properly configured and Node.js/NPM versions match
|
||||
2. **Peer Dependency Conflicts**: The workflow uses `--legacy-peer-deps` to handle React 18 + Material-UI conflicts
|
||||
3. **Version Calculation Errors**: Check that the `calculate-version.sh` script is accessible and the branch naming follows GitFlow conventions
|
||||
|
||||
### Debugging
|
||||
|
||||
To debug build issues locally:
|
||||
```bash
|
||||
# Run with verbose output
|
||||
mvn clean compile test-compile -B -P ci package -X
|
||||
|
||||
# Check Node.js and NPM versions
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
# Test frontend build separately
|
||||
npm install --legacy-peer-deps
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Integration Status
|
||||
|
||||
✅ **Completed**:
|
||||
- Workflow configuration updated to use develop branch
|
||||
- Maven arguments configured for integrated build
|
||||
- CI profile properly configured
|
||||
- Local testing confirmed working
|
||||
- Documentation created
|
||||
|
||||
🔄 **Next Steps**:
|
||||
- Push changes to trigger GitHub Actions workflow
|
||||
- Monitor workflow execution and fix any issues
|
||||
- Test publishing workflow on appropriate branches
|
||||
@@ -0,0 +1,432 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies (required due to peer dependency conflicts)
|
||||
npm run install:legacy
|
||||
|
||||
# Development server (http://localhost:3000)
|
||||
npm start
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
|
||||
# Full Maven build (frontend + Java packaging)
|
||||
mvn clean package -Pci
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Type checking
|
||||
npm run type-check
|
||||
|
||||
# Linting
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a **metadata-driven React dashboard** that dynamically generates UI from QQQ backend metadata. The backend defines tables, processes, and apps; this frontend renders them without per-entity frontend code.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
QQQ Backend (Java)
|
||||
│
|
||||
▼ /metaData endpoint
|
||||
@qrunio/qqq-frontend-core (npm)
|
||||
│ TypeScript models (QInstance, QTableMetaData, etc.)
|
||||
▼
|
||||
qqq-frontend-material-dashboard
|
||||
│ Dynamic route generation
|
||||
▼
|
||||
MUI components + generated screens
|
||||
```
|
||||
|
||||
### Key Entry Points
|
||||
|
||||
- `src/App.tsx` - Main app, authentication, route generation from metadata, theme provider, BrandedHeaderBar
|
||||
- `src/QContext.tsx` - Global state (accentColor, tableMetaData, modalStack, branding)
|
||||
- `src/qqq/utils/qqq/Client.ts` - Singleton wrapper for `@qrunio/qqq-frontend-core` API client
|
||||
- `src/qqq/utils/themeUtils.ts` - Theme CSS variable injection and font loading
|
||||
|
||||
### Source Structure
|
||||
|
||||
```
|
||||
src/qqq/
|
||||
├── assets/theme/ # MUI theme tokens (colors, typography, shadows)
|
||||
│ ├── base/ # Design tokens: colors.ts, typography.ts, boxShadows.ts
|
||||
│ └── components/ # 55 MUI component style overrides
|
||||
├── components/
|
||||
│ ├── horseshoe/ # Core layout: SideNav, NavBar, Footer, Breadcrumbs, BrandedHeaderBar
|
||||
│ ├── forms/ # DynamicForm, EntityForm, field components
|
||||
│ ├── widgets/ # Dashboard widgets, charts, statistics
|
||||
│ ├── query/ # Table filtering components
|
||||
│ └── legacy/ # Material Dashboard 2 PRO base components, Theme.ts
|
||||
├── pages/
|
||||
│ ├── apps/ # App home pages
|
||||
│ ├── records/ # CRUD screens: query/, create/, edit/, view/
|
||||
│ └── processes/ # ProcessRun, ReportRun
|
||||
├── authorization/ # Auth0, OAuth2, Anonymous auth modules
|
||||
├── context/ # MaterialUIControllerProvider
|
||||
├── styles/ # qqq-override-styles.css, globals.scss
|
||||
└── utils/qqq/ # Client.ts, ProcessUtils, TableUtils
|
||||
```
|
||||
|
||||
### Route Generation
|
||||
|
||||
Routes are dynamically created from `QInstance.appTree` metadata in `App.tsx`:
|
||||
|
||||
- Apps become sidebar navigation entries
|
||||
- Tables get CRUD routes: `/app/{table}`, `/app/{table}/create`, `/app/{table}/:id`, `/app/{table}/:id/edit`
|
||||
- Processes get execution routes under their parent table or app
|
||||
|
||||
## Pluggable Themes System (Implemented)
|
||||
|
||||
### Overview
|
||||
|
||||
The dashboard supports **runtime theme customization** via CSS custom properties. Theme configuration is defined in `QThemeMetaData` on the Java backend and injected as `--qqq-*` CSS variables by `themeUtils.ts`.
|
||||
|
||||
### Theme Configuration (Java Backend)
|
||||
|
||||
```java
|
||||
qInstance.setTheme(new QThemeMetaData()
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSidebarBackgroundColor("#1E1E2D")
|
||||
.withBrandedHeaderEnabled(true)
|
||||
.withBrandedHeaderTagline("My Application"));
|
||||
```
|
||||
|
||||
### Available Theme Properties (60+)
|
||||
|
||||
| Category | Properties |
|
||||
|----------|------------|
|
||||
| **Core Colors** | `primaryColor`, `secondaryColor`, `backgroundColor`, `surfaceColor`, `textPrimary`, `textSecondary`, `errorColor`, `warningColor`, `successColor`, `infoColor` |
|
||||
| **Typography Base** | `fontFamily`, `headerFontFamily`, `monoFontFamily`, `fontSizeBase`, `fontWeightLight`, `fontWeightRegular`, `fontWeightMedium`, `fontWeightBold` |
|
||||
| **Typography Variants** | `typographyH1FontSize`, `typographyH1FontWeight`, `typographyH1LineHeight`, `typographyH1LetterSpacing`, `typographyH1TextTransform` (same pattern for H2-H6, Body1, Body2, Button, Caption) |
|
||||
| **Branded Header** | `brandedHeaderEnabled`, `brandedHeaderBackgroundColor`, `brandedHeaderTextColor`, `brandedHeaderLogoPath`, `brandedHeaderLogoAltText`, `brandedHeaderHeight`, `brandedHeaderTagline` |
|
||||
| **App Bar** | `appBarBackgroundColor`, `appBarTextColor` |
|
||||
| **Sidebar** | `sidebarBackgroundColor`, `sidebarTextColor`, `sidebarIconColor`, `sidebarSelectedBackgroundColor`, `sidebarSelectedTextColor`, `sidebarHoverBackgroundColor`, `sidebarDividerColor` |
|
||||
| **Tables** | `tableHeaderBackgroundColor`, `tableHeaderTextColor`, `tableRowHoverColor`, `tableRowSelectedColor`, `tableBorderColor` |
|
||||
| **General** | `dividerColor`, `borderColor`, `cardBorderColor`, `borderRadius`, `density`, `iconStyle` |
|
||||
| **Assets** | `logoPath`, `iconPath`, `faviconPath` |
|
||||
| **Custom** | `customCss` (raw CSS injection) |
|
||||
|
||||
### CSS Variables
|
||||
|
||||
All properties become CSS variables with `--qqq-` prefix:
|
||||
- `primaryColor` → `--qqq-primary-color`
|
||||
- `sidebarBackgroundColor` → `--qqq-sidebar-background-color`
|
||||
|
||||
Additional generated variables:
|
||||
- Grey scale: `--qqq-grey-100` through `--qqq-grey-900`
|
||||
- Charts: `--qqq-chart-grid-color`, `--qqq-chart-text-color`, `--qqq-chart-background-color`
|
||||
- Links: `--qqq-link-color`
|
||||
- Spacing: `--qqq-spacing-base`, `--qqq-spacing-small`, `--qqq-spacing-medium`, `--qqq-spacing-large`
|
||||
- Actions: `--qqq-action-active`, `--qqq-action-hover`, `--qqq-action-selected`, `--qqq-action-disabled`
|
||||
- Status variants: `--qqq-info-light`, `--qqq-info-dark`, etc.
|
||||
|
||||
Component-specific variables (override via customCss):
|
||||
- Stepper: `--qqq-stepper-inactive-color`
|
||||
- Tooltip: `--qqq-tooltip-background-color`, `--qqq-tooltip-text-color`, `--qqq-tooltip-shadow`
|
||||
- Inputs: `--qqq-input-border-color`
|
||||
- Menu: `--qqq-menu-hover-color`
|
||||
- Switch: `--qqq-switch-track-color`
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/qqq/utils/themeUtils.ts` - CSS variable injection, font loading
|
||||
- `src/qqq/components/horseshoe/BrandedHeaderBar.tsx` - Optional header banner
|
||||
- `src/qqq/styles/qqq-override-styles.css` - CSS variable overrides for MUI components
|
||||
|
||||
### Documentation
|
||||
|
||||
- `docs/QQQ_THEMING_GUIDE.md` - Complete theming reference for LLMs/developers
|
||||
- `docs/QQQ_CSS_SELECTORS_GUIDE.md` - CSS selector reference for targeting UI elements
|
||||
|
||||
## CSS Selectors System (Implemented)
|
||||
|
||||
### Overview
|
||||
|
||||
All major UI elements have stable `data-qqq-id` attributes for targeted styling via `QThemeMetaData.customCss`. IDs are generated from element names using `sanitizeId()` which converts to lowercase and replaces non-alphanumeric characters with hyphens.
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/qqq/utils/qqqIdUtils.ts` - ID sanitization utilities
|
||||
- `src/qqq/context/QqqIdContext.tsx` - Optional scope context provider
|
||||
|
||||
### Selector Patterns
|
||||
|
||||
| Element | Pattern | Example |
|
||||
|---------|---------|---------|
|
||||
| Buttons | `button-{text}` | `button-save` |
|
||||
| Icon buttons | `button-icon-{icon}` | `button-icon-delete` |
|
||||
| Menu items | `menu-item-{text}` | `menu-item-bulk-edit` |
|
||||
| Nav items | `sidenav-{name}` | `sidenav-orders` |
|
||||
| Inputs | `input-{field}` | `input-first-name` |
|
||||
| Selects | `select-{field}` | `select-status` |
|
||||
| Table headers | `table-header-{field}` | `table-header-order-id` |
|
||||
| Record view | `record-view-{element}-{table}` | `record-view-header-orders` |
|
||||
| Record create | `record-create-{element}-{table}` | `record-create-title-person` |
|
||||
| Record edit | `record-edit-{element}-{table}` | `record-edit-avatar-customer` |
|
||||
| Sections | `record-section-{name}` | `record-section-identity` |
|
||||
| Form sections | `form-section-{name}` | `form-section-details` |
|
||||
| Sidebar items | `sidebar-item-{name}` | `sidebar-item-identity` |
|
||||
| Table cards | `table-card-{name}` | `table-card-orders` |
|
||||
| Process cards | `process-card-{name}` | `process-card-bulk-load` |
|
||||
| App headers | `app-header-{name}` | `app-header-inventory` |
|
||||
|
||||
### Example CSS Usage
|
||||
|
||||
```css
|
||||
/* Hide delete menu item */
|
||||
[data-qqq-id="menu-item-delete"] { display: none; }
|
||||
|
||||
/* Style specific table view header */
|
||||
[data-qqq-id="record-view-header-orders"] {
|
||||
background: linear-gradient(to right, #1976D2, #42A5F5);
|
||||
}
|
||||
|
||||
/* Style all sidebar items */
|
||||
[data-qqq-id^="sidebar-item-"] {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Repository Dependencies
|
||||
|
||||
This project is part of a three-repo system:
|
||||
|
||||
| Repository | Artifact | Current Version |
|
||||
|------------|----------|-----------------|
|
||||
| qqq (backend-core) | `com.kingsrook.qqq:qqq-backend-core` | `0.36.0-SNAPSHOT` |
|
||||
| qqq-frontend-core | `@qrunio/qqq-frontend-core` | `1.4.2-SNAPSHOT` |
|
||||
| qqq-frontend-material-dashboard | `com.kingsrook.qqq:qqq-frontend-material-dashboard` | `0.36.0-SNAPSHOT` |
|
||||
|
||||
**Dependency order for commits/publishes:** backend-core → frontend-core → dashboard
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Dependencies
|
||||
|
||||
- `@qrunio/qqq-frontend-core` - QQQ data models and API client (no UI)
|
||||
- `@mui/material` 5.11.1 - Component library
|
||||
- `@mui/x-data-grid-pro` - Data grid (requires license key)
|
||||
- `react-router-dom` 6.x - Routing
|
||||
|
||||
### Authentication
|
||||
|
||||
- `@auth0/auth0-react` - Auth0 integration
|
||||
- `oidc-client-ts` + `react-oidc-context` - OAuth2/OIDC
|
||||
|
||||
### Peer Dependency Note
|
||||
|
||||
This project requires `--legacy-peer-deps` due to React 18 conflicts with some MUI dependencies. Always use `npm run install:legacy` for installation.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
REACT_APP_QQQ_API_BASE_URL=https://your-qqq-server.com/api
|
||||
REACT_APP_MATERIAL_UI_LICENSE_KEY=your-mui-license
|
||||
REACT_APP_AUTH0_DOMAIN=your-domain.auth0.com
|
||||
REACT_APP_AUTH0_CLIENT_ID=your-client-id
|
||||
```
|
||||
|
||||
## Context Providers
|
||||
|
||||
The app uses two main context providers:
|
||||
|
||||
1. **MaterialUIControllerProvider** (`src/qqq/context/index.tsx`)
|
||||
- `miniSidenav`, `sidenavColor`, `darkMode`, `direction`
|
||||
|
||||
2. **QContext** (`src/QContext.tsx`)
|
||||
- `accentColor`, `accentColorLight` - Theme colors
|
||||
- `tableMetaData`, `tableProcesses` - Current table context
|
||||
- `modalStack` - Nested modal management
|
||||
- `branding` - Backend branding metadata
|
||||
- `pathToLabelMap` - Route breadcrumb labels
|
||||
|
||||
## Working with QQQ Backend
|
||||
|
||||
The dashboard expects a QQQ backend serving:
|
||||
- `GET /metaData` - Returns `QInstance` with apps, tables, processes, branding
|
||||
- `GET /qqq/v1/*` - CRUD operations, process execution
|
||||
|
||||
The `Client.ts` singleton handles all API communication and 401 redirects to logout.
|
||||
|
||||
## Backend Asset Overlay System
|
||||
|
||||
QQQ backend supports a `material-dashboard-overlay/` directory for serving custom static assets (logos, icons, images) alongside the dashboard.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Backend setup** (in your QQQ Java project):
|
||||
```
|
||||
src/main/resources/material-dashboard-overlay/
|
||||
├── my-logo.png
|
||||
├── my-icon.png
|
||||
└── other-assets/
|
||||
```
|
||||
|
||||
2. **Branding configuration** (in Java MetaDataProvider):
|
||||
```java
|
||||
qInstance.setBranding(new QBrandingMetaData()
|
||||
.withLogo("/my-logo.png")
|
||||
.withIcon("/my-icon.png")
|
||||
.withAccentColor("#1976D2")
|
||||
.withAppName("My App"));
|
||||
```
|
||||
|
||||
3. **Frontend resolution**: Assets are served at web root, and `resolveAssetUrl()` in `PathUtils.ts` handles base path resolution.
|
||||
|
||||
### What the Overlay Does vs. What Pluggable Themes Does
|
||||
|
||||
| Capability | Overlay | Pluggable Themes |
|
||||
|------------|---------|------------------|
|
||||
| Logo/icon | Yes | Yes |
|
||||
| Single accent color | Yes | Yes |
|
||||
| Full color palette | No | Yes |
|
||||
| Typography/fonts | No | Yes |
|
||||
| Spacing system | No | Yes |
|
||||
| Component styles | No | Yes |
|
||||
| Dark mode | No | Yes |
|
||||
| Runtime switching | Partial (accentColor) | Full |
|
||||
|
||||
The overlay system handles **branding assets** (logos, icons). Pluggable themes handles **comprehensive styling** (colors, typography, shadows, component overrides).
|
||||
|
||||
## Testing
|
||||
|
||||
### Unified Test Runner
|
||||
|
||||
```bash
|
||||
# Run all tests (Playwright + Selenium)
|
||||
./run-tests.sh
|
||||
|
||||
# Run only Playwright tests
|
||||
./run-tests.sh --playwright
|
||||
|
||||
# Run only Selenium tests
|
||||
./run-tests.sh --selenium
|
||||
|
||||
# Run with visible browser
|
||||
QQQ_SELENIUM_HEADLESS=false ./run-tests.sh --selenium
|
||||
```
|
||||
|
||||
### Playwright E2E Tests
|
||||
|
||||
Playwright tests verify theme CSS variable injection and UI behavior.
|
||||
|
||||
```bash
|
||||
# Run Playwright tests with fixture server
|
||||
npm run e2e
|
||||
|
||||
# Run with UI mode for debugging
|
||||
npm run test:e2e:ui
|
||||
|
||||
# Run with visible browser
|
||||
npm run test:e2e:headed
|
||||
```
|
||||
|
||||
Key files:
|
||||
- `playwright.config.ts` - Configuration with dual web servers
|
||||
- `e2e/fixture-server.js` - Node.js server for fixture JSON (port 8001)
|
||||
- `e2e/tests/theme.spec.ts` - Theme verification tests (26 tests)
|
||||
|
||||
### Selenium Integration Tests
|
||||
|
||||
Selenium tests use a two-server setup:
|
||||
- **React dev server** on port 3001 - serves the dashboard frontend
|
||||
- **Javalin mock server** on port 8001 - serves fixture JSON responses
|
||||
|
||||
```bash
|
||||
# Manual run (requires React server running)
|
||||
PORT=3001 REACT_APP_PROXY_LOCALHOST_PORT=8001 npm start &
|
||||
QQQ_SELENIUM_HEADLESS=true mvn test -Dtest="**/selenium/tests/*IT"
|
||||
```
|
||||
|
||||
Key test classes:
|
||||
- `QBaseSeleniumTest` - Base class, starts Javalin on 8001
|
||||
- `QSeleniumJavalin` - Serves fixture JSON from `src/test/resources/fixtures/`
|
||||
- `ServerHealthChecker` - Waits for React server readiness
|
||||
- `ThemeIT` - Tests theme CSS variable injection
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
Located in `src/test/resources/fixtures/metaData/`:
|
||||
- `index.json` - Default metaData response
|
||||
- `withFullCustomTheme.json` - Theme with all properties set
|
||||
- `withBrandedHeader.json` - Theme with branded header enabled
|
||||
|
||||
### CI/CD
|
||||
|
||||
CircleCI runs both test suites:
|
||||
- **Playwright tests** - Custom job using `mcr.microsoft.com/playwright:v1.57.0-jammy`
|
||||
- **Selenium tests** - Via `qqq-orb/mvn_frontend_test_only`
|
||||
|
||||
## Session State & Continuity
|
||||
|
||||
**IMPORTANT:** Never use `~/.claude/session-state.md`. Always use local repo files.
|
||||
|
||||
### On "continue from last session"
|
||||
|
||||
Read these files in order:
|
||||
1. `docs/SESSION_STATE.md` - Current status, branch, recent commits, next steps
|
||||
2. `docs/TODO.md` - Active and completed tasks
|
||||
3. This file (`CLAUDE.md`) - Project context
|
||||
|
||||
### Current Status (as of 2026-01-10)
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Branch | `develop` |
|
||||
| Version | `0.36.0-SNAPSHOT` |
|
||||
| Latest Commit | `bb6d0dd` - docs: update session state after Playwright integration complete |
|
||||
| CI Status | All passing, snapshot published |
|
||||
|
||||
### Recent Merges
|
||||
|
||||
| PR | Description |
|
||||
|----|-------------|
|
||||
| #127 | Playwright e2e test integration (26 tests) |
|
||||
| #125 | Pluggable themes + CSS selectors system |
|
||||
| #123 | Virtual fields support |
|
||||
| #122 | Form adjusters |
|
||||
|
||||
### Test Status
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright e2e | 26 | PASS |
|
||||
| Selenium fixture-based | 115 | PASS |
|
||||
| Java unit | 3 | PASS |
|
||||
| Selenium full-server | 19 | Infrastructure issue - hangs locally |
|
||||
|
||||
The `seleniumwithqapplication` tests require a full QQQ backend server and hang during local execution. They may work in CI.
|
||||
|
||||
### Key Documentation Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `docs/SESSION_STATE.md` | Current work status, branch, version, recent commits |
|
||||
| `docs/TODO.md` | Task tracking for current feature |
|
||||
| `docs/QQQ_THEMING_GUIDE.md` | Complete theming reference |
|
||||
| `docs/QQQ_CSS_SELECTORS_GUIDE.md` | CSS selector patterns reference |
|
||||
| `docs/THEME_TESTING_GUIDE.md` | Guide for testing theme features |
|
||||
| `docs/PLAN-css-selectors.md` | CSS selectors implementation plan (COMPLETE) |
|
||||
| `docs/PLAN-pluggable-themes-mui-refactor.md` | MUI refactor plan (COMPLETE) |
|
||||
| `docs/TODO-hardcoded-colors.md` | Hardcoded color conversion checklist (COMPLETE) |
|
||||
|
||||
### Publishing Snapshots
|
||||
|
||||
Snapshots publish automatically when merged to `develop` via CircleCI `publish_snapshot` workflow.
|
||||
|
||||
Manual publish using gitops script:
|
||||
```bash
|
||||
/Users/james.maes/.local/bin/gitops-publish.sh
|
||||
```
|
||||
|
||||
This creates a `publish-{commit}` tag that triggers CI/CD to build and publish the JAR.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Code of Conduct
|
||||
|
||||
|
||||
See Project level [Code of Conduct](https://github.com/Kingsrook/qqq/blob/develop/CODE_OF_CONDUCT.md)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Code Style Guide
|
||||
|
||||
* See Project [Code Style Guide](https://github.com/Kingsrook/qqq/blob/develop/CODE_STYLE.md)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Contributing to QBit User Role Permissions
|
||||
|
||||
* See Project [Contributing](https://github.com/Kingsrook/qqq/blob/develop/CONTRIBUTING.md)
|
||||
|
||||
+636
@@ -0,0 +1,636 @@
|
||||
# Development Guide
|
||||
|
||||
This document explains how to work with the QQQ Frontend Material Dashboard repository for both local development and CI/CD pipelines.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Project Overview](#project-overview)
|
||||
- [Local Development](#local-development)
|
||||
- [CI/CD Pipeline](#cicd-pipeline)
|
||||
- [Build Process](#build-process)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## 🏗️ Project Overview
|
||||
|
||||
This repository contains a **hybrid Maven + React project** that combines:
|
||||
- **Frontend**: React 18 + TypeScript + Material-UI dashboard
|
||||
- **Backend**: Java 17 + Maven for packaging and distribution
|
||||
- **Integration**: Maven builds the React frontend and packages it with Java code
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ QQQ Frontend Material Dashboard │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Frontend (React/TypeScript) │ Backend (Java/Maven) │
|
||||
│ ├── src/ │ ├── src/main/java/ │
|
||||
│ │ ├── App.tsx │ │ └── com/kingsrook/ │
|
||||
│ │ ├── qqq/ │ └── pom.xml │
|
||||
│ │ └── ... │ │
|
||||
│ ├── package.json │ Maven Plugins: │
|
||||
│ └── tsconfig.json │ ├── frontend-maven-plugin │
|
||||
│ │ ├── maven-resources-plugin│
|
||||
│ │ └── maven-jar-plugin │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🚀 Local Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: 18.x LTS or higher
|
||||
- **npm**: 9.x or higher
|
||||
- **Java**: 17 or higher
|
||||
- **Maven**: 3.8+ (optional for frontend-only development)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
|
||||
cd qqq-frontend-material-dashboard
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
npm run install:legacy
|
||||
```
|
||||
|
||||
3. **Start development server**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
4. **Open browser**
|
||||
Navigate to `http://localhost:3000`
|
||||
|
||||
### Development Workflows
|
||||
|
||||
#### Frontend-Only Development
|
||||
|
||||
For UI development, you typically only need the React frontend:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm run install:legacy
|
||||
|
||||
# Start development server with hot reloading
|
||||
npm start
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run linting
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
|
||||
# Type checking
|
||||
npm run type-check
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### Full-Stack Development
|
||||
|
||||
When you need to work with both frontend and backend:
|
||||
|
||||
```bash
|
||||
# Build everything (frontend + backend)
|
||||
mvn clean package
|
||||
|
||||
# Run only the frontend build
|
||||
mvn clean compile
|
||||
|
||||
# Run tests (both frontend and backend)
|
||||
mvn test
|
||||
|
||||
# Build with specific profile
|
||||
mvn clean package -Pci
|
||||
```
|
||||
|
||||
### Available Scripts
|
||||
|
||||
| Script | Purpose | When to Use |
|
||||
|--------|---------|-------------|
|
||||
| `npm start` | Start React dev server | Frontend development |
|
||||
| `npm run dev` | Alias for start | Frontend development |
|
||||
| `npm run build` | Build React for production | Before deployment |
|
||||
| `npm test` | Run Jest tests | Testing |
|
||||
| `npm run test:ci` | Run tests for CI | CI/CD pipelines |
|
||||
| `npm run test:coverage` | Run tests with coverage | Code quality |
|
||||
| `npm run lint` | Check code quality | Code review |
|
||||
| `npm run lint:fix` | Auto-fix linting issues | Code cleanup |
|
||||
| `npm run type-check` | Validate TypeScript | Type safety |
|
||||
| `npm run clean` | Clean build artifacts | Troubleshooting |
|
||||
| `npm run install:legacy` | Install with legacy peer deps | Initial setup |
|
||||
|
||||
### Development Best Practices
|
||||
|
||||
1. **Use npm scripts for frontend development**
|
||||
```bash
|
||||
npm start # For development
|
||||
npm test # For testing
|
||||
npm run lint # For code quality
|
||||
```
|
||||
|
||||
2. **Use Maven for full builds**
|
||||
```bash
|
||||
mvn clean package # For complete builds
|
||||
```
|
||||
|
||||
3. **Run type checking before commits**
|
||||
```bash
|
||||
npm run type-check
|
||||
npm run lint
|
||||
```
|
||||
|
||||
4. **Test both frontend and backend**
|
||||
```bash
|
||||
npm test # Frontend tests
|
||||
mvn test # Backend tests
|
||||
```
|
||||
|
||||
## 🔄 CI/CD Pipeline
|
||||
|
||||
### Overview
|
||||
|
||||
The CI/CD pipeline uses **Maven as the single build tool** that handles both frontend and backend compilation. This approach provides:
|
||||
|
||||
- ✅ **Single build command**: `mvn clean package`
|
||||
- ✅ **Consistent environments**: Maven manages Node.js/npm versions
|
||||
- ✅ **Atomic builds**: Either everything succeeds or everything fails
|
||||
- ✅ **No external dependencies**: CI/CD only needs Java
|
||||
|
||||
### NPM Peer Dependency Configuration
|
||||
|
||||
This project uses `--legacy-peer-deps` due to peer dependency conflicts between:
|
||||
- **@types/react**: `18.0.0` (React 18)
|
||||
- **@mui/icons-material**: `5.4.1` (expects React 16-17)
|
||||
|
||||
When using the QQQ GitHub Actions library, configure it to handle these conflicts:
|
||||
|
||||
```yaml
|
||||
with:
|
||||
npm-use-legacy-peer-deps: 'true' # Handle peer dependency conflicts
|
||||
```
|
||||
|
||||
### Pipeline Steps
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Checkout Code] --> B[Setup Java 17]
|
||||
B --> C[Run Maven Build]
|
||||
C --> D[Install Node.js/npm]
|
||||
D --> E[Install npm Dependencies]
|
||||
E --> F[Build React Frontend]
|
||||
F --> G[Copy Frontend to Maven Resources]
|
||||
G --> H[Compile Java Code]
|
||||
H --> I[Run Tests]
|
||||
I --> J[Package JAR]
|
||||
J --> K[Deploy/Archive]
|
||||
```
|
||||
|
||||
### CI/CD Configuration
|
||||
|
||||
#### GitHub Actions Example (Using QQQ Actions Library)
|
||||
|
||||
```yaml
|
||||
name: 'CI/CD Pipeline'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, 'release/**', 'hotfix/**', 'feature/**']
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: Kingsrook/github-actions-library/.github/workflows/reusable-gitflow-test@main
|
||||
with:
|
||||
project-type: 'hybrid'
|
||||
java-version: '17'
|
||||
node-version: '18'
|
||||
npm-use-legacy-peer-deps: 'true' # Handle peer dependency conflicts
|
||||
secrets:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
snapshot:
|
||||
uses: Kingsrook/github-actions-library/.github/workflows/reusable-gitflow-snapshot@main
|
||||
with:
|
||||
project-type: 'hybrid'
|
||||
java-version: '17'
|
||||
node-version: '18'
|
||||
npm-use-legacy-peer-deps: 'true' # Handle peer dependency conflicts
|
||||
secrets:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GPG_PRIVATE_KEY_B64: ${{ secrets.GPG_PRIVATE_KEY_B64 }}
|
||||
GPG_KEYNAME: ${{ secrets.GPG_KEYNAME }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
|
||||
CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
release:
|
||||
uses: Kingsrook/github-actions-library/.github/workflows/reusable-gitflow-release@main
|
||||
with:
|
||||
project-type: 'hybrid'
|
||||
java-version: '17'
|
||||
node-version: '18'
|
||||
npm-use-legacy-peer-deps: 'true' # Handle peer dependency conflicts
|
||||
secrets:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GPG_PRIVATE_KEY_B64: ${{ secrets.GPG_PRIVATE_KEY_B64 }}
|
||||
GPG_KEYNAME: ${{ secrets.GPG_KEYNAME }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
|
||||
CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
```
|
||||
|
||||
#### Manual GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
name: Build and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
|
||||
|
||||
- name: Build with Maven
|
||||
run: mvn clean package -Pci
|
||||
# Maven automatically:
|
||||
# 1. Downloads Node.js v18.17.0
|
||||
# 2. Installs npm dependencies with --legacy-peer-deps
|
||||
# 3. Builds React frontend
|
||||
# 4. Compiles Java code
|
||||
# 5. Packages everything into JAR
|
||||
|
||||
- name: Run tests
|
||||
run: mvn test
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: qqq-frontend-material-dashboard
|
||||
path: target/*.jar
|
||||
```
|
||||
|
||||
#### Jenkins Example
|
||||
|
||||
```groovy
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
tools {
|
||||
jdk '17'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build') {
|
||||
steps {
|
||||
sh 'mvn clean package -Pci'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
steps {
|
||||
sh 'mvn test'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Archive') {
|
||||
steps {
|
||||
archiveArtifacts artifacts: 'target/*.jar', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Maven Profiles
|
||||
|
||||
The project includes different profiles for different environments:
|
||||
|
||||
#### `ci` Profile
|
||||
```bash
|
||||
mvn clean package -Pci
|
||||
```
|
||||
- Optimized for CI/CD environments
|
||||
- Uses specific Node.js/npm versions
|
||||
- Minimal logging for faster builds
|
||||
|
||||
#### `release` Profile
|
||||
```bash
|
||||
mvn clean package -Prelease
|
||||
```
|
||||
- Includes source and javadoc generation
|
||||
- GPG signing for Maven Central
|
||||
- Full artifact publishing
|
||||
|
||||
#### Default Profile
|
||||
```bash
|
||||
mvn clean package
|
||||
```
|
||||
- Standard development build
|
||||
- Includes all plugins and configurations
|
||||
|
||||
## 🔧 Build Process
|
||||
|
||||
### How Maven Builds the Project
|
||||
|
||||
The build process is orchestrated by Maven plugins:
|
||||
|
||||
#### 1. Frontend Maven Plugin
|
||||
```xml
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<!-- Downloads Node.js v18.17.0 -->
|
||||
<execution>
|
||||
<id>install-node-and-npm</id>
|
||||
<goals><goal>install-node-and-npm</goal></goals>
|
||||
</execution>
|
||||
|
||||
<!-- Runs: npm install --legacy-peer-deps -->
|
||||
<execution>
|
||||
<id>npm-install</id>
|
||||
<goals><goal>npm</goal></goals>
|
||||
</execution>
|
||||
|
||||
<!-- Runs: npm run build -->
|
||||
<execution>
|
||||
<id>npm-build</id>
|
||||
<goals><goal>npm</goal></goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
```
|
||||
|
||||
#### 2. Maven Resources Plugin
|
||||
```xml
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<executions>
|
||||
<!-- Copies build/ to target/classes/static/ -->
|
||||
<execution>
|
||||
<id>copy-frontend-build</id>
|
||||
<phase>process-resources</phase>
|
||||
<goals><goal>copy-resources</goal></goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
```
|
||||
|
||||
### Build Output
|
||||
|
||||
After running `mvn clean package`, you get:
|
||||
|
||||
```
|
||||
target/
|
||||
├── classes/
|
||||
│ ├── com/kingsrook/qqq/ # Compiled Java classes
|
||||
│ └── static/ # React build output
|
||||
│ ├── index.html
|
||||
│ ├── static/css/
|
||||
│ └── static/js/
|
||||
├── qqq-frontend-material-dashboard-0.26.0-SNAPSHOT.jar
|
||||
└── qqq-frontend-material-dashboard-0.26.0-SNAPSHOT-tests.jar
|
||||
```
|
||||
|
||||
### Build Phases
|
||||
|
||||
1. **Clean**: Remove previous build artifacts
|
||||
2. **Validate**: Verify project structure
|
||||
3. **Initialize**: Set up build environment
|
||||
4. **Generate Sources**: Create any generated code
|
||||
5. **Process Resources**: Copy static resources
|
||||
6. **Compile**: Compile Java source code
|
||||
7. **Process Test Resources**: Copy test resources
|
||||
8. **Test Compile**: Compile test code
|
||||
9. **Test**: Run unit tests
|
||||
10. **Package**: Create JAR file
|
||||
11. **Verify**: Run integration tests
|
||||
12. **Install**: Install to local repository
|
||||
13. **Deploy**: Deploy to remote repository
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Node.js/npm Version Conflicts
|
||||
```bash
|
||||
# Problem: Different Node.js versions between environments
|
||||
# Solution: Maven manages Node.js versions automatically
|
||||
|
||||
# Check what Maven is using:
|
||||
mvn help:effective-pom | grep nodeVersion
|
||||
```
|
||||
|
||||
#### 2. Peer Dependency Issues
|
||||
```bash
|
||||
# Problem: npm install fails with peer dependency conflicts
|
||||
# Solution: Use the legacy peer deps flag
|
||||
|
||||
npm run install:legacy
|
||||
# or
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
|
||||
**For CI/CD**: Configure the GitHub Actions library to handle peer dependencies:
|
||||
```yaml
|
||||
with:
|
||||
npm-use-legacy-peer-deps: 'true'
|
||||
```
|
||||
|
||||
#### 3. Build Failures
|
||||
```bash
|
||||
# Problem: Maven build fails
|
||||
# Solution: Clean and rebuild
|
||||
|
||||
mvn clean
|
||||
mvn clean package
|
||||
```
|
||||
|
||||
#### 4. Frontend Not Updating
|
||||
```bash
|
||||
# Problem: Frontend changes not reflected
|
||||
# Solution: Clean frontend build
|
||||
|
||||
npm run clean
|
||||
npm run install:legacy
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### 5. TypeScript Errors
|
||||
```bash
|
||||
# Problem: TypeScript compilation errors
|
||||
# Solution: Check types and fix issues
|
||||
|
||||
npm run type-check
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check Maven effective POM
|
||||
mvn help:effective-pom
|
||||
|
||||
# Check Maven dependencies
|
||||
mvn dependency:tree
|
||||
|
||||
# Run Maven with debug output
|
||||
mvn clean package -X
|
||||
|
||||
# Check Node.js version used by Maven
|
||||
mvn frontend:install-node-and-npm -X
|
||||
|
||||
# Verify frontend build
|
||||
ls -la build/
|
||||
ls -la target/classes/static/
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
For development, you may need these environment variables:
|
||||
|
||||
```bash
|
||||
# Frontend development
|
||||
export BROWSER=none # Prevent auto-browser opening
|
||||
export REACT_APP_API_BASE_URL=... # Backend API URL
|
||||
export REACT_APP_WS_BASE_URL=... # WebSocket URL
|
||||
|
||||
# Maven build
|
||||
export MAVEN_OPTS="-Xmx2048m" # Increase Maven memory
|
||||
export JAVA_HOME=/path/to/java17 # Java home directory
|
||||
```
|
||||
|
||||
## 📚 Best Practices
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Start with frontend development**
|
||||
```bash
|
||||
npm start # Quick iteration
|
||||
```
|
||||
|
||||
2. **Test frequently**
|
||||
```bash
|
||||
npm test # Frontend tests
|
||||
npm run type-check # Type safety
|
||||
npm run lint # Code quality
|
||||
```
|
||||
|
||||
3. **Build before committing**
|
||||
```bash
|
||||
npm run build # Frontend build
|
||||
mvn clean package # Full build
|
||||
```
|
||||
|
||||
4. **Use Maven for CI/CD**
|
||||
```bash
|
||||
mvn clean package -Pci # CI/CD builds
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
1. **Run linting before commits**
|
||||
```bash
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
2. **Ensure type safety**
|
||||
```bash
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
3. **Test both frontend and backend**
|
||||
```bash
|
||||
npm test # Frontend
|
||||
mvn test # Backend
|
||||
```
|
||||
|
||||
### Performance
|
||||
|
||||
1. **Use development mode for frontend**
|
||||
```bash
|
||||
npm start # Fast development builds
|
||||
```
|
||||
|
||||
2. **Use production builds for testing**
|
||||
```bash
|
||||
npm run build # Optimized builds
|
||||
```
|
||||
|
||||
3. **Cache dependencies in CI/CD**
|
||||
```yaml
|
||||
# Cache Maven dependencies
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
1. **Use specific versions**
|
||||
- Node.js: v18.17.0 (managed by Maven)
|
||||
- npm: v9.6.7 (managed by Maven)
|
||||
- Java: 17 (specified in pom.xml)
|
||||
|
||||
2. **Keep dependencies updated**
|
||||
```bash
|
||||
npm audit
|
||||
npm audit fix
|
||||
```
|
||||
|
||||
3. **Use environment variables for secrets**
|
||||
```bash
|
||||
export REACT_APP_API_KEY=...
|
||||
```
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues and questions:
|
||||
|
||||
- **Main Repository**: https://github.com/Kingsrook/qqq
|
||||
- **Issues**: https://github.com/Kingsrook/qqq/issues
|
||||
- **Documentation**: https://github.com/Kingsrook/qqq.wiki
|
||||
- **Email**: contact@kingsrook.com
|
||||
|
||||
---
|
||||
|
||||
**This is a component of the QQQ framework. For complete information, visit: https://github.com/Kingsrook/qqq**
|
||||
@@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright
|
||||
owner or by an individual or Legal Entity authorized to submit on
|
||||
behalf of the copyright owner. For the purposes of this definition,
|
||||
"submitted" means any form of electronic, verbal, or written
|
||||
communication sent to the Licensor or its representatives, including
|
||||
but not limited to communication on electronic mailing lists, source
|
||||
code control systems, and issue tracking systems that are managed by,
|
||||
or on behalf of, the Licensor for the purpose of discussing and
|
||||
improving the Work, but excluding communication that is conspicuously
|
||||
marked or otherwise designated in writing by the copyright owner as
|
||||
"Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,15 @@
|
||||
QQQ Frontend Material Dashboard
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
|
||||
This product includes software developed at QRunIO, LLC (https://qrun.io/).
|
||||
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
|
||||
================================================================================
|
||||
|
||||
Trademarks:
|
||||
|
||||
QQQ, qBits, qRun, qStudio, and the QRunIO logo are trademarks of QRunIO, LLC.
|
||||
Use of these trademarks must comply with QRunIO's Trademark Guidelines.
|
||||
|
||||
================================================================================
|
||||
@@ -0,0 +1,297 @@
|
||||
# Path-Agnostic SPA Implementation for qfmd
|
||||
|
||||
## Overview
|
||||
|
||||
The qfmd (Material Dashboard) SPA is now fully path-agnostic. It can be hosted at any path (/, /admin, /dashboard, etc.) without requiring any configuration or rebuild.
|
||||
|
||||
### Key Design: Separation of SPA and API Paths
|
||||
|
||||
- **SPA Assets**: Can be hosted at any path (e.g., `/admin`, `/dashboard`, `/`)
|
||||
- **API Endpoints**: Always served from the root path (/) regardless of the SPA's location
|
||||
- `/metaData/authentication` (always at root)
|
||||
- `/qqq/v1/data/person` (always at root)
|
||||
- `/qqq-api/` (always at root)
|
||||
|
||||
This separation allows maximum flexibility: change the SPA's hosting path without affecting API routing.
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. New File: `src/qqq/utils/PathUtils.ts`
|
||||
|
||||
This utility provides two functions for path detection:
|
||||
|
||||
- **`detectBasePath(): string`** - Detects the base path where the SPA is running
|
||||
- Used for React Router basename configuration
|
||||
- Used for serving static assets from the correct path
|
||||
- Primary strategy: Extracts path from the current script's source location
|
||||
- Fallback: Detects from known SPA path patterns in `window.location.pathname`
|
||||
- Default: Returns "/" if no path is detected
|
||||
|
||||
- **`logBasePathDetection(): void`** - Logs the detected base path for debugging
|
||||
|
||||
### 2. Updated: `src/qqq/utils/qqq/Client.ts`
|
||||
|
||||
The Client class detects the SPA base path for logging/routing but **always uses the root path for API calls**:
|
||||
|
||||
```typescript
|
||||
private static initializeBasePath(): void {
|
||||
if (!this.basePath) {
|
||||
this.basePath = detectBasePath();
|
||||
console.log(`[QQQ] SPA base path detected: ${this.basePath}`);
|
||||
console.log("[QQQ] API calls will be directed to root path (/, /metaData, /qqq/v1, etc.)");
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): QController {
|
||||
if (this.qController == null) {
|
||||
this.initializeBasePath();
|
||||
// APIs are always served from root (/) regardless of SPA base path
|
||||
// Empty string baseUrl means use the current origin as the base
|
||||
this.qController = new QController("", this.handleException);
|
||||
}
|
||||
return this.qController;
|
||||
}
|
||||
|
||||
public static getInstanceV1(path: string = "/qqq/v1"): QControllerV1 {
|
||||
if (this.qControllerV1 == null) {
|
||||
this.initializeBasePath();
|
||||
// APIs are always served from root (/), not from the SPA's base path
|
||||
this.qControllerV1 = new QControllerV1(path, this.handleException);
|
||||
}
|
||||
return this.qControllerV1;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Point**: Both controllers use root-relative paths (`""` and `/qqq/v1`) so all API calls go to the root.
|
||||
|
||||
### 3. Updated: `src/index.tsx`
|
||||
|
||||
React Router is configured with the detected base path (for routing only):
|
||||
|
||||
```typescript
|
||||
import {detectBasePath, logBasePathDetection} from "qqq/utils/PathUtils";
|
||||
|
||||
// Log base path detection for debugging
|
||||
logBasePathDetection();
|
||||
|
||||
// Get the detected base path for React Router
|
||||
const basename = detectBasePath();
|
||||
|
||||
// Use basename in all BrowserRouter instances
|
||||
root.render(<BrowserRouter basename={basename}>
|
||||
<Auth0RouterBody />
|
||||
</BrowserRouter>);
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Runtime Detection & Request Flow
|
||||
|
||||
#### Example: SPA hosted at `/admin`
|
||||
|
||||
**Server Configuration:**
|
||||
```java
|
||||
.withAdditionalRouteProvider(
|
||||
new IsolatedSpaRouteProvider("/admin", "material-dashboard")
|
||||
.withLoadFromJar(true)
|
||||
.withSpaIndexFile("material-dashboard/index.html")
|
||||
)
|
||||
```
|
||||
|
||||
**What happens on page load:**
|
||||
|
||||
1. Browser requests: `http://localhost:8000/admin/`
|
||||
2. Javalin's `IsolatedSpaRouteProvider` serves the bundled `index.html`
|
||||
3. React app loads scripts from: `http://localhost:8000/admin/static/js/main.abc123.js`
|
||||
4. `detectBasePath()` extracts `/admin` from the script source
|
||||
5. React Router is configured with `basename="/admin"`
|
||||
6. `Client.getInstance()` is called:
|
||||
- Detects base path: `/admin` (for logging)
|
||||
- Creates QController with `baseURL: ""` (root-relative)
|
||||
|
||||
**User navigates to `/admin/apps`:**
|
||||
- Browser URL: `http://localhost:8000/admin/apps`
|
||||
- React Router (with `basename="/admin"`) sees path: `/apps`
|
||||
- Correct component renders ✅
|
||||
|
||||
**App fetches authentication metadata:**
|
||||
- Component calls: `Client.getInstance().getAuthenticationMetaData()`
|
||||
- QController with `baseURL: ""` makes request to: `/metaData/authentication`
|
||||
- Request goes to: `http://localhost:8000/metaData/authentication` ✅
|
||||
- Server returns authentication data from root path
|
||||
|
||||
**App queries data:**
|
||||
- Component calls: `Client.getInstance().getTableMetaData("person")`
|
||||
- QController makes request to: `/data/person`
|
||||
- Request goes to: `http://localhost:8000/data/person` ✅
|
||||
- Server returns table metadata from root path
|
||||
|
||||
#### Example: Change SPA to `/dashboard`
|
||||
|
||||
**Just change Server.java:**
|
||||
```java
|
||||
.withAdditionalRouteProvider(
|
||||
new IsolatedSpaRouteProvider("/dashboard", "material-dashboard")
|
||||
.withLoadFromJar(true)
|
||||
)
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
- Scripts now load from: `http://localhost:8000/dashboard/static/js/main.abc123.js`
|
||||
- Detection extracts: `/dashboard`
|
||||
- React Router uses: `basename="/dashboard"`
|
||||
- **API calls still go to root** (no change needed) ✅
|
||||
- **All routing still works** (deep linking, navigation, etc.) ✅
|
||||
- **No rebuild, no code changes** ✅
|
||||
|
||||
### Path Detection Strategy
|
||||
|
||||
The detection uses a multi-strategy approach for maximum reliability:
|
||||
|
||||
1. **Primary**: Extracts from `document.currentScript.src`
|
||||
- Most reliable as it directly identifies the script's load location
|
||||
- Example: `/admin/static/js/main.abc123.js` → `/admin`
|
||||
- Works regardless of how the user navigated to the app
|
||||
|
||||
2. **Fallback**: Checks known SPA path patterns in `window.location.pathname`
|
||||
- Useful if primary detection fails
|
||||
- Supports known paths: "admin", "dashboard", "app", "manager", "console"
|
||||
- Example: `window.location.pathname = "/admin/apps"` → `/admin`
|
||||
|
||||
3. **Default**: Falls back to "/" if detection fails
|
||||
- Ensures the SPA always has a valid base path
|
||||
- Example: Root deployment → `/`
|
||||
|
||||
## API Request Flow Examples
|
||||
|
||||
### Example 1: SPA at `/admin`, APIs at `/`
|
||||
|
||||
```
|
||||
User navigates to: http://localhost:8000/admin/apps
|
||||
|
||||
React Router flow (using basename="/admin"):
|
||||
- Page URL: /admin/apps
|
||||
- basename: /admin
|
||||
- Rendered route: /apps ✅
|
||||
|
||||
API call flow (using baseURL=""):
|
||||
- Component: qController.getMetaData()
|
||||
- axios baseURL: "" (empty = root-relative)
|
||||
- Full URL: http://localhost:8000/metaData/... ✅
|
||||
```
|
||||
|
||||
### Example 2: SPA at `/`, APIs at `/`
|
||||
|
||||
```
|
||||
User navigates to: http://localhost:8000/apps
|
||||
|
||||
React Router flow (using basename="/"):
|
||||
- Page URL: /apps
|
||||
- basename: / (no prefix)
|
||||
- Rendered route: /apps ✅
|
||||
|
||||
API call flow (using baseURL=""):
|
||||
- Component: qController.getMetaData()
|
||||
- axios baseURL: "" (empty = root-relative)
|
||||
- Full URL: http://localhost:8000/metaData/... ✅
|
||||
```
|
||||
|
||||
### Example 3: SPA at `/dashboard`, APIs at `/`
|
||||
|
||||
```
|
||||
User navigates to: http://localhost:8000/dashboard/records
|
||||
|
||||
React Router flow (using basename="/dashboard"):
|
||||
- Page URL: /dashboard/records
|
||||
- basename: /dashboard
|
||||
- Rendered route: /records ✅
|
||||
|
||||
API call flow (using baseURL=""):
|
||||
- Component: qController.getTableMetaData("person")
|
||||
- axios baseURL: "" (empty = root-relative)
|
||||
- Full URL: http://localhost:8000/data/person ✅
|
||||
```
|
||||
|
||||
## Features Enabled
|
||||
|
||||
✅ **Deep Linking** - Routes like `/admin/apps/123` work correctly after page refresh
|
||||
✅ **Dynamic SPA Paths** - Can host at any path without configuration or rebuild
|
||||
✅ **Root-Level APIs** - All API endpoints remain at root path regardless of SPA location
|
||||
✅ **Static Assets** - Served from SPA's path (e.g., `/admin/static/js/main.js`)
|
||||
✅ **No Configuration** - SPA adapts automatically to its hosting path
|
||||
✅ **No Rebuild** - Same compiled JAR works at any path
|
||||
✅ **Backward Compatible** - Works perfectly at root path (/)
|
||||
|
||||
## Debug Logging
|
||||
|
||||
The SPA logs its configuration on startup:
|
||||
|
||||
```
|
||||
[QQQ] Detected SPA base path: /admin
|
||||
[QQQ] API calls will be directed to root path (/, /metaData, /qqq/v1, etc.)
|
||||
[QQQ] SPA base path detected: /admin
|
||||
[QQQ] API calls will be directed to root path (/, /metaData, /qqq/v1, etc.)
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Why This Design?
|
||||
|
||||
1. **APIs are centralized at root**: Simplifies backend configuration and routing rules
|
||||
2. **SPA can be anywhere**: Frontend can be deployed at any sub-path without backend changes
|
||||
3. **Clean separation of concerns**: Router handles SPA routing, HTTP client handles API routing
|
||||
4. **No URL rewriting needed**: No proxy rules or URL rewrites required
|
||||
|
||||
### Supported Hosting Paths
|
||||
|
||||
By default, the detection recognizes these path patterns:
|
||||
- `/` (root)
|
||||
- `/admin`
|
||||
- `/dashboard`
|
||||
- `/app`
|
||||
- `/manager`
|
||||
- `/console`
|
||||
|
||||
To add more paths, edit the allowed list in `PathUtils.ts`:
|
||||
|
||||
```typescript
|
||||
if (["admin", "dashboard", "app", "manager", "console"].includes(firstPart))
|
||||
```
|
||||
|
||||
## Testing the Implementation
|
||||
|
||||
### Test Case 1: Root Deployment
|
||||
- Deploy SPA at: `/`
|
||||
- Expected API calls: `/metaData/...`, `/data/...`
|
||||
- Deep linking: `/apps/123` should work ✅
|
||||
|
||||
### Test Case 2: Subpath Deployment
|
||||
- Deploy SPA at: `/admin`
|
||||
- Expected UI paths: `/admin/apps`, `/admin/records`
|
||||
- Expected API calls: `/metaData/...`, `/data/...` (still at root)
|
||||
- Deep linking: `/admin/apps/123` should work ✅
|
||||
|
||||
### Test Case 3: Multiple SPAs
|
||||
- Deploy admin SPA at: `/admin`
|
||||
- Deploy dashboard SPA at: `/dashboard`
|
||||
- Both should use same APIs at: `/metaData/...`, `/data/...` ✅
|
||||
|
||||
## Deployment Benefits
|
||||
|
||||
1. **Single Build Artifact**: One compiled JAR works in all environments
|
||||
2. **Flexible Hosting**: Can host at any path without recompilation
|
||||
3. **Easy Configuration**: Only configure the path in Server.java, SPA adapts automatically
|
||||
4. **Simplified Backend**: No need for path-specific API endpoints
|
||||
5. **Framework Agnostic**: Works with any hosting platform that can serve static files
|
||||
6. **No Runtime Configuration**: No environment variables or config files needed for paths
|
||||
|
||||
## Migration from Previous Versions
|
||||
|
||||
If previously the SPA was hardcoded to expect APIs at a specific path, this implementation automatically adapts:
|
||||
|
||||
- Old: SPA at `/admin` expected APIs at `/admin/metaData`
|
||||
- New: SPA at `/admin` automatically uses APIs at `/metaData`
|
||||
- Old: Changing path required rebuilding with new API base path
|
||||
- New: Changing path requires only Server.java configuration change
|
||||
|
||||
|
||||
@@ -1,261 +1,98 @@
|
||||
# [Material Dashboard 2 PRO React TS](http://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/dashboards/analytics?ref=readme-mdpr) [](https://twitter.com/intent/tweet?url=https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts&text=Check%20Material%20Dashboard%202%20Pro%React%20made%20by%20@CreativeTim%20#webdesign%20#dashboard%20#materialdesign%20#react%20https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts)
|
||||
# qqq-frontend-material-dashboard
|
||||
|
||||
 [](https://github.com/creativetimofficial/ct-material-dashboard-pro-react/issues?q=is%3Aopen+is%3Aissue) [](https://github.com/creativetimofficial/ct-material-dashboard-pro-react/issues?q=is%3Aissue+is%3Aclosed)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||

|
||||
React admin dashboard for QQQ applications.
|
||||
|
||||
Material Dashboard 2 PRO React TS is our newest premium MUI Admin Template based on React & TypeScript. If you’re a developer looking to create an admin dashboard that is developer-friendly, rich with features, and highly customisable, here is your match. Our innovative MUI + React + TypeScript dashboard comes with a beautiful design inspired by Google's Material Design and it will help you create stunning websites & web apps to delight your clients.
|
||||
**For:** Teams using QQQ who want a ready-to-use admin UI without writing frontend code
|
||||
**Status:** Stable
|
||||
|
||||
**Fully Coded Elements**
|
||||
Material Dashboard 2 PRO React TS is built with over 200 frontend individual elements, like buttons, inputs, navbars, nav tabs, cards, or alerts, giving you the freedom of choosing and combining. All components can take variations in color, which you can easily modify using MUI styled() API and sx prop. You will save a lot of time going from prototyping to full-functional code because all elements are implemented.
|
||||
## Why This Exists
|
||||
|
||||
This Premium MUI + React + TypeScript Dashboard is coming with prebuilt design blocks, so the development process is seamless,
|
||||
switching from our pages to the real website is very easy to be done.
|
||||
QQQ generates backend APIs automatically, but you still need a UI. This dashboard connects to any QQQ backend and renders:
|
||||
|
||||
View [all components here](https://www.creative-tim.com/learning-lab/react/alerts/material-dashboard/).
|
||||
- Table views with sorting, filtering, and pagination
|
||||
- Record forms with validation
|
||||
- Process execution screens
|
||||
- Navigation based on your QQQ metadata
|
||||
|
||||
**Documentation built by Developers**
|
||||
No frontend code required. The dashboard reads your QQQ metadata and builds the interface dynamically.
|
||||
|
||||
Each element is well presented in very complex documentation.
|
||||
If you need a custom UI, use [qqq-frontend-core](https://github.com/QRun-IO/qqq-frontend-core) directly instead.
|
||||
|
||||
You can read more about the [documentation here](https://www.creative-tim.com/learning-lab/react/overview/material-dashboard/).
|
||||
## Features
|
||||
|
||||
**Example Pages**
|
||||
- **Auto-generated screens** - Tables, forms, and processes from QQQ metadata
|
||||
- **Material-UI styling** - Clean, professional appearance out of the box
|
||||
- **Authentication** - Auth0, OAuth2, or anonymous modes
|
||||
- **Customizable** - Override components when defaults aren't enough
|
||||
- **Responsive** - Works on desktop and tablet
|
||||
|
||||
If you want to get inspiration or just show something directly to your clients, you can jump-start your development with our pre-built example pages. You will be able to quickly set up the basic structure for your web project.
|
||||
## Quick Start
|
||||
|
||||
View [example pages here](https://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/dashboards/analytics).
|
||||
**Prerequisites:** Node.js 18+, running QQQ backend
|
||||
|
||||
**HELPFUL LINKS**
|
||||
|
||||
- View [Github Repository](https://github.com/creativetimofficial/ct-material-dashboard-pro-react)
|
||||
- Check [FAQ Page](https://www.creative-tim.com/faq)
|
||||
|
||||
#### Special thanks
|
||||
|
||||
During the development of this dashboard, we have used many existing resources from awesome developers. We want to thank them for providing their tools open source:
|
||||
|
||||
- [MUI](https://mui.com/) - The React UI library for faster and easier web development.
|
||||
- [React Table](https://react-table.tanstack.com/) - Lightweight and extensible data tables for React.
|
||||
- [React Flatpickr](https://github.com/haoxins/react-flatpickr) - Useful library used to select date.
|
||||
- [React ChartJS 2](http://reactchartjs.github.io/react-chartjs-2/#/) - Simple yet flexible React charting for designers & developers.
|
||||
- [Full Calendar](https://fullcalendar.io/) - Full-sized drag & drop event calendar.
|
||||
- [Dropzone](https://www.dropzonejs.com/) - An open source library that provides drag & drop file uploads with image previews.
|
||||
- [React Kanban](https://github.com/asseinfo/react-kanban) - Kanban/Trello board lib for React.
|
||||
- [React Images Viewer](https://guonanci.github.io/react-images-viewer/) - A simple, responsive images viewer component for ReactJS.
|
||||
- [React Quill](https://github.com/zenoamaro/react-quill) - A free, open source WYSIWYG editor built for the modern web.
|
||||
- [Formik](https://formik.org/) - Formik is the world's most popular open source form library for React and React Native.
|
||||
- [ChromaJS](https://gka.github.io/chroma.js/) - A small-ish zero-dependency JavaScript library for all kinds of color conversions and color scales.
|
||||
- [UUID](https://github.com/uuidjs/uuid) - JavaScript library for generating random id numbers.
|
||||
- [HTML React Parser](https://github.com/remarkablemark/html-react-parser) - A utility for converting HTML strings into React components.
|
||||
|
||||
Let us know your thoughts below. And good luck with development!
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Versions](#versions)
|
||||
- [Demo](#demo)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Documentation](#documentation)
|
||||
- [File Structure](#file-structure)
|
||||
- [Browser Support](#browser-support)
|
||||
- [Resources](#resources)
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Technical Support or Questions](#technical-support-or-questions)
|
||||
- [Licensing](#licensing)
|
||||
- [Useful Links](#useful-links)
|
||||
|
||||
## Versions
|
||||
|
||||
[<img src="https://raw.githubusercontent.com/creativetimofficial/public-assets/master/logos/react-logo.png" width="60" height="60" />](https://www.creative-tim.com/product/material-dashboard-pro-react?ref=readme-mdpr)
|
||||
[<img src="https://raw.githubusercontent.com/creativetimofficial/public-assets/master/logos/typescript.png" width="60" height="60" />](https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts?ref=readme-mdpr)
|
||||
|
||||
| React | TypeScript |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [](http://demos.creative-tim.com/material-dashboard-pro-react/#/dashboards/analytics?ref=readme-mdpr) | [](http://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/dashboards/analytics?ref=readme-mdpr) |
|
||||
|
||||
## Demo
|
||||
|
||||
- [Dashboard](http://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/dashboards/analytics?ref=readme-mdpr)
|
||||
- [Sales](http://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/dashboards/sales?ref=readme-mdpr)
|
||||
- [Profile](https://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/pages/profile/profile-overview?ref=readme-mdpr)
|
||||
- [Account](https://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/pages/account/settings?ref=readme-mdpr)
|
||||
- [RTL](https://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/pages/rtl?ref=readme-mdpr)
|
||||
- [Applications](https://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/applications/kanban?ref=readme-mdpr)
|
||||
- [Ecommerce](https://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/ecommerce/products/product-page?ref=readme-mdpr)
|
||||
- [Sign In](https://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/authentication/sign-in/basic?ref=readme-mdpr)
|
||||
- [Sign Up](https://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/authentication/sign-up/cover?ref=readme-mdpr)
|
||||
|
||||
[View More](https://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/dashboards/analytics?ref=readme-mdpr).
|
||||
|
||||
## Quick start
|
||||
|
||||
Quick start options:
|
||||
|
||||
- Buy React Version from [Creative Tim](https://www.creative-tim.com/product/material-dashboard-pro-react?ref=readme-mdpr).
|
||||
- Buy React + TypeScript Version from [Creative Tim](https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts?ref=readme-mdpr).
|
||||
|
||||
## Terminal Commands
|
||||
|
||||
1. Download and Install NodeJs LTS version from [NodeJs Official Page](https://nodejs.org/en/download/).
|
||||
2. Navigate to the root ./ directory of the product and run `yarn install` or `npm install` to install our local dependencies.
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation for the Material Dashboard is hosted at our [website](https://www.creative-tim.com/learning-lab/react/overview/material-dashboard/?ref=readme-mdpr).
|
||||
|
||||
### What's included
|
||||
|
||||
Within the download you'll find the following directories and files:
|
||||
|
||||
```
|
||||
material-dashboard-2-pro-react-ts
|
||||
├── public
|
||||
│ ├── apple-icon.png
|
||||
│ ├── favicon.png
|
||||
│ ├── index.html
|
||||
│ ├── manifest.json
|
||||
│ └── robots.txt
|
||||
├── src
|
||||
│ ├── assets
|
||||
│ │ ├── images
|
||||
│ │ ├── theme
|
||||
│ │ │ ├── base
|
||||
│ │ │ ├── components
|
||||
│ │ │ ├── functions
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── theme-rtl.ts
|
||||
│ │ └── theme-dark
|
||||
│ │ ├── base
|
||||
│ │ ├── components
|
||||
│ │ ├── functions
|
||||
│ │ ├── index.ts
|
||||
│ │ └── theme-rtl.ts
|
||||
│ ├── components
|
||||
│ │ ├── MDAlert
|
||||
│ │ ├── MDAvatar
|
||||
│ │ ├── MDBadge
|
||||
│ │ ├── MDBadgeDot
|
||||
│ │ ├── MDBox
|
||||
│ │ ├── MDButton
|
||||
│ │ ├── MDDatePicker
|
||||
│ │ ├── MDDropzone
|
||||
│ │ ├── MDEditor
|
||||
│ │ ├── MDInput
|
||||
│ │ ├── MDPagination
|
||||
│ │ ├── MDProgress
|
||||
│ │ ├── MDSnackbar
|
||||
│ │ ├── MDSocialButton
|
||||
│ │ └── MDTypography
|
||||
│ ├── context
|
||||
│ ├── examples
|
||||
│ │ ├── Breadcrumbs
|
||||
│ │ ├── Calendar
|
||||
│ │ ├── Cards
|
||||
│ │ ├── Charts
|
||||
│ │ ├── Configurator
|
||||
│ │ ├── Footer
|
||||
│ │ ├── Items
|
||||
│ │ ├── LayoutContainers
|
||||
│ │ ├── Lists
|
||||
│ │ ├── Navbars
|
||||
│ │ ├── Sidenav
|
||||
│ │ ├── Tables
|
||||
│ │ └── Timeline
|
||||
│ ├── layouts
|
||||
│ │ ├── applications
|
||||
│ │ │ ├── calendar
|
||||
│ │ │ ├── data-tables
|
||||
│ │ │ ├── kanban
|
||||
│ │ │ └── wizard
|
||||
│ │ ├── authentication
|
||||
│ │ │ ├── components
|
||||
│ │ │ ├── reset-password
|
||||
│ │ │ ├── sign-in
|
||||
│ │ │ └── sign-up
|
||||
│ │ ├── dashboards
|
||||
│ │ │ ├── analytics
|
||||
│ │ │ └── sales
|
||||
│ │ ├── ecommerce
|
||||
│ │ │ ├── orders
|
||||
│ │ │ └── products
|
||||
│ │ └── pages
|
||||
│ │ ├── account
|
||||
│ │ ├── charts
|
||||
│ │ ├── notifications
|
||||
│ │ ├── pricing-page
|
||||
│ │ ├── profile
|
||||
│ │ ├── projects
|
||||
│ │ ├── rtl
|
||||
│ │ ├── users
|
||||
│ │ └── widgets
|
||||
│ ├── types
|
||||
│ ├── App.tsx
|
||||
│ ├── BaseLayout.tsx
|
||||
│ ├── page.routes.tsx
|
||||
│ └── routes.tsx
|
||||
├── .eslintignore
|
||||
├── .eslintrc.json
|
||||
├── .prettierrc.json
|
||||
├── CHANGELOG.md
|
||||
├── ISSUE_TEMPLATE.md
|
||||
├── package.json
|
||||
├── README.md
|
||||
└── tsconfig.json
|
||||
```bash
|
||||
git clone https://github.com/QRun-IO/qqq-frontend-material-dashboard
|
||||
cd qqq-frontend-material-dashboard
|
||||
npm run install:legacy
|
||||
npm start
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
Opens at http://localhost:3000. Point it at your QQQ server.
|
||||
|
||||
At present, we officially aim to support the last two versions of the following browsers:
|
||||
## Configuration
|
||||
|
||||
<img src="https://s3.amazonaws.com/creativetim_bucket/github/browser/chrome.png" width="64" height="64"> <img src="https://s3.amazonaws.com/creativetim_bucket/github/browser/firefox.png" width="64" height="64"> <img src="https://s3.amazonaws.com/creativetim_bucket/github/browser/edge.png" width="64" height="64"> <img src="https://s3.amazonaws.com/creativetim_bucket/github/browser/safari.png" width="64" height="64"> <img src="https://s3.amazonaws.com/creativetim_bucket/github/browser/opera.png" width="64" height="64">
|
||||
Create `.env`:
|
||||
|
||||
## Resources
|
||||
```env
|
||||
REACT_APP_QQQ_API_BASE_URL=https://your-qqq-server.com/api
|
||||
```
|
||||
|
||||
- [Live Preview](https://demos.creative-tim.com/material-dashboard-2-pro-react-ts/#/dashboards/analytics?ref=readme-mdpr)
|
||||
- [Buy Page](https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts?ref=readme-mdpr)
|
||||
- Documentation is [here](https://www.creative-tim.com/learning-lab/react/overview/material-dashboard/?ref=readme-mdpr)
|
||||
- [License Agreement](https://www.creative-tim.com/license?ref=readme-mdpr)
|
||||
- [Support](https://www.creative-tim.com/contact-us?ref=readme-mdpr)
|
||||
- Issues: [Github Issues Page](https://github.com/creativetimofficial/ct-material-dashboard-pro-react/issues)
|
||||
For authentication:
|
||||
|
||||
## Reporting Issues
|
||||
```env
|
||||
REACT_APP_AUTH0_DOMAIN=your-domain.auth0.com
|
||||
REACT_APP_AUTH0_CLIENT_ID=your-client-id
|
||||
```
|
||||
|
||||
We use GitHub Issues as the official bug tracker for the Material Dashboard 2 PRO React. Here are some advices for our users that want to report an issue:
|
||||
## Usage
|
||||
|
||||
1. Make sure that you are using the latest version of the Material Dashboard 2 PRO React. Check the CHANGELOG from your dashboard on our [website](https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts?ref=readme-mdpr).
|
||||
2. Providing us reproducible steps for the issue will shorten the time it takes for it to be fixed.
|
||||
3. Some issues may be browser specific, so specifying in what browser you encountered the issue might help.
|
||||
### Development
|
||||
|
||||
## Technical Support or Questions
|
||||
```bash
|
||||
npm start # Start dev server
|
||||
npm test # Run tests
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
If you have questions or need help integrating the product please [contact us](https://www.creative-tim.com/contact-us?ref=readme-mdpr) instead of opening an issue.
|
||||
### Production Build
|
||||
|
||||
## Licensing
|
||||
```bash
|
||||
npm run build
|
||||
# Output in build/ directory
|
||||
```
|
||||
|
||||
- Copyright 2022 [Creative Tim](https://www.creative-tim.com?ref=readme-mdpr)
|
||||
- Creative Tim [license](https://www.creative-tim.com/license?ref=readme-mdpr)
|
||||
### With Maven (full QQQ integration)
|
||||
|
||||
## Useful Links
|
||||
```bash
|
||||
mvn clean package -Pci
|
||||
```
|
||||
|
||||
- [More products](https://www.creative-tim.com/templates?ref=readme-mdpr) from Creative Tim
|
||||
## Project Status
|
||||
|
||||
- [Tutorials](https://www.youtube.com/channel/UCVyTG4sCw-rOvB9oHkzZD1w)
|
||||
**Maturity:** Stable, production use
|
||||
**Breaking changes:** Follows QQQ versioning
|
||||
|
||||
- [Freebies](https://www.creative-tim.com/bootstrap-themes/free?ref=readme-mdpr) from Creative Tim
|
||||
## Contributing
|
||||
|
||||
- [Affiliate Program](https://www.creative-tim.com/affiliates/new?ref=readme-mdpr) (earn money)
|
||||
Submit issues and PRs to the [main QQQ repository](https://github.com/QRun-IO/qqq/issues).
|
||||
|
||||
##### Social Media
|
||||
|
||||
Twitter: <https://twitter.com/CreativeTim>
|
||||
|
||||
Facebook: <https://www.facebook.com/CreativeTim>
|
||||
|
||||
Dribbble: <https://dribbble.com/creativetim>
|
||||
|
||||
Google+: <https://plus.google.com/+CreativetimPage>
|
||||
|
||||
Instagram: <https://instagram.com/creativetimofficial>
|
||||
```bash
|
||||
npm run install:legacy
|
||||
npm test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 - See [LICENSE](LICENSE) for details.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Security Policy
|
||||
|
||||
* See Project [Security Policy](https://github.com/Kingsrook/qqq/blob/develop/SECURITY.md)
|
||||
@@ -0,0 +1,38 @@
|
||||
# PLAN: Fix CI Playwright Tests
|
||||
|
||||
## Goal
|
||||
|
||||
Fix Playwright e2e tests failing in CircleCI due to webserver timeout.
|
||||
|
||||
## Problem
|
||||
|
||||
The React dev server (`npm start`) times out in CI because webpack compilation takes >120s in the Playwright Docker container.
|
||||
|
||||
## Approach
|
||||
|
||||
Combine the fixture server to serve both:
|
||||
1. Static React build (from `build/` directory)
|
||||
2. API fixtures (from `src/test/resources/fixtures/`)
|
||||
|
||||
This eliminates the need for a proxy and speeds up startup.
|
||||
|
||||
## Changes Made
|
||||
|
||||
1. **package.json** - Added `serve` package (may not be needed now)
|
||||
2. **e2e/fixture-server.js** - Added static file serving for React build
|
||||
3. **playwright.config.ts** - Simplified to use single server on port 8001
|
||||
4. **.circleci/config.yml** - Added `npm run build` step before tests
|
||||
|
||||
## Current Status: IN PROGRESS
|
||||
|
||||
Tests still fail locally - React app renders blank page. Need to debug:
|
||||
- Check if all required API endpoints are mocked
|
||||
- Verify static file paths resolve correctly
|
||||
- Check browser console for JS errors
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Run fixture server manually, open browser, check console errors
|
||||
2. Compare working Selenium setup to see what's different
|
||||
3. May need to add more API endpoint stubs to fixture-server.js
|
||||
4. Test with `npx playwright test --debug` for interactive debugging
|
||||
@@ -0,0 +1,65 @@
|
||||
# PLAN: Stable CSS Selectors via `data-qqq-*` Attributes
|
||||
|
||||
**Status:** COMPLETE
|
||||
|
||||
## Goal
|
||||
|
||||
Add stable, semantic CSS selectors so QQQ app developers can target specific UI elements in `QThemeMetaData.customCss`.
|
||||
|
||||
## Approach
|
||||
|
||||
- **Auto-generate** unique IDs for key interactive elements (buttons, inputs, nav items, menu items, table headers)
|
||||
- **Require explicit `qqqId`** prop for custom overrides
|
||||
- ID format: `data-qqq-id="{type}-{identifier}"`
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### New Files Created
|
||||
- `src/qqq/utils/qqqIdUtils.ts` - ID sanitization utilities
|
||||
- `src/qqq/context/QqqIdContext.tsx` - Optional scope context provider
|
||||
- `docs/QQQ_CSS_SELECTORS_GUIDE.md` - Complete selector reference
|
||||
|
||||
### Components Modified
|
||||
|
||||
| Component | Selectors Added |
|
||||
|-----------|-----------------|
|
||||
| MDButton | `button-{text}`, `button-icon-{icon}` |
|
||||
| SideNav | `sidenav-root`, `sidenav-{name}`, `sidenav-logout-button` |
|
||||
| DynamicFormField | `input-{field}` |
|
||||
| DynamicSelect | `select-{field}` |
|
||||
| DataGridUtils | `table-header-{field}` |
|
||||
| QueryScreenActionMenu | `menu-item-{text}` |
|
||||
| RecordView | `record-view-*`, `record-section-*`, `menu-item-*` |
|
||||
| EntityForm | `record-{mode}-*`, `form-section-*` |
|
||||
| RecordSidebar | `record-sidebar`, `sidebar-item-*` |
|
||||
| Home | `app-header-*`, `app-section-*`, `table-card-*`, `process-card-*` |
|
||||
|
||||
## Selector Patterns
|
||||
|
||||
| Element | Pattern | Example |
|
||||
|---------|---------|---------|
|
||||
| Buttons | `button-{text}` | `button-save` |
|
||||
| Icon buttons | `button-icon-{icon}` | `button-icon-delete` |
|
||||
| Menu items | `menu-item-{text}` | `menu-item-bulk-edit` |
|
||||
| Nav items | `sidenav-{name}` | `sidenav-orders` |
|
||||
| Inputs | `input-{field}` | `input-first-name` |
|
||||
| Selects | `select-{field}` | `select-status` |
|
||||
| Table headers | `table-header-{field}` | `table-header-order-id` |
|
||||
| Record view | `record-view-{element}-{table}` | `record-view-header-orders` |
|
||||
| Sections | `record-section-{name}` | `record-section-identity` |
|
||||
|
||||
## Example CSS Usage
|
||||
|
||||
```css
|
||||
[data-qqq-id="menu-item-delete"] { display: none; }
|
||||
[data-qqq-id="record-view-header-orders"] { background: #1976D2; }
|
||||
[data-qqq-id^="sidebar-item-"] { border-left: 3px solid transparent; }
|
||||
```
|
||||
|
||||
## Explicit Override
|
||||
|
||||
All components accept `qqqId` prop:
|
||||
```tsx
|
||||
<MDButton qqqId="custom-action">Do Something</MDButton>
|
||||
// Result: data-qqq-id="button-custom-action"
|
||||
```
|
||||
@@ -0,0 +1,476 @@
|
||||
# PLAN: Pluggable Themes - MUI ThemeProvider Refactor
|
||||
|
||||
## Overview
|
||||
|
||||
Refactor the pluggable themes implementation from CSS-variable-override approach to MUI ThemeProvider-first approach. The goal is to eliminate the 1200-line CSS override file by letting MUI style its own components correctly from the start.
|
||||
|
||||
## Git Branch Surgery
|
||||
|
||||
### Current State
|
||||
- `develop` branch contains 42 commits of CSS variable theme work (since 0.35.0)
|
||||
- All commits have been pushed to `origin/develop`
|
||||
- Commit `759581b` is the version bump to 0.36.0-SNAPSHOT (clean base)
|
||||
- Commit `f807698` is the 0.35.0 release
|
||||
|
||||
### Target State
|
||||
- CSS variable work preserved on `feature/pluggable-themes-v1-css-vars`
|
||||
- `develop` reset to `759581b` (0.36.0-SNAPSHOT, pre-theme work)
|
||||
- New `feature/pluggable-themes-v2-mui` branch for MUI refactor
|
||||
|
||||
### Step 1: Create Branch to Preserve CSS Variable Work
|
||||
```bash
|
||||
# From develop (which has all the CSS var work)
|
||||
git checkout develop
|
||||
git checkout -b feature/pluggable-themes-v1-css-vars
|
||||
|
||||
# Push to origin to preserve it
|
||||
git push -u origin feature/pluggable-themes-v1-css-vars
|
||||
```
|
||||
|
||||
### Step 2: Reset develop to Pre-Theme State
|
||||
```bash
|
||||
# Switch back to develop
|
||||
git checkout develop
|
||||
|
||||
# Reset to the version bump commit (before any theme work)
|
||||
git reset --hard 759581b
|
||||
|
||||
# Force push to origin (CAUTION: coordinate with team first)
|
||||
git push --force-with-lease origin develop
|
||||
```
|
||||
|
||||
### Step 3: Create MUI Refactor Branch
|
||||
```bash
|
||||
# Create new feature branch from clean develop
|
||||
git checkout -b feature/pluggable-themes-v2-mui
|
||||
|
||||
# Push to origin
|
||||
git push -u origin feature/pluggable-themes-v2-mui
|
||||
```
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
# Verify develop is clean
|
||||
git log --oneline develop -5
|
||||
# Should show: 759581b as HEAD
|
||||
|
||||
# Verify v1 branch has all the CSS var work
|
||||
git log --oneline feature/pluggable-themes-v1-css-vars -5
|
||||
# Should show: 385130a (chore: ignore Playwright test artifacts) as HEAD
|
||||
|
||||
# Verify v2 branch is ready for new work
|
||||
git log --oneline feature/pluggable-themes-v2-mui -5
|
||||
# Should show: 759581b as HEAD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Infrastructure Setup (Day 1)
|
||||
|
||||
#### 1.1 Copy Essential Files from v1 Branch
|
||||
Files to bring over (they contain valuable work):
|
||||
```
|
||||
src/qqq/utils/themeUtils.ts # DEFAULT_THEME, helper functions
|
||||
src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ThemeIT.java
|
||||
src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ThemeComponentRenderingIT.java
|
||||
src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ThemeMuiComponentIT.java
|
||||
src/test/resources/fixtures/metaData/withFullCustomTheme.json
|
||||
src/test/resources/fixtures/metaData/withBrandedHeader.json
|
||||
src/test/resources/fixtures/metaData/withCompactDensity.json
|
||||
docs/blog-drafts/* # Blog posts for reference
|
||||
```
|
||||
|
||||
```bash
|
||||
# From feature/pluggable-themes-v2-mui
|
||||
git checkout feature/pluggable-themes-v1-css-vars -- src/qqq/utils/themeUtils.ts
|
||||
git checkout feature/pluggable-themes-v1-css-vars -- "src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/Theme*.java"
|
||||
git checkout feature/pluggable-themes-v1-css-vars -- "src/test/resources/fixtures/metaData/with*.json"
|
||||
git checkout feature/pluggable-themes-v1-css-vars -- docs/blog-drafts/
|
||||
```
|
||||
|
||||
#### 1.2 Create createDynamicTheme.ts
|
||||
New file: `src/qqq/utils/createDynamicTheme.ts`
|
||||
|
||||
```typescript
|
||||
import { createTheme, Theme } from "@mui/material";
|
||||
import { QThemeMetaData } from "@qrunio/qqq-frontend-core/lib/model/metaData/QThemeMetaData";
|
||||
import { DEFAULT_THEME } from "./themeUtils";
|
||||
|
||||
export function createDynamicTheme(themeData?: QThemeMetaData): Theme {
|
||||
// Merge with defaults
|
||||
const theme = { ...DEFAULT_THEME, ...filterUndefined(themeData) };
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
primary: { main: theme.primaryColor },
|
||||
secondary: { main: theme.secondaryColor },
|
||||
background: {
|
||||
default: theme.backgroundColor,
|
||||
paper: theme.surfaceColor,
|
||||
},
|
||||
text: {
|
||||
primary: theme.textPrimary,
|
||||
secondary: theme.textSecondary,
|
||||
},
|
||||
error: { main: theme.errorColor },
|
||||
warning: { main: theme.warningColor },
|
||||
success: { main: theme.successColor },
|
||||
info: { main: theme.infoColor },
|
||||
},
|
||||
typography: {
|
||||
fontFamily: theme.fontFamily,
|
||||
h1: {
|
||||
fontSize: theme.typographyH1FontSize,
|
||||
fontWeight: theme.typographyH1FontWeight,
|
||||
lineHeight: theme.typographyH1LineHeight,
|
||||
letterSpacing: theme.typographyH1LetterSpacing,
|
||||
},
|
||||
// ... h2-h6, body1, body2, button, caption
|
||||
},
|
||||
shape: {
|
||||
borderRadius: parseInt(theme.borderRadius) || 8,
|
||||
},
|
||||
components: {
|
||||
// Component overrides go here - see Phase 2
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function filterUndefined(obj?: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!obj) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, v]) => v !== undefined)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 Create injectIslandVariables.ts
|
||||
New file: `src/qqq/utils/injectIslandVariables.ts`
|
||||
|
||||
This injects CSS variables ONLY for sidebar, branded header, and non-MUI components.
|
||||
|
||||
```typescript
|
||||
import { QThemeMetaData } from "@qrunio/qqq-frontend-core/lib/model/metaData/QThemeMetaData";
|
||||
import { DEFAULT_THEME } from "./themeUtils";
|
||||
|
||||
const CSS_VAR_PREFIX = "--qqq-";
|
||||
|
||||
export function injectIslandVariables(themeData?: QThemeMetaData): void {
|
||||
const root = document.documentElement;
|
||||
const theme = { ...DEFAULT_THEME, ...filterUndefined(themeData) };
|
||||
|
||||
// Sidebar island
|
||||
setVar(root, "sidebar-background-color", theme.sidebarBackgroundColor);
|
||||
setVar(root, "sidebar-text-color", theme.sidebarTextColor);
|
||||
setVar(root, "sidebar-icon-color", theme.sidebarIconColor);
|
||||
setVar(root, "sidebar-selected-background-color", theme.sidebarSelectedBackgroundColor);
|
||||
setVar(root, "sidebar-selected-text-color", theme.sidebarSelectedTextColor);
|
||||
setVar(root, "sidebar-hover-background-color", theme.sidebarHoverBackgroundColor);
|
||||
setVar(root, "sidebar-divider-color", theme.sidebarDividerColor);
|
||||
|
||||
// Branded header island
|
||||
setVar(root, "branded-header-background-color", theme.brandedHeaderBackgroundColor);
|
||||
setVar(root, "branded-header-text-color", theme.brandedHeaderTextColor);
|
||||
setVar(root, "branded-header-height", theme.brandedHeaderHeight);
|
||||
|
||||
// DataGrid (has its own theming needs)
|
||||
setVar(root, "table-header-background-color", theme.tableHeaderBackgroundColor);
|
||||
setVar(root, "table-header-text-color", theme.tableHeaderTextColor);
|
||||
setVar(root, "table-row-hover-color", theme.tableRowHoverColor);
|
||||
setVar(root, "table-row-selected-color", theme.tableRowSelectedColor);
|
||||
setVar(root, "table-border-color", theme.tableBorderColor);
|
||||
|
||||
// Icon style (for material icons variant selection)
|
||||
setVar(root, "icon-style", theme.iconStyle);
|
||||
}
|
||||
|
||||
function setVar(root: HTMLElement, name: string, value: unknown): void {
|
||||
if (value != null) {
|
||||
root.style.setProperty(`${CSS_VAR_PREFIX}${name}`, String(value));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 Update App.tsx Theme Integration
|
||||
Modify `src/App.tsx` to use the new theme builder:
|
||||
|
||||
```typescript
|
||||
// Old imports
|
||||
import { injectThemeVariables } from "qqq/utils/themeUtils";
|
||||
import { createQqqTheme } from "qqq/components/legacy/Theme";
|
||||
|
||||
// New imports
|
||||
import { createDynamicTheme } from "qqq/utils/createDynamicTheme";
|
||||
import { injectIslandVariables } from "qqq/utils/injectIslandVariables";
|
||||
|
||||
// In the metadata loading effect:
|
||||
useEffect(() => {
|
||||
// ... existing metadata loading ...
|
||||
|
||||
// OLD:
|
||||
// injectThemeVariables(metaData.theme);
|
||||
// setTheme(createQqqTheme());
|
||||
|
||||
// NEW:
|
||||
const muiTheme = createDynamicTheme(metaData.theme);
|
||||
setTheme(muiTheme);
|
||||
injectIslandVariables(metaData.theme);
|
||||
|
||||
}, [metaData]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Migrate Component Overrides (Days 1-2)
|
||||
|
||||
The goal is to move component styling from CSS overrides into MUI's theme.components.
|
||||
|
||||
#### 2.1 Component Override Migration Order
|
||||
|
||||
| Priority | Component | Current Location | Complexity |
|
||||
|----------|-----------|------------------|------------|
|
||||
| 1 | MuiButton | button/contained.ts, button/outlined.ts | Low - already uses CSS vars |
|
||||
| 2 | MuiCard | card/index.ts | Low |
|
||||
| 3 | MuiTextField, MuiInput | form/input.ts, form/textField.ts | Medium |
|
||||
| 4 | MuiTypography | (CSS overrides) | Medium |
|
||||
| 5 | MuiAppBar | appBar.ts | Low |
|
||||
| 6 | MuiDivider | divider.ts | Low |
|
||||
| 7 | MuiTableHead, MuiTableCell | table/*.ts | Medium |
|
||||
| 8 | MuiStepper, MuiStep* | stepper/*.ts | Medium |
|
||||
| 9 | MuiTabs, MuiTab | tabs/*.ts | Low |
|
||||
| 10 | MuiMenu, MuiMenuItem | menu/*.ts | Low |
|
||||
| 11 | MuiTooltip | tooltip.ts | Low |
|
||||
| 12 | MuiDialog* | dialog/*.ts | Low |
|
||||
| 13 | Remaining components | Various | Low-Medium |
|
||||
|
||||
#### 2.2 Migration Pattern
|
||||
|
||||
For each component:
|
||||
|
||||
1. **Read current CSS override** in `qqq-override-styles.css`
|
||||
2. **Read current component file** in `assets/theme/components/`
|
||||
3. **Identify which styles need theme values**
|
||||
4. **Add to createDynamicTheme.ts** components section
|
||||
5. **Remove from CSS override file**
|
||||
6. **Run tests** to verify
|
||||
|
||||
Example for MuiButton:
|
||||
|
||||
```typescript
|
||||
// In createDynamicTheme.ts components section:
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
borderRadius: theme.borderRadius,
|
||||
},
|
||||
containedPrimary: {
|
||||
backgroundColor: theme.primaryColor,
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.primaryColor,
|
||||
filter: 'brightness(0.9)',
|
||||
},
|
||||
},
|
||||
containedSecondary: {
|
||||
backgroundColor: theme.secondaryColor,
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.secondaryColor,
|
||||
filter: 'brightness(0.9)',
|
||||
},
|
||||
},
|
||||
outlinedPrimary: {
|
||||
borderColor: theme.primaryColor,
|
||||
color: theme.primaryColor,
|
||||
'&:hover': {
|
||||
borderColor: theme.primaryColor,
|
||||
backgroundColor: `${theme.primaryColor}10`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
#### 2.3 Files to Modify
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/qqq/utils/createDynamicTheme.ts` | Add component overrides progressively |
|
||||
| `src/qqq/assets/theme/components/button/*.ts` | May delete or simplify |
|
||||
| `src/qqq/assets/theme/components/card/*.ts` | May delete or simplify |
|
||||
| `src/qqq/assets/theme/components/form/*.ts` | May delete or simplify |
|
||||
| `src/qqq/assets/theme/components/table/*.ts` | May delete or simplify |
|
||||
| `src/qqq/assets/theme/components/menu/*.ts` | May delete or simplify |
|
||||
| `src/qqq/assets/theme/components/stepper/*.ts` | May delete or simplify |
|
||||
| `src/qqq/assets/theme/components/tabs/*.ts` | May delete or simplify |
|
||||
| `src/qqq/assets/theme/components/dialog/*.ts` | May delete or simplify |
|
||||
| `src/qqq/assets/theme/base/colors.ts` | Simplify - remove CSS var getters |
|
||||
| `src/qqq/components/legacy/Theme.ts` | Simplify or remove |
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Reduce CSS Override File (Day 2-3)
|
||||
|
||||
#### 3.1 Categorize Current Overrides
|
||||
|
||||
The current `qqq-override-styles.css` has ~1213 lines. Categorize:
|
||||
|
||||
| Category | Lines (est.) | Action |
|
||||
|----------|--------------|--------|
|
||||
| DataGrid-specific | ~300 | Keep (DataGrid has own theming) |
|
||||
| Sidebar (island) | ~80 | Keep (CSS vars for island) |
|
||||
| Branded Header (island) | ~30 | Keep (CSS vars for island) |
|
||||
| MUI component colors | ~400 | DELETE (handled by MUI theme) |
|
||||
| MUI component spacing/layout | ~200 | Review - some may stay |
|
||||
| Typography overrides | ~100 | DELETE (handled by MUI theme) |
|
||||
| Miscellaneous/utility | ~100 | Review case by case |
|
||||
|
||||
#### 3.2 Lines to Delete
|
||||
|
||||
Search and remove patterns like:
|
||||
```css
|
||||
/* DELETE - MUI theme handles this */
|
||||
.MuiButton-containedPrimary { background-color: var(--qqq-primary-color) !important; }
|
||||
.MuiTypography-body2:not(...) { color: var(--qqq-text-secondary) !important; }
|
||||
.MuiCard-root { border-color: var(--qqq-card-border-color) !important; }
|
||||
```
|
||||
|
||||
#### 3.3 Lines to Keep
|
||||
|
||||
Keep patterns like:
|
||||
```css
|
||||
/* KEEP - DataGrid specific */
|
||||
.MuiDataGrid-cell { font-size: 0.85rem; }
|
||||
.MuiDataGrid-columnHeaderTitle { font-size: 0.75rem; }
|
||||
|
||||
/* KEEP - Sidebar island */
|
||||
.MuiDrawer-root .MuiListItemText-primary { color: var(--qqq-sidebar-text-color); }
|
||||
|
||||
/* KEEP - Layout/structure (not theming) */
|
||||
.recordQuery .MuiDataGrid-virtualScrollerContent { min-height: calc(100vh - 450px); }
|
||||
```
|
||||
|
||||
#### 3.4 Target Metrics
|
||||
|
||||
| Metric | Before | After |
|
||||
|--------|--------|-------|
|
||||
| Total lines | 1213 | ~300-400 |
|
||||
| `!important` count | 82 | ~10-15 |
|
||||
| `:not()` chains | 19 | ~2-3 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Testing and Validation (Day 3)
|
||||
|
||||
#### 4.1 Playwright Test Execution
|
||||
|
||||
```bash
|
||||
# Start React dev server
|
||||
PORT=3001 REACT_APP_PROXY_LOCALHOST_PORT=8001 npm start
|
||||
|
||||
# In another terminal, run theme tests
|
||||
QQQ_SELENIUM_HEADLESS=true mvn test -Dtest=ThemeIT
|
||||
QQQ_SELENIUM_HEADLESS=true mvn test -Dtest=ThemeComponentRenderingIT
|
||||
QQQ_SELENIUM_HEADLESS=true mvn test -Dtest=ThemeMuiComponentIT
|
||||
```
|
||||
|
||||
#### 4.2 Expected Test Behavior
|
||||
|
||||
The tests verify RENDERED colors, not implementation. They should pass with no changes because:
|
||||
- Tests check `getComputedStyle(element).backgroundColor`
|
||||
- Whether that color came from CSS vars or MUI theme, the rendered result is the same
|
||||
- If a test fails, it indicates a regression in the refactor
|
||||
|
||||
#### 4.3 Manual Verification Checklist
|
||||
|
||||
- [ ] Primary color applies to buttons, links, active states
|
||||
- [ ] Secondary color applies where expected
|
||||
- [ ] Background color applies to page background
|
||||
- [ ] Surface color applies to cards, paper elements
|
||||
- [ ] Text primary/secondary colors render correctly
|
||||
- [ ] Sidebar has correct background, text, icon colors
|
||||
- [ ] Sidebar hover and selected states work
|
||||
- [ ] Branded header (when enabled) has correct colors
|
||||
- [ ] DataGrid headers and rows have correct colors
|
||||
- [ ] Typography variants (h1-h6, body1, body2) look correct
|
||||
- [ ] Border radius applies to buttons, cards, inputs
|
||||
- [ ] No visual regressions in existing app screens
|
||||
|
||||
#### 4.4 CI Pipeline
|
||||
|
||||
Ensure CircleCI runs all tests:
|
||||
```bash
|
||||
# Full test suite
|
||||
mvn verify -Pci
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Inventory
|
||||
|
||||
### New Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/qqq/utils/createDynamicTheme.ts` | MUI theme builder from QThemeMetaData |
|
||||
| `src/qqq/utils/injectIslandVariables.ts` | CSS vars for sidebar/header islands only |
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/App.tsx` | Use new theme builder |
|
||||
| `src/qqq/utils/themeUtils.ts` | Keep DEFAULT_THEME, remove injectThemeVariables |
|
||||
| `src/qqq/styles/qqq-override-styles.css` | Delete ~800 lines |
|
||||
| `src/qqq/assets/theme/base/colors.ts` | Simplify, remove getters |
|
||||
| `src/qqq/components/legacy/Theme.ts` | Simplify or delete |
|
||||
| `src/qqq/assets/theme/components/*.ts` | Many files simplified or deleted |
|
||||
|
||||
### Files to Delete (Maybe)
|
||||
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `src/qqq/assets/theme/components/button/contained.ts` | Moved to createDynamicTheme |
|
||||
| `src/qqq/assets/theme/components/button/outlined.ts` | Moved to createDynamicTheme |
|
||||
| (evaluate each component file) | |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. All 26 Playwright tests pass
|
||||
2. CSS override file reduced from 1213 to <400 lines
|
||||
3. `!important` count reduced from 82 to <15
|
||||
4. Theme data flows directly: QThemeMetaData -> MUI Theme -> Components
|
||||
5. No visual regressions in app
|
||||
6. CI pipeline passes
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If refactor causes significant issues:
|
||||
```bash
|
||||
# The v1 CSS variable approach is preserved
|
||||
git checkout feature/pluggable-themes-v1-css-vars
|
||||
|
||||
# Or cherry-pick specific fixes back
|
||||
git cherry-pick <commit-hash>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
| Phase | Duration | Dependencies |
|
||||
|-------|----------|--------------|
|
||||
| Git branch surgery | 30 min | None |
|
||||
| Phase 1: Infrastructure | 4-6 hours | Branch surgery complete |
|
||||
| Phase 2: Component migration | 8-12 hours | Phase 1 complete |
|
||||
| Phase 3: CSS reduction | 4-6 hours | Phase 2 complete |
|
||||
| Phase 4: Testing | 4-6 hours | Phase 3 complete |
|
||||
| Buffer for issues | 4-8 hours | |
|
||||
| **Total** | **3-5 days** | |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
||||
# QQQ CSS Selectors Guide
|
||||
|
||||
QQQ automatically generates stable `data-qqq-id` attributes on key UI elements, enabling precise CSS targeting via `QThemeMetaData.customCss`.
|
||||
|
||||
## ID Format
|
||||
|
||||
```
|
||||
data-qqq-id="{type}-{identifier}"
|
||||
```
|
||||
|
||||
IDs are generated from element text, field names, or labels. Values are lowercase, alphanumeric with dashes, max 50 characters.
|
||||
|
||||
## Auto-Generated Elements
|
||||
|
||||
| Element | ID Source | Example |
|
||||
|---------|-----------|---------|
|
||||
| Buttons | Text content or icon | `button-save`, `button-icon-edit` |
|
||||
| Text Inputs | Field name or label | `input-first-name` |
|
||||
| Selects | Field name or label | `select-status` |
|
||||
| Checkboxes | Field name or label | `switch-is-active` |
|
||||
| Nav Items | Name prop | `sidenav-orders` |
|
||||
| Menu Items | Text content | `menu-item-bulk-edit` |
|
||||
| Table Headers | Field name | `table-header-order-id` |
|
||||
|
||||
## CSS Usage Examples
|
||||
|
||||
```css
|
||||
/* Target specific button */
|
||||
[data-qqq-id="button-save"] {
|
||||
background-color: green;
|
||||
}
|
||||
|
||||
/* Target inputs by pattern */
|
||||
[data-qqq-id^="input-order"] {
|
||||
border-color: blue;
|
||||
}
|
||||
|
||||
/* Hide specific menu item */
|
||||
[data-qqq-id="menu-item-developer-mode"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Style table header */
|
||||
[data-qqq-id="table-header-status"] {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Combine with scope context */
|
||||
[data-qqq-scope="record-view"] [data-qqq-id="button-edit"] {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
## Explicit Override
|
||||
|
||||
Components accept an optional `qqqId` prop for explicit IDs:
|
||||
|
||||
```tsx
|
||||
<MDButton qqqId="custom-action">Do Something</MDButton>
|
||||
// Result: data-qqq-id="button-custom-action"
|
||||
```
|
||||
|
||||
## Scoping Context
|
||||
|
||||
Use `QqqIdProvider` to add scope attributes for page-level targeting:
|
||||
|
||||
```tsx
|
||||
<QqqIdProvider scope="record-view">
|
||||
{/* Children get wrapped in data-qqq-scope="record-view" */}
|
||||
</QqqIdProvider>
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/qqq/utils/qqqIdUtils.ts` - ID generation utilities
|
||||
- `src/qqq/context/QqqIdContext.tsx` - Scoping context provider
|
||||
@@ -0,0 +1,301 @@
|
||||
# QQQ Theming System Guide
|
||||
|
||||
This document explains the QQQ pluggable theming system for AI assistants and developers.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
QQQ uses a **backend-driven theming model**:
|
||||
|
||||
1. **Java Backend**: Define theme in `QThemeMetaData` object
|
||||
2. **Frontend Core**: TypeScript `QThemeMetaData` interface mirrors Java class
|
||||
3. **Frontend Dashboard**: `themeUtils.ts` injects CSS custom properties at runtime
|
||||
|
||||
Theme data flows: `QThemeMetaData.java` → `/metaData` API → `QThemeMetaData.ts` → CSS variables (`--qqq-*`)
|
||||
|
||||
## Configuring a Theme (Java Backend)
|
||||
|
||||
Create a `QThemeMetaData` instance using fluent setters:
|
||||
|
||||
```java
|
||||
QThemeMetaData theme = new QThemeMetaData()
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSidebarBackgroundColor("#1E1E2D");
|
||||
|
||||
qInstance.setTheme(theme);
|
||||
```
|
||||
|
||||
All properties are optional. Omitted properties use frontend defaults.
|
||||
|
||||
---
|
||||
|
||||
## Available Theme Properties
|
||||
|
||||
### Core Color Palette
|
||||
|
||||
| Property | Purpose | Example |
|
||||
|----------|---------|---------|
|
||||
| `primaryColor` | Main brand color (buttons, links, highlights) | `#1976D2` |
|
||||
| `secondaryColor` | Secondary accent color | `#9C27B0` |
|
||||
| `backgroundColor` | Page/app background | `#F5F5F5` |
|
||||
| `surfaceColor` | Card/panel backgrounds | `#FFFFFF` |
|
||||
| `textPrimary` | Primary text color | `#212121` |
|
||||
| `textSecondary` | Muted/secondary text | `#757575` |
|
||||
| `errorColor` | Error states | `#D32F2F` |
|
||||
| `warningColor` | Warning states | `#F57C00` |
|
||||
| `successColor` | Success states | `#388E3C` |
|
||||
| `infoColor` | Info states | `#1976D2` |
|
||||
|
||||
### Typography - Base Settings
|
||||
|
||||
| Property | Purpose | Example |
|
||||
|----------|---------|---------|
|
||||
| `fontFamily` | Primary body font stack | `"Inter", "Roboto", sans-serif` |
|
||||
| `headerFontFamily` | Heading font stack | `"Poppins", "Roboto", sans-serif` |
|
||||
| `monoFontFamily` | Monospace font stack | `"JetBrains Mono", monospace` |
|
||||
| `fontSizeBase` | Base font size | `14px` |
|
||||
| `fontWeightLight` | Light weight value | `300` |
|
||||
| `fontWeightRegular` | Regular weight value | `400` |
|
||||
| `fontWeightMedium` | Medium weight value | `500` |
|
||||
| `fontWeightBold` | Bold weight value | `700` |
|
||||
|
||||
### Typography Variants
|
||||
|
||||
Each variant (h1-h6, body1, body2, button, caption) has 5 properties:
|
||||
- `FontSize` - e.g., `2.5rem`
|
||||
- `FontWeight` - e.g., `700`
|
||||
- `LineHeight` - e.g., `1.2`
|
||||
- `LetterSpacing` - e.g., `-0.02em`
|
||||
- `TextTransform` - e.g., `none`, `uppercase`
|
||||
|
||||
**Property naming pattern:** `typography{Variant}{Property}`
|
||||
|
||||
Examples:
|
||||
- `typographyH1FontSize`, `typographyH1FontWeight`, `typographyH1LineHeight`
|
||||
- `typographyBody1FontSize`, `typographyButtonTextTransform`
|
||||
|
||||
**Available variants:** `H1`, `H2`, `H3`, `H4`, `H5`, `H6`, `Body1`, `Body2`, `Button`, `Caption`
|
||||
|
||||
### Sizing & Density
|
||||
|
||||
| Property | Purpose | Values/Example |
|
||||
|----------|---------|----------------|
|
||||
| `borderRadius` | Default corner radius | `8px` |
|
||||
| `density` | UI density | `compact`, `normal`, `comfortable` |
|
||||
|
||||
### Asset Paths
|
||||
|
||||
| Property | Purpose |
|
||||
|----------|---------|
|
||||
| `logoPath` | Main logo (sidebar, login) |
|
||||
| `iconPath` | Small icon (favicon base) |
|
||||
| `faviconPath` | Browser favicon |
|
||||
|
||||
### Icon Style
|
||||
|
||||
| Property | Values |
|
||||
|----------|--------|
|
||||
| `iconStyle` | `filled`, `outlined`, `rounded`, `sharp`, `two-tone` |
|
||||
|
||||
### Branded Header Bar
|
||||
|
||||
Optional banner at viewport top, above the main navigation.
|
||||
|
||||
| Property | Purpose | Example |
|
||||
|----------|---------|---------|
|
||||
| `brandedHeaderEnabled` | Toggle visibility | `true` / `false` |
|
||||
| `brandedHeaderBackgroundColor` | Banner background | `#0D47A1` |
|
||||
| `brandedHeaderTextColor` | Banner text color | `#FFFFFF` |
|
||||
| `brandedHeaderLogoPath` | Logo image path | `/header-logo.png` |
|
||||
| `brandedHeaderLogoAltText` | Logo alt text | `Company Logo` |
|
||||
| `brandedHeaderHeight` | Banner height | `48px` |
|
||||
| `brandedHeaderTagline` | Optional tagline text | `Enterprise Platform` |
|
||||
|
||||
### App Bar (Top Navigation)
|
||||
|
||||
| Property | Purpose |
|
||||
|----------|---------|
|
||||
| `appBarBackgroundColor` | NavBar background |
|
||||
| `appBarTextColor` | NavBar text/icons |
|
||||
|
||||
### Sidebar Navigation
|
||||
|
||||
| Property | Purpose |
|
||||
|----------|---------|
|
||||
| `sidebarBackgroundColor` | Sidebar background |
|
||||
| `sidebarTextColor` | Sidebar text |
|
||||
| `sidebarIconColor` | Sidebar icons |
|
||||
| `sidebarSelectedBackgroundColor` | Selected item background |
|
||||
| `sidebarSelectedTextColor` | Selected item text |
|
||||
| `sidebarHoverBackgroundColor` | Hover state background |
|
||||
| `sidebarDividerColor` | Section dividers |
|
||||
|
||||
### Data Tables
|
||||
|
||||
| Property | Purpose |
|
||||
|----------|---------|
|
||||
| `tableHeaderBackgroundColor` | Header row background |
|
||||
| `tableHeaderTextColor` | Header text |
|
||||
| `tableRowHoverColor` | Row hover background |
|
||||
| `tableRowSelectedColor` | Selected row background |
|
||||
| `tableBorderColor` | Table borders |
|
||||
|
||||
### General UI Elements
|
||||
|
||||
| Property | Purpose |
|
||||
|----------|---------|
|
||||
| `dividerColor` | Horizontal/vertical dividers |
|
||||
| `borderColor` | Input/form borders |
|
||||
| `cardBorderColor` | Card borders |
|
||||
|
||||
### Form Inputs
|
||||
|
||||
| Property | Purpose |
|
||||
|----------|---------|
|
||||
| `inputBorderColor` | Input field border color |
|
||||
|
||||
### Custom CSS
|
||||
|
||||
| Property | Purpose |
|
||||
|----------|---------|
|
||||
| `customCss` | Raw CSS injection for advanced customization |
|
||||
|
||||
---
|
||||
|
||||
## CSS Variable Reference
|
||||
|
||||
All theme properties become CSS custom properties with `--qqq-` prefix using kebab-case:
|
||||
|
||||
| Java Property | CSS Variable |
|
||||
|--------------|--------------|
|
||||
| `primaryColor` | `--qqq-primary-color` |
|
||||
| `sidebarBackgroundColor` | `--qqq-sidebar-background-color` |
|
||||
| `typographyH1FontSize` | `--qqq-typography-h1-font-size` |
|
||||
|
||||
**Pattern:** `camelCase` → `--qqq-kebab-case`
|
||||
|
||||
### Additional Generated Variables
|
||||
|
||||
The frontend also generates these derived variables:
|
||||
|
||||
```css
|
||||
/* Grey scale */
|
||||
--qqq-grey-100 through --qqq-grey-900
|
||||
|
||||
/* Chart colors */
|
||||
--qqq-chart-grid-color
|
||||
--qqq-chart-text-color
|
||||
--qqq-chart-background-color
|
||||
|
||||
/* Link color (derived from primaryColor) */
|
||||
--qqq-link-color
|
||||
|
||||
/* Spacing (derived from density) */
|
||||
--qqq-spacing-base
|
||||
--qqq-spacing-small
|
||||
--qqq-spacing-medium
|
||||
--qqq-spacing-large
|
||||
|
||||
/* Action colors */
|
||||
--qqq-action-active
|
||||
--qqq-action-hover
|
||||
--qqq-action-selected
|
||||
--qqq-action-disabled
|
||||
|
||||
/* Status variants (light/dark derived from status colors) */
|
||||
--qqq-info-light, --qqq-info-dark
|
||||
--qqq-success-light, --qqq-success-dark
|
||||
--qqq-warning-light, --qqq-warning-dark
|
||||
--qqq-error-light, --qqq-error-dark
|
||||
|
||||
/* Primary contrast text */
|
||||
--qqq-primary-contrast-text
|
||||
```
|
||||
|
||||
### Component-Specific Variables
|
||||
|
||||
These variables are used by components with fallback values. Override them via `customCss`:
|
||||
|
||||
```css
|
||||
/* Stepper */
|
||||
--qqq-stepper-inactive-color: #9fc9ff;
|
||||
|
||||
/* Tooltip */
|
||||
--qqq-tooltip-background-color: #ffffff;
|
||||
--qqq-tooltip-text-color: var(--qqq-text-primary, #344767);
|
||||
--qqq-tooltip-shadow: 0px 0px 12px rgba(128, 128, 128, 0.40);
|
||||
|
||||
/* Form Inputs */
|
||||
--qqq-input-border-color: #d2d6da;
|
||||
|
||||
/* Menu */
|
||||
--qqq-menu-hover-color: #f0f2f5;
|
||||
|
||||
/* Switch */
|
||||
--qqq-switch-track-color: #bdbdbd;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using CSS Variables in Components
|
||||
|
||||
Frontend components use CSS variables with fallbacks:
|
||||
|
||||
```css
|
||||
background-color: var(--qqq-primary-color, #1976D2);
|
||||
color: var(--qqq-sidebar-text-color, #A2A3B7);
|
||||
```
|
||||
|
||||
The fallback value (after comma) is used if the variable is undefined.
|
||||
|
||||
---
|
||||
|
||||
## Theme Application Flow
|
||||
|
||||
1. Backend serves `QThemeMetaData` via `/metaData` endpoint
|
||||
2. Frontend receives theme in `QInstance.theme`
|
||||
3. `themeUtils.ts` calls `injectThemeVariables(theme)`
|
||||
4. Function sets CSS custom properties on `:root`
|
||||
5. Components read values via `var(--qqq-*)` references
|
||||
6. Icon fonts are dynamically loaded based on `iconStyle`
|
||||
|
||||
---
|
||||
|
||||
## Minimal vs Complete Configuration
|
||||
|
||||
**Minimal** - Override only what differs from defaults:
|
||||
```java
|
||||
new QThemeMetaData()
|
||||
.withPrimaryColor("#E91E63")
|
||||
.withBrandedHeaderEnabled(true);
|
||||
```
|
||||
|
||||
**Complete** - Specify all properties for full control (see `EXAMPLE_THEME_CONFIGURATION.md`).
|
||||
|
||||
---
|
||||
|
||||
## Dark Theme Considerations
|
||||
|
||||
For dark themes, invert the typical light/dark relationships:
|
||||
- `backgroundColor`: Dark (e.g., `#121212`)
|
||||
- `surfaceColor`: Slightly lighter dark (e.g., `#1E1E1E`)
|
||||
- `textPrimary`: Light (e.g., `#FFFFFF`)
|
||||
- `primaryColor`: Use lighter/brighter variant for visibility
|
||||
- Adjust sidebar, table, and border colors accordingly
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
| Repository | Key Files |
|
||||
|------------|-----------|
|
||||
| qqq-backend-core | `QThemeMetaData.java` |
|
||||
| qqq-frontend-core | `QThemeMetaData.ts` |
|
||||
| qqq-frontend-material-dashboard | `themeUtils.ts`, `qqq-override-styles.css` |
|
||||
|
||||
---
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
- All properties are optional
|
||||
- Missing properties use sensible defaults
|
||||
- Existing applications without theme configuration continue to work unchanged
|
||||
@@ -0,0 +1,76 @@
|
||||
# Session State - QQQ Frontend Material Dashboard
|
||||
|
||||
**Last Updated:** 2026-01-10
|
||||
**Branch:** `develop`
|
||||
**Version:** `0.36.0-SNAPSHOT`
|
||||
|
||||
## Current Status
|
||||
|
||||
**IN PROGRESS:** Fixing CI Playwright test failures.
|
||||
|
||||
The `publish_snapshot` workflow (pipeline #1361) failed because the React dev server times out in CI (120s not enough for webpack compilation).
|
||||
|
||||
## What Was Done This Session
|
||||
|
||||
1. Diagnosed CI failure - webserver timeout in `playwright_tests` job
|
||||
2. Added `serve` package for static file serving
|
||||
3. Modified `e2e/fixture-server.js` to serve both static React build AND API fixtures
|
||||
4. Updated `playwright.config.ts` to use single combined server
|
||||
5. Updated `.circleci/config.yml` to run `npm run build` before tests
|
||||
6. Tests still fail locally - React app renders blank page (debugging needed)
|
||||
|
||||
## Files Modified (Uncommitted)
|
||||
|
||||
- `package.json` - Added serve dependency
|
||||
- `package-lock.json` - Updated
|
||||
- `e2e/fixture-server.js` - Added static file serving
|
||||
- `playwright.config.ts` - Simplified to single server
|
||||
- `.circleci/config.yml` - Added build step
|
||||
|
||||
## Next Steps (Resume Here)
|
||||
|
||||
1. **Debug blank page issue:**
|
||||
- Run `node e2e/fixture-server.js` manually
|
||||
- Open http://localhost:8001 in browser
|
||||
- Check browser console for JavaScript errors
|
||||
- May need to add missing API endpoint stubs
|
||||
|
||||
2. **Compare with Selenium setup:**
|
||||
- Selenium tests work - check what's different
|
||||
- Look at `QSeleniumJavalin.java` for endpoint list
|
||||
|
||||
3. **Test with Playwright debug mode:**
|
||||
- `npx playwright test --debug`
|
||||
- Step through to see what requests fail
|
||||
|
||||
4. **Once tests pass locally:**
|
||||
- Commit and push
|
||||
- Monitor CI pipeline
|
||||
|
||||
## Test Status
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright e2e | 26 | FAILING (blank page) |
|
||||
| Selenium fixture-based | 115 | PASS |
|
||||
| Java unit | 3 | PASS |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Build React app (required before Playwright tests)
|
||||
npm run build
|
||||
|
||||
# Run fixture server manually for debugging
|
||||
node e2e/fixture-server.js
|
||||
|
||||
# Run Playwright in debug mode
|
||||
npx playwright test --debug
|
||||
|
||||
# Run single test
|
||||
npx playwright test --grep "primaryColor"
|
||||
```
|
||||
|
||||
## Plan Document
|
||||
|
||||
See `docs/PLAN-ci-playwright-fix.md` for detailed approach.
|
||||
@@ -0,0 +1,206 @@
|
||||
# QQQ Pluggable Theme System - Testing Guide
|
||||
|
||||
**Version:** 0.36.0-SNAPSHOT
|
||||
**Date:** 2026-01-03
|
||||
|
||||
## Overview
|
||||
|
||||
The QQQ dashboard now supports runtime theme customization via CSS custom properties. Theme configuration is defined in Java and automatically injected as `--qqq-*` CSS variables.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
<version>0.36.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
Add theme configuration to your QInstance:
|
||||
|
||||
```java
|
||||
QThemeMetaData theme = new QThemeMetaData()
|
||||
// Core colors
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSecondaryColor("#9C27B0")
|
||||
.withBackgroundColor("#F5F5F5")
|
||||
.withSurfaceColor("#FFFFFF")
|
||||
.withTextPrimary("#212121")
|
||||
.withTextSecondary("#757575")
|
||||
|
||||
// Status colors
|
||||
.withErrorColor("#D32F2F")
|
||||
.withWarningColor("#F57C00")
|
||||
.withSuccessColor("#388E3C")
|
||||
.withInfoColor("#1976D2")
|
||||
|
||||
// Sidebar
|
||||
.withSidebarBackgroundColor("#1E1E2D")
|
||||
.withSidebarTextColor("#FFFFFF")
|
||||
.withSidebarIconColor("#FFFFFF")
|
||||
|
||||
// Tables
|
||||
.withTableHeaderBackgroundColor("#F5F5F5")
|
||||
.withTableHeaderTextColor("#212121")
|
||||
|
||||
// Typography
|
||||
.withFontFamily("Inter, sans-serif")
|
||||
.withTypographyH1FontSize("2.5rem")
|
||||
.withTypographyH1FontWeight(700)
|
||||
|
||||
// Sizing
|
||||
.withBorderRadius("8px")
|
||||
.withDensity("comfortable");
|
||||
|
||||
qInstance.withSupplementalMetaData(theme);
|
||||
```
|
||||
|
||||
## Available Properties
|
||||
|
||||
### Core Colors
|
||||
| Property | CSS Variable | Default |
|
||||
|----------|--------------|---------|
|
||||
| `primaryColor` | `--qqq-primary-color` | `#0062FF` |
|
||||
| `secondaryColor` | `--qqq-secondary-color` | `#7b809a` |
|
||||
| `backgroundColor` | `--qqq-background-color` | `#f0f2f5` |
|
||||
| `surfaceColor` | `--qqq-surface-color` | `#ffffff` |
|
||||
| `textPrimary` | `--qqq-text-primary` | `#344767` |
|
||||
| `textSecondary` | `--qqq-text-secondary` | `#7b809a` |
|
||||
|
||||
### Status Colors
|
||||
| Property | CSS Variable | Default |
|
||||
|----------|--------------|---------|
|
||||
| `errorColor` | `--qqq-error-color` | `#F44335` |
|
||||
| `warningColor` | `--qqq-warning-color` | `#fb8c00` |
|
||||
| `successColor` | `--qqq-success-color` | `#4CAF50` |
|
||||
| `infoColor` | `--qqq-info-color` | `#0062FF` |
|
||||
|
||||
### Sidebar
|
||||
| Property | CSS Variable |
|
||||
|----------|--------------|
|
||||
| `sidebarBackgroundColor` | `--qqq-sidebar-background-color` |
|
||||
| `sidebarTextColor` | `--qqq-sidebar-text-color` |
|
||||
| `sidebarIconColor` | `--qqq-sidebar-icon-color` |
|
||||
| `sidebarSelectedBackgroundColor` | `--qqq-sidebar-selected-background-color` |
|
||||
| `sidebarSelectedTextColor` | `--qqq-sidebar-selected-text-color` |
|
||||
| `sidebarHoverBackgroundColor` | `--qqq-sidebar-hover-background-color` |
|
||||
| `sidebarDividerColor` | `--qqq-sidebar-divider-color` |
|
||||
|
||||
### Tables
|
||||
| Property | CSS Variable |
|
||||
|----------|--------------|
|
||||
| `tableHeaderBackgroundColor` | `--qqq-table-header-background-color` |
|
||||
| `tableHeaderTextColor` | `--qqq-table-header-text-color` |
|
||||
| `tableRowHoverColor` | `--qqq-table-row-hover-color` |
|
||||
| `tableRowSelectedColor` | `--qqq-table-row-selected-color` |
|
||||
| `tableBorderColor` | `--qqq-table-border-color` |
|
||||
|
||||
### Typography
|
||||
| Property | CSS Variable |
|
||||
|----------|--------------|
|
||||
| `fontFamily` | `--qqq-font-family` |
|
||||
| `headerFontFamily` | `--qqq-header-font-family` |
|
||||
| `monoFontFamily` | `--qqq-mono-font-family` |
|
||||
| `typographyH1FontSize` | `--qqq-typography-h1-font-size` |
|
||||
| `typographyH1FontWeight` | `--qqq-typography-h1-font-weight` |
|
||||
| `typographyH1LineHeight` | `--qqq-typography-h1-line-height` |
|
||||
| `typographyH1LetterSpacing` | `--qqq-typography-h1-letter-spacing` |
|
||||
|
||||
Same pattern for H2-H6, Body1, Body2, Button, Caption.
|
||||
|
||||
### General
|
||||
| Property | CSS Variable | Values |
|
||||
|----------|--------------|--------|
|
||||
| `borderRadius` | `--qqq-border-radius` | CSS size (e.g., `8px`) |
|
||||
| `density` | `--qqq-density` | `compact`, `normal`, `comfortable` |
|
||||
| `dividerColor` | `--qqq-divider-color` | Hex color |
|
||||
| `borderColor` | `--qqq-border-color` | Hex color |
|
||||
| `cardBorderColor` | `--qqq-card-border-color` | Hex color |
|
||||
|
||||
### Branded Header
|
||||
| Property | CSS Variable |
|
||||
|----------|--------------|
|
||||
| `brandedHeaderEnabled` | `--qqq-branded-header-enabled` |
|
||||
| `brandedHeaderBackgroundColor` | `--qqq-branded-header-background-color` |
|
||||
| `brandedHeaderTextColor` | `--qqq-branded-header-text-color` |
|
||||
| `brandedHeaderLogoPath` | `--qqq-branded-header-logo-path` |
|
||||
| `brandedHeaderLogoAltText` | N/A (used in component) |
|
||||
| `brandedHeaderHeight` | `--qqq-branded-header-height` |
|
||||
| `brandedHeaderTagline` | N/A (used in component) |
|
||||
|
||||
### Assets & Icons
|
||||
| Property | Values |
|
||||
|----------|--------|
|
||||
| `logoPath` | URL to logo image |
|
||||
| `iconPath` | URL to icon |
|
||||
| `faviconPath` | URL to favicon |
|
||||
| `iconStyle` | `filled`, `outlined`, `rounded`, `sharp`, `two-tone` |
|
||||
| `customCss` | Raw CSS string injected into page |
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 1. Basic Color Test
|
||||
```java
|
||||
new QThemeMetaData()
|
||||
.withPrimaryColor("#FF5722")
|
||||
.withSidebarBackgroundColor("#263238");
|
||||
```
|
||||
**Verify:** Primary buttons are orange, sidebar is dark blue-gray.
|
||||
|
||||
### 2. Typography Test
|
||||
```java
|
||||
new QThemeMetaData()
|
||||
.withFontFamily("Georgia, serif")
|
||||
.withTypographyH1FontSize("3rem")
|
||||
.withTypographyH1FontWeight(800);
|
||||
```
|
||||
**Verify:** Page uses serif font, H1 headers are large and bold.
|
||||
|
||||
### 3. Branded Header Test
|
||||
```java
|
||||
new QThemeMetaData()
|
||||
.withBrandedHeaderEnabled(true)
|
||||
.withBrandedHeaderBackgroundColor("#1a237e")
|
||||
.withBrandedHeaderTextColor("#ffffff")
|
||||
.withBrandedHeaderTagline("My Application")
|
||||
.withBrandedHeaderHeight("48px");
|
||||
```
|
||||
**Verify:** Fixed header bar appears at top with tagline.
|
||||
|
||||
### 4. Density Test
|
||||
```java
|
||||
new QThemeMetaData()
|
||||
.withDensity("compact");
|
||||
```
|
||||
**Verify:** Reduced spacing throughout the UI.
|
||||
|
||||
### 5. DevTools Verification
|
||||
1. Open browser DevTools
|
||||
2. Inspect `:root` element
|
||||
3. Verify `--qqq-*` CSS variables are present with configured values
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
1. Properties serialize to JSON via `/metaData` endpoint under `supplementalMetaData.theme`
|
||||
2. Frontend reads theme from metadata response
|
||||
3. `themeUtils.ts` injects values as CSS custom properties on `:root`
|
||||
4. MUI components and CSS overrides reference these variables
|
||||
5. Unset properties fall back to sensible defaults
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Theme not applying:**
|
||||
- Check `/metaData` response includes `supplementalMetaData.theme`
|
||||
- Verify CSS variables exist on `:root` in DevTools
|
||||
- Clear browser cache and hard refresh
|
||||
|
||||
**Colors look wrong:**
|
||||
- Ensure hex colors include `#` prefix
|
||||
- Verify color format is `#RGB`, `#RRGGBB`, or `#RRGGBBAA`
|
||||
|
||||
**Typography not changing:**
|
||||
- Font family must be available (loaded via Google Fonts or local)
|
||||
- Check `--qqq-font-family` variable is set correctly
|
||||
@@ -0,0 +1,192 @@
|
||||
# TODO: Convert Hardcoded Colors to CSS Variables
|
||||
|
||||
## Priority 1: Component Files (User-Facing)
|
||||
|
||||
### 1. Widget.tsx
|
||||
**File:** `src/qqq/components/widgets/Widget.tsx`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 754 | `background: colors.info.main` | `background: "var(--qqq-info-color)"` |
|
||||
| 772 | `background: colors.info.main` | `background: "var(--qqq-info-color)"` |
|
||||
|
||||
Remove `colors` import if no longer used.
|
||||
|
||||
---
|
||||
|
||||
### 2. ChartSubheaderWithData.tsx
|
||||
**File:** `src/qqq/components/widgets/components/ChartSubheaderWithData.tsx`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 49 | `const GOOD_COLOR = colors.success.main;` | `const GOOD_COLOR = "var(--qqq-success-color)";` |
|
||||
| 50 | `const BAD_COLOR = colors.error.main;` | `const BAD_COLOR = "var(--qqq-error-color)";` |
|
||||
|
||||
Remove `colors` import if no longer used.
|
||||
|
||||
---
|
||||
|
||||
### 3. FilterUtils.tsx
|
||||
**File:** `src/qqq/utils/qqq/FilterUtils.tsx`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 608 | `background: "#0062FF"` | `background: "var(--qqq-info-color)"` |
|
||||
| 609 | `background: "#757575"` | `background: "var(--qqq-grey-600, #757575)"` |
|
||||
| 610 | `background: "#009971"` | `background: "var(--qqq-success-color)"` |
|
||||
|
||||
---
|
||||
|
||||
### 4. HorizontalBarChart.tsx
|
||||
**File:** `src/qqq/components/widgets/charts/barchart/HorizontalBarChart.tsx`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 136 | `colors.info.main` | `"var(--qqq-info-color)"` |
|
||||
|
||||
Note: This is inside a chart config - verify CSS vars work in chart.js context.
|
||||
|
||||
---
|
||||
|
||||
### 5. CronUIWidget.tsx
|
||||
**File:** `src/qqq/components/widgets/misc/CronUIWidget.tsx`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 861 | `color={colors.error.main}` | `color="var(--qqq-error-color)"` |
|
||||
|
||||
---
|
||||
|
||||
### 6. RowBuilderWidget.tsx
|
||||
**File:** `src/qqq/components/widgets/misc/RowBuilderWidget.tsx`
|
||||
|
||||
| Lines | Current | Change To |
|
||||
|-------|---------|-----------|
|
||||
| 963-966 | `color: colors.error.main` (4 instances) | `color: "var(--qqq-error-color)"` |
|
||||
|
||||
All 4 lines are delete button hover states.
|
||||
|
||||
---
|
||||
|
||||
### 7. ShareModal.tsx
|
||||
**File:** `src/qqq/components/sharing/ShareModal.tsx`
|
||||
|
||||
| Lines | Current | Change To |
|
||||
|-------|---------|-----------|
|
||||
| 482-485 | `color: colors.error.main` (4 instances) | `color: "var(--qqq-error-color)"` |
|
||||
|
||||
All 4 lines are delete button hover states.
|
||||
|
||||
---
|
||||
|
||||
## Priority 2: Bulk Load Components
|
||||
|
||||
### 8. BulkLoadFileMappingField.tsx
|
||||
**File:** `src/qqq/components/processes/BulkLoadFileMappingField.tsx`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 64-67 | `color: colors.error.main` (4 instances) | `color: "var(--qqq-error-color)"` |
|
||||
| 313 | `color={colors.warning.main}` | `color="var(--qqq-warning-color)"` |
|
||||
| 319 | `color={colors.error.main}` | `color="var(--qqq-error-color)"` |
|
||||
|
||||
---
|
||||
|
||||
### 9. BulkLoadFileMappingForm.tsx
|
||||
**File:** `src/qqq/components/processes/BulkLoadFileMappingForm.tsx`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 274 | `color={colors.error.main}` | `color="var(--qqq-error-color)"` |
|
||||
|
||||
---
|
||||
|
||||
### 10. BulkLoadValueMappingForm.tsx
|
||||
**File:** `src/qqq/components/processes/BulkLoadValueMappingForm.tsx`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 220 | `color={colors.error.main}` | `color="var(--qqq-error-color)"` |
|
||||
|
||||
---
|
||||
|
||||
### 11. SavedBulkLoadProfiles.tsx
|
||||
**File:** `src/qqq/components/misc/SavedBulkLoadProfiles.tsx`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 634 | `color={colors.success.main}` | `color="var(--qqq-success-color)"` |
|
||||
| 637 | `color={colors.error.main}` | `color="var(--qqq-error-color)"` |
|
||||
|
||||
---
|
||||
|
||||
## Priority 3: Theme Components
|
||||
|
||||
### 12. tabs/index.ts
|
||||
**File:** `src/qqq/assets/theme/components/tabs/index.ts`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 69 | `borderBottomColor: colors.info.main` | `borderBottomColor: "var(--qqq-info-color)"` |
|
||||
|
||||
---
|
||||
|
||||
### 13. flatpickr.ts
|
||||
**File:** `src/qqq/assets/theme/components/flatpickr.ts`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 46 | `background: \`${gradients.info.state} !important\`` | `background: "var(--qqq-info-color) !important"` |
|
||||
|
||||
---
|
||||
|
||||
### 14. switchButton.ts
|
||||
**File:** `src/qqq/assets/theme/components/form/switchButton.ts`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 68 | `backgroundImage: linearGradient(gradients.info.main, gradients.info.state)` | `backgroundImage: "linear-gradient(195deg, var(--qqq-info-color), var(--qqq-info-color-dark, var(--qqq-info-color)))"` |
|
||||
|
||||
---
|
||||
|
||||
## Priority 4: Legacy Components
|
||||
|
||||
### 15. MDBadgeDot/index.tsx
|
||||
**File:** `src/qqq/components/legacy/MDBadgeDot/index.tsx`
|
||||
|
||||
| Lines | Current | Change To |
|
||||
|-------|---------|-----------|
|
||||
| 94 | `"primary": "#e91e63"` | `"primary": "var(--qqq-primary-color, #e91e63)"` |
|
||||
| 97 | `"success": "#4CAF50"` | `"success": "var(--qqq-success-color, #4CAF50)"` |
|
||||
| 98 | `"warning": "#fb8c00"` | `"warning": "var(--qqq-warning-color, #fb8c00)"` |
|
||||
| 99 | `"error": "#F44335"` | `"error": "var(--qqq-error-color, #F44335)"` |
|
||||
|
||||
Note: Line 96 already uses `accentColor` which is correct.
|
||||
|
||||
---
|
||||
|
||||
### 16. MDProgressRoot.tsx
|
||||
**File:** `src/qqq/components/legacy/MDProgress/MDProgressRoot.tsx`
|
||||
|
||||
| Line | Current | Change To |
|
||||
|------|---------|-----------|
|
||||
| 42 | `linearGradient(gradients.info.main, gradients.info.state)` | `"linear-gradient(195deg, var(--qqq-info-color), var(--qqq-info-color-dark, var(--qqq-info-color)))"` |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Priority | Files | Instances |
|
||||
|----------|-------|-----------|
|
||||
| P1: Components | 7 | ~15 |
|
||||
| P2: Bulk Load | 4 | ~10 |
|
||||
| P3: Theme | 3 | ~3 |
|
||||
| P4: Legacy | 2 | ~5 |
|
||||
| **Total** | **16** | **~33** |
|
||||
|
||||
## After Each Change
|
||||
|
||||
1. Remove unused `colors` import if file no longer uses it
|
||||
2. Remove unused `gradients` import if file no longer uses it
|
||||
3. Run `npm run build` to verify no TypeScript errors
|
||||
4. Test visually that colors still appear correctly
|
||||
@@ -0,0 +1,39 @@
|
||||
# TODO - QQQ Frontend Material Dashboard
|
||||
|
||||
**Last Updated:** 2026-01-10
|
||||
|
||||
## Active Tasks
|
||||
|
||||
### Fix CI Playwright Tests (IN PROGRESS)
|
||||
|
||||
- [x] Added `serve` package for static file serving
|
||||
- [x] Updated playwright.config.ts to use combined server approach
|
||||
- [x] Updated CircleCI to pre-build React app before tests
|
||||
- [x] Modified fixture-server.js to serve static files
|
||||
- [ ] **Debug why React app renders blank page**
|
||||
- [ ] Test locally with `--debug` flag
|
||||
- [ ] Check browser console for JS errors
|
||||
- [ ] Verify all required API endpoints are mocked
|
||||
- [ ] Push fix once tests pass locally
|
||||
|
||||
## Completed (Recent)
|
||||
|
||||
- [x] **PR #127 merged** - Playwright e2e test integration
|
||||
- [x] **Playwright tests integrated** - 26 tests from v1 branch
|
||||
- [x] **run-tests.sh improved** - Unified script for Playwright + Selenium
|
||||
- [x] **CircleCI config updated** - Playwright job added to pipelines
|
||||
|
||||
## Test Status
|
||||
|
||||
| Suite | Tests | Status |
|
||||
|-------|-------|--------|
|
||||
| Playwright e2e | 26 | FAILING (blank page) |
|
||||
| Selenium fixture-based | 115 | PASS |
|
||||
| Java unit | 3 | PASS |
|
||||
| Selenium full-server | 19 | Blocked (infrastructure) |
|
||||
|
||||
## Known Issues
|
||||
|
||||
- CI Playwright tests fail - webserver timeout (being fixed)
|
||||
- React app renders blank in combined fixture server (debugging)
|
||||
- `seleniumwithqapplication` tests hang locally (require full QQQ backend)
|
||||
@@ -0,0 +1,70 @@
|
||||
# Lets build pluggable themes. I'm Scared.
|
||||
|
||||
Every enterprise app eventually gets the same request. "Can we make it blue instead of purple?" "Can we put our logo up there?" "The sidebar needs to match our brand guidelines."
|
||||
|
||||
And every time it's grep through the codebase for hex codes, find them in twelve different files, change them, miss one, deploy, customer notices, repeat.
|
||||
|
||||
So let's build a proper theme system.
|
||||
|
||||
The theme should be defined on the backend, in Java, because that's where the rest of the app configuration lives. Tables, processes, reports - all defined in Java metadata. The theme should work the same way.
|
||||
|
||||
```java
|
||||
qInstance.setTheme(new QThemeMetaData()
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSecondaryColor("#9C27B0")
|
||||
.withSidebarBackgroundColor("#1E1E2D")
|
||||
.withSidebarTextColor("#FFFFFF")
|
||||
.withFontFamily("Inter")
|
||||
.withBrandedHeaderEnabled(true)
|
||||
.withBrandedHeaderTagline("Acme Corp Portal"));
|
||||
```
|
||||
|
||||
Fluent API. Discoverable. Type-safe.
|
||||
|
||||
How does that get to the frontend? Two options. Bake it into the build - generate CSS at compile time. But the frontend is built and packaged upstream as a static SPA. Downstream apps consume it as a dependency. They can't rebuild it with different colors.
|
||||
|
||||
So it has to be runtime. Send the theme with the metadata response. The frontend already fetches `/metaData` on load. Include the theme there. Each consuming app defines its own theme in Java, the frontend picks it up and applies it.
|
||||
|
||||
So the frontend gets a theme object. How do you apply it?
|
||||
|
||||
React context is one option - pass theme values down through providers. But every component needs to consume the context. That's a lot of changes. And it doesn't help with third-party components.
|
||||
|
||||
CSS-in-JS with a theme provider is another option. MUI supports this. But we're already fighting MUI's default styles. More abstraction isn't the answer.
|
||||
|
||||
CSS custom properties though. Set them once at the root:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--qqq-primary-color: #1976D2;
|
||||
--qqq-sidebar-background: #1E1E2D;
|
||||
}
|
||||
```
|
||||
|
||||
Reference them everywhere:
|
||||
|
||||
```css
|
||||
.sidebar {
|
||||
background-color: var(--qqq-sidebar-background);
|
||||
}
|
||||
```
|
||||
|
||||
This cascades naturally. Set the variables, everything that uses them just works. No prop drilling. No context. No build step.
|
||||
|
||||
The frontend reads the theme from metadata, injects variables into the document root. Done.
|
||||
|
||||
The challenge is MUI. It has its own theming system, its own styled-components that inject styles at runtime with high specificity. CSS variables don't automatically win against that. Override styles are needed, with enough specificity to beat MUI's generated classes.
|
||||
|
||||
And there are multiple theming islands. The sidebar has its own colors - background, text, icons, hover states, selected states, dividers. The branded header has its own colors. Data tables have their own. They can't just inherit from global theme variables.
|
||||
|
||||
So we're looking at roughly 60 properties:
|
||||
|
||||
- Core palette: primary, secondary, background, surface, error, warning, success, info
|
||||
- Typography: font family, sizes, weights for different variants
|
||||
- Sidebar: background, text, icons, hover, selected, dividers
|
||||
- Branded header: background, text, logo path, tagline
|
||||
- Tables: header background, header text, row hover, borders
|
||||
- General: border radius, spacing scale, density
|
||||
|
||||
Each one flows from Java to frontend to CSS to rendered component.
|
||||
|
||||
The infrastructure first - theme object flowing from backend to frontend, CSS variable injection. Then components one by one. Buttons, inputs, sidebar, tables.
|
||||
@@ -0,0 +1,59 @@
|
||||
# The Implementation Grind
|
||||
|
||||
First attempt:
|
||||
|
||||
```css
|
||||
.MuiButton-root {
|
||||
background-color: var(--qqq-primary-color);
|
||||
}
|
||||
```
|
||||
|
||||
Doesn't work. MUI generates class names like `.css-1hw9j7s` and injects them with inline-level specificity. Regular stylesheets don't stand a chance.
|
||||
|
||||
`!important` it is:
|
||||
|
||||
```css
|
||||
.MuiButton-containedPrimary {
|
||||
background-color: var(--qqq-primary-color) !important;
|
||||
}
|
||||
```
|
||||
|
||||
Not elegant, but effective. Buttons, inputs, cards - all picking up theme colors now.
|
||||
|
||||
Then a weirder problem. A test for `textSecondary` keeps failing even though the CSS variable is set correctly and the value matches.
|
||||
|
||||
After debugging: the test was finding a `.MuiTypography-body2` element in the sidebar, not the main content. The sidebar has its own text color variable. The test found an element, checked its color, but checked the wrong element.
|
||||
|
||||
The fix:
|
||||
|
||||
```css
|
||||
.MuiTypography-body2:not(.MuiDrawer-root *):not(.MuiDrawer-paper *) {
|
||||
color: var(--qqq-text-secondary) !important;
|
||||
}
|
||||
```
|
||||
|
||||
"Apply this style, but not if inside the drawer."
|
||||
|
||||
Same pattern needed for the branded header - it has its own text color too. So now there are `:not()` selectors everywhere.
|
||||
|
||||
```css
|
||||
.MuiTypography-body2:not(.MuiDrawer-root *):not(.MuiDrawer-paper *):not(.qqq-branded-header-bar *) {
|
||||
color: var(--qqq-text-secondary) !important;
|
||||
}
|
||||
```
|
||||
|
||||
The CSS override file is around 1200 lines now. Organized by component group:
|
||||
|
||||
- Core colors and typography
|
||||
- Sidebar states (default, hover, selected)
|
||||
- Branded header
|
||||
- Data tables (headers, cells, borders)
|
||||
- Form inputs
|
||||
- Cards and surfaces
|
||||
- Buttons and links
|
||||
|
||||
Each section targets the MUI classes for that component type and forces them to use CSS variables.
|
||||
|
||||
What's working: font family and sizes flow through cleanly. Border radius applies everywhere. Table headers picked up the colors without issues. Sidebar selected and hover states work correctly.
|
||||
|
||||
Next: actual browser tests. Playwright, not Jest. Render the page and check `getComputedStyle()`. The tests need to verify what the user actually sees, not just that variables are set.
|
||||
@@ -0,0 +1,136 @@
|
||||
# 26 Tests Green
|
||||
|
||||
All green. Finally.
|
||||
|
||||
Pushed it to CI, watched the pipeline, held my breath... and it passed. 26 Playwright tests, all checking actual rendered colors on actual components. Not mocked. Not faked. Real browser, real CSS, real values.
|
||||
|
||||
Getting here was annoying though.
|
||||
|
||||
My first tests were wrong. Like, fundamentally wrong. They checked if the CSS variable was *set*:
|
||||
|
||||
```typescript
|
||||
const cssVar = getComputedStyle(root).getPropertyValue('--qqq-primary-color');
|
||||
expect(cssVar).toBe('#E91E63');
|
||||
```
|
||||
|
||||
Those passed. Great, right? No. The button on screen was still the wrong color. The variable existed, the component just wasn't using it.
|
||||
|
||||
So I had to rewrite everything to check what actually renders:
|
||||
|
||||
```typescript
|
||||
const btn = document.querySelector('.MuiButton-containedPrimary');
|
||||
const bg = getComputedStyle(btn).backgroundColor;
|
||||
```
|
||||
|
||||
If the button looks wrong, the test fails. That's the whole point.
|
||||
|
||||
## The Testing Architecture
|
||||
|
||||
Ended up with three test classes, each with a different purpose:
|
||||
|
||||
**ThemeIT** - CSS variable injection tests. Does the theme JSON get converted into `--qqq-*` variables? Simple, fast, catches the obvious stuff.
|
||||
|
||||
**ThemeComponentRenderingIT** - Component rendering tests. Given a sidebar with `sidebarBackgroundColor: '#1B5E20'`, does the MuiDrawer actually render green? This is where we catch the "variable exists but nothing uses it" problem.
|
||||
|
||||
**ThemeMuiComponentIT** - Strict MUI component tests. These are the brutal ones. They find the exact MUI element and assert the exact computed style. No fallbacks, no "well close enough."
|
||||
|
||||
```java
|
||||
WebElement drawer = findElementOrFail(
|
||||
By.cssSelector(".MuiDrawer-paper"),
|
||||
"MUI Drawer (sidebar)"
|
||||
);
|
||||
|
||||
String drawerBgColor = getComputedStyle(drawer, "background-color");
|
||||
assertThat(normalizeColorToHex(drawerBgColor))
|
||||
.as("MUI Drawer sidebar MUST render with green background #1B5E20, but was %s", drawerBgColor)
|
||||
.isEqualToIgnoringCase("#1B5E20");
|
||||
```
|
||||
|
||||
No silent skipping. `findElementOrFail` means if the element doesn't exist, the test fails with a clear message. Not "element not found," but "Could not find MUI Drawer (sidebar)."
|
||||
|
||||
## The Two-Server Setup
|
||||
|
||||
Tests run against a real React dev server and a mock Javalin backend. React on port 3001, Javalin on 8001. The mock serves JSON fixtures from `src/test/resources/fixtures/`.
|
||||
|
||||
Different fixtures for different test scenarios:
|
||||
- `withFullCustomTheme.json` - all 60+ properties set
|
||||
- `withCompactDensity.json` - compact spacing values
|
||||
- `withBrandedHeader.json` - branded header enabled
|
||||
- `index.json` - no theme, uses defaults
|
||||
|
||||
Each test picks its fixture, restarts Javalin, loads the page, runs assertions.
|
||||
|
||||
## The Color Comparison Problem
|
||||
|
||||
The annoying part was elements showing up in the wrong containers. I'd query for `.MuiTypography-body2` and find one - but it was in the sidebar, which has its own color scheme. Or in the branded header. Both of which should be *excluded* from the general text color rules.
|
||||
|
||||
So now every test does this dance:
|
||||
|
||||
```typescript
|
||||
for (const el of allElements) {
|
||||
if (el.closest('.MuiDrawer-root')) continue;
|
||||
if (el.closest('.qqq-branded-header-bar')) continue;
|
||||
return getComputedStyle(el).color;
|
||||
}
|
||||
```
|
||||
|
||||
Loop through, skip the containers we don't want, grab the first valid one.
|
||||
|
||||
Oh and there was this one bug that took me way too long. Test output said:
|
||||
|
||||
```
|
||||
expected #CFD8DC, got #CFD8DC
|
||||
```
|
||||
|
||||
Same value. Still failing. What?
|
||||
|
||||
Turns out my color comparison function was trying to parse both as RGB, failing because they were hex, and returning false. I just needed to check if both start with `#` and compare directly. Twenty minutes I'll never get back.
|
||||
|
||||
```java
|
||||
private String normalizeColorToHex(String color)
|
||||
{
|
||||
if(color.startsWith("#"))
|
||||
{
|
||||
return color.toUpperCase();
|
||||
}
|
||||
|
||||
if(color.startsWith("rgb"))
|
||||
{
|
||||
String numbers = color.replaceAll("[^0-9,.]", "");
|
||||
String[] parts = numbers.split(",");
|
||||
int r = Integer.parseInt(parts[0].trim());
|
||||
int g = Integer.parseInt(parts[1].trim());
|
||||
int b = Integer.parseInt(parts[2].trim());
|
||||
return String.format("#%02X%02X%02X", r, g, b);
|
||||
}
|
||||
return color.toUpperCase();
|
||||
}
|
||||
```
|
||||
|
||||
Browsers return computed colors as RGB. Theme fixtures use hex. Need to normalize both to the same format before comparing.
|
||||
|
||||
## What's Not Tested Yet
|
||||
|
||||
Hover and focus states are partially covered. We use JavaScript to dispatch mouse events:
|
||||
|
||||
```java
|
||||
js.executeScript("arguments[0].dispatchEvent(new MouseEvent('mouseenter', {bubbles: true}));", item);
|
||||
```
|
||||
|
||||
But it's flaky. The hover state might not apply before we read the style. There's a `waitForSeconds(1)` in there which is gross. Need a better way to verify CSS transitions complete.
|
||||
|
||||
Visual regression testing would catch things unit tests miss. Compare screenshots across builds, flag pixel differences. Tools like Percy or Chromatic do this. Worth exploring for the theme system specifically.
|
||||
|
||||
Cross-browser testing is another gap. These run in headless Chrome. Safari renders things slightly differently. Firefox has its own quirks. CI should probably run the suite against multiple browsers.
|
||||
|
||||
Accessibility testing for color contrast. If someone sets `textPrimary: '#CCCCCC'` on `backgroundColor: '#EEEEEE'`, that's technically valid but unreadable. Should probably warn about insufficient contrast ratios.
|
||||
|
||||
## Where This Goes Next
|
||||
|
||||
The test suite proves the theme system works. But it's testing qfmd in isolation. Downstream apps like me-health-portal have their own test suites. They pull qfmd as a dependency, apply their own theme, and their tests should verify the theming works in their context.
|
||||
|
||||
Right now other teams are pulling the snapshot to test in their apps. Real usage, real feedback. If something breaks, I'll hear about it.
|
||||
|
||||
Then docs. 60+ theme properties need to be documented somewhere. But that's a problem for next week.
|
||||
|
||||
[43522d6](https://github.com/QRun-IO/qqq-frontend-material-dashboard/commit/43522d6)
|
||||
@@ -0,0 +1,113 @@
|
||||
# Where We Are Now
|
||||
|
||||
The theme system works. 60+ properties flow from Java metadata to CSS variables to rendered components. Tests pass. Other teams are testing in their apps. The feature is real.
|
||||
|
||||
But after living with the code for a few days, I'm not happy with how we got here.
|
||||
|
||||
## What's Working
|
||||
|
||||
The architecture holds up. Define theme in Java, ship it with metadata, inject as CSS variables, components pick it up. No rebuild required. No separate config files. Each tenant can have different colors without touching the frontend codebase.
|
||||
|
||||
```java
|
||||
qInstance.setTheme(new QThemeMetaData()
|
||||
.withPrimaryColor("#1976D2")
|
||||
.withSidebarBackgroundColor("#1E1E2D"));
|
||||
```
|
||||
|
||||
That's it. Two lines and the app looks different.
|
||||
|
||||
The sidebar theming is solid. Background, text, icons, hover, selected states - all wired up. Same with the branded header. Same with data tables. The component "islands" each have their own color namespace and it works.
|
||||
|
||||
## What's Ugly
|
||||
|
||||
The CSS override file. 1200+ lines of specificity battles with MUI.
|
||||
|
||||
```css
|
||||
.MuiButton-containedPrimary {
|
||||
background-color: var(--qqq-primary-color) !important;
|
||||
}
|
||||
```
|
||||
|
||||
`!important` everywhere. 82 of them. That's not engineering, that's brute force.
|
||||
|
||||
The `:not()` chains are getting ridiculous:
|
||||
|
||||
```css
|
||||
.MuiTypography-body2:not(.MuiDrawer-root *):not(.MuiDrawer-paper *):not(.qqq-branded-header-bar *) {
|
||||
color: var(--qqq-text-secondary) !important;
|
||||
}
|
||||
```
|
||||
|
||||
Every new MUI component needs its own override. Every MUI version bump might break something. Every new island component means adding more `:not()` exclusions everywhere.
|
||||
|
||||
The disconnect hit me when I traced the data flow:
|
||||
|
||||
```
|
||||
QThemeMetaData (backend)
|
||||
-> injectThemeVariables() -> CSS variables on :root
|
||||
-> colors.ts reads CSS vars -> Theme.ts builds MUI theme
|
||||
-> ThemeProvider wraps app
|
||||
-> PLUS: 1200 lines of CSS overrides fighting MUI anyway
|
||||
```
|
||||
|
||||
We're already using MUI's ThemeProvider. We're already building a dynamic theme. But then we're overriding everything with CSS because we didn't trust MUI to do its job.
|
||||
|
||||
Two theming systems fighting each other. That's the problem.
|
||||
|
||||
## The Decision
|
||||
|
||||
We're going to refactor.
|
||||
|
||||
The CSS variable approach taught us a lot. We understand the 60+ theme properties. We know which components need which colors. We have 26 Playwright tests that verify the rendered output. That's not wasted work - that's reconnaissance.
|
||||
|
||||
But the implementation needs to change. Instead of CSS variables overriding MUI from the outside, we're going to:
|
||||
|
||||
1. Build the MUI theme directly from QThemeMetaData
|
||||
2. Let MUI style its own components correctly from the start
|
||||
3. Keep CSS variables only for "islands" (sidebar, branded header) and non-MUI components
|
||||
4. Delete most of the 1200-line override file
|
||||
|
||||
```typescript
|
||||
// Before: CSS variables -> colors.ts reads them -> Theme.ts -> overrides anyway
|
||||
// After: QThemeMetaData -> createDynamicTheme() -> MUI handles it
|
||||
|
||||
function createDynamicTheme(themeData: QThemeMetaData): Theme {
|
||||
return createTheme({
|
||||
palette: {
|
||||
primary: { main: themeData.primaryColor },
|
||||
secondary: { main: themeData.secondaryColor },
|
||||
background: { default: themeData.backgroundColor },
|
||||
text: { primary: themeData.textPrimary },
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
containedPrimary: {
|
||||
backgroundColor: themeData.primaryColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Direct data flow. No round-trip through CSS variables. MUI components styled correctly without `!important`. The browser's CSS engine isn't fighting itself.
|
||||
|
||||
## What We Gain
|
||||
|
||||
- **Smaller override file.** 1200 lines -> maybe 200-300 for islands and DataGrid.
|
||||
- **Fewer `!important`.** 82 -> maybe 10-15.
|
||||
- **Easier debugging.** Theme is an inspectable object, not scattered CSS vars.
|
||||
- **MUI version safety.** We're using MUI's API, not fighting its internals.
|
||||
- **Future features.** Dark mode, hot reload, theme switching - all become possible.
|
||||
|
||||
## What's Next
|
||||
|
||||
The 26 Playwright tests stay. They verify rendered colors, not implementation details. When the refactor is done, they should all pass with no changes.
|
||||
|
||||
The CSS variable work moves to a feature branch. It's not getting deleted - it's useful as reference and for the blog posts. But develop goes back to pre-theme state.
|
||||
|
||||
New feature branch for the MUI refactor. Clean implementation, right architecture.
|
||||
|
||||
Sometimes the fastest way forward is to step back and do it right.
|
||||
@@ -0,0 +1,180 @@
|
||||
# The Selector Problem
|
||||
|
||||
The theme system works. Colors flow from Java to CSS variables to rendered components. Ship it, right?
|
||||
|
||||
Then the first real feedback: "How do I hide the Developer Mode menu item?"
|
||||
|
||||
Good question. Check the DOM. The menu item is a `<li>` with class `.MuiMenuItem-root` and some generated hash like `.css-kk1bwy-MuiMenuItem-root`. No stable identifier. No way to target just that one menu item.
|
||||
|
||||
"Use the text content?"
|
||||
|
||||
```css
|
||||
/* This doesn't exist in CSS */
|
||||
.MuiMenuItem-root:contains("Developer Mode") {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
CSS doesn't have a `:contains()` selector. JavaScript does, but we're trying to enable customization through `customCss` in `QThemeMetaData`. Pure CSS. No JS allowed.
|
||||
|
||||
The customer could override the whole menu. Or hide all menu items and recreate them. But that's insane for "hide one button."
|
||||
|
||||
## The MUI Class Name Problem
|
||||
|
||||
MUI generates class names at build time. They're deterministic but meaningless:
|
||||
|
||||
```html
|
||||
<button class="MuiButton-root MuiButton-containedPrimary css-1hw9j7s">
|
||||
Save
|
||||
</button>
|
||||
```
|
||||
|
||||
That `css-1hw9j7s` changes if MUI's internals change. Can't rely on it. The `MuiButton-*` classes are stable but they're type selectors, not instance selectors. Every primary button has the same classes.
|
||||
|
||||
Checked if MUI has built-in solutions. It does have a `className` generator you can configure. Doesn't help - it changes the hash algorithm, not the semantic meaning. Still no way to say "this specific button."
|
||||
|
||||
There's no built-in "give every element a unique stable ID" feature. MUI expects you to know which button you're styling because you wrote the code. Makes sense for apps, breaks down for configurable frameworks.
|
||||
|
||||
## Data Attributes
|
||||
|
||||
The answer is `data-*` attributes. HTML's escape hatch for custom metadata.
|
||||
|
||||
```html
|
||||
<button data-qqq-id="button-save" class="MuiButton-root ...">
|
||||
Save
|
||||
</button>
|
||||
```
|
||||
|
||||
Now CSS can target it:
|
||||
|
||||
```css
|
||||
[data-qqq-id="button-save"] {
|
||||
background-color: green !important;
|
||||
}
|
||||
```
|
||||
|
||||
Stable. Semantic. Survives MUI upgrades. Works in `customCss`.
|
||||
|
||||
## Auto-Generation
|
||||
|
||||
Could require developers to manually add `qqqId` props everywhere. But that's tedious and error-prone. Most elements have obvious identifiers already - button text, field names, menu item labels.
|
||||
|
||||
So: auto-generate from what's already there.
|
||||
|
||||
```typescript
|
||||
function generateButtonId(qqqId?: string, children?: ReactNode, iconName?: string): string {
|
||||
if (qqqId) return `button-${sanitizeId(qqqId)}`;
|
||||
|
||||
const text = extractTextFromChildren(children);
|
||||
if (text) return `button-${sanitizeId(text)}`;
|
||||
|
||||
if (iconName) return `button-icon-${sanitizeId(iconName)}`;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
A "Save" button becomes `button-save`. An icon-only edit button becomes `button-icon-edit`. Explicit `qqqId` prop overrides everything.
|
||||
|
||||
Same pattern for inputs (from field name), selects (from field name), nav items (from route), menu items (from text), table headers (from column field).
|
||||
|
||||
The sanitizer strips everything down to lowercase alphanumeric with dashes:
|
||||
|
||||
```typescript
|
||||
function sanitizeId(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.substring(0, 50);
|
||||
}
|
||||
```
|
||||
|
||||
"Bulk Edit With File" becomes `bulk-edit-with-file`. Predictable. Debuggable.
|
||||
|
||||
## The Implementation
|
||||
|
||||
Modified eight components:
|
||||
|
||||
- `MDButton` - core button, auto-generates from children or icon
|
||||
- `DefaultButtons` - standard buttons get explicit IDs (`button-save`, `button-cancel`, etc.)
|
||||
- `SideNavItem` - nav items from `name` prop
|
||||
- `QueryScreenActionMenu` - all menu items from text
|
||||
- `DynamicFormField` - text inputs from field name
|
||||
- `DynamicSelect` - dropdowns from field name
|
||||
- `DataGridUtils` - table headers from column field
|
||||
|
||||
Created utility file `qqqIdUtils.ts` with generators for each element type. Created `QqqIdContext.tsx` for optional page-level scoping (not used yet, but there for future).
|
||||
|
||||
Total: ~200 lines of new code, ~50 lines of modifications across existing components.
|
||||
|
||||
## What You Can Do Now
|
||||
|
||||
Hide a menu item:
|
||||
|
||||
```java
|
||||
.withCustomCss("""
|
||||
[data-qqq-id="menu-item-developer-mode"] {
|
||||
display: none;
|
||||
}
|
||||
""")
|
||||
```
|
||||
|
||||
Style a specific button:
|
||||
|
||||
```css
|
||||
[data-qqq-id="button-save"] {
|
||||
background-color: #4CAF50 !important;
|
||||
}
|
||||
```
|
||||
|
||||
Target inputs by pattern:
|
||||
|
||||
```css
|
||||
[data-qqq-id^="input-order"] {
|
||||
border-left: 3px solid blue;
|
||||
}
|
||||
```
|
||||
|
||||
Hide a nav item:
|
||||
|
||||
```css
|
||||
[data-qqq-id="sidenav-admin"] {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
Style table headers:
|
||||
|
||||
```css
|
||||
[data-qqq-id="table-header-status"] {
|
||||
font-weight: bold;
|
||||
background-color: #FFF3E0;
|
||||
}
|
||||
```
|
||||
|
||||
Inspect any element in DevTools, find its `data-qqq-id`, target it in CSS. That simple.
|
||||
|
||||
## What's Not Covered Yet
|
||||
|
||||
Tabs aren't wired up. Neither are cards, modals, or the dozens of other component types. The pattern is established though - adding more is mechanical.
|
||||
|
||||
No tests yet for the selectors themselves. The theme tests verify colors render correctly; should add tests that verify IDs appear on elements.
|
||||
|
||||
The scoping context exists but isn't used. Idea is to wrap pages in `<QqqIdProvider scope="record-view">` so you can write:
|
||||
|
||||
```css
|
||||
[data-qqq-scope="record-view"] [data-qqq-id="button-edit"] {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
Hide the edit button only on view screens, not everywhere. That's future work.
|
||||
|
||||
## The Pattern
|
||||
|
||||
This keeps happening. Build a feature, ship it, get feedback that reveals a gap in the abstraction. Theme colors work but you can't target specific elements. Fix that, ship it, wait for the next gap.
|
||||
|
||||
The architecture is holding up though. Java metadata → frontend → CSS → rendered output. Each layer adds capability without breaking the ones below.
|
||||
|
||||
Version for testing: `0.36.0-pluggable-themes-css-selectors-SNAPSHOT`
|
||||
@@ -0,0 +1,71 @@
|
||||
# We're Dropping AGPL. Here's Why.
|
||||
|
||||
Starting with version 0.36, qqq and its related modules are Apache 2.0.
|
||||
|
||||
This isn't a small change. AGPL was our license from day one. It felt right - copyleft, network-use provisions, protect the commons. But after talking to dozens of developers and potential users, we kept hitting the same wall.
|
||||
|
||||
"We can't use it. Legal won't approve AGPL for our SaaS."
|
||||
|
||||
## The Problem
|
||||
|
||||
AGPL Section 13 requires anyone running AGPL software as a service to open-source their entire application. That's the point of the license - if you use it, you contribute back.
|
||||
|
||||
But our target customers are SaaS builders. Startups. Teams building proprietary products on tight timelines. They don't want to open-source their business logic. They can't. And we don't think they should have to.
|
||||
|
||||
We said "free for startups under $10M ARR" but that wasn't legally accurate. Under AGPL, they'd have to release their code regardless of revenue. The license contradicted our messaging.
|
||||
|
||||
## Why Apache 2.0
|
||||
|
||||
Apache 2.0 is what Kubernetes uses. And React. And Supabase. There's a reason.
|
||||
|
||||
- **No copyleft.** Build proprietary software. Keep your code.
|
||||
- **Explicit patent grant.** Protection that MIT doesn't provide.
|
||||
- **Enterprise-friendly.** Legal teams approve it without a fight.
|
||||
- **SaaS-compatible.** Deploy as a service, no obligations.
|
||||
|
||||
The trade-off is real. Cloud providers could fork qqq tomorrow. Competitors can build on our work without contributing back. We're betting we can out-execute rather than out-license.
|
||||
|
||||
## What Changes
|
||||
|
||||
**Apache 2.0 (open, free, no obligations):**
|
||||
- `qqq` - core framework
|
||||
- `qqq-frontend-core` - frontend foundation
|
||||
- `qqq-frontend-material-dashboard` - Material UI dashboard
|
||||
- `qqq-android` - mobile
|
||||
- `qctl` - CLI tools
|
||||
- Community qBits (process-trace, webhooks, geo-data, etc.)
|
||||
- Templates and tooling
|
||||
|
||||
**Proprietary (premium features):**
|
||||
- qStudio
|
||||
- qRun platform
|
||||
- Premium qBits (OMS, WMS, CRM when they ship)
|
||||
- Enterprise support
|
||||
|
||||
The core stays open. The stuff that costs us money to build and support stays commercial. Open-core model, same as GitLab or HashiCorp.
|
||||
|
||||
## What This Means For You
|
||||
|
||||
If you're building on qqq today: nothing changes in your workflow. The API is the same. The framework works the same. You just have more freedom with what you build.
|
||||
|
||||
If you were waiting because of AGPL: the door is open. Build your SaaS. Keep your code private. We won't ask for anything except that you build something great.
|
||||
|
||||
If you were contributing under AGPL: thank you. Your contributions are now under Apache 2.0. Same code, more permissive license.
|
||||
|
||||
## The Version Boundary
|
||||
|
||||
Everything before 0.36 stays AGPL-3.0. We can't retroactively change that.
|
||||
|
||||
0.36 and forward is Apache 2.0. Clean break.
|
||||
|
||||
If you need AGPL for some reason (it does exist), pin to 0.35.x. But we think most people will prefer the freedom.
|
||||
|
||||
## What's Next
|
||||
|
||||
The LICENSE file in each repository is the authoritative source. Those are updated now with Apache 2.0. NOTICE files with proper attribution are in place. README badges reflect the change.
|
||||
|
||||
You'll still see old AGPL headers in some source files. That's cosmetic - the LICENSE file governs. We'll update file headers gradually as we touch code, but don't let stale headers confuse you. Check the LICENSE file in the repo root. That's what counts.
|
||||
|
||||
The framework itself keeps improving. This is a legal change, not a technical one. Same roadmap. Same velocity. Just fewer barriers to adoption.
|
||||
|
||||
Build your SaaS on qqq. Your code stays yours.
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Combined fixture server for Playwright e2e tests.
|
||||
* Serves:
|
||||
* 1. Static React build from build/ directory
|
||||
* 2. Fixture JSON files from src/test/resources/fixtures/
|
||||
*
|
||||
* This allows e2e tests to run against a production build
|
||||
* without needing a proxy between frontend and API.
|
||||
*/
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = process.env.FIXTURE_PORT || 8001;
|
||||
const FIXTURES_DIR = path.join(__dirname, '..', 'src', 'test', 'resources', 'fixtures');
|
||||
const BUILD_DIR = path.join(__dirname, '..', 'build');
|
||||
|
||||
// Determine which metaData fixture to serve based on env var
|
||||
const FIXTURE_NAME = process.env.THEME_FIXTURE || 'withFullCustomTheme';
|
||||
|
||||
// MIME types for static file serving
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.map': 'application/json'
|
||||
};
|
||||
|
||||
function loadFixture(relativePath) {
|
||||
const fixturePath = path.join(FIXTURES_DIR, relativePath);
|
||||
if (fs.existsSync(fixturePath)) {
|
||||
return JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function serveJson(res, data) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function serve404(res, pathname) {
|
||||
console.log(`404: ${pathname}`);
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Not found', path: pathname }));
|
||||
}
|
||||
|
||||
function serveStaticFile(res, pathname) {
|
||||
// Map pathname to file in build directory
|
||||
let filePath = path.join(BUILD_DIR, pathname);
|
||||
|
||||
// For SPA routing, serve index.html for non-file paths
|
||||
if (!path.extname(pathname)) {
|
||||
filePath = path.join(BUILD_DIR, 'index.html');
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Fall back to index.html for SPA routing
|
||||
filePath = path.join(BUILD_DIR, 'index.html');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath);
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Error reading file ${filePath}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Enable CORS for local development
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// /metaData endpoint - return main metadata with theme
|
||||
if (pathname === '/metaData' || pathname === '/metaData/' || pathname === '/api/metaData') {
|
||||
const data = loadFixture(`metaData/${FIXTURE_NAME}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// /metaData/authentication - authentication config
|
||||
if (pathname === '/metaData/authentication' || pathname === '/api/metaData/authentication') {
|
||||
const data = loadFixture('metaData/authentication.json');
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// /metaData/table/{tableName} - table metadata
|
||||
const tableMetaMatch = pathname.match(/^\/metaData\/table\/(\w+)$/);
|
||||
if (tableMetaMatch) {
|
||||
const tableName = tableMetaMatch[1];
|
||||
const data = loadFixture(`metaData/table/${tableName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
// If specific table not found, return generic person table
|
||||
const personData = loadFixture('metaData/table/person.json');
|
||||
if (personData) {
|
||||
serveJson(res, personData);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// /qqq/v1/metaData/table/{tableName} - API table metadata
|
||||
const apiTableMetaMatch = pathname.match(/^\/qqq\/v1\/metaData\/table\/(\w+)$/);
|
||||
if (apiTableMetaMatch) {
|
||||
const tableName = apiTableMetaMatch[1];
|
||||
const data = loadFixture(`qqq/v1/metaData/table/${tableName}.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// /processes/querySavedView/init - saved view init
|
||||
if (pathname === '/processes/querySavedView/init') {
|
||||
const data = loadFixture('processes/querySavedView/init.json');
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
// Default response if fixture not found
|
||||
serveJson(res, { values: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
// /manageSession - session management
|
||||
if (pathname === '/manageSession' || pathname === '/api/manageSession') {
|
||||
serveJson(res, {
|
||||
uuid: 'test-session-uuid',
|
||||
values: {}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /qqq/v1/table/{tableName}/query - table queries
|
||||
const queryMatch = pathname.match(/^\/qqq\/v1\/table\/(\w+)\/query$/);
|
||||
if (queryMatch) {
|
||||
const tableName = queryMatch[1];
|
||||
const data = loadFixture(`qqq/v1/table/${tableName}/query.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
// Default query response
|
||||
serveJson(res, {
|
||||
records: [
|
||||
{ values: { id: 1, firstName: 'Test', lastName: 'User' } },
|
||||
{ values: { id: 2, firstName: 'Another', lastName: 'Person' } }
|
||||
],
|
||||
count: 2
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// /qqq/v1/table/{tableName}/count - table counts
|
||||
const countMatch = pathname.match(/^\/qqq\/v1\/table\/(\w+)\/count$/);
|
||||
if (countMatch) {
|
||||
const tableName = countMatch[1];
|
||||
const data = loadFixture(`qqq/v1/table/${tableName}/count.json`);
|
||||
if (data) {
|
||||
serveJson(res, data);
|
||||
return;
|
||||
}
|
||||
serveJson(res, { count: 2 });
|
||||
return;
|
||||
}
|
||||
|
||||
// /data/{tableName}/query - alternate query endpoint
|
||||
const dataQueryMatch = pathname.match(/^\/data\/(\w+)\/query$/);
|
||||
if (dataQueryMatch) {
|
||||
serveJson(res, {
|
||||
records: [],
|
||||
count: 0
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try serving static file (for React build)
|
||||
if (serveStaticFile(res, pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Default 404
|
||||
serve404(res, pathname);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Fixture server running on http://localhost:${PORT}`);
|
||||
console.log(`Using fixture: ${FIXTURE_NAME}`);
|
||||
});
|
||||
@@ -0,0 +1,594 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* STRICT Theme Verification Tests for QQQ QThemeMetaData
|
||||
*
|
||||
* ALL tests verify ACTUAL rendered values on components.
|
||||
* Tests FAIL if:
|
||||
* 1. The expected element is not found
|
||||
* 2. The rendered value doesn't match the configured value
|
||||
*
|
||||
* Theme values from withFullCustomTheme.json fixture
|
||||
*/
|
||||
const THEME = {
|
||||
// Core colors
|
||||
primaryColor: '#E91E63',
|
||||
secondaryColor: '#9C27B0',
|
||||
backgroundColor: '#ECEFF1',
|
||||
surfaceColor: '#FAFAFA',
|
||||
textPrimary: '#263238',
|
||||
textSecondary: '#607D8B',
|
||||
// Status colors
|
||||
errorColor: '#D50000',
|
||||
warningColor: '#FF6D00',
|
||||
successColor: '#00C853',
|
||||
infoColor: '#2962FF',
|
||||
// Typography
|
||||
fontFamily: '"Inter", "Helvetica", sans-serif',
|
||||
fontSizeBase: '15px',
|
||||
fontWeightLight: 300,
|
||||
fontWeightRegular: 400,
|
||||
fontWeightMedium: 500,
|
||||
fontWeightBold: 700,
|
||||
// Button typography (from fixture - buttons use this, not fontWeightMedium)
|
||||
typographyButtonFontWeight: 600,
|
||||
// UI
|
||||
borderRadius: '12px',
|
||||
density: 'normal',
|
||||
iconStyle: 'rounded',
|
||||
// Branded header
|
||||
brandedHeaderEnabled: true,
|
||||
brandedHeaderBackgroundColor: '#1A237E',
|
||||
brandedHeaderTextColor: '#E8EAF6',
|
||||
brandedHeaderHeight: '56px',
|
||||
// Sidebar
|
||||
sidebarBackgroundColor: '#1B5E20',
|
||||
sidebarTextColor: '#C8E6C9',
|
||||
sidebarIconColor: '#A5D6A7',
|
||||
sidebarSelectedBackgroundColor: '#2E7D32',
|
||||
sidebarSelectedTextColor: '#FFFFFF',
|
||||
sidebarHoverBackgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
sidebarDividerColor: 'rgba(255, 255, 255, 0.25)',
|
||||
// Tables
|
||||
tableHeaderBackgroundColor: '#7B1FA2',
|
||||
tableHeaderTextColor: '#FFFFFF',
|
||||
tableRowHoverColor: '#F3E5F5',
|
||||
tableBorderColor: '#E1BEE7',
|
||||
// General UI
|
||||
dividerColor: '#CFD8DC',
|
||||
borderColor: '#B0BEC5',
|
||||
};
|
||||
|
||||
// Helper to convert hex to RGB
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
// Helper to parse rgb/rgba string
|
||||
function parseRgb(rgbString: string): { r: number; g: number; b: number; a?: number } | null {
|
||||
// Handle rgb(r, g, b) and rgba(r, g, b, a)
|
||||
const rgbMatch = rgbString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (rgbMatch) {
|
||||
return {
|
||||
r: parseInt(rgbMatch[1]),
|
||||
g: parseInt(rgbMatch[2]),
|
||||
b: parseInt(rgbMatch[3]),
|
||||
a: rgbMatch[4] ? parseFloat(rgbMatch[4]) : undefined
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to parse rgba from expected value (could be hex or rgba string)
|
||||
function parseExpectedColor(color: string): { r: number; g: number; b: number; a?: number } | null {
|
||||
// If it's already rgba format
|
||||
if (color.startsWith('rgba')) {
|
||||
return parseRgb(color);
|
||||
}
|
||||
// If it's hex
|
||||
if (color.startsWith('#')) {
|
||||
const rgb = hexToRgb(color);
|
||||
return { ...rgb, a: undefined };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to check if colors match (with tolerance)
|
||||
function colorsMatch(actual: string, expected: string, tolerance = 2): boolean {
|
||||
// Handle hex-to-hex comparison (case insensitive)
|
||||
if (actual.startsWith('#') && expected.startsWith('#')) {
|
||||
return actual.toLowerCase() === expected.toLowerCase();
|
||||
}
|
||||
|
||||
// Parse actual color (could be rgb, rgba, or hex)
|
||||
let actualRgb = parseRgb(actual);
|
||||
if (!actualRgb && actual.startsWith('#')) {
|
||||
const hex = hexToRgb(actual);
|
||||
actualRgb = { ...hex, a: undefined };
|
||||
}
|
||||
|
||||
const expectedRgb = parseExpectedColor(expected);
|
||||
if (!actualRgb || !expectedRgb) return false;
|
||||
|
||||
const rgbMatch = (
|
||||
Math.abs(actualRgb.r - expectedRgb.r) <= tolerance &&
|
||||
Math.abs(actualRgb.g - expectedRgb.g) <= tolerance &&
|
||||
Math.abs(actualRgb.b - expectedRgb.b) <= tolerance
|
||||
);
|
||||
|
||||
// If both have alpha, compare alpha too
|
||||
if (actualRgb.a !== undefined && expectedRgb.a !== undefined) {
|
||||
return rgbMatch && Math.abs(actualRgb.a - expectedRgb.a) <= 0.05;
|
||||
}
|
||||
|
||||
return rgbMatch;
|
||||
}
|
||||
|
||||
// Helper to format assertion message
|
||||
function colorMismatch(property: string, expected: string, actual: string): string {
|
||||
return `${property}: expected ${expected}, got ${actual}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CORE COLORS - Actual Rendered Values
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Core Colors - Rendered', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
test('primaryColor: CSS variable is set correctly', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Check if CSS variable is properly set - this is the authoritative source
|
||||
// Note: This app uses "info" colored buttons, not "primary", so we verify
|
||||
// the CSS variable rather than button backgrounds
|
||||
const cssVar = getComputedStyle(document.documentElement).getPropertyValue('--qqq-primary-color').trim();
|
||||
if (cssVar) {
|
||||
return { found: true, value: cssVar, debug: 'CSS variable --qqq-primary-color' };
|
||||
}
|
||||
// Fallback: Try to find primary-styled buttons
|
||||
const selectors = [
|
||||
'.MuiButton-containedPrimary',
|
||||
'button[class*="containedPrimary"]',
|
||||
'.MuiButton-root[class*="Primary"]'
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const btn = document.querySelector(sel);
|
||||
if (btn) {
|
||||
const bg = getComputedStyle(btn).backgroundColor;
|
||||
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
|
||||
return { found: true, value: bg, debug: btn.className };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { found: false, value: '', debug: 'No primary color CSS variable or primary button found' };
|
||||
});
|
||||
expect(result.found, `Primary color not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.primaryColor), colorMismatch('primaryColor', THEME.primaryColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('secondaryColor: links use configured color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('a.MuiLink-root, main a:not(.MuiButton-root), .MuiTypography-root a');
|
||||
if (!el) return { found: false, value: '', debug: 'No link found' };
|
||||
return { found: true, value: getComputedStyle(el).color, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Link not found: ${result.debug}`).toBe(true);
|
||||
// Accept either primary or secondary color for links
|
||||
const matchesPrimary = colorsMatch(result.value, THEME.primaryColor);
|
||||
const matchesSecondary = colorsMatch(result.value, THEME.secondaryColor);
|
||||
expect(matchesPrimary || matchesSecondary, colorMismatch('secondaryColor', THEME.secondaryColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('backgroundColor: page body uses configured background', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
return { value: getComputedStyle(document.body).backgroundColor };
|
||||
});
|
||||
expect(colorsMatch(result.value, THEME.backgroundColor), colorMismatch('backgroundColor', THEME.backgroundColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('surfaceColor: cards use configured background', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Find cards/paper in main content, NOT in the drawer (sidebar)
|
||||
const el = document.querySelector('main .MuiCard-root, main .MuiPaper-root, .MuiCard-root:not(.MuiDrawer-paper):not(.MuiDrawer-root *)');
|
||||
if (!el) return { found: false, value: '', debug: 'No card/paper found in main content' };
|
||||
return { found: true, value: getComputedStyle(el).backgroundColor, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Card/Paper not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.surfaceColor), colorMismatch('surfaceColor', THEME.surfaceColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('textPrimary: headings use configured color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Find headings NOT in the sidebar/drawer
|
||||
const allHeadings = document.querySelectorAll('h1, h2, h3, h4, h5, h6, .MuiTypography-h1, .MuiTypography-h2, .MuiTypography-h3, .MuiTypography-h4, .MuiTypography-h5, .MuiTypography-h6');
|
||||
for (const el of allHeadings) {
|
||||
// Skip headings inside drawer/sidebar
|
||||
if (el.closest('.MuiDrawer-root') || el.closest('.MuiDrawer-paper')) continue;
|
||||
return { found: true, value: getComputedStyle(el).color, debug: el.className };
|
||||
}
|
||||
return { found: false, value: '', debug: 'No heading found outside sidebar' };
|
||||
});
|
||||
expect(result.found, `Heading not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.textPrimary), colorMismatch('textPrimary', THEME.textPrimary, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('textSecondary: body2/caption use configured color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Find body2/caption NOT in sidebar, drawer, or branded header
|
||||
const allElements = document.querySelectorAll('.MuiTypography-body2, .MuiTypography-caption, .MuiTypography-subtitle2');
|
||||
for (const el of allElements) {
|
||||
// Skip elements inside drawer, sidebar, or branded header
|
||||
if (el.closest('.MuiDrawer-root') || el.closest('.MuiDrawer-paper') || el.closest('.qqq-branded-header-bar')) continue;
|
||||
return { found: true, value: getComputedStyle(el).color, debug: el.className };
|
||||
}
|
||||
// Fallback: check if the CSS variable is properly set
|
||||
const cssVar = getComputedStyle(document.documentElement).getPropertyValue('--qqq-text-secondary').trim();
|
||||
if (cssVar) {
|
||||
return { found: true, value: cssVar, debug: 'CSS variable --qqq-text-secondary' };
|
||||
}
|
||||
return { found: false, value: '', debug: 'No body2/caption found outside sidebar/header and no CSS variable' };
|
||||
});
|
||||
expect(result.found, `Secondary text not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.textSecondary), colorMismatch('textSecondary', THEME.textSecondary, result.value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BRANDED HEADER - Actual Rendered Values
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Branded Header - Rendered', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test('brandedHeaderBackgroundColor: header uses configured background', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const selectors = ['.qqq-branded-header-bar', '[class*="NavBar"]', 'header', '.MuiAppBar-root'];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
if (bg && bg !== 'rgba(0, 0, 0, 0)') {
|
||||
return { found: true, value: bg, selector: sel };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { found: false, value: '', selector: '' };
|
||||
});
|
||||
expect(result.found, 'Branded header not found').toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.brandedHeaderBackgroundColor), colorMismatch('brandedHeaderBackgroundColor', THEME.brandedHeaderBackgroundColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('brandedHeaderTextColor: header text uses configured color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Specifically look for the branded header bar
|
||||
const header = document.querySelector('.qqq-branded-header-bar');
|
||||
if (!header) return { found: false, value: '', debug: 'No .qqq-branded-header-bar found' };
|
||||
|
||||
// First try to find a typography element with actual text
|
||||
const textElements = header.querySelectorAll('.MuiTypography-root, span:not(:empty)');
|
||||
for (const el of textElements) {
|
||||
const text = el.textContent?.trim();
|
||||
if (text && text.length > 0) {
|
||||
return { found: true, value: getComputedStyle(el).color, debug: `${el.className}: "${text.substring(0, 20)}"` };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check the CSS variable is set correctly
|
||||
const cssVar = getComputedStyle(document.documentElement).getPropertyValue('--qqq-branded-header-text-color').trim();
|
||||
if (cssVar) {
|
||||
return { found: true, value: cssVar, debug: 'CSS variable --qqq-branded-header-text-color' };
|
||||
}
|
||||
|
||||
return { found: false, value: '', debug: 'No text element in branded header' };
|
||||
});
|
||||
expect(result.found, `Header text not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.brandedHeaderTextColor), colorMismatch('brandedHeaderTextColor', THEME.brandedHeaderTextColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('brandedHeaderHeight: header uses configured height', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const header = document.querySelector('.qqq-branded-header-bar')
|
||||
|| document.querySelector('[class*="NavBar"]')
|
||||
|| document.querySelector('header');
|
||||
if (!header) return { found: false, value: '', debug: 'No header found' };
|
||||
return { found: true, value: getComputedStyle(header).height, debug: '' };
|
||||
});
|
||||
expect(result.found, `Header not found: ${result.debug}`).toBe(true);
|
||||
expect(result.value, colorMismatch('brandedHeaderHeight', THEME.brandedHeaderHeight, result.value)).toBe(THEME.brandedHeaderHeight);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// SIDEBAR - Actual Rendered Values
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Sidebar - Rendered', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test('sidebarBackgroundColor: drawer uses configured background', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper');
|
||||
if (!el) return { found: false, value: '', debug: 'No .MuiDrawer-paper found' };
|
||||
return { found: true, value: getComputedStyle(el).backgroundColor, debug: '' };
|
||||
});
|
||||
expect(result.found, `Sidebar drawer not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.sidebarBackgroundColor), colorMismatch('sidebarBackgroundColor', THEME.sidebarBackgroundColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebarTextColor: nav item text uses configured color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper .MuiListItemText-primary, .MuiDrawer-paper .MuiListItemText-root span');
|
||||
if (!el) return { found: false, value: '', debug: 'No sidebar text found' };
|
||||
return { found: true, value: getComputedStyle(el).color, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Sidebar text not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.sidebarTextColor), colorMismatch('sidebarTextColor', THEME.sidebarTextColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebarIconColor: nav item icons use configured color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper .MuiSvgIcon-root, .MuiDrawer-paper svg, .MuiDrawer-paper .MuiIcon-root');
|
||||
if (!el) return { found: false, value: '', debug: 'No sidebar icon found' };
|
||||
return { found: true, value: getComputedStyle(el).color, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Sidebar icon not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.sidebarIconColor), colorMismatch('sidebarIconColor', THEME.sidebarIconColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebarSelectedBackgroundColor: selected nav item uses configured background', async ({ page }) => {
|
||||
// Click on a nav item to select it
|
||||
await page.click('.MuiDrawer-paper .MuiListItem-root');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper .qqq-sidebar-active');
|
||||
if (!el) return { found: false, value: '', debug: 'No .qqq-sidebar-active found' };
|
||||
return { found: true, value: getComputedStyle(el).backgroundColor, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Selected sidebar item not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.sidebarSelectedBackgroundColor), colorMismatch('sidebarSelectedBackgroundColor', THEME.sidebarSelectedBackgroundColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebarSelectedTextColor: selected nav item text uses configured color', async ({ page }) => {
|
||||
await page.click('.MuiDrawer-paper .MuiListItem-root');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const selected = document.querySelector('.MuiDrawer-paper .qqq-sidebar-active');
|
||||
if (!selected) return { found: false, value: '', debug: 'No .qqq-sidebar-active found' };
|
||||
const text = selected.querySelector('.MuiTypography-root, span') || selected;
|
||||
return { found: true, value: getComputedStyle(text).color, debug: (text as Element).className };
|
||||
});
|
||||
expect(result.found, `Selected sidebar text not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.sidebarSelectedTextColor), colorMismatch('sidebarSelectedTextColor', THEME.sidebarSelectedTextColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('sidebarDividerColor: sidebar dividers use configured color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDrawer-paper .MuiDivider-root, .MuiDrawer-paper hr');
|
||||
if (!el) return { found: false, value: '', debug: 'No sidebar divider found' };
|
||||
const styles = getComputedStyle(el);
|
||||
return { found: true, value: styles.borderColor || styles.backgroundColor, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Sidebar divider not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.sidebarDividerColor), colorMismatch('sidebarDividerColor', THEME.sidebarDividerColor, result.value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// TABLES (MUI DataGrid) - Actual Rendered Values
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Tables - Rendered', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
test('tableHeaderBackgroundColor: DataGrid header cells use configured background', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDataGrid-columnHeader');
|
||||
if (!el) return { found: false, value: '', debug: 'No .MuiDataGrid-columnHeader found' };
|
||||
return { found: true, value: getComputedStyle(el).backgroundColor, debug: '' };
|
||||
});
|
||||
expect(result.found, `DataGrid column header not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.tableHeaderBackgroundColor), colorMismatch('tableHeaderBackgroundColor', THEME.tableHeaderBackgroundColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('tableHeaderTextColor: DataGrid header text uses configured color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiDataGrid-columnHeaderTitle');
|
||||
if (!el) return { found: false, value: '', debug: 'No .MuiDataGrid-columnHeaderTitle found' };
|
||||
return { found: true, value: getComputedStyle(el).color, debug: '' };
|
||||
});
|
||||
expect(result.found, `DataGrid header text not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.tableHeaderTextColor), colorMismatch('tableHeaderTextColor', THEME.tableHeaderTextColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('tableBorderColor: DataGrid cells use configured border color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const cell = document.querySelector('.MuiDataGrid-cell');
|
||||
if (!cell) return { found: false, value: '', debug: 'No .MuiDataGrid-cell found' };
|
||||
return { found: true, value: getComputedStyle(cell).borderColor, debug: '' };
|
||||
});
|
||||
expect(result.found, `DataGrid cell not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.tableBorderColor), colorMismatch('tableBorderColor', THEME.tableBorderColor, result.value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// TYPOGRAPHY - Actual Rendered Values
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Typography - Rendered', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test('fontFamily: body uses configured font family', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
return { value: getComputedStyle(document.body).fontFamily };
|
||||
});
|
||||
expect(result.value, 'fontFamily should contain Inter').toMatch(/Inter/i);
|
||||
});
|
||||
|
||||
test('fontSizeBase: body uses configured font size', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
return { value: getComputedStyle(document.body).fontSize };
|
||||
});
|
||||
expect(result.value, colorMismatch('fontSizeBase', THEME.fontSizeBase, result.value)).toBe(THEME.fontSizeBase);
|
||||
});
|
||||
|
||||
test('fontWeightRegular: body text uses configured weight', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiTypography-body1, p, span');
|
||||
if (!el) return { found: false, value: '', debug: 'No body text found' };
|
||||
return { found: true, value: getComputedStyle(el).fontWeight, debug: el.className };
|
||||
});
|
||||
expect(result.found, `Body text not found: ${result.debug}`).toBe(true);
|
||||
expect(result.value, colorMismatch('fontWeightRegular', String(THEME.fontWeightRegular), result.value)).toBe(String(THEME.fontWeightRegular));
|
||||
});
|
||||
|
||||
test('typographyButtonFontWeight: buttons use configured weight', async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const btn = document.querySelector('.MuiButton-root');
|
||||
if (!btn) return { found: false, value: '', debug: 'No button found' };
|
||||
return { found: true, value: getComputedStyle(btn).fontWeight, debug: btn.className };
|
||||
});
|
||||
expect(result.found, `Button not found: ${result.debug}`).toBe(true);
|
||||
// Buttons use typographyButtonFontWeight, NOT fontWeightMedium
|
||||
expect(result.value, colorMismatch('typographyButtonFontWeight', String(THEME.typographyButtonFontWeight), result.value)).toBe(String(THEME.typographyButtonFontWeight));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// UI ELEMENTS - Actual Rendered Values
|
||||
// ============================================================================
|
||||
|
||||
test.describe('UI Elements - Rendered', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2500);
|
||||
});
|
||||
|
||||
test('borderRadius: buttons use configured border radius', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const btn = document.querySelector('.MuiButton-root');
|
||||
if (!btn) return { found: false, value: '', debug: 'No button found' };
|
||||
return { found: true, value: getComputedStyle(btn).borderRadius, debug: btn.className };
|
||||
});
|
||||
expect(result.found, `Button not found: ${result.debug}`).toBe(true);
|
||||
expect(result.value, colorMismatch('borderRadius', THEME.borderRadius, result.value)).toBe(THEME.borderRadius);
|
||||
});
|
||||
|
||||
test('borderColor: inputs use configured border color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
const el = document.querySelector('.MuiOutlinedInput-notchedOutline');
|
||||
if (!el) return { found: false, value: '', debug: 'No .MuiOutlinedInput-notchedOutline found' };
|
||||
return { found: true, value: getComputedStyle(el).borderColor, debug: '' };
|
||||
});
|
||||
expect(result.found, `Input border not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.borderColor), colorMismatch('borderColor', THEME.borderColor, result.value)).toBe(true);
|
||||
});
|
||||
|
||||
test('dividerColor: dividers use configured color', async ({ page }) => {
|
||||
const result = await page.evaluate(() => {
|
||||
// Find a divider NOT in the drawer (sidebar has its own divider color)
|
||||
const selectors = [
|
||||
'main .MuiDivider-root',
|
||||
'.MuiDivider-root:not(.MuiDrawer-root *):not(.MuiDrawer-paper *)'
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
const styles = getComputedStyle(el);
|
||||
return { found: true, value: styles.borderColor || styles.backgroundColor, debug: el.className };
|
||||
}
|
||||
}
|
||||
// If no divider element found, check if the CSS variable is properly set
|
||||
const cssVar = getComputedStyle(document.documentElement).getPropertyValue('--qqq-divider-color').trim();
|
||||
if (cssVar) {
|
||||
return { found: true, value: cssVar, debug: 'CSS variable --qqq-divider-color' };
|
||||
}
|
||||
return { found: false, value: '', debug: 'No divider found outside drawer' };
|
||||
});
|
||||
expect(result.found, `Divider not found: ${result.debug}`).toBe(true);
|
||||
expect(colorsMatch(result.value, THEME.dividerColor), colorMismatch('dividerColor', THEME.dividerColor, result.value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DIAGNOSTIC DUMP - outputs actual values for debugging
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Diagnostic Dump', () => {
|
||||
test('output all rendered theme values', async ({ page }) => {
|
||||
await page.goto('/testApp/person');
|
||||
await page.waitForSelector('[id="root"]');
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const diagnostics = await page.evaluate(() => {
|
||||
const getColor = (selector: string, prop: string) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return { found: false, value: 'NOT FOUND' };
|
||||
return { found: true, value: getComputedStyle(el)[prop as any] };
|
||||
};
|
||||
|
||||
return {
|
||||
primaryButton: getColor('.MuiButton-containedPrimary', 'backgroundColor'),
|
||||
link: getColor('a.MuiLink-root, main a', 'color'),
|
||||
body: getColor('body', 'backgroundColor'),
|
||||
card: getColor('.MuiCard-root, .MuiPaper-root', 'backgroundColor'),
|
||||
heading: getColor('h1, h2, h3, h4', 'color'),
|
||||
body2: getColor('.MuiTypography-body2', 'color'),
|
||||
brandedHeader: getColor('.qqq-branded-header-bar', 'backgroundColor'),
|
||||
brandedHeaderText: getColor('.qqq-branded-header-bar span', 'color'),
|
||||
sidebarBg: getColor('.MuiDrawer-paper', 'backgroundColor'),
|
||||
sidebarText: getColor('.MuiDrawer-paper .MuiListItemText-primary', 'color'),
|
||||
sidebarIcon: getColor('.MuiDrawer-paper .MuiSvgIcon-root', 'color'),
|
||||
sidebarActive: getColor('.qqq-sidebar-active', 'backgroundColor'),
|
||||
sidebarActiveText: getColor('.qqq-sidebar-active .MuiTypography-root', 'color'),
|
||||
tableHeader: getColor('.MuiDataGrid-columnHeader', 'backgroundColor'),
|
||||
tableHeaderText: getColor('.MuiDataGrid-columnHeaderTitle', 'color'),
|
||||
tableCell: getColor('.MuiDataGrid-cell', 'borderColor'),
|
||||
inputBorder: getColor('.MuiOutlinedInput-notchedOutline', 'borderColor'),
|
||||
buttonRadius: getColor('.MuiButton-root', 'borderRadius'),
|
||||
buttonWeight: getColor('.MuiButton-root', 'fontWeight'),
|
||||
bodyWeight: getColor('.MuiTypography-body1', 'fontWeight'),
|
||||
};
|
||||
});
|
||||
|
||||
console.log('=== DIAGNOSTIC OUTPUT ===');
|
||||
console.log(JSON.stringify(diagnostics, null, 2));
|
||||
console.log('=========================');
|
||||
|
||||
// This test always passes - it's just for diagnostics
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
(set -o igncr) 2>/dev/null && set -o igncr; # cygwin encoding fix
|
||||
|
||||
basedir=`dirname "$0"`
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
NODE_EXE="$basedir/node.exe"
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE="$basedir/node"
|
||||
fi
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE=node
|
||||
fi
|
||||
|
||||
# this path is passed to node.exe, so it needs to match whatever
|
||||
# kind of paths Node.js thinks it's using, typically win32 paths.
|
||||
CLI_BASEDIR="$("$NODE_EXE" -p 'require("path").dirname(process.execPath)')"
|
||||
NPM_CLI_JS="$CLI_BASEDIR/node_modules/npm/bin/npm-cli.js"
|
||||
|
||||
NPM_PREFIX=`"$NODE_EXE" "$NPM_CLI_JS" prefix -g`
|
||||
if [ $? -ne 0 ]; then
|
||||
# if this didn't work, then everything else below will fail
|
||||
echo "Could not determine Node.js install directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
NPM_PREFIX_NPM_CLI_JS="$NPM_PREFIX/node_modules/npm/bin/npm-cli.js"
|
||||
|
||||
# a path that will fail -f test on any posix bash
|
||||
NPM_WSL_PATH="/.."
|
||||
|
||||
# WSL can run Windows binaries, so we have to give it the win32 path
|
||||
# however, WSL bash tests against posix paths, so we need to construct that
|
||||
# to know if npm is installed globally.
|
||||
if [ `uname` = 'Linux' ] && type wslpath &>/dev/null ; then
|
||||
NPM_WSL_PATH=`wslpath "$NPM_PREFIX_NPM_CLI_JS"`
|
||||
fi
|
||||
if [ -f "$NPM_PREFIX_NPM_CLI_JS" ] || [ -f "$NPM_WSL_PATH" ]; then
|
||||
NPM_CLI_JS="$NPM_PREFIX_NPM_CLI_JS"
|
||||
fi
|
||||
|
||||
"$NODE_EXE" "$NPM_CLI_JS" "$@"
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
:: Created by npm, please don't edit manually.
|
||||
@ECHO OFF
|
||||
|
||||
SETLOCAL
|
||||
|
||||
SET "NODE_EXE=%~dp0\node.exe"
|
||||
IF NOT EXIST "%NODE_EXE%" (
|
||||
SET "NODE_EXE=node"
|
||||
)
|
||||
|
||||
SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
|
||||
FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_CLI_JS%" prefix -g') DO (
|
||||
SET "NPM_PREFIX_NPM_CLI_JS=%%F\node_modules\npm\bin\npm-cli.js"
|
||||
)
|
||||
IF EXIST "%NPM_PREFIX_NPM_CLI_JS%" (
|
||||
SET "NPM_CLI_JS=%NPM_PREFIX_NPM_CLI_JS%"
|
||||
)
|
||||
|
||||
"%NODE_EXE%" "%NPM_CLI_JS%" %*
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This is used by the Node.js installer, which expects the cygwin/mingw
|
||||
# shell script to already be present in the npm dependency folder.
|
||||
|
||||
(set -o igncr) 2>/dev/null && set -o igncr; # cygwin encoding fix
|
||||
|
||||
basedir=`dirname "$0"`
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
NODE_EXE="$basedir/node.exe"
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE="$basedir/node"
|
||||
fi
|
||||
if ! [ -x "$NODE_EXE" ]; then
|
||||
NODE_EXE=node
|
||||
fi
|
||||
|
||||
# these paths are passed to node.exe, so they need to match whatever
|
||||
# kind of paths Node.js thinks it's using, typically win32 paths.
|
||||
CLI_BASEDIR="$("$NODE_EXE" -p 'require("path").dirname(process.execPath)')"
|
||||
if [ $? -ne 0 ]; then
|
||||
# if this didn't work, then everything else below will fail
|
||||
echo "Could not determine Node.js install directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
NPM_CLI_JS="$CLI_BASEDIR/node_modules/npm/bin/npm-cli.js"
|
||||
NPX_CLI_JS="$CLI_BASEDIR/node_modules/npm/bin/npx-cli.js"
|
||||
NPM_PREFIX=`"$NODE_EXE" "$NPM_CLI_JS" prefix -g`
|
||||
NPM_PREFIX_NPX_CLI_JS="$NPM_PREFIX/node_modules/npm/bin/npx-cli.js"
|
||||
|
||||
# a path that will fail -f test on any posix bash
|
||||
NPX_WSL_PATH="/.."
|
||||
|
||||
# WSL can run Windows binaries, so we have to give it the win32 path
|
||||
# however, WSL bash tests against posix paths, so we need to construct that
|
||||
# to know if npm is installed globally.
|
||||
if [ `uname` = 'Linux' ] && type wslpath &>/dev/null ; then
|
||||
NPX_WSL_PATH=`wslpath "$NPM_PREFIX_NPX_CLI_JS"`
|
||||
fi
|
||||
if [ -f "$NPM_PREFIX_NPX_CLI_JS" ] || [ -f "$NPX_WSL_PATH" ]; then
|
||||
NPX_CLI_JS="$NPM_PREFIX_NPX_CLI_JS"
|
||||
fi
|
||||
|
||||
"$NODE_EXE" "$NPX_CLI_JS" "$@"
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
:: Created by npm, please don't edit manually.
|
||||
@ECHO OFF
|
||||
|
||||
SETLOCAL
|
||||
|
||||
SET "NODE_EXE=%~dp0\node.exe"
|
||||
IF NOT EXIST "%NODE_EXE%" (
|
||||
SET "NODE_EXE=node"
|
||||
)
|
||||
|
||||
SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
|
||||
SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
|
||||
FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_CLI_JS%" prefix -g') DO (
|
||||
SET "NPM_PREFIX_NPX_CLI_JS=%%F\node_modules\npm\bin\npx-cli.js"
|
||||
)
|
||||
IF EXIST "%NPM_PREFIX_NPX_CLI_JS%" (
|
||||
SET "NPX_CLI_JS=%NPM_PREFIX_NPX_CLI_JS%"
|
||||
)
|
||||
|
||||
"%NODE_EXE%" "%NPX_CLI_JS%" %*
|
||||
Generated
+23550
-38364
File diff suppressed because it is too large.
Load diff
+126
-108
@@ -1,110 +1,128 @@
|
||||
{
|
||||
"name": "qqq-frontend-material-dashboard",
|
||||
"version": "1.0.0",
|
||||
"description": "QQQ Default Dashboard",
|
||||
"dependencies": {
|
||||
"@auth0/auth0-react": "1.10.2",
|
||||
"@emotion/react": "11.7.1",
|
||||
"@emotion/styled": "11.6.0",
|
||||
"@kingsrook/qqq-frontend-core": "1.0.110",
|
||||
"@mui/icons-material": "5.4.1",
|
||||
"@mui/material": "5.11.1",
|
||||
"@mui/styles": "5.11.1",
|
||||
"@mui/system": "5.11.1",
|
||||
"@mui/x-data-grid": "5.17.23",
|
||||
"@mui/x-data-grid-pro": "5.17.23",
|
||||
"@mui/x-date-pickers": "7.1.1",
|
||||
"@mui/x-license-pro": "5.12.3",
|
||||
"@react-jvectormap/core": "1.0.1",
|
||||
"@react-jvectormap/unitedstates": "1.0.1",
|
||||
"@react-oauth/google": "0.2.8",
|
||||
"@types/prop-types": "^15.7.5",
|
||||
"@types/react": "18.0.0",
|
||||
"@types/react-dom": "18.0.0",
|
||||
"@types/react-router-hash-link": "2.4.5",
|
||||
"ace-builds": "1.12.3",
|
||||
"chart.js": "3.4.1",
|
||||
"chroma-js": "2.4.2",
|
||||
"cmdk": "0.2.0",
|
||||
"datejs": "1.0.0-rc3",
|
||||
"dayjs": "1.11.10",
|
||||
"downshift": "3.2.10",
|
||||
"faker": "5.5.3",
|
||||
"form-data": "4.0.0",
|
||||
"formik": "2.2.9",
|
||||
"html-react-parser": "1.4.8",
|
||||
"html-to-text": "^9.0.5",
|
||||
"http-proxy-middleware": "2.0.6",
|
||||
"jwt-decode": "3.1.2",
|
||||
"rapidoc": "9.3.4",
|
||||
"react": "18.0.0",
|
||||
"react-ace": "10.1.0",
|
||||
"react-chartjs-2": "3.0.4",
|
||||
"react-cookie": "4.1.1",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "18.0.0",
|
||||
"react-ga4": "2.1.0",
|
||||
"react-github-btn": "1.2.1",
|
||||
"react-google-drive-picker": "^1.2.0",
|
||||
"react-markdown": "9.0.1",
|
||||
"react-router-dom": "6.2.1",
|
||||
"react-router-hash-link": "2.4.3",
|
||||
"react-table": "7.7.0",
|
||||
"sass": "1.63.4",
|
||||
"ts-md5": "1.2.11",
|
||||
"yup": "0.32.11"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "react-scripts build",
|
||||
"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",
|
||||
"npm-install": "npm install --legacy-peer-deps",
|
||||
"prepublishOnly": "tsc -p ./ --outDir lib/",
|
||||
"start": "BROWSER=none react-scripts --max-http-header-size=65535 start",
|
||||
"test": "react-scripts test"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@types/chroma-js": "2.1.3",
|
||||
"@types/faker": "5.5.9",
|
||||
"@types/react-table": "7.7.9",
|
||||
"@typescript-eslint/eslint-plugin": "5.10.2",
|
||||
"@typescript-eslint/parser": "5.10.2",
|
||||
"eslint": "8.8.0",
|
||||
"eslint-import-resolver-typescript": "2.5.0",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-react": "7.28.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"overrides": {
|
||||
"@svgr/webpack": "$@svgr/webpack"
|
||||
},
|
||||
"main": "qqq-frontend-material-dashboard.js",
|
||||
"module": "lib/qqq-frontend-material-dashboard.js",
|
||||
"files": [
|
||||
"lib",
|
||||
"package.json"
|
||||
]
|
||||
"name": "qqq-frontend-material-dashboard",
|
||||
"version": "0.36.0-SNAPSHOT",
|
||||
"homepage": ".",
|
||||
"description": "QQQ Default Dashboard",
|
||||
"dependencies": {
|
||||
"@auth0/auth0-react": "1.10.2",
|
||||
"@emotion/react": "11.7.1",
|
||||
"@emotion/styled": "11.6.0",
|
||||
"@mui/icons-material": "5.4.1",
|
||||
"@mui/material": "5.11.1",
|
||||
"@mui/styles": "5.11.1",
|
||||
"@mui/system": "5.11.1",
|
||||
"@mui/x-data-grid": "5.17.23",
|
||||
"@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": "1.4.2-SNAPSHOT",
|
||||
"@react-jvectormap/core": "1.0.1",
|
||||
"@react-jvectormap/unitedstates": "1.0.1",
|
||||
"@react-oauth/google": "0.2.8",
|
||||
"@types/prop-types": "^15.7.5",
|
||||
"@types/react": "18.0.0",
|
||||
"@types/react-dom": "18.0.0",
|
||||
"@types/react-router-hash-link": "2.4.5",
|
||||
"ace-builds": "1.12.3",
|
||||
"chart.js": "3.4.1",
|
||||
"chroma-js": "2.4.2",
|
||||
"cmdk": "0.2.0",
|
||||
"datejs": "1.0.0-rc3",
|
||||
"dayjs": "1.11.10",
|
||||
"downshift": "3.2.10",
|
||||
"faker": "5.5.3",
|
||||
"form-data": "4.0.4",
|
||||
"formik": "2.2.9",
|
||||
"html-react-parser": "1.4.8",
|
||||
"html-to-text": "^9.0.5",
|
||||
"http-proxy-middleware": "2.0.9",
|
||||
"jwt-decode": "3.1.2",
|
||||
"lodash": "4.17.21",
|
||||
"oidc-client-ts": "2.4.1",
|
||||
"rapidoc": "9.3.4",
|
||||
"react": "18.0.0",
|
||||
"react-ace": "10.1.0",
|
||||
"react-chartjs-2": "3.0.4",
|
||||
"react-cookie": "8.0.1",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "18.0.0",
|
||||
"react-dropzone": "14.3.5",
|
||||
"react-ga4": "2.1.0",
|
||||
"react-github-btn": "1.2.1",
|
||||
"react-google-drive-picker": "^1.2.0",
|
||||
"react-markdown": "9.0.1",
|
||||
"react-oidc-context": "2.3.1",
|
||||
"react-router-dom": "6.2.1",
|
||||
"react-router-hash-link": "2.4.3",
|
||||
"react-table": "7.7.0",
|
||||
"sass": "1.63.4",
|
||||
"ts-md5": "1.2.11",
|
||||
"yup": "0.32.11"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"npm-install": "npm install --legacy-peer-deps",
|
||||
"prepublishOnly": "tsc -p ./ --outDir lib/",
|
||||
"start": "BROWSER=none react-scripts --max-http-header-size=65535 start",
|
||||
"test": "react-scripts test",
|
||||
"install:legacy": "npm install --legacy-peer-deps",
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:e2e:ui": "npx playwright test --ui",
|
||||
"test:e2e:headed": "npx playwright test --headed",
|
||||
"fixture-server": "node e2e/fixture-server.js",
|
||||
"e2e": "concurrently --kill-others \"npm run fixture-server\" \"npx playwright test\""
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@types/chroma-js": "2.1.3",
|
||||
"@types/faker": "5.5.9",
|
||||
"@types/react-table": "7.7.9",
|
||||
"@typescript-eslint/eslint-plugin": "5.10.2",
|
||||
"@typescript-eslint/parser": "5.10.2",
|
||||
"concurrently": "^8.2.0",
|
||||
"eslint": "8.8.0",
|
||||
"eslint-import-resolver-typescript": "2.5.0",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-react": "7.28.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"serve": "^14.2.5",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"overrides": {
|
||||
"@svgr/webpack": "$@svgr/webpack"
|
||||
},
|
||||
"main": "qqq-frontend-material-dashboard.js",
|
||||
"module": "lib/qqq-frontend-material-dashboard.js",
|
||||
"files": [
|
||||
"lib",
|
||||
"package.json"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e/tests',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: [['html', { open: 'never' }], ['list']],
|
||||
timeout: 60000,
|
||||
use: {
|
||||
baseURL: 'http://localhost:8001',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'node e2e/fixture-server.js',
|
||||
url: 'http://localhost:8001',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30000,
|
||||
},
|
||||
});
|
||||
@@ -20,178 +20,321 @@
|
||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-material-dashboard</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-material-dashboard</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<revision>0.23.0</revision>
|
||||
<name>QQQ Frontend Material Dashboard</name>
|
||||
<description>Low-code Application Framework for Engineers - Frontend Material Dashboard Component</description>
|
||||
<url>https://github.com/Kingsrook/qqq-frontend-material-dashboard</url>
|
||||
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<maven.compiler.showDeprecation>true</maven.compiler.showDeprecation>
|
||||
<maven.compiler.showWarnings>true</maven.compiler.showWarnings>
|
||||
</properties>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>GNU Affero General Public License v3.0</name>
|
||||
<url>https://www.gnu.org/licenses/agpl-3.0.html</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
<!--
|
||||
When qqq-backend-core was added as a dependency here, it brought with it some transitive
|
||||
dependencies which conflicted with selenium. Excluding these from qqq-backend-core fixes us.
|
||||
-->
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>netty-nio-client</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<scm>
|
||||
<connection>scm:git:git://github.com/Kingsrook/qqq-frontend-material-dashboard.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:Kingsrook/qqq-frontend-material-dashboard.git</developerConnection>
|
||||
<url>https://github.com/Kingsrook/qqq-frontend-material-dashboard</url>
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
<version>0.21.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.seleniumhq.selenium</groupId>
|
||||
<artifactId>selenium-java</artifactId>
|
||||
<version>4.15.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.bonigarcia</groupId>
|
||||
<artifactId>webdrivermanager</artifactId>
|
||||
<version>5.6.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.11.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-engine</artifactId>
|
||||
<version>5.8.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-params</artifactId>
|
||||
<version>5.8.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.23.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>5.4.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>20231013</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-api</artifactId>
|
||||
<version>2.17.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-core</artifactId>
|
||||
<version>2.17.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<developers>
|
||||
<developer>
|
||||
<name>Kingsrook Development Team</name>
|
||||
<email>contact@kingsrook.com</email>
|
||||
<organization>Kingsrook, LLC</organization>
|
||||
<organizationUrl>https://kingsrook.com</organizationUrl>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<properties>
|
||||
<revision>0.36.0-SNAPSHOT</revision>
|
||||
<qqq.version>0.36.0-SNAPSHOT</qqq.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<maven.compiler.showDeprecation>true</maven.compiler.showDeprecation>
|
||||
<maven.compiler.showWarnings>true</maven.compiler.showWarnings>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
<optional>true</optional>
|
||||
<scope>provided</scope>
|
||||
<!--
|
||||
When qqq-backend-core was added as a dependency here, it brought with it some transitive
|
||||
dependencies which conflicted with selenium. Excluding these from qqq-backend-core fixes us.
|
||||
-->
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>netty-nio-client</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<optional>true</optional>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.seleniumhq.selenium</groupId>
|
||||
<artifactId>selenium-java</artifactId>
|
||||
<version>4.35.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.bonigarcia</groupId>
|
||||
<artifactId>webdrivermanager</artifactId>
|
||||
<version>6.1.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.14.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-engine</artifactId>
|
||||
<version>5.8.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-params</artifactId>
|
||||
<version>5.8.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.23.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>6.7.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>20231013</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-api</artifactId>
|
||||
<version>2.17.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-core</artifactId>
|
||||
<version>2.25.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<!-- Release profile for Maven Central: attach sources/javadocs and sign artifacts -->
|
||||
<id>release</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.0.0-M5</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.amashchenko.maven.plugin</groupId>
|
||||
<artifactId>gitflow-maven-plugin</artifactId>
|
||||
<version>1.18.0</version>
|
||||
<configuration>
|
||||
<gitFlowConfig>
|
||||
<productionBranch>main</productionBranch>
|
||||
<developmentBranch>dev</developmentBranch>
|
||||
<versionTagPrefix>version-</versionTagPrefix>
|
||||
</gitFlowConfig>
|
||||
<skipFeatureVersion>true</skipFeatureVersion> <!-- Keep feature names out of versions -->
|
||||
<commitDevelopmentVersionAtStart>true</commitDevelopmentVersionAtStart>
|
||||
<versionDigitToIncrement>1</versionDigitToIncrement> <!-- In general, we update the minor -->
|
||||
<versionProperty>revision</versionProperty>
|
||||
<skipUpdateVersion>true</skipUpdateVersion>
|
||||
<skipTestProject>true</skipTestProject> <!-- we allow CI to do the tests -->
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- Publish this project's test code as a jar -->
|
||||
<plugin>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<artifactId>maven-source-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>test-jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>attach-sources</id>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-javadoc-plugin</artifactId>
|
||||
<version>3.6.2</version>
|
||||
<configuration>
|
||||
<failOnError>false</failOnError>
|
||||
<doclint>none</doclint>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-javadocs</id>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<version>3.2.4</version>
|
||||
<configuration>
|
||||
<keyname>${gpg.keyname}</keyname>
|
||||
<gpgArguments>
|
||||
<arg>--pinentry-mode</arg>
|
||||
<arg>loopback</arg>
|
||||
</gpgArguments>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-artifacts</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>sign</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<version>0.8.0</version>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<publishingServerId>central</publishingServerId>
|
||||
<autoPublish>true</autoPublish>
|
||||
<waitUntil>uploaded</waitUntil>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
</plugins>
|
||||
</build>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.0.0-M5</version>
|
||||
<configuration>
|
||||
<rerunFailingTestsCount>5</rerunFailingTestsCount>
|
||||
<!-- Until mvn verify is used in CI (e.g., to run Integration Tests separately from Unit
|
||||
Tests), run both here -->
|
||||
<includes>
|
||||
<include>**/*Test.java</include>
|
||||
<include>**/*IT.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.amashchenko.maven.plugin</groupId>
|
||||
<artifactId>gitflow-maven-plugin</artifactId>
|
||||
<version>1.18.0</version>
|
||||
<configuration>
|
||||
<gitFlowConfig>
|
||||
<productionBranch>main</productionBranch>
|
||||
<developmentBranch>develop</developmentBranch>
|
||||
<versionTagPrefix>v</versionTagPrefix>
|
||||
</gitFlowConfig>
|
||||
<skipFeatureVersion>true</skipFeatureVersion> <!-- Keep feature names out of versions -->
|
||||
<commitDevelopmentVersionAtStart>true</commitDevelopmentVersionAtStart>
|
||||
<versionDigitToIncrement>1</versionDigitToIncrement> <!-- In general, we update the minor -->
|
||||
<versionProperty>revision</versionProperty>
|
||||
<skipUpdateVersion>true</skipUpdateVersion>
|
||||
<skipTestProject>true</skipTestProject> <!-- we allow CI to do the tests -->
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>github-qqq-maven-registry</id>
|
||||
<name>GitHub QQQ Maven Registry</name>
|
||||
<url>https://maven.pkg.github.com/Kingsrook/qqq-maven-registry</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
<!-- Publish this project's test code as a jar -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>test-jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<distributionManagement>
|
||||
<repository>
|
||||
<id>github-qqq-maven-registry</id>
|
||||
<name>GitHub QQQ Maven Registry</name>
|
||||
<url>https://maven.pkg.github.com/Kingsrook/qqq-maven-registry</url>
|
||||
</repository>
|
||||
</distributionManagement>
|
||||
<!-- GPG signing for Maven Central -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-artifacts</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>sign</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Sonatype Central publishing (replaces deploy to distributionManagement) -->
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<version>0.8.0</version>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<!-- Server credentials with this ID must be provided in settings.xml -->
|
||||
<publishingServerId>central</publishingServerId>
|
||||
<autoPublish>true</autoPublish>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>sonatype-snapshots</id>
|
||||
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
</project>
|
||||
Executable
+254
@@ -0,0 +1,254 @@
|
||||
#!/bin/bash
|
||||
############################################################################
|
||||
## run-tests.sh
|
||||
## Run all tests for qqq-frontend-material-dashboard
|
||||
##
|
||||
## This script handles:
|
||||
## - Starting required servers (React, fixture server)
|
||||
## - Running Playwright e2e tests
|
||||
## - Running Selenium integration tests
|
||||
## - Cleanup and teardown
|
||||
##
|
||||
## Usage:
|
||||
## ./run-tests.sh # Run all tests
|
||||
## ./run-tests.sh --playwright # Run only Playwright tests
|
||||
## ./run-tests.sh --selenium # Run only Selenium tests
|
||||
## ./run-tests.sh --help # Show help
|
||||
############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# PID tracking
|
||||
REACT_PID=""
|
||||
FIXTURE_PID=""
|
||||
|
||||
log() { echo -e "${GREEN}[run-tests]${NC} $1"; }
|
||||
info() { echo -e "${BLUE}[run-tests]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[run-tests]${NC} $1"; }
|
||||
error() { echo -e "${RED}[run-tests]${NC} $1"; }
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --playwright Run only Playwright e2e tests"
|
||||
echo " --selenium Run only Selenium integration tests"
|
||||
echo " --all Run all tests (default)"
|
||||
echo " --help Show this help message"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " QQQ_SELENIUM_HEADLESS=true Run browser in headless mode"
|
||||
exit 0
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
log "Cleaning up..."
|
||||
|
||||
if [ -n "$REACT_PID" ] && kill -0 "$REACT_PID" 2>/dev/null; then
|
||||
log "Stopping React dev server (PID: $REACT_PID)"
|
||||
kill "$REACT_PID" 2>/dev/null || true
|
||||
wait "$REACT_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -n "$FIXTURE_PID" ] && kill -0 "$FIXTURE_PID" 2>/dev/null; then
|
||||
log "Stopping fixture server (PID: $FIXTURE_PID)"
|
||||
kill "$FIXTURE_PID" 2>/dev/null || true
|
||||
wait "$FIXTURE_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Kill any orphaned processes on our ports
|
||||
lsof -ti :3001 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
lsof -ti :8001 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
free_port() {
|
||||
local port=$1
|
||||
if lsof -i :$port >/dev/null 2>&1; then
|
||||
warn "Port $port is in use. Killing process..."
|
||||
lsof -ti :$port | xargs kill -9 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
}
|
||||
|
||||
start_react_server() {
|
||||
log "Starting React dev server on https://localhost:3001..."
|
||||
HTTPS=true PORT=3001 REACT_APP_PROXY_LOCALHOST_PORT=8001 npm start > /tmp/react-dev-server.log 2>&1 &
|
||||
REACT_PID=$!
|
||||
|
||||
log "Waiting for React server to be ready..."
|
||||
local MAX_WAIT=120
|
||||
local WAITED=0
|
||||
while ! curl -sk https://localhost:3001 >/dev/null 2>&1; do
|
||||
if ! kill -0 "$REACT_PID" 2>/dev/null; then
|
||||
error "React server failed to start. Check /tmp/react-dev-server.log"
|
||||
tail -50 /tmp/react-dev-server.log
|
||||
exit 1
|
||||
fi
|
||||
if [ $WAITED -ge $MAX_WAIT ]; then
|
||||
error "Timeout waiting for React server after ${MAX_WAIT}s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
WAITED=$((WAITED + 2))
|
||||
echo -n "."
|
||||
done
|
||||
echo ""
|
||||
log "React server is ready!"
|
||||
}
|
||||
|
||||
start_fixture_server() {
|
||||
log "Starting fixture server on http://localhost:8001..."
|
||||
node e2e/fixture-server.js > /tmp/fixture-server.log 2>&1 &
|
||||
FIXTURE_PID=$!
|
||||
|
||||
log "Waiting for fixture server to be ready..."
|
||||
local MAX_WAIT=30
|
||||
local WAITED=0
|
||||
while ! curl -s http://localhost:8001/metaData >/dev/null 2>&1; do
|
||||
if ! kill -0 "$FIXTURE_PID" 2>/dev/null; then
|
||||
error "Fixture server failed to start. Check /tmp/fixture-server.log"
|
||||
tail -20 /tmp/fixture-server.log
|
||||
exit 1
|
||||
fi
|
||||
if [ $WAITED -ge $MAX_WAIT ]; then
|
||||
error "Timeout waiting for fixture server after ${MAX_WAIT}s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
WAITED=$((WAITED + 1))
|
||||
echo -n "."
|
||||
done
|
||||
echo ""
|
||||
log "Fixture server is ready!"
|
||||
}
|
||||
|
||||
run_playwright_tests() {
|
||||
info "=========================================="
|
||||
info "Running Playwright E2E Tests"
|
||||
info "=========================================="
|
||||
|
||||
# Playwright manages its own servers via playwright.config.ts
|
||||
# but we can start them manually for consistency
|
||||
npx playwright test --reporter=list
|
||||
|
||||
local result=$?
|
||||
if [ $result -eq 0 ]; then
|
||||
log "Playwright tests PASSED"
|
||||
else
|
||||
error "Playwright tests FAILED"
|
||||
fi
|
||||
return $result
|
||||
}
|
||||
|
||||
run_selenium_tests() {
|
||||
info "=========================================="
|
||||
info "Running Selenium Integration Tests"
|
||||
info "=========================================="
|
||||
|
||||
# Selenium needs React server but Javalin starts its own mock server
|
||||
# So we stop the fixture server to free port 8001 for Javalin
|
||||
if [ -n "$FIXTURE_PID" ] && kill -0 "$FIXTURE_PID" 2>/dev/null; then
|
||||
log "Stopping fixture server for Selenium tests..."
|
||||
kill "$FIXTURE_PID" 2>/dev/null || true
|
||||
wait "$FIXTURE_PID" 2>/dev/null || true
|
||||
FIXTURE_PID=""
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# Free port 8001 for Javalin
|
||||
free_port 8001
|
||||
|
||||
# Run Selenium tests with headless mode
|
||||
QQQ_SELENIUM_HEADLESS=${QQQ_SELENIUM_HEADLESS:-true} mvn test -Dtest="**/selenium/tests/*IT" "$@"
|
||||
|
||||
local result=$?
|
||||
if [ $result -eq 0 ]; then
|
||||
log "Selenium tests PASSED"
|
||||
else
|
||||
error "Selenium tests FAILED"
|
||||
fi
|
||||
return $result
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
RUN_PLAYWRIGHT=false
|
||||
RUN_SELENIUM=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--playwright)
|
||||
RUN_PLAYWRIGHT=true
|
||||
shift
|
||||
;;
|
||||
--selenium)
|
||||
RUN_SELENIUM=true
|
||||
shift
|
||||
;;
|
||||
--all)
|
||||
RUN_PLAYWRIGHT=true
|
||||
RUN_SELENIUM=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
# Pass remaining args to mvn
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Default to running all tests if nothing specified
|
||||
if [ "$RUN_PLAYWRIGHT" = false ] && [ "$RUN_SELENIUM" = false ]; then
|
||||
RUN_PLAYWRIGHT=true
|
||||
RUN_SELENIUM=true
|
||||
fi
|
||||
|
||||
# Track overall success
|
||||
FAILED=false
|
||||
|
||||
info "=========================================="
|
||||
info "QQQ Frontend Material Dashboard - Test Runner"
|
||||
info "=========================================="
|
||||
|
||||
# Free ports before starting
|
||||
free_port 3001
|
||||
free_port 8001
|
||||
|
||||
# Start React server (needed for both test types)
|
||||
start_react_server
|
||||
|
||||
# Run Playwright tests
|
||||
if [ "$RUN_PLAYWRIGHT" = true ]; then
|
||||
start_fixture_server
|
||||
run_playwright_tests || FAILED=true
|
||||
fi
|
||||
|
||||
# Run Selenium tests
|
||||
if [ "$RUN_SELENIUM" = true ]; then
|
||||
run_selenium_tests "$@" || FAILED=true
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
info "=========================================="
|
||||
if [ "$FAILED" = true ]; then
|
||||
error "SOME TESTS FAILED"
|
||||
exit 1
|
||||
else
|
||||
log "ALL TESTS PASSED"
|
||||
exit 0
|
||||
fi
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Server Manager Script for Maven Integration
|
||||
* Handles starting and stopping the React development server
|
||||
*/
|
||||
|
||||
const { spawn, exec } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PID_FILE = path.join(__dirname, '..', '.react-server.pid');
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
function writePidFile(pid) {
|
||||
fs.writeFileSync(PID_FILE, pid.toString());
|
||||
}
|
||||
|
||||
function readPidFile() {
|
||||
try {
|
||||
return fs.readFileSync(PID_FILE, 'utf8').trim();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deletePidFile() {
|
||||
try {
|
||||
fs.unlinkSync(PID_FILE);
|
||||
} catch (error) {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
function isServerRunning(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForServer(maxAttempts = 30, interval = 1000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempts = 0;
|
||||
|
||||
// Detect CI environment for different behavior
|
||||
const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true' || process.env.QQQ_SELENIUM_HEADLESS === 'true';
|
||||
|
||||
if (isCI) {
|
||||
console.log('🔍 CI environment detected - using extended server wait parameters');
|
||||
maxAttempts = Math.max(maxAttempts, 60); // Minimum 60 attempts in CI
|
||||
interval = Math.max(interval, 2000); // Minimum 2 second intervals in CI
|
||||
}
|
||||
|
||||
const checkServer = () => {
|
||||
attempts++;
|
||||
|
||||
// Use curl to check if server is ready (more reliable for HTTPS)
|
||||
const { exec } = require('child_process');
|
||||
const curlCommand = `curl -k -s -o /dev/null -w "%{http_code}" https://localhost:${PORT}`;
|
||||
|
||||
exec(curlCommand, (error, stdout, stderr) => {
|
||||
if (stdout && stdout.trim() !== '000') {
|
||||
console.log(`✅ React server is ready on port ${PORT} (HTTP ${stdout.trim()}) after ${attempts} attempts`);
|
||||
resolve();
|
||||
} else if (attempts >= maxAttempts) {
|
||||
reject(new Error(`Server failed to start after ${maxAttempts} attempts (${maxAttempts * interval / 1000} seconds)`));
|
||||
} else {
|
||||
// Log every 5th attempt, or every attempt in CI
|
||||
if (attempts % 5 === 0 || isCI) {
|
||||
console.log(`⏳ Waiting for React server on port ${PORT}... (attempt ${attempts}/${maxAttempts})`);
|
||||
if (isCI && attempts > 10) {
|
||||
console.log(` Error details: ${error ? error.message : 'Connection refused'}`);
|
||||
}
|
||||
}
|
||||
setTimeout(checkServer, interval);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkServer();
|
||||
});
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
console.log('🚀 Starting React development server...');
|
||||
|
||||
// Detect CI environment for different behavior
|
||||
const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true' || process.env.QQQ_SELENIUM_HEADLESS === 'true';
|
||||
|
||||
if (isCI) {
|
||||
console.log('🔍 CI environment detected - using enhanced startup process');
|
||||
}
|
||||
|
||||
// Start the server in the background and exit immediately
|
||||
const child = spawn('npm', ['start'], {
|
||||
stdio: 'ignore', // Don't capture output - let it run independently
|
||||
detached: true, // Detach from parent process
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none',
|
||||
PORT: PORT,
|
||||
HTTPS: 'true',
|
||||
GENERATE_SOURCEMAP: 'false',
|
||||
FAST_REFRESH: 'false'
|
||||
}
|
||||
});
|
||||
|
||||
writePidFile(child.pid);
|
||||
console.log(`📋 Server PID: ${child.pid}`);
|
||||
console.log('🚀 React server started in background');
|
||||
|
||||
// Unref so parent can exit without killing child
|
||||
child.unref();
|
||||
|
||||
// Exit immediately - server runs in background
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function stopServer() {
|
||||
console.log('🛑 Stopping React development server...');
|
||||
|
||||
const pid = readPidFile();
|
||||
if (pid && isServerRunning(pid)) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
console.log(`✅ Stopped server (PID: ${pid})`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to stop server:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No running server found');
|
||||
}
|
||||
|
||||
deletePidFile();
|
||||
}
|
||||
|
||||
function waitForServerOnly() {
|
||||
console.log('⏳ Waiting for React server to be ready...');
|
||||
|
||||
const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true' || process.env.QQQ_SELENIUM_HEADLESS === 'true';
|
||||
const maxAttempts = isCI ? 60 : 30;
|
||||
const interval = isCI ? 2000 : 1000;
|
||||
|
||||
if (isCI) {
|
||||
console.log('🔍 CI environment detected - using extended wait parameters');
|
||||
}
|
||||
|
||||
waitForServer(maxAttempts, interval)
|
||||
.then(() => {
|
||||
console.log('✅ React server is ready for integration tests');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Server wait failed:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle command line arguments
|
||||
const command = process.argv[2];
|
||||
|
||||
switch (command) {
|
||||
case 'start':
|
||||
startServer();
|
||||
break;
|
||||
case 'stop':
|
||||
stopServer();
|
||||
break;
|
||||
case 'wait':
|
||||
waitForServerOnly();
|
||||
break;
|
||||
default:
|
||||
console.log('Usage: node server-manager.js [start|stop|wait]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Handle process termination
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n🛑 Received SIGINT, stopping server...');
|
||||
stopServer();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n🛑 Received SIGTERM, stopping server...');
|
||||
stopServer();
|
||||
process.exit(0);
|
||||
});
|
||||
+242
-168
@@ -19,32 +19,41 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useAuth0} from "@auth0/auth0-react";
|
||||
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
|
||||
import {QAppNodeType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppNodeType";
|
||||
import {QAppTreeNode} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppTreeNode";
|
||||
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
||||
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QException} from "@qrunio/qqq-frontend-core/lib/exceptions/QException";
|
||||
import {QAppNodeType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAppNodeType";
|
||||
import {QAppTreeNode} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAppTreeNode";
|
||||
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
import {QBrandingMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QProcessMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import {QThemeMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QThemeMetaData";
|
||||
import {Theme} from "@mui/material";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import {ThemeProvider} from "@mui/material/styles";
|
||||
import {LicenseInfo} from "@mui/x-license-pro";
|
||||
import CommandMenu from "CommandMenu";
|
||||
import jwt_decode from "jwt-decode";
|
||||
import QContext from "QContext";
|
||||
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
|
||||
import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule";
|
||||
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
|
||||
import BrandedHeaderBar from "qqq/components/horseshoe/BrandedHeaderBar";
|
||||
import Sidenav from "qqq/components/horseshoe/sidenav/SideNav";
|
||||
import theme from "qqq/components/legacy/Theme";
|
||||
import {setMiniSidenav, setOpenConfigurator, useMaterialUIController} from "qqq/context";
|
||||
import {createDynamicTheme} from "qqq/utils/createDynamicTheme";
|
||||
import {injectIslandVariables} from "qqq/utils/injectIslandVariables";
|
||||
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
|
||||
import {setMiniSidenav, useMaterialUIController} from "qqq/context";
|
||||
import AppHome from "qqq/pages/apps/Home";
|
||||
import NoApps from "qqq/pages/apps/NoApps";
|
||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||
import ReportRun from "qqq/pages/processes/ReportRun";
|
||||
import EntityCreate from "qqq/pages/records/create/RecordCreate";
|
||||
import TableDeveloperView from "qqq/pages/records/developer/TableDeveloperView";
|
||||
import {resolveAssetUrl, detectBasePath} from "qqq/utils/PathUtils";
|
||||
import EntityEdit from "qqq/pages/records/edit/RecordEdit";
|
||||
import RecordQuery from "qqq/pages/records/query/RecordQuery";
|
||||
import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView";
|
||||
@@ -53,7 +62,7 @@ import RecordViewByUniqueKey from "qqq/pages/records/view/RecordViewByUniqueKey"
|
||||
import GoogleAnalyticsUtils, {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
|
||||
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
|
||||
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useMemo, useState,} from "react";
|
||||
import {useCookies} from "react-cookie";
|
||||
import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom";
|
||||
import {Md5} from "ts-md5/dist/md5";
|
||||
@@ -62,80 +71,37 @@ import {Md5} from "ts-md5/dist/md5";
|
||||
const qController = Client.getInstance();
|
||||
export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
|
||||
|
||||
export default function App()
|
||||
interface Props
|
||||
{
|
||||
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
|
||||
const {user, getAccessTokenSilently, logout} = useAuth0();
|
||||
authenticationMetaData: QAuthenticationMetaData;
|
||||
}
|
||||
|
||||
export default function App({authenticationMetaData}: Props)
|
||||
{
|
||||
const [, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
|
||||
const [loadingToken, setLoadingToken] = useState(false);
|
||||
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
|
||||
const [profileRoutes, setProfileRoutes] = useState({});
|
||||
const [branding, setBranding] = useState({} as QBrandingMetaData);
|
||||
const [themeMetaData, setThemeMetaData] = useState(null as QThemeMetaData | null);
|
||||
const [metaData, setMetaData] = useState({} as QInstance);
|
||||
const [needLicenseKey, setNeedLicenseKey] = useState(true);
|
||||
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
|
||||
const [defaultRoute, setDefaultRoute] = useState("/no-apps");
|
||||
const [earlyReturnForAuth, setEarlyReturnForAuth] = useState(null as JSX.Element);
|
||||
|
||||
const {setupSession: auth0SetupSession, logout: auth0Logout} = useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
|
||||
const {setupSession: oauth2SetupSession, logout: oauth2Logout} = useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext: authenticationMetaData.type === "OAUTH2"});
|
||||
const {setupSession: anonymousSetupSession, logout: anonymousLogout} = useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// tell the client how to do a logout if it sees a 401 //
|
||||
/////////////////////////////////////////////////////////
|
||||
Client.setUnauthorizedCallback(() =>
|
||||
{
|
||||
logout();
|
||||
});
|
||||
|
||||
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
|
||||
{
|
||||
if (!cookies[SESSION_UUID_COOKIE_NAME])
|
||||
{
|
||||
console.log("No session uuid cookie - so we should store a new one.");
|
||||
return (true);
|
||||
}
|
||||
|
||||
if (!oldToken)
|
||||
{
|
||||
console.log("No accessToken in localStorage - so we should store a new one.");
|
||||
return (true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const oldJSON: any = jwt_decode(oldToken);
|
||||
const newJSON: any = jwt_decode(newToken);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the old (local storage) token is expired, then we need to store the new one //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
const oldExp = oldJSON["exp"];
|
||||
if (oldExp * 1000 < (new Date().getTime()))
|
||||
{
|
||||
console.log("Access token in local storage was expired - so we should store a new one.");
|
||||
return (true);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// remove the exp & iat values from what we compare - as they are always different from auth0 //
|
||||
// note, this is only deleting them from what we compare, not from what we'd store. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
delete newJSON["exp"];
|
||||
delete newJSON["iat"];
|
||||
delete oldJSON["exp"];
|
||||
delete oldJSON["iat"];
|
||||
|
||||
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
|
||||
if (different)
|
||||
{
|
||||
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
|
||||
}
|
||||
return (different);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log("Caught in shouldStoreNewToken: " + e);
|
||||
}
|
||||
|
||||
return (true);
|
||||
};
|
||||
Client.setUnauthorizedCallback(() => doLogout());
|
||||
|
||||
/////////////////////////////////////////////////
|
||||
// deal with making sure user is authenticated //
|
||||
/////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
if (loadingToken)
|
||||
@@ -146,65 +112,17 @@ export default function App()
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const authenticationMetaData: QAuthenticationMetaData = await qController.getAuthenticationMetaData();
|
||||
|
||||
if (authenticationMetaData.type === "AUTH_0")
|
||||
{
|
||||
/////////////////////////////////////////
|
||||
// use auth0 if auth type is ... auth0 //
|
||||
/////////////////////////////////////////
|
||||
try
|
||||
{
|
||||
console.log("Loading token from auth0...");
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
|
||||
const lsAccessToken = localStorage.getItem("accessToken");
|
||||
if (shouldStoreNewToken(accessToken, lsAccessToken))
|
||||
{
|
||||
console.log("Sending accessToken to backend, requesting a sessionUUID...");
|
||||
const {uuid: newSessionUuid, values} = await qController.manageSession(accessToken, null);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the request to the backend should send a header to set the cookie, so we don't need to do it ourselves. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"});
|
||||
|
||||
localStorage.setItem("accessToken", accessToken);
|
||||
localStorage.setItem("sessionValues", JSON.stringify(values));
|
||||
console.log("Got new sessionUUID from backend, and stored new accessToken");
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log("Using existing sessionUUID cookie");
|
||||
}
|
||||
|
||||
setIsFullyAuthenticated(true);
|
||||
qController.setGotAuthentication();
|
||||
|
||||
setLoggedInUser(user);
|
||||
console.log("Token load complete.");
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log(`Error loading token: ${JSON.stringify(e)}`);
|
||||
qController.clearAuthenticationMetaDataLocalStorage();
|
||||
localStorage.removeItem("accessToken");
|
||||
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
await auth0SetupSession();
|
||||
}
|
||||
else if (authenticationMetaData.type === "OAUTH2")
|
||||
{
|
||||
await oauth2SetupSession();
|
||||
}
|
||||
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
|
||||
{
|
||||
/////////////////////////////////////////////
|
||||
// use a random token if anonymous or mock //
|
||||
/////////////////////////////////////////////
|
||||
console.log("Generating random token...");
|
||||
setIsFullyAuthenticated(true);
|
||||
qController.setGotAuthentication();
|
||||
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
|
||||
console.log("Token generation complete.");
|
||||
return;
|
||||
await anonymousSetupSession();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -220,13 +138,36 @@ export default function App()
|
||||
(async () =>
|
||||
{
|
||||
const metaData: QInstance = await qController.loadMetaData();
|
||||
LicenseInfo.setLicenseKey(metaData.environmentValues.get("MATERIAL_UI_LICENSE_KEY") || process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY);
|
||||
LicenseInfo.setLicenseKey(metaData.environmentValues?.get("MATERIAL_UI_LICENSE_KEY") || process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY);
|
||||
setNeedLicenseKey(false);
|
||||
})();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** call appropriate logout function based on authentication meta data type
|
||||
***************************************************************************/
|
||||
function doLogout()
|
||||
{
|
||||
if (authenticationMetaData?.type === "AUTH_0")
|
||||
{
|
||||
auth0Logout();
|
||||
}
|
||||
else if (authenticationMetaData?.type === "OAUTH2")
|
||||
{
|
||||
oauth2Logout();
|
||||
}
|
||||
else if (authenticationMetaData?.type === "FULLY_ANONYMOUS" || authenticationMetaData?.type === "MOCK")
|
||||
{
|
||||
anonymousLogout();
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log(`No logout callback for authentication type [${authenticationMetaData?.type}].`);
|
||||
}
|
||||
}
|
||||
|
||||
const [controller, dispatch] = useMaterialUIController();
|
||||
const {miniSidenav, direction, layout, openConfigurator, sidenavColor} = controller;
|
||||
const {miniSidenav, direction, sidenavColor} = controller;
|
||||
const [onMouseEnter, setOnMouseEnter] = useState(false);
|
||||
const {pathname} = useLocation();
|
||||
const [queryParams] = useSearchParams();
|
||||
@@ -342,7 +283,9 @@ export default function App()
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
foundFirstApp = true;
|
||||
setDefaultRoute(path);
|
||||
console.log("Set default route to: " + path);
|
||||
const basePath = detectBasePath();
|
||||
const fullPath = basePath !== "/" ? basePath + path : path;
|
||||
console.log(`Set default route to: ${path} (browser URL: ${fullPath})`);
|
||||
}
|
||||
}
|
||||
else if (app.type === QAppNodeType.TABLE)
|
||||
@@ -443,23 +386,57 @@ export default function App()
|
||||
});
|
||||
});
|
||||
|
||||
const runRecordScriptProcess = metaData.processes.get("runRecordScript");
|
||||
if (runRecordScriptProcess)
|
||||
const materialDashboardInstanceMetaData = metaData.supplementalInstanceMetaData?.get("materialDashboard");
|
||||
if (materialDashboardInstanceMetaData)
|
||||
{
|
||||
const process = runRecordScriptProcess;
|
||||
routeList.push({
|
||||
name: process.label,
|
||||
key: process.name,
|
||||
route: `${path}/${process.name}`,
|
||||
component: <RecordQuery table={table} key={`${table.name}-${process.name}`} launchProcess={process} />,
|
||||
});
|
||||
const processNamesToAddToAllQueryAndViewScreens = materialDashboardInstanceMetaData.processNamesToAddToAllQueryAndViewScreens;
|
||||
if (processNamesToAddToAllQueryAndViewScreens)
|
||||
{
|
||||
for (let processName of processNamesToAddToAllQueryAndViewScreens)
|
||||
{
|
||||
const process = metaData.processes.get(processName);
|
||||
if (process)
|
||||
{
|
||||
routeList.push({
|
||||
name: process.label,
|
||||
key: process.name,
|
||||
route: `${path}/${process.name}`,
|
||||
component: <RecordQuery table={table} key={`${table.name}-${process.name}`} launchProcess={process} />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
name: process.label,
|
||||
key: `${app.name}/${process.name}`,
|
||||
route: `${path}/:id/${process.name}`,
|
||||
component: <RecordView table={table} launchProcess={process} />,
|
||||
});
|
||||
routeList.push({
|
||||
name: process.label,
|
||||
key: `${app.name}/${process.name}`,
|
||||
route: `${path}/:id/${process.name}`,
|
||||
component: <RecordView table={table} launchProcess={process} />,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
////////////////
|
||||
// deprecated //
|
||||
////////////////
|
||||
const runRecordScriptProcess = metaData.processes.get("runRecordScript");
|
||||
if (runRecordScriptProcess)
|
||||
{
|
||||
const process = runRecordScriptProcess;
|
||||
routeList.push({
|
||||
name: process.label,
|
||||
key: process.name,
|
||||
route: `${path}/${process.name}`,
|
||||
component: <RecordQuery table={table} key={`${table.name}-${process.name}`} launchProcess={process} />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
name: process.label,
|
||||
key: `${app.name}/${process.name}`,
|
||||
route: `${path}/:id/${process.name}`,
|
||||
component: <RecordView table={table} launchProcess={process} />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const reportsForTable = ProcessUtils.getReportsForTable(metaData, table.name, true);
|
||||
@@ -507,11 +484,11 @@ export default function App()
|
||||
const appleIcon = document.querySelector("link[rel~='apple-touch-icon']") as HTMLLinkElement;
|
||||
if (favicon)
|
||||
{
|
||||
favicon.href = metaData.branding.icon;
|
||||
favicon.href = resolveAssetUrl(metaData.branding.icon);
|
||||
}
|
||||
if (appleIcon)
|
||||
{
|
||||
appleIcon.href = metaData.branding.icon;
|
||||
appleIcon.href = resolveAssetUrl(metaData.branding.icon);
|
||||
}
|
||||
if (metaData.branding.accentColor)
|
||||
{
|
||||
@@ -519,11 +496,18 @@ export default function App()
|
||||
}
|
||||
}
|
||||
|
||||
let profileRoutes = {};
|
||||
///////////////////////////////////////////////
|
||||
// Set theme metadata for dynamic theming //
|
||||
///////////////////////////////////////////////
|
||||
if (metaData.theme)
|
||||
{
|
||||
setThemeMetaData(metaData.theme);
|
||||
}
|
||||
|
||||
const gravatarBase = "https://www.gravatar.com/avatar/";
|
||||
const hash = Md5.hashStr(loggedInUser?.email || "user");
|
||||
const profilePicture = `${gravatarBase}${hash}`;
|
||||
profileRoutes = {
|
||||
const profileRoutes = {
|
||||
type: "collapse",
|
||||
name: loggedInUser?.name ?? "Anonymous",
|
||||
key: "username",
|
||||
@@ -592,10 +576,7 @@ export default function App()
|
||||
localStorage.removeItem("accessToken");
|
||||
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// todo - this is auth0 logout... make more generic //
|
||||
//////////////////////////////////////////////////////
|
||||
logout();
|
||||
doLogout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -603,7 +584,9 @@ export default function App()
|
||||
})();
|
||||
}, [needToLoadRoutes, isFullyAuthenticated]);
|
||||
|
||||
// Open sidenav when mouse enter on mini sidenav
|
||||
///////////////////////////////////////////////////
|
||||
// Open sidenav when mouse enter on mini sidenav //
|
||||
///////////////////////////////////////////////////
|
||||
const handleOnMouseEnter = () =>
|
||||
{
|
||||
if (miniSidenav && !onMouseEnter)
|
||||
@@ -613,7 +596,9 @@ export default function App()
|
||||
}
|
||||
};
|
||||
|
||||
// Close sidenav when mouse leave mini sidenav
|
||||
/////////////////////////////////////////////////
|
||||
// Close sidenav when mouse leave mini sidenav //
|
||||
/////////////////////////////////////////////////
|
||||
const handleOnMouseLeave = () =>
|
||||
{
|
||||
if (onMouseEnter)
|
||||
@@ -623,16 +608,14 @@ export default function App()
|
||||
}
|
||||
};
|
||||
|
||||
// Change the openConfigurator state
|
||||
const handleConfiguratorOpen = () => setOpenConfigurator(dispatch, !openConfigurator);
|
||||
|
||||
// Setting the dir attribute for the body element
|
||||
useEffect(() =>
|
||||
{
|
||||
document.body.setAttribute("dir", direction);
|
||||
}, [direction]);
|
||||
|
||||
// Setting page scroll to 0 when changing the route
|
||||
//////////////////////////////////////////////////////
|
||||
// Setting page scroll to 0 when changing the route //
|
||||
//////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
document.documentElement.scrollTop = 0;
|
||||
@@ -664,22 +647,41 @@ export default function App()
|
||||
},
|
||||
);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Create dynamic MUI theme from themeMetaData //
|
||||
// Theme is rebuilt when themeMetaData changes (from backend) //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
const dynamicTheme: Theme = useMemo(() =>
|
||||
{
|
||||
return createDynamicTheme(themeMetaData || undefined);
|
||||
}, [themeMetaData]);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Inject CSS variables for island components (sidebar, header, DataGrid) //
|
||||
// These components can't use MUI palette, so they need CSS variables //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
injectIslandVariables(themeMetaData || undefined);
|
||||
}, [themeMetaData]);
|
||||
|
||||
const [pageHeader, setPageHeader] = useState("" as string | JSX.Element);
|
||||
const [accentColor, setAccentColor] = useState("#0062FF");
|
||||
const [accentColorLight, setAccentColorLight] = useState("#C0D6F7");
|
||||
const [tableMetaData, setTableMetaData] = useState(null);
|
||||
const [tableProcesses, setTableProcesses] = useState(null);
|
||||
const [dotMenuOpen, setDotMenuOpen] = useState(false);
|
||||
const [modalStack, setModalStack] = useState([] as string[]);
|
||||
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
|
||||
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
|
||||
const [userId, setUserId] = useState(user?.email);
|
||||
const [userId, setUserId] = useState(loggedInUser?.email);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setUserId(user?.email)
|
||||
}, [user]);
|
||||
setUserId(loggedInUser?.email);
|
||||
}, [loggedInUser]);
|
||||
|
||||
|
||||
|
||||
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -687,7 +689,72 @@ export default function App()
|
||||
*******************************************************************************/
|
||||
function recordAnalytics(model: AnalyticsModel)
|
||||
{
|
||||
googleAnalyticsUtils.recordAnalytics(model)
|
||||
googleAnalyticsUtils.recordAnalytics(model);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// if any of the auth/session setup code determined that we need //
|
||||
// to render something and return early - then do so here. //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
if (earlyReturnForAuth)
|
||||
{
|
||||
return (earlyReturnForAuth);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function banner(): JSX.Element | null
|
||||
{
|
||||
const banner = getBanner(metaData?.branding, "QFMD_TOP_OF_SITE");
|
||||
|
||||
if (!banner)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
return (<Box className={getBannerClassName(banner)} sx={{display: "flex", justifyContent: "center", padding: "0.5rem", position: "sticky", top: "0", zIndex: 1, ...getBannerStyles(banner)}}>
|
||||
{makeBannerContent(banner)}
|
||||
</Box>);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
function pushModalOnStack(modalIdentifier: string): void
|
||||
{
|
||||
if(modalStack.length > 0 && modalStack[modalStack.length] == modalIdentifier)
|
||||
{
|
||||
console.warn(`Pushing a new modal on the QContext modalStack that is a duplicate of the current top-of-stack [${modalIdentifier}]`)
|
||||
}
|
||||
modalStack.push(modalIdentifier);
|
||||
setModalStack([...modalStack]);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
function popModalOffStack(modalIdentifier: string): void
|
||||
{
|
||||
if(modalStack.length > 0 && modalStack[modalStack.length - 1] != modalIdentifier)
|
||||
{
|
||||
console.warn(`Request to pop a modal [${modalIdentifier}] off the QContext modalStack that is not currently on top [${modalStack[modalStack.length - 1]}]`)
|
||||
return;
|
||||
}
|
||||
modalStack.pop();
|
||||
setModalStack([...modalStack]);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
function clearModalStack(): void
|
||||
{
|
||||
setModalStack([]);
|
||||
}
|
||||
|
||||
|
||||
@@ -701,6 +768,7 @@ export default function App()
|
||||
tableMetaData: tableMetaData,
|
||||
tableProcesses: tableProcesses,
|
||||
dotMenuOpen: dotMenuOpen,
|
||||
modalStack: modalStack,
|
||||
keyboardHelpOpen: keyboardHelpOpen,
|
||||
helpHelpActive: helpHelpActive,
|
||||
userId: userId,
|
||||
@@ -711,22 +779,28 @@ export default function App()
|
||||
setTableProcesses: (tableProcesses: QProcessMetaData[]) => setTableProcesses(tableProcesses),
|
||||
setDotMenuOpen: (dotMenuOpent: boolean) => setDotMenuOpen(dotMenuOpent),
|
||||
setKeyboardHelpOpen: (keyboardHelpOpen: boolean) => setKeyboardHelpOpen(keyboardHelpOpen),
|
||||
pushModalOnStack: pushModalOnStack,
|
||||
popModalOffStack: popModalOffStack,
|
||||
clearModalStack: clearModalStack,
|
||||
recordAnalytics: recordAnalytics,
|
||||
pathToLabelMap: pathToLabelMap,
|
||||
branding: branding
|
||||
}}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ThemeProvider theme={dynamicTheme}>
|
||||
<CssBaseline />
|
||||
<CommandMenu metaData={metaData} />
|
||||
{banner()}
|
||||
<BrandedHeaderBar theme={themeMetaData} />
|
||||
<Sidenav
|
||||
color={sidenavColor}
|
||||
icon={branding.icon}
|
||||
logo={branding.logo}
|
||||
icon={resolveAssetUrl(branding.icon)}
|
||||
logo={resolveAssetUrl(branding.logo)}
|
||||
appName={branding.appName}
|
||||
branding={branding}
|
||||
routes={sideNavRoutes}
|
||||
onMouseEnter={handleOnMouseEnter}
|
||||
onMouseLeave={handleOnMouseLeave}
|
||||
logout={doLogout}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path="*" element={<Navigate to={defaultRoute} />} />
|
||||
|
||||
+6
-6
@@ -19,12 +19,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability";
|
||||
import {QAppMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppMetaData";
|
||||
import {QAppNodeType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppNodeType";
|
||||
import {QAppTreeNode} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppTreeNode";
|
||||
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {Capability} from "@qrunio/qqq-frontend-core/lib/model/metaData/Capability";
|
||||
import {QAppMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAppMetaData";
|
||||
import {QAppNodeType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAppNodeType";
|
||||
import {QAppTreeNode} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAppTreeNode";
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
|
||||
+10
-4
@@ -19,9 +19,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
||||
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QBrandingMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
||||
import {QProcessMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
|
||||
import {createContext} from "react";
|
||||
|
||||
@@ -42,6 +42,11 @@ interface QContext
|
||||
keyboardHelpOpen: boolean;
|
||||
setKeyboardHelpOpen?: (keyboardHelpOpen: boolean) => void;
|
||||
|
||||
modalStack: string[];
|
||||
pushModalOnStack?: (modalIdentifier: string) => void;
|
||||
popModalOffStack?: (modalIdentifier: string) => void;
|
||||
clearModalStack?: () => void;
|
||||
|
||||
tableMetaData?: QTableMetaData;
|
||||
setTableMetaData?: (tableMetaData: QTableMetaData) => void;
|
||||
|
||||
@@ -70,7 +75,8 @@ const defaultState = {
|
||||
keyboardHelpOpen: false,
|
||||
pathToLabelMap: {},
|
||||
helpHelpActive: false,
|
||||
modalStack: [] as string[],
|
||||
};
|
||||
|
||||
const QContext = createContext<QContext>(defaultState);
|
||||
export default QContext;
|
||||
export default QContext;
|
||||
+79
-81
@@ -19,116 +19,114 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Auth0Provider} from "@auth0/auth0-react";
|
||||
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
import React from "react";
|
||||
import {CookiesProvider} from "react-cookie";
|
||||
import ReactDOM from "react-dom";
|
||||
import {createRoot} from "react-dom/client";
|
||||
import {BrowserRouter, useNavigate, useSearchParams} from "react-router-dom";
|
||||
import App from "App";
|
||||
import "qqq/styles/qqq-override-styles.css";
|
||||
import "qqq/styles/form-animation.css";
|
||||
import "qqq/styles/globals.scss";
|
||||
import "qqq/styles/raycast.scss";
|
||||
import HandleAuthorizationError from "HandleAuthorizationError";
|
||||
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
|
||||
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
|
||||
import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule";
|
||||
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
|
||||
import {MaterialUIControllerProvider} from "qqq/context";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import {detectBasePath} from "qqq/utils/PathUtils";
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
// Expose React and ReactDOM as globals, for use by dynamically loaded modules //
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
(window as any).React = React;
|
||||
(window as any).ReactDOM = ReactDOM;
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
if(document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1)
|
||||
if (document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1)
|
||||
{
|
||||
qController.clearAuthenticationMetaDataLocalStorage()
|
||||
qController.clearAuthenticationMetaDataLocalStorage();
|
||||
}
|
||||
|
||||
const authenticationMetaDataPromise: Promise<QAuthenticationMetaData> = qController.getAuthenticationMetaData()
|
||||
|
||||
const authenticationMetaDataPromise: Promise<QAuthenticationMetaData> = qController.getAuthenticationMetaData();
|
||||
|
||||
authenticationMetaDataPromise.then((authenticationMetaData) =>
|
||||
{
|
||||
// @ts-ignore
|
||||
function Auth0ProviderWithRedirectCallback({children, ...props})
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// @ts-ignore
|
||||
const onRedirectCallback = (appState) =>
|
||||
{
|
||||
navigate((appState && appState.returnTo) || window.location.pathname);
|
||||
};
|
||||
if (searchParams.get("error"))
|
||||
{
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Auth0Provider {...props}>
|
||||
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
|
||||
</Auth0Provider>
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
|
||||
{children}
|
||||
</Auth0Provider>
|
||||
);
|
||||
}
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function Auth0RouterBody()
|
||||
{
|
||||
const {renderAppWrapper} = useAuth0AuthenticationModule({});
|
||||
return (renderAppWrapper(authenticationMetaData));
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function OAuth2RouterBody()
|
||||
{
|
||||
const {renderAppWrapper} = useOAuth2AuthenticationModule({inOAuthContext: false});
|
||||
return (renderAppWrapper(authenticationMetaData, (
|
||||
<MaterialUIControllerProvider>
|
||||
<App authenticationMetaData={authenticationMetaData} />
|
||||
</MaterialUIControllerProvider>
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function AnonymousRouterBody()
|
||||
{
|
||||
const {renderAppWrapper} = useAnonymousAuthenticationModule({});
|
||||
return (renderAppWrapper(authenticationMetaData, (
|
||||
<MaterialUIControllerProvider>
|
||||
<App authenticationMetaData={authenticationMetaData} />
|
||||
</MaterialUIControllerProvider>
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container);
|
||||
|
||||
if (authenticationMetaData.type === "AUTH_0")
|
||||
{
|
||||
// @ts-ignore
|
||||
let domain: string = authenticationMetaData.data.baseUrl;
|
||||
|
||||
// @ts-ignore
|
||||
const clientId = authenticationMetaData.data.clientId;
|
||||
|
||||
// @ts-ignore
|
||||
const audience = authenticationMetaData.data.audience;
|
||||
|
||||
if(!domain || !clientId)
|
||||
{
|
||||
root.render(
|
||||
<div>Error: AUTH0 authenticationMetaData is missing domain [{domain}] and/or clientId [{clientId}].</div>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if(domain.endsWith("/"))
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// auth0 lib fails if we have a trailing slash. be a bit more graceful than that. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
domain = domain.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<Auth0ProviderWithRedirectCallback
|
||||
domain={domain}
|
||||
clientId={clientId}
|
||||
audience={audience}
|
||||
redirectUri={`${window.location.origin}/`}
|
||||
>
|
||||
<MaterialUIControllerProvider>
|
||||
<ProtectedRoute component={App} />
|
||||
</MaterialUIControllerProvider>
|
||||
</Auth0ProviderWithRedirectCallback>
|
||||
root.render(<CookiesProvider>
|
||||
<BrowserRouter basename={detectBasePath()}>
|
||||
<Auth0RouterBody />
|
||||
</BrowserRouter>
|
||||
);
|
||||
</CookiesProvider>);
|
||||
}
|
||||
else if (authenticationMetaData.type === "OAUTH2")
|
||||
{
|
||||
root.render(<CookiesProvider>
|
||||
<BrowserRouter basename={detectBasePath()}>
|
||||
<OAuth2RouterBody />
|
||||
</BrowserRouter>
|
||||
</CookiesProvider>);
|
||||
}
|
||||
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
|
||||
{
|
||||
root.render(<CookiesProvider>
|
||||
<BrowserRouter basename={detectBasePath()}>
|
||||
<AnonymousRouterBody />
|
||||
</BrowserRouter>
|
||||
</CookiesProvider>);
|
||||
}
|
||||
else
|
||||
{
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<MaterialUIControllerProvider>
|
||||
<App />
|
||||
</MaterialUIControllerProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
root.render(<div>
|
||||
Error: Unknown authenticationMetaData type: [{authenticationMetaData.type}].
|
||||
</div>);
|
||||
}
|
||||
|
||||
})
|
||||
});
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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.actions.formadjuster;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Input wrapper to a {@link FormAdjusterInterface}
|
||||
*
|
||||
* Contains values from the browser.
|
||||
*******************************************************************************/
|
||||
public class FormAdjusterInput
|
||||
{
|
||||
private String event;
|
||||
private String fieldName;
|
||||
|
||||
private Serializable newValue;
|
||||
private Map<String, Serializable> allValues;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* For a given field name, return the current value for it. Which will
|
||||
* be `newValue` if `fieldName` is `this.fieldName` (e.g., this is a field
|
||||
* level event, and you're requesting the effected field's value) - or -
|
||||
* the value for the field from the allValues map.
|
||||
*
|
||||
* @param fieldName what field to get a value for
|
||||
* @return the field's new value (if appropriate), or existing value possibly
|
||||
* null.
|
||||
***************************************************************************/
|
||||
public Serializable getFieldValue(String fieldName)
|
||||
{
|
||||
if(fieldName.equals(this.fieldName))
|
||||
{
|
||||
return getNewValue();
|
||||
}
|
||||
return getAllValues().get(fieldName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for event
|
||||
*******************************************************************************/
|
||||
public String getEvent()
|
||||
{
|
||||
return (this.event);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for event
|
||||
*******************************************************************************/
|
||||
public void setEvent(String event)
|
||||
{
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for event
|
||||
*******************************************************************************/
|
||||
public FormAdjusterInput withEvent(String event)
|
||||
{
|
||||
this.event = event;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldName
|
||||
*******************************************************************************/
|
||||
public String getFieldName()
|
||||
{
|
||||
return (this.fieldName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for fieldName
|
||||
*******************************************************************************/
|
||||
public void setFieldName(String fieldName)
|
||||
{
|
||||
this.fieldName = fieldName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fieldName
|
||||
*******************************************************************************/
|
||||
public FormAdjusterInput withFieldName(String fieldName)
|
||||
{
|
||||
this.fieldName = fieldName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for newValue
|
||||
*******************************************************************************/
|
||||
public Serializable getNewValue()
|
||||
{
|
||||
return (this.newValue);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for newValue
|
||||
*******************************************************************************/
|
||||
public void setNewValue(Serializable newValue)
|
||||
{
|
||||
this.newValue = newValue;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for newValue
|
||||
*******************************************************************************/
|
||||
public FormAdjusterInput withNewValue(Serializable newValue)
|
||||
{
|
||||
this.newValue = newValue;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allValues
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> getAllValues()
|
||||
{
|
||||
return (this.allValues);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for allValues
|
||||
*******************************************************************************/
|
||||
public void setAllValues(Map<String, Serializable> allValues)
|
||||
{
|
||||
this.allValues = allValues;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for allValues
|
||||
*******************************************************************************/
|
||||
public FormAdjusterInput withAllValues(Map<String, Serializable> allValues)
|
||||
{
|
||||
this.allValues = allValues;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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.actions.formadjuster;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** interface to be implemented by application-specific form-adjusters
|
||||
*******************************************************************************/
|
||||
public interface FormAdjusterInterface
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
FormAdjusterOutput execute(FormAdjusterInput input) throws QException;
|
||||
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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.actions.formadjuster;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class FormAdjusterOutput
|
||||
{
|
||||
private Map<String, QFrontendFieldMetaData> updatedFieldMetaData = null;
|
||||
private Map<String, Serializable> updatedFieldValues = null;
|
||||
private Map<String, String> updatedFieldDisplayValues = null;
|
||||
private Set<String> fieldsToClear = null;
|
||||
private Map<String, QFieldSection> updatedSectionMetaData = null;
|
||||
|
||||
private Boolean isFormDisabled = false;
|
||||
private String formDisabledMessage = null;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for updatedFieldValues
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> getUpdatedFieldValues()
|
||||
{
|
||||
return (this.updatedFieldValues);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for updatedFieldValues
|
||||
*******************************************************************************/
|
||||
public void setUpdatedFieldValues(Map<String, Serializable> updatedFieldValues)
|
||||
{
|
||||
this.updatedFieldValues = updatedFieldValues;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for updatedFieldValues
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutput withUpdatedFieldValues(Map<String, Serializable> updatedFieldValues)
|
||||
{
|
||||
this.updatedFieldValues = updatedFieldValues;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldsToClear
|
||||
*******************************************************************************/
|
||||
public Set<String> getFieldsToClear()
|
||||
{
|
||||
return (this.fieldsToClear);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for fieldsToClear
|
||||
*******************************************************************************/
|
||||
public void setFieldsToClear(Set<String> fieldsToClear)
|
||||
{
|
||||
this.fieldsToClear = fieldsToClear;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fieldsToClear
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutput withFieldsToClear(Set<String> fieldsToClear)
|
||||
{
|
||||
this.fieldsToClear = fieldsToClear;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for updatedFieldMetaData
|
||||
*******************************************************************************/
|
||||
public Map<String, QFrontendFieldMetaData> getUpdatedFieldMetaData()
|
||||
{
|
||||
return (this.updatedFieldMetaData);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for updatedFieldMetaData
|
||||
*******************************************************************************/
|
||||
public void setUpdatedFieldMetaData(Map<String, QFrontendFieldMetaData> updatedFieldMetaData)
|
||||
{
|
||||
this.updatedFieldMetaData = updatedFieldMetaData;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for updatedFieldMetaData
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutput withUpdatedFieldMetaData(Map<String, QFrontendFieldMetaData> updatedFieldMetaData)
|
||||
{
|
||||
this.updatedFieldMetaData = updatedFieldMetaData;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for updatedFieldDisplayValues
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getUpdatedFieldDisplayValues()
|
||||
{
|
||||
return (this.updatedFieldDisplayValues);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for updatedFieldDisplayValues
|
||||
*******************************************************************************/
|
||||
public void setUpdatedFieldDisplayValues(Map<String, String> updatedFieldDisplayValues)
|
||||
{
|
||||
this.updatedFieldDisplayValues = updatedFieldDisplayValues;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for updatedFieldDisplayValues
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutput withUpdatedFieldDisplayValues(Map<String, String> updatedFieldDisplayValues)
|
||||
{
|
||||
this.updatedFieldDisplayValues = updatedFieldDisplayValues;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for updatedSectionMetaData
|
||||
* @see #withUpdatedSectionMetaData(Map)
|
||||
*******************************************************************************/
|
||||
public Map<String, QFieldSection> getUpdatedSectionMetaData()
|
||||
{
|
||||
return (this.updatedSectionMetaData);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for updatedSectionMetaData
|
||||
* @see #withUpdatedSectionMetaData(Map)
|
||||
*******************************************************************************/
|
||||
public void setUpdatedSectionMetaData(Map<String, QFieldSection> updatedSectionMetaData)
|
||||
{
|
||||
this.updatedSectionMetaData = updatedSectionMetaData;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for updatedSectionMetaData
|
||||
*
|
||||
* @param updatedSectionMetaData
|
||||
* Map of QFieldSections to be changed by this request - e.g., to hide or change
|
||||
* labels or icons (is probably about all you can do)
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutput withUpdatedSectionMetaData(Map<String, QFieldSection> updatedSectionMetaData)
|
||||
{
|
||||
this.updatedSectionMetaData = updatedSectionMetaData;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for isFormDisabled
|
||||
* @see #withIsFormDisabled(Boolean)
|
||||
*******************************************************************************/
|
||||
public Boolean getIsFormDisabled()
|
||||
{
|
||||
return (this.isFormDisabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for isFormDisabled
|
||||
* @see #withIsFormDisabled(Boolean)
|
||||
*******************************************************************************/
|
||||
public void setIsFormDisabled(Boolean isFormDisabled)
|
||||
{
|
||||
this.isFormDisabled = isFormDisabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for isFormDisabled
|
||||
*
|
||||
* @param isFormDisabled
|
||||
* boolean, defaults to false. If set to true, then the frontend should disable
|
||||
* the form, fully blocking edits.
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutput withIsFormDisabled(Boolean isFormDisabled)
|
||||
{
|
||||
this.isFormDisabled = isFormDisabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for formDisabledMessage
|
||||
* @see #withFormDisabledMessage(String)
|
||||
*******************************************************************************/
|
||||
public String getFormDisabledMessage()
|
||||
{
|
||||
return (this.formDisabledMessage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for formDisabledMessage
|
||||
* @see #withFormDisabledMessage(String)
|
||||
*******************************************************************************/
|
||||
public void setFormDisabledMessage(String formDisabledMessage)
|
||||
{
|
||||
this.formDisabledMessage = formDisabledMessage;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for formDisabledMessage
|
||||
*
|
||||
* @param formDisabledMessage
|
||||
* Message the frontend should display to the user when the form is disabled
|
||||
* (see withIsFormDisabled()). If this message isn't set, then the frontend
|
||||
* will display a generic message.
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutput withFormDisabledMessage(String formDisabledMessage)
|
||||
{
|
||||
this.formDisabledMessage = formDisabledMessage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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.actions.formadjuster;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Builder class to help make a FormAdjusterOutput
|
||||
***************************************************************************/
|
||||
public class FormAdjusterOutputBuilder
|
||||
{
|
||||
private QTableMetaData table;
|
||||
|
||||
private Map<String, FormAdjusterOutputBuilderFieldChanges> fieldChangesMap = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Constructor for a new builder for a form based on a table.
|
||||
*
|
||||
* @param tableName the table that the form is based on. e.g., where the
|
||||
* fields will come from
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutputBuilder(String tableName)
|
||||
{
|
||||
this.table = QContext.getQInstance().getTable(tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* complete the job of the builder, making its collected changes into
|
||||
* a {@link FormAdjusterOutput}.
|
||||
*
|
||||
* @return the resulting {@link FormAdjusterOutput}
|
||||
***************************************************************************/
|
||||
public FormAdjusterOutput build()
|
||||
{
|
||||
FormAdjusterOutput output = new FormAdjusterOutput();
|
||||
|
||||
for(FormAdjusterOutputBuilderFieldChanges fieldChanges : fieldChangesMap.values())
|
||||
{
|
||||
fieldChanges.apply(table, output);
|
||||
}
|
||||
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to set a field's label
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
* @param label new lame for the field.
|
||||
***************************************************************************/
|
||||
public void makeFieldLabel(String fieldName, String label)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeLabelTo(label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field hidden or shown based on
|
||||
* a boolean.
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
* @param condition if true, the field will be set to not hidden;
|
||||
* if false the field will be set to be hidden.
|
||||
***************************************************************************/
|
||||
public void makeFieldShownIf(String fieldName, boolean condition)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsHiddenTo(!condition);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field hidden or shown based on
|
||||
* a boolean.
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
* @param condition if true, the field will be set to be hidden;
|
||||
* if false the field will be set to not hidden.
|
||||
***************************************************************************/
|
||||
public void makeFieldHiddenIf(String fieldName, boolean condition)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsHiddenTo(condition);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field required or optional based on
|
||||
* a boolean.
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
* @param condition if true, the field will be set to be required;
|
||||
* if false the field will be set to not required.
|
||||
***************************************************************************/
|
||||
public void makeFieldRequiredIf(String fieldName, boolean condition)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsRequiredTo(condition);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field required or optional based on
|
||||
* a boolean.
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
* @param condition if true, the field will be set to not required;
|
||||
* if false the field will be set to be required.
|
||||
***************************************************************************/
|
||||
public void makeFieldOptionalIf(String fieldName, boolean condition)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsRequiredTo(!condition);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field editable or read-only based on
|
||||
* a boolean.
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
* @param condition if true, the field will be set to be editable;
|
||||
* if false the field will be set to not editable.
|
||||
***************************************************************************/
|
||||
public void makeFieldEditableIf(String fieldName, boolean condition)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsEditableTo(condition);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field editable or read-only based on
|
||||
* a boolean.
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
* @param condition if true, the field will be set to not editable;
|
||||
* if false the field will be set to be editable.
|
||||
***************************************************************************/
|
||||
public void makeFieldReadOnlyIf(String fieldName, boolean condition)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsEditableTo(!condition);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field shown (not hidden).
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
***************************************************************************/
|
||||
public void makeFieldShown(String fieldName)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsHiddenTo(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field hidden.
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
***************************************************************************/
|
||||
public void makeFieldHidden(String fieldName)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsHiddenTo(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field required.
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
***************************************************************************/
|
||||
public void makeFieldRequired(String fieldName)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsRequiredTo(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field optional (not required).
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
***************************************************************************/
|
||||
public void makeFieldOptional(String fieldName)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsRequiredTo(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field editable.
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
***************************************************************************/
|
||||
public void makeFieldEditable(String fieldName)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsEditableTo(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add a change to this builder to make a field read-only (not editable).
|
||||
*
|
||||
* @param fieldName name of the field to change
|
||||
***************************************************************************/
|
||||
public void makeFieldReadOnly(String fieldName)
|
||||
{
|
||||
getFieldChanges(fieldName).withChangeIsEditableTo(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* get an existing {@link FormAdjusterOutputBuilderFieldChanges} object
|
||||
* for a given field name - creating one (and adding it to `this`) if it did
|
||||
* not previously exist
|
||||
*
|
||||
* @param fieldName name of the field to get a change-tracker for.
|
||||
* @return existing or new FormAdjusterOutputBuilderFieldChanges object for
|
||||
* the field.
|
||||
***************************************************************************/
|
||||
private FormAdjusterOutputBuilderFieldChanges getFieldChanges(String fieldName)
|
||||
{
|
||||
return (fieldChangesMap.computeIfAbsent(fieldName, k -> new FormAdjusterOutputBuilderFieldChanges(fieldName)));
|
||||
}
|
||||
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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.actions.formadjuster;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.frontend.QFrontendFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* package-private class used internally by {@link FormAdjusterOutputBuilder}
|
||||
* to track changes to an individual field.
|
||||
***************************************************************************/
|
||||
class FormAdjusterOutputBuilderFieldChanges
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(FormAdjusterOutputBuilderFieldChanges.class);
|
||||
|
||||
private String fieldName;
|
||||
|
||||
private Boolean changeIsHiddenTo = null;
|
||||
private Boolean changeIsRequiredTo = null;
|
||||
private Boolean changeIsEditableTo = null;
|
||||
private String changeLabelTo = null;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Constructor
|
||||
*
|
||||
* @param fieldName which field the changes will be for.
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutputBuilderFieldChanges(String fieldName)
|
||||
{
|
||||
this.fieldName = fieldName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* add all changes for this field to the input formAdjusterOutput.
|
||||
* @param table the qqq table meta-data that the field is a part of.
|
||||
* note that if the field isn't found in the table, this method
|
||||
* will log a warning and return with noop.
|
||||
* @param formAdjusterOutput object being built (e.g., by a
|
||||
* {@link FormAdjusterOutputBuilder} that the changes
|
||||
* for this field are added to.
|
||||
***************************************************************************/
|
||||
void apply(QTableMetaData table, FormAdjusterOutput formAdjusterOutput)
|
||||
{
|
||||
QFieldMetaData originalField = table.getFields().get(fieldName);
|
||||
if(originalField == null)
|
||||
{
|
||||
LOG.warn("Field not found", logPair("fieldName", fieldName));
|
||||
return;
|
||||
}
|
||||
|
||||
QFieldMetaData cloneField = originalField.clone();
|
||||
boolean anyChanges = false;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// if any of our changeXXX attributes are set, then set the appropriate //
|
||||
// value in the clone of the field, and record that we have changes //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
if(changeIsHiddenTo != null)
|
||||
{
|
||||
cloneField.setIsHidden(changeIsHiddenTo);
|
||||
anyChanges = true;
|
||||
}
|
||||
|
||||
if(changeIsEditableTo != null)
|
||||
{
|
||||
cloneField.setIsEditable(changeIsEditableTo);
|
||||
anyChanges = true;
|
||||
}
|
||||
|
||||
if(changeIsRequiredTo != null)
|
||||
{
|
||||
cloneField.setIsRequired(changeIsRequiredTo);
|
||||
anyChanges = true;
|
||||
}
|
||||
|
||||
if(changeLabelTo != null)
|
||||
{
|
||||
cloneField.setLabel(changeLabelTo);
|
||||
anyChanges = true;
|
||||
}
|
||||
|
||||
if(anyChanges)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we had any changes, lazy-init an UpdatedFieldMetaData map in the output object //
|
||||
// and put this field in that map. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
if(formAdjusterOutput.getUpdatedFieldMetaData() == null)
|
||||
{
|
||||
formAdjusterOutput.setUpdatedFieldMetaData(new HashMap<>());
|
||||
}
|
||||
|
||||
formAdjusterOutput.getUpdatedFieldMetaData().put(fieldName, new QFrontendFieldMetaData(cloneField));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for changeIsHiddenTo
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Boolean getChangeIsHiddenTo()
|
||||
{
|
||||
return changeIsHiddenTo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for changeIsHiddenTo
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void setChangeIsHiddenTo(Boolean changeIsHiddenTo)
|
||||
{
|
||||
this.changeIsHiddenTo = changeIsHiddenTo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for changeIsHiddenTo
|
||||
**
|
||||
* @param changeIsHiddenTo if non-null, marks that this "change" object, when
|
||||
* applied to a {@link FormAdjusterOutput}, should
|
||||
* include setting this field's isHidden value accordingly.
|
||||
* if null, then isHidden is not changed.
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutputBuilderFieldChanges withChangeIsHiddenTo(Boolean changeIsHiddenTo)
|
||||
{
|
||||
this.changeIsHiddenTo = changeIsHiddenTo;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for changeLabelTo
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getChangeLabelTo()
|
||||
{
|
||||
return changeLabelTo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for changeLabelTo
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void setChangeLabelTo(String changeLabelTo)
|
||||
{
|
||||
this.changeLabelTo = changeLabelTo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for changeLabelTo
|
||||
**
|
||||
* @param changeLabelTo if non-null, marks that this "change" object, when
|
||||
* applied to a {@link FormAdjusterOutput}, should
|
||||
* include setting this field's label accordingly.
|
||||
* if null, then label is not changed.
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutputBuilderFieldChanges withChangeLabelTo(String changeLabelTo)
|
||||
{
|
||||
this.changeLabelTo = changeLabelTo;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldName
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getFieldName()
|
||||
{
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for changeIsRequiredTo
|
||||
* @see #withChangeIsRequiredTo(Boolean)
|
||||
*******************************************************************************/
|
||||
public Boolean getChangeIsRequiredTo()
|
||||
{
|
||||
return (this.changeIsRequiredTo);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for changeIsRequiredTo
|
||||
* @see #withChangeIsRequiredTo(Boolean)
|
||||
*******************************************************************************/
|
||||
public void setChangeIsRequiredTo(Boolean changeIsRequiredTo)
|
||||
{
|
||||
this.changeIsRequiredTo = changeIsRequiredTo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for changeIsRequiredTo
|
||||
*
|
||||
* @param changeIsRequiredTo if non-null, marks that this "change" object, when
|
||||
* applied to a {@link FormAdjusterOutput}, should
|
||||
* include setting this field's isRequired value accordingly.
|
||||
* if null, then isRequired is not changed.
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutputBuilderFieldChanges withChangeIsRequiredTo(Boolean changeIsRequiredTo)
|
||||
{
|
||||
this.changeIsRequiredTo = changeIsRequiredTo;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for changeIsEditableTo
|
||||
* @see #withChangeIsEditableTo(Boolean)
|
||||
*******************************************************************************/
|
||||
public Boolean getChangeIsEditableTo()
|
||||
{
|
||||
return (this.changeIsEditableTo);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for changeIsEditableTo
|
||||
* @see #withChangeIsEditableTo(Boolean)
|
||||
*******************************************************************************/
|
||||
public void setChangeIsEditableTo(Boolean changeIsEditableTo)
|
||||
{
|
||||
this.changeIsEditableTo = changeIsEditableTo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for changeIsEditableTo
|
||||
*
|
||||
* @param changeIsEditableTo if non-null, marks that this "change" object, when
|
||||
* applied to a {@link FormAdjusterOutput}, should
|
||||
* include setting this field's isEditable value accordingly.
|
||||
* if null, then isEditable is not changed.
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public FormAdjusterOutputBuilderFieldChanges withChangeIsEditableTo(Boolean changeIsEditableTo)
|
||||
{
|
||||
this.changeIsEditableTo = changeIsEditableTo;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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.actions.formadjuster;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.metadata.JavalinRouteProviderMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Class that stores code-references for the application's defined fromAdjusters
|
||||
** This class also, when registering its first formAdjuster, adds the route to
|
||||
** the javalin instance to service form-adjuster calls from the frontend.
|
||||
*******************************************************************************/
|
||||
public class FormAdjusterRegistry
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(FormAdjusterRegistry.class);
|
||||
|
||||
private static boolean didRegisterRouteProvider = false;
|
||||
private static QInstance lastRegisteredQInstance = null;
|
||||
|
||||
private static Map<String, QCodeReference> onChangeAdjusters = new HashMap<>();
|
||||
private static Map<String, QCodeReference> onLoadAdjusters = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* register on-load and on-change form adjusters for a given field.
|
||||
***************************************************************************/
|
||||
public static void registerFormAdjusters(QInstance qInstance, MaterialDashboardFieldMetaData materialDashboardFieldMetaData) throws QException
|
||||
{
|
||||
////////////////////////////////////////////////////////////////
|
||||
// add the code-references to the map of registered adjusters //
|
||||
////////////////////////////////////////////////////////////////
|
||||
String identifier = materialDashboardFieldMetaData.getFormAdjusterIdentifier();
|
||||
|
||||
QCodeReference onChangeCode = materialDashboardFieldMetaData.getOnChangeFormAdjuster();
|
||||
if(onChangeCode != null)
|
||||
{
|
||||
registerOnChangeFormAdjuster(qInstance, identifier, onChangeCode);
|
||||
}
|
||||
|
||||
QCodeReference onLoadCode = materialDashboardFieldMetaData.getOnLoadFormAdjuster();
|
||||
if(onLoadCode != null)
|
||||
{
|
||||
registerOnLoadFormAdjuster(qInstance, identifier, onLoadCode);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* make sure the route-provider is registered in javalin
|
||||
***************************************************************************/
|
||||
private static void registerRouteProvider(QInstance qInstance) throws QException
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// support hot-swaps, by checking if the input qInstance is different from one we previously registered for //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(didRegisterRouteProvider && lastRegisteredQInstance != qInstance)
|
||||
{
|
||||
didRegisterRouteProvider = false;
|
||||
onChangeAdjusters.clear();
|
||||
onLoadAdjusters.clear();
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we need to register the javalin router, do so (only once per qInstance) //
|
||||
// note, javalin is optional dep, so make sure it's available before try to use it //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
if(!didRegisterRouteProvider)
|
||||
{
|
||||
if(ClassPathUtils.isClassAvailable(QJavalinMetaData.class.getName()))
|
||||
{
|
||||
QJavalinMetaData javalinMetaData = QJavalinMetaData.ofOrWithNew(qInstance);
|
||||
javalinMetaData.withRouteProvider(new JavalinRouteProviderMetaData()
|
||||
.withHostedPath("/material-dashboard-backend/form-adjuster/{identifier}/{event}")
|
||||
.withMethods(List.of("POST"))
|
||||
.withProcessName(RunFormAdjusterProcess.NAME)
|
||||
);
|
||||
|
||||
qInstance.add(new RunFormAdjusterProcess().produce(qInstance));
|
||||
}
|
||||
|
||||
didRegisterRouteProvider = true;
|
||||
lastRegisteredQInstance = qInstance;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* register an on-change form adjuster
|
||||
***************************************************************************/
|
||||
public static void registerOnChangeFormAdjuster(QInstance qInstance, String identifier, QCodeReference codeReference) throws QException
|
||||
{
|
||||
registerRouteProvider(qInstance);
|
||||
|
||||
if(onChangeAdjusters.containsKey(identifier) && !onChangeAdjusters.get(identifier).equals(codeReference))
|
||||
{
|
||||
LOG.warn("Attempt to register more than one onChangeFormAdjuster with identifier: " + identifier);
|
||||
}
|
||||
onChangeAdjusters.put(identifier, codeReference);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* register an on-load form adjuster
|
||||
***************************************************************************/
|
||||
public static void registerOnLoadFormAdjuster(QInstance qInstance, String identifier, QCodeReference codeReference) throws QException
|
||||
{
|
||||
registerRouteProvider(qInstance);
|
||||
|
||||
if(onLoadAdjusters.containsKey(identifier) && !onLoadAdjusters.get(identifier).equals(codeReference))
|
||||
{
|
||||
LOG.warn("Attempt to register more than one onLoadFormAdjuster with identifier: " + identifier);
|
||||
}
|
||||
onLoadAdjusters.put(identifier, codeReference);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
static FormAdjusterInterface getOnChangeAdjuster(String identifier)
|
||||
{
|
||||
QCodeReference codeReference = onChangeAdjusters.get(identifier);
|
||||
if(codeReference != null)
|
||||
{
|
||||
return QCodeLoader.getAdHoc(FormAdjusterInterface.class, codeReference);
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
static FormAdjusterInterface getOnLoadAdjuster(String identifier)
|
||||
{
|
||||
QCodeReference codeReference = onLoadAdjusters.get(identifier);
|
||||
if(codeReference != null)
|
||||
{
|
||||
return QCodeLoader.getAdHoc(FormAdjusterInterface.class, codeReference);
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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.actions.formadjuster;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
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.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.routeproviders.ProcessBasedRouterPayload;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** process that looks up a form adjuster from the registry, and then runs it
|
||||
*******************************************************************************/
|
||||
public class RunFormAdjusterProcess implements BackendStep, MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "MaterialDashboardRunFormAdjusterProcess";
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(RunFormAdjusterProcess.class);
|
||||
|
||||
public static final String EVENT_ON_LOAD = "onLoad";
|
||||
public static final String EVENT_ON_CHANGE = "onChange";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
|
||||
.withStep(new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withCode(new QCodeReference(getClass())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
|
||||
{
|
||||
ProcessBasedRouterPayload payload = runBackendStepInput.getProcessPayload(ProcessBasedRouterPayload.class);
|
||||
|
||||
String identifier = payload.getPathParams().get("identifier");
|
||||
String event = payload.getPathParams().get("event");
|
||||
|
||||
try
|
||||
{
|
||||
FormAdjusterInterface formAdjuster = switch(event)
|
||||
{
|
||||
case EVENT_ON_CHANGE -> FormAdjusterRegistry.getOnChangeAdjuster(identifier);
|
||||
case EVENT_ON_LOAD -> FormAdjusterRegistry.getOnLoadAdjuster(identifier);
|
||||
default -> throw new QException("Unknown event type: " + event);
|
||||
};
|
||||
|
||||
if(formAdjuster == null)
|
||||
{
|
||||
throw new QException("No form adjuster found for identifier: " + identifier + " and event: " + event);
|
||||
}
|
||||
|
||||
FormAdjusterInput input = new FormAdjusterInput();
|
||||
input.setEvent(event);
|
||||
input.setFieldName(payload.getFormParam("fieldName"));
|
||||
input.setNewValue(payload.getFormParam("newValue"));
|
||||
|
||||
String allValuesJson = payload.getFormParam("allValues");
|
||||
Map<String, Serializable> allValues = StringUtils.hasContent(allValuesJson) ? JsonUtils.toObject(allValuesJson, new TypeReference<>() {}) : Collections.emptyMap();
|
||||
input.setAllValues(allValues);
|
||||
|
||||
FormAdjusterOutput output = formAdjuster.execute(input);
|
||||
|
||||
payload.setResponseString(JsonUtils.toJson(output));
|
||||
runBackendStepOutput.setProcessPayload(payload);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error running form adjuster process", e, logPair("identifier", identifier), logPair("event", event));
|
||||
throw new QException("Error running form adjuster process: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+13
-15
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
@@ -19,20 +19,18 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useAuth0} from "@auth0/auth0-react";
|
||||
import {Button} from "@mui/material";
|
||||
import React from "react";
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
function AuthenticationButton()
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.branding.BannerSlot;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public enum MaterialDashboardBannerSlots implements BannerSlot
|
||||
{
|
||||
const {loginWithRedirect, logout, isAuthenticated} = useAuth0();
|
||||
|
||||
if (isAuthenticated)
|
||||
{
|
||||
return <Button onClick={() => logout({returnTo: window.location.origin})}>Log Out</Button>;
|
||||
}
|
||||
|
||||
return <Button onClick={() => loginWithRedirect()}>Log In</Button>;
|
||||
QFMD_TOP_OF_SITE,
|
||||
QFMD_TOP_OF_BODY,
|
||||
QFMD_SIDE_NAV_UNDER_LOGO
|
||||
}
|
||||
|
||||
export default AuthenticationButton;
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
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.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.FormAdjusterInterface;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.FormAdjusterRegistry;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class MaterialDashboardFieldMetaData extends QSupplementalFieldMetaData
|
||||
{
|
||||
public static final String TYPE = "materialDashboard";
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(MaterialDashboardFieldMetaData.class);
|
||||
|
||||
private String formAdjusterIdentifier = null;
|
||||
private QCodeReference onChangeFormAdjuster = null;
|
||||
private QCodeReference onLoadFormAdjuster = null;
|
||||
private Set<String> fieldsToDisableWhileRunningAdjusters = null;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean includeInFrontendMetaData()
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for onChangeFormAdjuster
|
||||
*******************************************************************************/
|
||||
public QCodeReference getOnChangeFormAdjuster()
|
||||
{
|
||||
return (this.onChangeFormAdjuster);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for onChangeFormAdjuster
|
||||
*******************************************************************************/
|
||||
public void setOnChangeFormAdjuster(QCodeReference onChangeFormAdjuster)
|
||||
{
|
||||
this.onChangeFormAdjuster = onChangeFormAdjuster;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for onChangeFormAdjuster
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardFieldMetaData withOnChangeFormAdjuster(QCodeReference onChangeFormAdjuster)
|
||||
{
|
||||
this.onChangeFormAdjuster = onChangeFormAdjuster;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for onLoadFormAdjuster
|
||||
*******************************************************************************/
|
||||
public QCodeReference getOnLoadFormAdjuster()
|
||||
{
|
||||
return (this.onLoadFormAdjuster);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for onLoadFormAdjuster
|
||||
*******************************************************************************/
|
||||
public void setOnLoadFormAdjuster(QCodeReference onLoadFormAdjuster)
|
||||
{
|
||||
this.onLoadFormAdjuster = onLoadFormAdjuster;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for onLoadFormAdjuster
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardFieldMetaData withOnLoadFormAdjuster(QCodeReference onLoadFormAdjuster)
|
||||
{
|
||||
this.onLoadFormAdjuster = onLoadFormAdjuster;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void enrich(QInstance qInstance, QFieldMetaData fieldMetaData)
|
||||
{
|
||||
try
|
||||
{
|
||||
FormAdjusterRegistry.registerFormAdjusters(qInstance, this);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error enriching MaterialDashboardFieldMetaData", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, QFieldMetaData fieldMetaData, QInstanceValidator qInstanceValidator)
|
||||
{
|
||||
String prefix = "MaterialDashboardFieldMetaData for field [" + fieldMetaData.getName() + "]";
|
||||
|
||||
boolean needsFormAdjusterIdentifer = false;
|
||||
if(onChangeFormAdjuster != null)
|
||||
{
|
||||
needsFormAdjusterIdentifer = true;
|
||||
qInstanceValidator.validateSimpleCodeReference(prefix + ", onChangeFormAdjuster", onChangeFormAdjuster, FormAdjusterInterface.class);
|
||||
}
|
||||
|
||||
if(onLoadFormAdjuster != null)
|
||||
{
|
||||
needsFormAdjusterIdentifer = true;
|
||||
qInstanceValidator.validateSimpleCodeReference(prefix + ", onLoadFormAdjuster", onLoadFormAdjuster, FormAdjusterInterface.class);
|
||||
}
|
||||
|
||||
if(needsFormAdjusterIdentifer)
|
||||
{
|
||||
qInstanceValidator.assertCondition(StringUtils.hasContent(formAdjusterIdentifier), prefix + ", formAdjusterIdentifier is required if using any FormAdjusters");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for formAdjusterIdentifier
|
||||
*******************************************************************************/
|
||||
public String getFormAdjusterIdentifier()
|
||||
{
|
||||
return (this.formAdjusterIdentifier);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for formAdjusterIdentifier
|
||||
*******************************************************************************/
|
||||
public void setFormAdjusterIdentifier(String formAdjusterIdentifier)
|
||||
{
|
||||
this.formAdjusterIdentifier = formAdjusterIdentifier;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for formAdjusterIdentifier
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardFieldMetaData withFormAdjusterIdentifier(String formAdjusterIdentifier)
|
||||
{
|
||||
this.formAdjusterIdentifier = formAdjusterIdentifier;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldsToDisableWhileRunningAdjusters
|
||||
*******************************************************************************/
|
||||
public Set<String> getFieldsToDisableWhileRunningAdjusters()
|
||||
{
|
||||
return (this.fieldsToDisableWhileRunningAdjusters);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for fieldsToDisableWhileRunningAdjusters
|
||||
*******************************************************************************/
|
||||
public void setFieldsToDisableWhileRunningAdjusters(Set<String> fieldsToDisableWhileRunningAdjusters)
|
||||
{
|
||||
this.fieldsToDisableWhileRunningAdjusters = fieldsToDisableWhileRunningAdjusters;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fieldsToDisableWhileRunningAdjusters
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardFieldMetaData withFieldsToDisableWhileRunningAdjusters(Set<String> fieldsToDisableWhileRunningAdjusters)
|
||||
{
|
||||
this.fieldsToDisableWhileRunningAdjusters = fieldsToDisableWhileRunningAdjusters;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2023. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** table-level meta-data for this module (handled as QSupplementalTableMetaData)
|
||||
*******************************************************************************/
|
||||
public class MaterialDashboardInstanceMetaData implements QSupplementalInstanceMetaData
|
||||
{
|
||||
public static final String TYPE = "materialDashboard";
|
||||
|
||||
private List<String> processNamesToAddToAllQueryAndViewScreens = new ArrayList<>();
|
||||
|
||||
private Integer queryScreenCopyFullQueryColumnValuesLimit = 100_000;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static MaterialDashboardInstanceMetaData ofOrWithNew(QInstance qInstance)
|
||||
{
|
||||
MaterialDashboardInstanceMetaData supplementalMetaData = (MaterialDashboardInstanceMetaData) qInstance.getSupplementalMetaData(TYPE);
|
||||
if(supplementalMetaData == null)
|
||||
{
|
||||
supplementalMetaData = new MaterialDashboardInstanceMetaData();
|
||||
qInstance.withSupplementalMetaData(supplementalMetaData);
|
||||
}
|
||||
|
||||
return (supplementalMetaData);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for processNamesToAddToAllQueryAndViewScreens
|
||||
*******************************************************************************/
|
||||
public List<String> getProcessNamesToAddToAllQueryAndViewScreens()
|
||||
{
|
||||
return (this.processNamesToAddToAllQueryAndViewScreens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void addProcessNameToAddToAllQueryAndViewScreens(String processNamesToAddToAllQueryAndViewScreens)
|
||||
{
|
||||
if(this.processNamesToAddToAllQueryAndViewScreens == null)
|
||||
{
|
||||
this.processNamesToAddToAllQueryAndViewScreens = new ArrayList<>();
|
||||
}
|
||||
this.processNamesToAddToAllQueryAndViewScreens.add(processNamesToAddToAllQueryAndViewScreens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for processNamesToAddToAllQueryAndViewScreens
|
||||
*******************************************************************************/
|
||||
public void setProcessNamesToAddToAllQueryAndViewScreens(List<String> processNamesToAddToAllQueryAndViewScreens)
|
||||
{
|
||||
this.processNamesToAddToAllQueryAndViewScreens = processNamesToAddToAllQueryAndViewScreens;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for processNamesToAddToAllQueryAndViewScreens
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardInstanceMetaData withProcessNamesToAddToAllQueryAndViewScreens(List<String> processNamesToAddToAllQueryAndViewScreens)
|
||||
{
|
||||
this.processNamesToAddToAllQueryAndViewScreens = processNamesToAddToAllQueryAndViewScreens;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for queryScreenCopyFullQueryColumnValuesLimit
|
||||
* @see #withQueryScreenCopyFullQueryColumnValuesLimit(Integer)
|
||||
*******************************************************************************/
|
||||
public Integer getQueryScreenCopyFullQueryColumnValuesLimit()
|
||||
{
|
||||
return (this.queryScreenCopyFullQueryColumnValuesLimit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for queryScreenCopyFullQueryColumnValuesLimit
|
||||
* @see #withQueryScreenCopyFullQueryColumnValuesLimit(Integer)
|
||||
*******************************************************************************/
|
||||
public void setQueryScreenCopyFullQueryColumnValuesLimit(Integer queryScreenCopyFullQueryColumnValuesLimit)
|
||||
{
|
||||
this.queryScreenCopyFullQueryColumnValuesLimit = queryScreenCopyFullQueryColumnValuesLimit;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for queryScreenCopyFullQueryColumnValuesLimit
|
||||
*
|
||||
* @param queryScreenCopyFullQueryColumnValuesLimit
|
||||
* for the Copy full query result option in the query screen's column menu,
|
||||
* this value is the max number of rows allowed in the query result in order
|
||||
* to allow the copy function to run.
|
||||
* If null, then it's up to the frontend what to do (original implementation had
|
||||
* a limit of 100,000). To effectively disable this feature, set to, e.g.,
|
||||
* Integer.MAX_VALUE. If set to 0 or negative, will basically make the feature
|
||||
* frustratingly unusable.
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardInstanceMetaData withQueryScreenCopyFullQueryColumnValuesLimit(Integer queryScreenCopyFullQueryColumnValuesLimit)
|
||||
{
|
||||
this.queryScreenCopyFullQueryColumnValuesLimit = queryScreenCopyFullQueryColumnValuesLimit;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+124
-1
@@ -27,12 +27,17 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
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.model.metadata.tables.QSupplementalTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.FormAdjusterInterface;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.actions.formadjuster.FormAdjusterRegistry;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -40,12 +45,17 @@ import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.Fi
|
||||
*******************************************************************************/
|
||||
public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(MaterialDashboardTableMetaData.class);
|
||||
|
||||
public static final String TYPE = "materialDashboard";
|
||||
|
||||
private List<List<String>> gotoFieldNames;
|
||||
private List<String> defaultQuickFilterFieldNames;
|
||||
private List<FieldRule> fieldRules;
|
||||
|
||||
private QCodeReference onLoadFormAdjuster = null;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
@@ -68,6 +78,7 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -84,6 +95,7 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for gotoFieldNames
|
||||
*******************************************************************************/
|
||||
@@ -116,7 +128,7 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** perform QInstance validation on this table meta data.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, QTableMetaData tableMetaData, QInstanceValidator qInstanceValidator)
|
||||
@@ -136,6 +148,37 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
{
|
||||
validateFieldRule(qInstance, tableMetaData, qInstanceValidator, fieldRule, prefix);
|
||||
}
|
||||
|
||||
if(onLoadFormAdjuster != null)
|
||||
{
|
||||
qInstanceValidator.validateSimpleCodeReference(prefix + ", onLoadFormAdjuster", onLoadFormAdjuster, FormAdjusterInterface.class);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* perform QInstance enrichment on this table meta data.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void enrich(QInstance qInstance, QTableMetaData table)
|
||||
{
|
||||
super.enrich(qInstance, table);
|
||||
|
||||
try
|
||||
{
|
||||
if(onLoadFormAdjuster != null)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// if there's an on-load form adjuster, add it to the form adjuster registry //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
FormAdjusterRegistry.registerOnLoadFormAdjuster(qInstance, "table:" + table.getName(), onLoadFormAdjuster);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error enriching MaterialDashboardTableMetaData", e, logPair("table", table == null ? null : table.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +261,7 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldRules
|
||||
*******************************************************************************/
|
||||
@@ -264,4 +308,83 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Getter for onLoadFormAdjuster
|
||||
* @see #withOnLoadFormAdjuster(QCodeReference)
|
||||
*******************************************************************************/
|
||||
public QCodeReference getOnLoadFormAdjuster()
|
||||
{
|
||||
return (this.onLoadFormAdjuster);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Setter for onLoadFormAdjuster
|
||||
* @see #withOnLoadFormAdjuster(QCodeReference)
|
||||
*******************************************************************************/
|
||||
public void setOnLoadFormAdjuster(QCodeReference onLoadFormAdjuster)
|
||||
{
|
||||
this.onLoadFormAdjuster = onLoadFormAdjuster;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Fluent setter for onLoadFormAdjuster
|
||||
*
|
||||
* @param onLoadFormAdjuster code reference to class of type
|
||||
* {@link FormAdjusterInterface} to be executed "on load"
|
||||
* for insert & edit screens for this table.
|
||||
* @return this
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardTableMetaData withOnLoadFormAdjuster(QCodeReference onLoadFormAdjuster)
|
||||
{
|
||||
this.onLoadFormAdjuster = onLoadFormAdjuster;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData abstractClone)
|
||||
{
|
||||
MaterialDashboardTableMetaData clone = (MaterialDashboardTableMetaData) abstractClone;
|
||||
|
||||
if(gotoFieldNames != null)
|
||||
{
|
||||
clone.gotoFieldNames = new ArrayList<>();
|
||||
for(List<String> gotoFieldNameSubList : gotoFieldNames)
|
||||
{
|
||||
clone.gotoFieldNames.add(new ArrayList<>(gotoFieldNameSubList));
|
||||
}
|
||||
}
|
||||
|
||||
if(defaultQuickFilterFieldNames != null)
|
||||
{
|
||||
clone.defaultQuickFilterFieldNames = new ArrayList<>(defaultQuickFilterFieldNames);
|
||||
}
|
||||
|
||||
if(fieldRules != null)
|
||||
{
|
||||
clone.fieldRules = new ArrayList<>();
|
||||
for(FieldRule fieldRule : fieldRules)
|
||||
{
|
||||
clone.fieldRules.add(fieldRule.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if(onLoadFormAdjuster != null)
|
||||
{
|
||||
clone.onLoadFormAdjuster = onLoadFormAdjuster.clone();
|
||||
}
|
||||
|
||||
return abstractClone;
|
||||
}
|
||||
|
||||
}
|
||||
+19
-1
@@ -31,7 +31,7 @@ import java.io.Serializable;
|
||||
** e.g., one field being changed causing different things to be needed in another
|
||||
** field.
|
||||
*******************************************************************************/
|
||||
public class FieldRule implements Serializable
|
||||
public class FieldRule implements Serializable, Cloneable
|
||||
{
|
||||
private FieldRuleTrigger trigger;
|
||||
private String sourceField;
|
||||
@@ -195,4 +195,22 @@ public class FieldRule implements Serializable
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public FieldRule clone()
|
||||
{
|
||||
try
|
||||
{
|
||||
FieldRule clone = (FieldRule) super.clone();
|
||||
return clone;
|
||||
}
|
||||
catch(CloneNotSupportedException e)
|
||||
{
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import colors from "qqq/assets/theme/base/colors";
|
||||
// Material Dashboard 2 PRO React TS Helper Functions
|
||||
import rgba from "qqq/assets/theme/functions/rgba";
|
||||
|
||||
const { info, white, gradients } = colors;
|
||||
const { info, white } = colors;
|
||||
|
||||
// types
|
||||
type Types = any;
|
||||
@@ -43,7 +43,7 @@ const flatpickr: Types = {
|
||||
|
||||
".flatpickr-day.selected, .flatpickr-day.selected:hover, .flatpickr-day.nextMonthDay.selected, .flatpickr-day.nextMonthDay.selected:hover, .flatpickr-day.nextMonthDay.selected:focus":
|
||||
{
|
||||
background: `${gradients.info.state} !important`,
|
||||
background: "var(--qqq-info-color, #0062FF) !important",
|
||||
color: white.main,
|
||||
border: "none",
|
||||
},
|
||||
|
||||
@@ -20,9 +20,8 @@ import boxShadows from "qqq/assets/theme/base/boxShadows";
|
||||
|
||||
// Material Dashboard 2 PRO React TS Helper Functions
|
||||
import pxToRem from "qqq/assets/theme/functions/pxToRem";
|
||||
import linearGradient from "qqq/assets/theme/functions/linearGradient";
|
||||
|
||||
const { white, gradients, grey, transparent } = colors;
|
||||
const { white, grey, transparent } = colors;
|
||||
const { borderWidth } = borders;
|
||||
const { md } = boxShadows;
|
||||
|
||||
@@ -36,26 +35,26 @@ const switchButton: Types = {
|
||||
|
||||
styleOverrides: {
|
||||
switchBase: {
|
||||
color: gradients.dark.main,
|
||||
color: "var(--qqq-switch-track-color, #42424a)",
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: transparent.main,
|
||||
},
|
||||
|
||||
"&.Mui-checked": {
|
||||
color: gradients.dark.main,
|
||||
color: "var(--qqq-switch-track-color, #42424a)",
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: transparent.main,
|
||||
},
|
||||
|
||||
"& .MuiSwitch-thumb": {
|
||||
borderColor: `${gradients.dark.main} !important`,
|
||||
borderColor: "var(--qqq-switch-track-color, #42424a) !important",
|
||||
},
|
||||
|
||||
"& + .MuiSwitch-track": {
|
||||
backgroundColor: `${gradients.dark.main} !important`,
|
||||
borderColor: `${gradients.dark.main} !important`,
|
||||
backgroundColor: "var(--qqq-switch-track-color, #42424a) !important",
|
||||
borderColor: "var(--qqq-switch-track-color, #42424a) !important",
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
@@ -65,7 +64,7 @@ const switchButton: Types = {
|
||||
},
|
||||
|
||||
"&.Mui-focusVisible .MuiSwitch-thumb": {
|
||||
backgroundImage: linearGradient(gradients.info.main, gradients.info.state),
|
||||
backgroundImage: "linear-gradient(195deg, var(--qqq-info-color, #0062FF), var(--qqq-info-color, #0062FF))",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const menu: Types = {
|
||||
fontSize: size.sm,
|
||||
color: text.main,
|
||||
textAlign: "left",
|
||||
backgroundColor: `${white.main} !important`,
|
||||
backgroundColor: "var(--qqq-surface-color, #FFFFFF) !important",
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,15 +16,12 @@ Coded by www.creative-tim.com
|
||||
// Material Dashboard 2 PRO React TS Base Styles
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import borders from "qqq/assets/theme/base/borders";
|
||||
import boxShadows from "qqq/assets/theme/base/boxShadows";
|
||||
|
||||
// Material Dashboard 2 PRO React TS Helper Functions
|
||||
import pxToRem from "qqq/assets/theme/functions/pxToRem";
|
||||
import linearGradient from "qqq/assets/theme/functions/linearGradient";
|
||||
|
||||
const { transparent, gradients } = colors;
|
||||
const { transparent } = colors;
|
||||
const { borderRadius } = borders;
|
||||
const { colored } = boxShadows;
|
||||
|
||||
// types
|
||||
type Types = any;
|
||||
@@ -32,10 +29,10 @@ type Types = any;
|
||||
const stepper: Types = {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: linearGradient(gradients.info.main, gradients.info.state),
|
||||
background: "var(--qqq-stepper-background, linear-gradient(195deg, var(--qqq-primary-color), var(--qqq-primary-color-dark, var(--qqq-primary-color))))",
|
||||
padding: `${pxToRem(24)} 0 ${pxToRem(16)}`,
|
||||
borderRadius: borderRadius.lg,
|
||||
boxShadow: colored.info,
|
||||
boxShadow: "var(--qqq-stepper-shadow, 0rem 0.25rem 1.25rem 0rem rgba(0, 0, 0, 0.14), 0rem 0.4375rem 0.625rem -0.3125rem rgba(0, 0, 0, 0.4))",
|
||||
|
||||
"&.MuiPaper-root": {
|
||||
backgroundColor: transparent.main,
|
||||
|
||||
@@ -15,9 +15,7 @@ Coded by www.creative-tim.com
|
||||
|
||||
// Material Dashboard 2 PRO React TS Base Styles
|
||||
import borders from "qqq/assets/theme/base/borders";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
|
||||
const { white } = colors;
|
||||
const { borderWidth } = borders;
|
||||
|
||||
// types
|
||||
@@ -26,15 +24,15 @@ type Types = any;
|
||||
const stepConnector: Types = {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: "#9fc9ff",
|
||||
color: "var(--qqq-stepper-inactive-color, rgba(255, 255, 255, 0.5))",
|
||||
transition: "all 200ms linear",
|
||||
|
||||
"&.Mui-active": {
|
||||
color: white.main,
|
||||
color: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
},
|
||||
|
||||
"&.Mui-completed": {
|
||||
color: white.main,
|
||||
color: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -13,14 +13,8 @@ Coded by www.creative-tim.com
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
*/
|
||||
|
||||
// Material Dashboard 2 PRO React TS Base Styles
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
|
||||
// Material Dashboard 2 PRO React TS Helper Functions
|
||||
import pxToRem from "qqq/assets/theme/functions/pxToRem";
|
||||
import boxShadow from "qqq/assets/theme/functions/boxShadow";
|
||||
|
||||
const { white } = colors;
|
||||
|
||||
// types
|
||||
type Types = any;
|
||||
@@ -28,9 +22,9 @@ type Types = any;
|
||||
const stepIcon: Types = {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: "#9fc9ff",
|
||||
fill: "#9fc9ff",
|
||||
stroke: "#9fc9ff",
|
||||
background: "var(--qqq-stepper-inactive-color, rgba(255, 255, 255, 0.5))",
|
||||
fill: "var(--qqq-stepper-inactive-color, rgba(255, 255, 255, 0.5))",
|
||||
stroke: "var(--qqq-stepper-inactive-color, rgba(255, 255, 255, 0.5))",
|
||||
strokeWidth: pxToRem(10),
|
||||
width: pxToRem(13),
|
||||
height: pxToRem(13),
|
||||
@@ -39,19 +33,19 @@ const stepIcon: Types = {
|
||||
transition: "all 200ms linear",
|
||||
|
||||
"&.Mui-active": {
|
||||
background: white.main,
|
||||
fill: white.main,
|
||||
stroke: white.main,
|
||||
borderColor: white.main,
|
||||
boxShadow: boxShadow([0, 0], [0, 2], white.main, 1),
|
||||
background: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
fill: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
stroke: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
borderColor: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
boxShadow: "0rem 0rem 0rem 0.125rem var(--qqq-stepper-active-color, #ffffff)",
|
||||
},
|
||||
|
||||
"&.Mui-completed": {
|
||||
background: white.main,
|
||||
fill: white.main,
|
||||
stroke: white.main,
|
||||
borderColor: white.main,
|
||||
boxShadow: boxShadow([0, 0], [0, 2], white.main, 1),
|
||||
background: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
fill: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
stroke: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
borderColor: "var(--qqq-stepper-active-color, #ffffff)",
|
||||
boxShadow: "0rem 0rem 0rem 0.125rem var(--qqq-stepper-active-color, #ffffff)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,14 +15,11 @@ Coded by www.creative-tim.com
|
||||
|
||||
// Material Dashboard 2 PRO React TS Base Styles
|
||||
import typography from "qqq/assets/theme/base/typography";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
|
||||
// Material Dashboard 2 PRO React TS Helper Functions
|
||||
import pxToRem from "qqq/assets/theme/functions/pxToRem";
|
||||
import rgba from "qqq/assets/theme/functions/rgba";
|
||||
|
||||
const { size, fontWeightRegular } = typography;
|
||||
const { white } = colors;
|
||||
|
||||
// types
|
||||
type Types = any;
|
||||
@@ -33,17 +30,17 @@ const stepLabel: Types = {
|
||||
marginTop: `${pxToRem(8)} !important`,
|
||||
fontWeight: fontWeightRegular,
|
||||
fontSize: size.xs,
|
||||
color: "#9fc9ff",
|
||||
color: "var(--qqq-stepper-inactive-color, rgba(255, 255, 255, 0.5))",
|
||||
textTransform: "uppercase",
|
||||
|
||||
"&.Mui-active": {
|
||||
fontWeight: `${fontWeightRegular} !important`,
|
||||
color: `${rgba(white.main, 0.8)} !important`,
|
||||
color: "var(--qqq-stepper-active-color, rgba(255, 255, 255, 0.9)) !important",
|
||||
},
|
||||
|
||||
"&.Mui-completed": {
|
||||
fontWeight: `${fontWeightRegular} !important`,
|
||||
color: `${rgba(white.main, 0.8)} !important`,
|
||||
color: "var(--qqq-stepper-active-color, rgba(255, 255, 255, 0.9)) !important",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -66,7 +66,7 @@ const tabs: Types = {
|
||||
borderRadius: 0,
|
||||
backgroundColor: white.main,
|
||||
borderBottom: "2px solid",
|
||||
borderBottomColor: colors.info.main,
|
||||
borderBottomColor: "var(--qqq-info-color, #0062FF)",
|
||||
transition: "all 500ms ease",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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 {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
import {SESSION_UUID_COOKIE_NAME} from "App";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import {useCookies} from "react-cookie";
|
||||
import {Md5} from "ts-md5/dist/md5";
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
interface Props
|
||||
{
|
||||
setIsFullyAuthenticated?: (is: boolean) => void;
|
||||
setLoggedInUser?: (user: any) => void;
|
||||
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** hook for working with the anonymous authentication module
|
||||
***************************************************************************/
|
||||
export default function useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}: Props)
|
||||
{
|
||||
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const setupSession = async () =>
|
||||
{
|
||||
console.log("Generating random token...");
|
||||
setIsFullyAuthenticated(true);
|
||||
Client.setGotAuthenticationInAllControllers();
|
||||
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
|
||||
console.log("Token generation complete.");
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const logout = () =>
|
||||
{
|
||||
qController.clearAuthenticationMetaDataLocalStorage();
|
||||
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
|
||||
{
|
||||
return children;
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
setupSession,
|
||||
logout,
|
||||
renderAppWrapper
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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 {Auth0Provider, useAuth0} from "@auth0/auth0-react";
|
||||
import {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
import App, {SESSION_UUID_COOKIE_NAME} from "App";
|
||||
import HandleAuthorizationError from "HandleAuthorizationError";
|
||||
import jwt_decode from "jwt-decode";
|
||||
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
|
||||
import {MaterialUIControllerProvider} from "qqq/context";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import {useCookies} from "react-cookie";
|
||||
import {useNavigate, useSearchParams} from "react-router-dom";
|
||||
|
||||
const qController = Client.getInstance();
|
||||
const qControllerV1 = Client.getInstanceV1();
|
||||
|
||||
interface Props
|
||||
{
|
||||
setIsFullyAuthenticated?: (is: boolean) => void;
|
||||
setLoggedInUser?: (user: any) => void;
|
||||
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** hook for working with the Auth0 authentication module
|
||||
***************************************************************************/
|
||||
export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser}: Props)
|
||||
{
|
||||
const {user: auth0User, getAccessTokenSilently: auth0GetAccessTokenSilently, logout: useAuth0Logout} = useAuth0();
|
||||
|
||||
const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
|
||||
{
|
||||
if (!cookies[SESSION_UUID_COOKIE_NAME])
|
||||
{
|
||||
console.log("No session uuid cookie - so we should store a new one.");
|
||||
return (true);
|
||||
}
|
||||
|
||||
if (!oldToken)
|
||||
{
|
||||
console.log("No accessToken in localStorage - so we should store a new one.");
|
||||
return (true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const oldJSON: any = jwt_decode(oldToken);
|
||||
const newJSON: any = jwt_decode(newToken);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the old (local storage) token is expired, then we need to store the new one //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
const oldExp = oldJSON["exp"];
|
||||
if (oldExp * 1000 < (new Date().getTime()))
|
||||
{
|
||||
console.log("Access token in local storage was expired - so we should store a new one.");
|
||||
return (true);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// remove the exp & iat values from what we compare - as they are always different from auth0 //
|
||||
// note, this is only deleting them from what we compare, not from what we'd store. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
delete newJSON["exp"];
|
||||
delete newJSON["iat"];
|
||||
delete oldJSON["exp"];
|
||||
delete oldJSON["iat"];
|
||||
|
||||
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
|
||||
if (different)
|
||||
{
|
||||
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
|
||||
}
|
||||
return (different);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log("Caught in shouldStoreNewToken: " + e);
|
||||
}
|
||||
|
||||
return (true);
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const setupSession = async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
console.log("Loading token from auth0...");
|
||||
const accessToken = await auth0GetAccessTokenSilently();
|
||||
|
||||
const lsAccessToken = localStorage.getItem("accessToken");
|
||||
if (shouldStoreNewToken(accessToken, lsAccessToken))
|
||||
{
|
||||
console.log("Sending accessToken to backend, requesting a sessionUUID...");
|
||||
const {uuid: values} = await qController.manageSession(accessToken, null);
|
||||
|
||||
localStorage.setItem("accessToken", accessToken);
|
||||
localStorage.setItem("sessionValues", JSON.stringify(values));
|
||||
console.log("Got new sessionUUID from backend, and stored new accessToken");
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log("Using existing sessionUUID cookie");
|
||||
}
|
||||
|
||||
setIsFullyAuthenticated(true);
|
||||
Client.setGotAuthenticationInAllControllers();
|
||||
|
||||
setLoggedInUser(auth0User);
|
||||
console.log("Token load complete.");
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log(`Error loading token: ${JSON.stringify(e)}`);
|
||||
qController.clearAuthenticationMetaDataLocalStorage();
|
||||
localStorage.removeItem("accessToken");
|
||||
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
||||
useAuth0Logout();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const logout = () =>
|
||||
{
|
||||
localStorage.removeItem("accessToken");
|
||||
useAuth0Logout({returnTo: window.location.origin});
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
// @ts-ignore
|
||||
function Auth0ProviderWithRedirectCallback({children, ...props})
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// @ts-ignore
|
||||
const onRedirectCallback = (appState) =>
|
||||
{
|
||||
navigate((appState && appState.returnTo) || window.location.pathname);
|
||||
};
|
||||
if (searchParams.get("error"))
|
||||
{
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Auth0Provider {...props}>
|
||||
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
|
||||
</Auth0Provider>
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
|
||||
{children}
|
||||
</Auth0Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData): JSX.Element =>
|
||||
{
|
||||
// @ts-ignore
|
||||
let domain: string = authenticationMetaData.data.baseUrl;
|
||||
|
||||
// @ts-ignore
|
||||
const clientId = authenticationMetaData.data.clientId;
|
||||
|
||||
// @ts-ignore
|
||||
const audience = authenticationMetaData.data.audience;
|
||||
|
||||
if (!domain || !clientId)
|
||||
{
|
||||
return (
|
||||
<div>Error: AUTH0 authenticationMetaData is missing baseUrl [{domain}] and/or clientId [{clientId}].</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (domain.endsWith("/"))
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// auth0 lib fails if we have a trailing slash. be a bit more graceful than that. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
domain = domain.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** simple Functional Component to wrap the <App> and pass the authentication-
|
||||
** MetaData prop in, so a simple Component can be passed into ProtectedRoute
|
||||
***************************************************************************/
|
||||
function WrappedApp()
|
||||
{
|
||||
return <App authenticationMetaData={authenticationMetaData} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Auth0ProviderWithRedirectCallback
|
||||
domain={domain}
|
||||
clientId={clientId}
|
||||
audience={audience}
|
||||
redirectUri={`${window.location.origin}/`}>
|
||||
<MaterialUIControllerProvider>
|
||||
<ProtectedRoute component={WrappedApp} />
|
||||
</MaterialUIControllerProvider>
|
||||
</Auth0ProviderWithRedirectCallback>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
setupSession,
|
||||
logout,
|
||||
renderAppWrapper
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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 {QAuthenticationMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
|
||||
import {SESSION_UUID_COOKIE_NAME} from "App";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import {detectBasePath} from "qqq/utils/PathUtils";
|
||||
import {useCookies} from "react-cookie";
|
||||
import {AuthContextProps, AuthProvider, useAuth} from "react-oidc-context";
|
||||
import {useNavigate, useSearchParams} from "react-router-dom";
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Singleton instance of the QQQ client controller for API communication * //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
const qController = Client.getInstance();
|
||||
|
||||
/**
|
||||
* Properties interface for the useOAuth2AuthenticationModule hook.
|
||||
*
|
||||
* @interface Props
|
||||
* @property {function} [setIsFullyAuthenticated] - Optional callback to update the authentication state in parent component
|
||||
* @property {function} [setLoggedInUser] - Optional callback to set the logged-in user object in parent component
|
||||
* @property {function} [setEarlyReturnForAuth] - Optional callback to display auth-related UI elements (e.g., loading, errors)
|
||||
* @property {boolean} inOAuthContext - Flag indicating whether the component is rendered within an OAuth context (AuthProvider)
|
||||
*/
|
||||
interface Props
|
||||
{
|
||||
setIsFullyAuthenticated?: (is: boolean) => void;
|
||||
setLoggedInUser?: (user: any) => void;
|
||||
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
|
||||
inOAuthContext: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom React hook for managing OAuth2 authentication flows using the Authorization Code Flow with PKCE.
|
||||
*
|
||||
* This hook handles the complete OAuth2 authentication lifecycle including:
|
||||
* - Initiating the OAuth2 login flow and redirecting to the identity provider
|
||||
* - Processing authorization codes returned from the identity provider
|
||||
* - Exchanging authorization codes for session tokens via the backend
|
||||
* - Validating existing session cookies
|
||||
* - Managing logout and session cleanup
|
||||
* - Wrapping the application with the necessary OAuth2 context provider
|
||||
*
|
||||
* The hook implements a three-stage authentication process:
|
||||
* 1. Initial redirect to identity provider (IP) for user login
|
||||
* 2. Callback handling at /token endpoint to exchange code for session
|
||||
* 3. Session validation on subsequent page loads using cookies
|
||||
*
|
||||
* @param {Props} props - Configuration object containing callbacks and context flags
|
||||
* @param {function} [props.setIsFullyAuthenticated] - Callback to update authentication state in parent
|
||||
* @param {function} [props.setLoggedInUser] - Callback to update logged-in user data in parent
|
||||
* @param {function} [props.setEarlyReturnForAuth] - Callback to display auth UI (loading, errors)
|
||||
* @param {boolean} props.inOAuthContext - Whether component is within OAuth AuthProvider context
|
||||
*
|
||||
* @returns {object} Object containing authentication methods
|
||||
* @returns {function} setupSession - Initiates or validates the authentication session
|
||||
* @returns {function} logout - Clears session and redirects to identity provider logout
|
||||
* @returns {function} renderAppWrapper - Wraps children with OAuth AuthProvider configuration
|
||||
*
|
||||
* @example
|
||||
* const { setupSession, logout, renderAppWrapper } = useOAuth2AuthenticationModule({
|
||||
* setIsFullyAuthenticated: setAuthState,
|
||||
* setLoggedInUser: setUser,
|
||||
* setEarlyReturnForAuth: setAuthUI,
|
||||
* inOAuthContext: true
|
||||
* });
|
||||
*/
|
||||
export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext}: Props)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// The useAuth hook should only be called if we're inside the <AuthProvider> element. //
|
||||
// On the page that uses this hook to call renderAppWrapper, we aren't in that //
|
||||
// element/context yet, so we conditionally call useAuth based on the inOAuthContext flag. //
|
||||
// This prevents "hook called outside of context" errors. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const authOidc: AuthContextProps | null = inOAuthContext ? useAuth() : null;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// ** Cookie management hook for reading the session UUID cookie * //
|
||||
// ** (Removal is done via direct document.cookie manipulation in logout) * //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
const [cookies] = useCookies([SESSION_UUID_COOKIE_NAME]);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** URL search parameters hook for extracting OAuth callback parameters (code, state) * //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// ** Navigation hook for programmatic redirects after authentication * //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
const navigate = useNavigate();
|
||||
|
||||
/**
|
||||
* Establishes or validates a user authentication session using OAuth2 flows.
|
||||
*
|
||||
* This function handles three distinct authentication scenarios:
|
||||
*
|
||||
* 1. **OAuth Callback (pathname === "/token")**:
|
||||
* - Extracts authorization code and state from URL parameters
|
||||
* - Retrieves PKCE verifier and redirect URI from local storage using state
|
||||
* - Exchanges code for session via backend API call
|
||||
* - Sets authentication state and redirects to original destination
|
||||
*
|
||||
* 2. **Existing Session Validation (has session cookie)**:
|
||||
* - Validates existing session UUID cookie with backend
|
||||
* - Updates authentication state if session is valid
|
||||
* - Allows user to continue to requested page
|
||||
*
|
||||
* 3. **New Authentication Required (no cookie, not callback)**:
|
||||
* - Saves current pathname for post-login redirect
|
||||
* - Initiates OAuth2 authorization code flow
|
||||
* - Redirects to identity provider's login page
|
||||
*
|
||||
* @async
|
||||
* @function setupSession
|
||||
* @returns {Promise<void>} Resolves when session setup is complete or logout is triggered
|
||||
* @throws {Error} Logs error and triggers logout if session validation/creation fails
|
||||
*/
|
||||
const setupSession = async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
/** Local storage key for storing the pre-signin redirect path */
|
||||
const preSigninRedirectPathnameKey = "oauth2.preSigninRedirect.pathname";
|
||||
|
||||
/** Check if we're at the OAuth callback endpoint (handles any base path + /token) */
|
||||
const isTokenCallback = window.location.pathname.endsWith("/token");
|
||||
|
||||
if (isTokenCallback)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// SCENARIO 1: OAuth2 Callback Handler (/token endpoint) //
|
||||
// This executes when the identity provider redirects back to our app //
|
||||
// after successful user authentication. We exchange the authorization //
|
||||
// code for a session token via our backend. //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// ** Authorization code received from the identity provider * //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
const code = searchParams.get("code");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// ** State parameter for CSRF protection and session correlation * //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
const state = searchParams.get("state");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Retrieve OIDC session data (PKCE verifier, redirect URI) from local storage using the state key * //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const oidcString = localStorage.getItem(`oidc.${state}`);
|
||||
|
||||
if (oidcString)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// ** Parse the stored OIDC data containing code_verifier and redirect_uri * //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
const oidcObject = JSON.parse(oidcString) as { [name: string]: any };
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Prepare request body with authorization code, PKCE verifier, and redirect URI * //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
const manageSessionRequestBody = {code: code, codeVerifier: oidcObject.code_verifier, redirectUri: oidcObject.redirect_uri};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// ** Exchange authorization code for session UUID via backend API * //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
const {uuid: newSessionUuid, values} = await qController.manageSession(null, null, manageSessionRequestBody);
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// ** Update parent component's authentication state * //
|
||||
/////////////////////////////////////////////////////////
|
||||
setIsFullyAuthenticated(true);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Notify all Client controller instances that authentication is established * //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
Client.setGotAuthenticationInAllControllers();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// ** Store the logged-in user information in parent component state * //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
setLoggedInUser(values?.user);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Retrieve the path the user was trying to access before authentication * //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
const preSigninRedirectPathname = localStorage.getItem(preSigninRedirectPathnameKey);
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// ** Clean up the stored redirect path from local storage * //
|
||||
///////////////////////////////////////////////////////////////
|
||||
localStorage.removeItem(preSigninRedirectPathnameKey);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// ** Navigate back to the original destination (or home if none stored) * //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
const targetPath = preSigninRedirectPathname ?? "/";
|
||||
navigate(targetPath, {replace: true});
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// State parameter not found in local storage - possible CSRF attack //
|
||||
// or expired/invalid OAuth flow. Try to recover gracefully. //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Check if we have a valid session cookie - if so, just navigate home
|
||||
const sessionUuid = cookies[SESSION_UUID_COOKIE_NAME];
|
||||
if (sessionUuid)
|
||||
{
|
||||
navigate("/", {replace: true});
|
||||
return;
|
||||
}
|
||||
|
||||
// No session and no valid state - show error
|
||||
setEarlyReturnForAuth(
|
||||
<div style={{padding: "20px"}}>
|
||||
<h2>Login Error</h2>
|
||||
<p>The login session expired. Please try again.</p>
|
||||
<button onClick={() => window.location.href = "/"}>Return to Home</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// SCENARIO 2 or 3: Either validate existing session or initiate new login //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// ** Attempt to retrieve existing session UUID from browser cookies * //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
const sessionUuid = cookies[SESSION_UUID_COOKIE_NAME];
|
||||
|
||||
if (sessionUuid)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// SCENARIO 2: Existing Session Validation //
|
||||
// User has a session cookie - validate it's still valid with the backend //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// ** Validate the session UUID with backend and retrieve user data * //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
const {values} = await qController.manageSession(null, sessionUuid, null);
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// ** Update parent component's authentication state * //
|
||||
/////////////////////////////////////////////////////////
|
||||
setIsFullyAuthenticated(true);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Notify all Client controller instances that authentication is established * //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
Client.setGotAuthenticationInAllControllers();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// ** Store the logged-in user information in parent component state * //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
setLoggedInUser(values?.user);
|
||||
}
|
||||
else
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
// SCENARIO 3: Initiate New OAuth2 Login Flow //
|
||||
// No session cookie exists - redirect user to identity provider for authentication //
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Store current pathname to redirect back after successful authentication * //
|
||||
// If we're at the base path (e.g., /admin), store "/" so we redirect to home //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
const basePath = detectBasePath();
|
||||
let pathToStore = window.location.pathname;
|
||||
|
||||
// If we're exactly at the base path, store "/" instead
|
||||
// This prevents redirecting to just the base path after login (which would match wildcard route)
|
||||
if (basePath && basePath !== "/" && pathToStore === basePath)
|
||||
{
|
||||
pathToStore = "/";
|
||||
}
|
||||
localStorage.setItem(preSigninRedirectPathnameKey, pathToStore);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Display "Signing in..." message to user while redirect is in progress * //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
setEarlyReturnForAuth(<div>Signing in...</div>);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Redirect to identity provider's authorization endpoint (triggers OAuth2 flow) * //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
await authOidc?.signinRedirect();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// ** Log authentication error and clean up any partial state * //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
console.error("OAuth2 authentication error:", e);
|
||||
|
||||
// Only trigger logout if we're not at the token callback
|
||||
// (to avoid infinite loops if the issue is with state lookup)
|
||||
if (!window.location.pathname.endsWith("/token"))
|
||||
{
|
||||
logout();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Logs out the current user by clearing all authentication data and redirecting to the identity provider's logout endpoint.
|
||||
*
|
||||
* This function performs a complete cleanup of the authentication session:
|
||||
* 1. Clears authentication metadata from local storage (via QQQ client controller)
|
||||
* 2. Removes the session UUID cookie from the browser
|
||||
* 3. Clears all OIDC-related localStorage items (session state, verifiers, etc.)
|
||||
* 4. Redirects to the identity provider's logout endpoint for server-side session cleanup
|
||||
*
|
||||
* After logout, the user will need to re-authenticate to access protected resources.
|
||||
*
|
||||
* @function logout
|
||||
* @returns {void}
|
||||
*/
|
||||
const logout = () =>
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Clear authentication metadata stored in local storage by the QQQ client * //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
qController.clearAuthenticationMetaDataLocalStorage();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Remove the session UUID cookie from the browser * //
|
||||
// ** Backend always sets the cookie at path="/", so we only need to remove it from that path * //
|
||||
// ** Use direct document.cookie manipulation (react-cookie's removeCookie has bugs) * //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
console.log("[OAuth2] Removing sessionUUID cookie (path: /)");
|
||||
document.cookie = `${SESSION_UUID_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; max-age=0;`;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Clear all OIDC-related items from localStorage (state, verifiers, etc.) * //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++)
|
||||
{
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.startsWith("oidc.") || key.startsWith("oauth2.")))
|
||||
{
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Redirect to the identity provider's logout endpoint to terminate the OAuth session * //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
authOidc?.signoutRedirect();
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Wraps the application with the OAuth2 AuthProvider context, configuring it with identity provider settings.
|
||||
*
|
||||
* This function creates and returns an AuthProvider component configured with the OAuth2/OIDC settings
|
||||
* from the authentication metadata. The AuthProvider enables OAuth2 functionality throughout the
|
||||
* component tree, including sign-in redirects, token management, and user context.
|
||||
*
|
||||
* The configuration uses the Authorization Code Flow with PKCE (Proof Key for Code Exchange) for
|
||||
* enhanced security in single-page applications.
|
||||
*
|
||||
* @function renderAppWrapper
|
||||
* @param {QAuthenticationMetaData} authenticationMetaData - Metadata containing OAuth2 provider configuration
|
||||
* @param {string} authenticationMetaData.data.baseUrl - The OAuth2 provider's base authority URL
|
||||
* @param {string} authenticationMetaData.data.clientId - The OAuth2 client identifier for this application
|
||||
* @param {JSX.Element} children - React children to be wrapped with the OAuth2 context
|
||||
*
|
||||
* @returns {JSX.Element} Either the AuthProvider wrapping the children, or an error message if configuration is invalid
|
||||
*
|
||||
* @example
|
||||
* const wrappedApp = renderAppWrapper(authMetaData, <App />);
|
||||
*/
|
||||
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
|
||||
{
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Extract the OAuth2 identity provider's base URL (authority) from metadata * //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
const authority: string = authenticationMetaData.data.baseUrl;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// ** Extract the OAuth2 client ID for this application from metadata * //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
const clientId = authenticationMetaData.data.clientId;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ** Validate required OAuth2 configuration - cannot proceed without authority and client ID * //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (!authority || !clientId)
|
||||
{
|
||||
return (<div>Error: OAuth2 authenticationMetaData is missing baseUrl [{authority}] and/or clientId [{clientId}].</div>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the OIDC (OpenID Connect) client settings for the AuthProvider.
|
||||
*
|
||||
* Configuration details:
|
||||
* - authority: The identity provider's base URL (e.g., https://auth.example.com)
|
||||
* - client_id: Unique identifier for this application registered with the provider
|
||||
* - redirect_uri: Where the provider redirects after authentication (our /token endpoint)
|
||||
* - post_logout_redirect_uri: Where to redirect after logout (back to base path)
|
||||
* - response_type: "code" indicates we're using Authorization Code Flow
|
||||
* - scope: Requested permissions - openid (required), profile, email, offline_access (refresh tokens)
|
||||
*/
|
||||
|
||||
// Detect base path at RUNTIME from current URL
|
||||
// This allows the app to work at any path without rebuild: /, /admin, /app, /this/that, etc.
|
||||
const basePath = detectBasePath();
|
||||
const baseUrl = (basePath && basePath !== "/") ? `${window.location.origin}${basePath}` : window.location.origin;
|
||||
|
||||
const oidcConfig = {
|
||||
authority: authority,
|
||||
client_id: clientId,
|
||||
redirect_uri: `${baseUrl}/token`,
|
||||
post_logout_redirect_uri: baseUrl,
|
||||
response_type: "code",
|
||||
scope: "openid profile email offline_access",
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// ** Return the application wrapped in the configured OAuth2 AuthProvider * //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
return (<AuthProvider {...oidcConfig}> {children} </AuthProvider>);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Return object containing the three main authentication operations.
|
||||
* These functions are exposed to parent components for managing the authentication lifecycle.
|
||||
*/
|
||||
return {
|
||||
setupSession, logout, renderAppWrapper
|
||||
};
|
||||
|
||||
}
|
||||
@@ -19,14 +19,16 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
||||
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
|
||||
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import {QueryJoin} from "@kingsrook/qqq-frontend-core/lib/model/query/QueryJoin";
|
||||
import {QException} from "@qrunio/qqq-frontend-core/lib/exceptions/QException";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QJobComplete} from "@qrunio/qqq-frontend-core/lib/model/processes/QJobComplete";
|
||||
import {QJobError} from "@qrunio/qqq-frontend-core/lib/model/processes/QJobError";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {QCriteriaOperator} from "@qrunio/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
||||
import {QFilterCriteria} from "@qrunio/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||
import {QFilterOrderBy} from "@qrunio/qqq-frontend-core/lib/model/query/QFilterOrderBy";
|
||||
import {QQueryFilter} from "@qrunio/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import {QueryJoin} from "@qrunio/qqq-frontend-core/lib/model/query/QueryJoin";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Icon from "@mui/material/Icon/Icon";
|
||||
@@ -34,6 +36,7 @@ import ToggleButton from "@mui/material/ToggleButton";
|
||||
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import FormData from "form-data";
|
||||
import QContext from "QContext";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
@@ -100,15 +103,15 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
|
||||
if (fieldName && (oldValue !== null || newValue !== null))
|
||||
{
|
||||
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName;
|
||||
if (oldValue !== undefined && newValue !== undefined)
|
||||
if (oldValue !== undefined && oldValue !== null && newValue !== undefined && newValue !== null)
|
||||
{
|
||||
return (<>{fieldLabel}: Changed from {(oldValue)} to <b>{(newValue)}</b></>);
|
||||
}
|
||||
else if (newValue !== undefined)
|
||||
else if (newValue !== undefined && newValue !== null)
|
||||
{
|
||||
return (<>{fieldLabel}: Set to <b>{(newValue)}</b></>);
|
||||
}
|
||||
else if (oldValue !== undefined)
|
||||
else if (oldValue !== undefined && oldValue !== null)
|
||||
{
|
||||
return (<>{fieldLabel}: Removed value {(oldValue)}</>);
|
||||
}
|
||||
@@ -192,26 +195,72 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
/////////////////////////////////
|
||||
// setup filter to load audits //
|
||||
/////////////////////////////////
|
||||
const filter = new QQueryFilter([
|
||||
new QFilterCriteria("auditTable.name", QCriteriaOperator.EQUALS, [tableMetaData.name]),
|
||||
new QFilterCriteria("recordId", QCriteriaOperator.EQUALS, [recordId]),
|
||||
], [
|
||||
new QFilterOrderBy("timestamp", sortDirection),
|
||||
new QFilterOrderBy("id", sortDirection),
|
||||
new QFilterOrderBy("auditDetail.id", true)
|
||||
], null, "AND", 0, limit);
|
||||
|
||||
///////////////////////////////
|
||||
// fetch audits in try-catch //
|
||||
///////////////////////////////
|
||||
let audits = [] as QRecord[];
|
||||
try
|
||||
{
|
||||
audits = await qController.query("audit", filter, [new QueryJoin("auditDetail", true, "LEFT")]);
|
||||
setAudits(audits);
|
||||
const qInstance = await qController.loadMetaData();
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// Originally audits were loaded by a direct query from this screen. //
|
||||
// but with the addition of table-personalization, and possibly future fetching //
|
||||
// of child records, instead call the GetAuditsForRecord process, if it exists. //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
const getAuditsProcessName = "GetAuditsForRecord";
|
||||
if(qInstance.processes.has(getAuditsProcessName))
|
||||
{
|
||||
const formData = new FormData();
|
||||
formData.append("tableName", tableMetaData.name);
|
||||
formData.append("recordId", recordId);
|
||||
formData.append("isSortAscending", sortDirection);
|
||||
formData.append("limit", limit);
|
||||
// todo if/when added: formData.append("includeChildren", includeChildren);
|
||||
const processResult = await qController.processRun(getAuditsProcessName, formData);
|
||||
|
||||
if (processResult instanceof QJobError)
|
||||
{
|
||||
const jobError = processResult as QJobError
|
||||
throw(jobError.userFacingError ?? jobError.error)
|
||||
}
|
||||
|
||||
const jobComplete = processResult as QJobComplete
|
||||
jobComplete.values["audits"]?.forEach((audit: any) => audits.push(new QRecord(audit)));
|
||||
setAudits(audits);
|
||||
|
||||
setTotal(null);
|
||||
if (jobComplete.values["distinctCount"])
|
||||
{
|
||||
setTotal(jobComplete.values["distinctCount"]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
/////////////////////////////////
|
||||
// setup filter to load audits //
|
||||
/////////////////////////////////
|
||||
const filter = new QQueryFilter([
|
||||
new QFilterCriteria("auditTable.name", QCriteriaOperator.EQUALS, [tableMetaData.name]),
|
||||
new QFilterCriteria("recordId", QCriteriaOperator.EQUALS, [recordId]),
|
||||
], [
|
||||
new QFilterOrderBy("timestamp", sortDirection),
|
||||
new QFilterOrderBy("id", sortDirection),
|
||||
new QFilterOrderBy("auditDetail.id", true)
|
||||
], null, "AND", 0, limit);
|
||||
|
||||
audits = await qController.query("audit", filter, [new QueryJoin("auditDetail", true, "LEFT")]);
|
||||
setAudits(audits);
|
||||
|
||||
//////////////////////////////////////////////////////////
|
||||
// if we fetched the limit, count the total for showing //
|
||||
//////////////////////////////////////////////////////////
|
||||
if (audits.length == limit)
|
||||
{
|
||||
const [count, distinctCount] = await qController.count("audit", filter, null, true); // todo validate distinct working here!
|
||||
setTotal(distinctCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
@@ -227,13 +276,6 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
|
||||
setStatusString("Error loading audits");
|
||||
}
|
||||
|
||||
// if we fetched the limit
|
||||
if (audits.length == limit)
|
||||
{
|
||||
const [count, distinctCount] = await qController.count("audit", filter, null, true); // todo validate distinct working here!
|
||||
setTotal(distinctCount);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// group the audits by auditId (e.g., this is a list that joined audit & auditDetail, so un-flatten it) //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -329,7 +371,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
|
||||
{
|
||||
if (total)
|
||||
{
|
||||
setStatusString(`Showing first ${limit?.toLocaleString()} of ${total?.toLocaleString()} audits for this record`);
|
||||
setStatusString(`Showing first ${limit?.toLocaleString()} of ${total?.toLocaleString()} audit details for this record`);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Elemen
|
||||
return (
|
||||
<Box display="inline-block" ml={standardML} mr={0} width={standardWidth}>
|
||||
<Link to={`${tablePath}/create`}>
|
||||
<MDButton variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
|
||||
<MDButton qqqId="create-new" variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
|
||||
Create New
|
||||
</MDButton>
|
||||
</Link>
|
||||
@@ -67,7 +67,7 @@ export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveBu
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton type="submit" variant="gradient" color="info" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
|
||||
<MDButton qqqId="save" type="submit" variant="gradient" color="info" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
|
||||
{label}
|
||||
</MDButton>
|
||||
</Box>
|
||||
@@ -88,7 +88,7 @@ export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): J
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton variant="gradient" color="primary" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>delete</Icon>} disabled={disabled}>
|
||||
<MDButton qqqId="delete" variant="gradient" color="primary" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>delete</Icon>} disabled={disabled}>
|
||||
Delete
|
||||
</MDButton>
|
||||
</Box>
|
||||
@@ -100,7 +100,7 @@ export function QEditButton(): JSX.Element
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<Link to="edit">
|
||||
<MDButton variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>}>
|
||||
<MDButton qqqId="edit" variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>}>
|
||||
Edit
|
||||
</MDButton>
|
||||
</Link>
|
||||
@@ -119,6 +119,7 @@ export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonP
|
||||
return (
|
||||
<Box width={standardWidth} ml={1}>
|
||||
<MDButton
|
||||
qqqId="actions-menu"
|
||||
variant={isOpen ? "contained" : "outlined"}
|
||||
color="dark"
|
||||
onClick={onClickHandler}
|
||||
@@ -145,8 +146,8 @@ export function QCancelButton({
|
||||
}: QCancelButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} mb={2} width={standardWidth}>
|
||||
<MDButton type="button" variant="outlined" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} onClick={onClickHandler} disabled={disabled}>
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton qqqId="cancel" type="button" variant="outlined" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} onClick={onClickHandler} disabled={disabled}>
|
||||
{label}
|
||||
</MDButton>
|
||||
</Box>
|
||||
@@ -169,7 +170,7 @@ export function QSubmitButton({label, iconName, disabled}: QSubmitButtonProps):
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton type="submit" variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
|
||||
<MDButton qqqId="submit" type="submit" variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
|
||||
{label}
|
||||
</MDButton>
|
||||
</Box>
|
||||
@@ -180,3 +181,24 @@ QSubmitButton.defaultProps = {
|
||||
label: "Submit",
|
||||
iconName: "check",
|
||||
};
|
||||
|
||||
interface QAlternateButtonProps
|
||||
{
|
||||
label: string,
|
||||
iconName?: string,
|
||||
disabled: boolean,
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function QAlternateButton({label, iconName, disabled, onClick}: QAlternateButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton qqqId="alternate" type="button" variant="gradient" color="secondary" size="small" fullWidth startIcon={iconName && <Icon>{iconName}</Icon>} onClick={onClick} disabled={disabled}>
|
||||
{label}
|
||||
</MDButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
QAlternateButton.defaultProps = {};
|
||||
@@ -24,6 +24,7 @@ import Button from "@mui/material/Button/Button";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {generateButtonId, generateMenuItemId} from "qqq/utils/qqqIdUtils";
|
||||
|
||||
|
||||
interface Props
|
||||
@@ -95,7 +96,7 @@ function MenuButton({label, iconName, options, disabled, callback}: Props)
|
||||
const menuItems: JSX.Element[] = []
|
||||
options.map((option, index) =>
|
||||
{
|
||||
menuItems.push(<MenuItem key={index} onClick={e => menuItemClicked(e, index)}>
|
||||
menuItems.push(<MenuItem key={index} data-qqq-id={generateMenuItemId(undefined, option)} onClick={e => menuItemClicked(e, index)}>
|
||||
{option}
|
||||
</MenuItem>);
|
||||
})
|
||||
@@ -105,6 +106,7 @@ function MenuButton({label, iconName, options, disabled, callback}: Props)
|
||||
<Button
|
||||
ref={anchorRef}
|
||||
id="composition-button"
|
||||
data-qqq-id={generateButtonId(undefined, label)}
|
||||
aria-controls={open ? "composition-menu" : undefined}
|
||||
aria-expanded={open ? "true" : undefined}
|
||||
aria-haspopup="true"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
|
||||
import {QJobError} from "@qrunio/qqq-frontend-core/lib/model/processes/QJobError";
|
||||
import {ToggleButton, ToggleButtonGroup, Typography} from "@mui/material";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
@@ -80,11 +80,12 @@ interface Props
|
||||
label: string;
|
||||
value: boolean;
|
||||
isDisabled: boolean;
|
||||
onChangeCallback?: (newValue: any) => void;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Element
|
||||
function BooleanFieldSwitch({name, label, value, isDisabled, onChangeCallback}: Props) : JSX.Element
|
||||
{
|
||||
const {setFieldValue} = useFormikContext();
|
||||
|
||||
@@ -93,6 +94,10 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
|
||||
if(!isDisabled)
|
||||
{
|
||||
setFieldValue(name, newValue);
|
||||
if(onChangeCallback)
|
||||
{
|
||||
onChangeCallback(newValue);
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
@@ -100,6 +105,10 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
|
||||
const toggleSwitch = () =>
|
||||
{
|
||||
setFieldValue(name, !value);
|
||||
if(onChangeCallback)
|
||||
{
|
||||
onChangeCallback(!value);
|
||||
}
|
||||
}
|
||||
|
||||
const classNullSwitch = (value === null || value == undefined || `${value}` == "") ? "nullSwitch" : "";
|
||||
|
||||
@@ -23,8 +23,10 @@ import {Chip} from "@mui/material";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {makeStyles} from "@mui/styles";
|
||||
import Downshift from "downshift";
|
||||
import {debounce} from "lodash";
|
||||
import {arrayOf, func, string} from "prop-types";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
|
||||
const useStyles = makeStyles((theme: any) => ({
|
||||
chip: {
|
||||
@@ -34,21 +36,107 @@ const useStyles = makeStyles((theme: any) => ({
|
||||
|
||||
function ChipTextField({...props})
|
||||
{
|
||||
const qController = Client.getInstance();
|
||||
const classes = useStyles();
|
||||
const {handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props;
|
||||
const {table, field, handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props;
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [chips, setChips] = useState([]);
|
||||
const [chipColors, setChipColors] = useState([]);
|
||||
const [chipValidity, setChipValidity] = useState([] as boolean[]);
|
||||
const [chipPVSIds, setChipPVSIds] = useState([] as any[]);
|
||||
const [isMakingRequest, setIsMakingRequest] = useState(false);
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// these refs are used for the async api call for possible values //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
const chipsRef = useRef<string[]>([]);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// use debounce library to not flood server as user types, wait a second before requesting //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
async function fetchPVSLabelsAndColorChips()
|
||||
{
|
||||
//////////////////////////////////////////////////////////
|
||||
// make a request for the possible value labels (chips) //
|
||||
//////////////////////////////////////////////////////////
|
||||
setIsMakingRequest(true);
|
||||
const currentChips = chipsRef.current;
|
||||
setChipColors([]);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Determine chip colors based on whether each chip value appears in results //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
const newChipColors = [] as string[];
|
||||
const chipValidity = [] as boolean[];
|
||||
const chipPVSIds = [] as any[];
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// make the request for all 'chips' with pagination to handle large sizes //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
const BATCH_SIZE = 250;
|
||||
for (let i = 0; i < currentChips.length; i += BATCH_SIZE)
|
||||
{
|
||||
const batch = currentChips.slice(i, i + BATCH_SIZE);
|
||||
const page = await qController.possibleValues(
|
||||
table.name,
|
||||
null,
|
||||
field.name,
|
||||
"",
|
||||
null,
|
||||
batch
|
||||
);
|
||||
for (let j = 0; j < batch.length; j++)
|
||||
{
|
||||
let found = false;
|
||||
for (let k = 0; k < page.length; k++)
|
||||
{
|
||||
const result = page[k];
|
||||
if (result.label.toLowerCase() === batch[j].toLowerCase())
|
||||
{
|
||||
chipPVSIds.push(result.id);
|
||||
newChipColors.push("info");
|
||||
chipValidity.push(true);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
chipPVSIds.push(null);
|
||||
chipValidity.push(false);
|
||||
newChipColors.push("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setChipPVSIds(chipPVSIds);
|
||||
setChipColors(newChipColors);
|
||||
setChipValidity(chipValidity);
|
||||
setIsMakingRequest(false);
|
||||
}
|
||||
|
||||
const debouncedApiCall = useRef(debounce(fetchPVSLabelsAndColorChips, 500)).current;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setChips(chipData);
|
||||
}, [chipData]);
|
||||
chipsRef.current = chipData;
|
||||
determineChipColors();
|
||||
|
||||
if (chipType !== "pvs")
|
||||
{
|
||||
const currentChipValidity = chips.map((chip, i) =>
|
||||
(chipType !== "number" || !Number.isNaN(Number(chips[i])))
|
||||
);
|
||||
setChipValidity(currentChipValidity);
|
||||
}
|
||||
}, [JSON.stringify(chipData), chips]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
handleChipChange(chips);
|
||||
}, [chips, handleChipChange]);
|
||||
handleChipChange(isMakingRequest, chipValidity, chipPVSIds);
|
||||
}, [chipValidity, chipPVSIds, isMakingRequest]);
|
||||
|
||||
function handleKeyDown(event: any)
|
||||
{
|
||||
@@ -64,13 +152,16 @@ function ChipTextField({...props})
|
||||
setInputValue("");
|
||||
return;
|
||||
}
|
||||
if (!event.target.value.replace(/\s/g, "").length) return;
|
||||
if (!event.target.value.replace(/\s/g, "").length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
setInputValue("");
|
||||
newChipList.push(event.target.value.trim());
|
||||
setChips(newChipList);
|
||||
setInputValue("");
|
||||
}
|
||||
else if (chips.length && !inputValue.length && event.key === "Backspace" )
|
||||
else if (chips.length && !inputValue.length && event.key === "Backspace")
|
||||
{
|
||||
setChips(chips.slice(0, chips.length - 1));
|
||||
}
|
||||
@@ -87,18 +178,26 @@ function ChipTextField({...props})
|
||||
setChips(newChipList);
|
||||
}
|
||||
|
||||
const handleDelete = (item: any) => () =>
|
||||
{
|
||||
const newChipList = [...chips];
|
||||
newChipList.splice(newChipList.indexOf(item), 1);
|
||||
setChips(newChipList);
|
||||
};
|
||||
|
||||
function handleInputChange(event: { target: { value: React.SetStateAction<string>; }; })
|
||||
{
|
||||
setInputValue(event.target.value);
|
||||
}
|
||||
|
||||
function determineChipColors(): any
|
||||
{
|
||||
if (chipType === "pvs")
|
||||
{
|
||||
debouncedApiCall();
|
||||
}
|
||||
else
|
||||
{
|
||||
const newChipColors = chips.map((chip, i) =>
|
||||
(chipType !== "number" || !Number.isNaN(Number(chips[i]))) ? "info" : "error"
|
||||
);
|
||||
setChipColors(newChipColors);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
@@ -116,7 +215,7 @@ function ChipTextField({...props})
|
||||
});
|
||||
// @ts-ignore
|
||||
return (
|
||||
<div id="chip-text-field-container" style={{flexWrap: "wrap", display:"flex"}}>
|
||||
<div id="chip-text-field-container" style={{flexWrap: "wrap", display: "flex"}}>
|
||||
<TextField
|
||||
sx={{width: "99%"}}
|
||||
disabled={disabled}
|
||||
@@ -125,16 +224,16 @@ function ChipTextField({...props})
|
||||
startAdornment:
|
||||
<div style={{overflowY: "auto", overflowX: "hidden", margin: "-10px", width: "calc(100% + 20px)", padding: "10px", marginBottom: "-20px", height: "calc(100% + 10px)"}}>
|
||||
{
|
||||
chips.map((item, i) => (
|
||||
chips.map((item, index) => (
|
||||
<Chip
|
||||
color={(chipType !== "number" || ! Number.isNaN(Number(item))) ? "info" : "error"}
|
||||
key={`${item}-${i}`}
|
||||
onChange={determineChipColors}
|
||||
color={chipColors[index]}
|
||||
key={`${item}-${index}`}
|
||||
variant="outlined"
|
||||
tabIndex={-1}
|
||||
label={item}
|
||||
className={classes.chip}
|
||||
/>
|
||||
|
||||
))
|
||||
}
|
||||
</div>,
|
||||
@@ -158,6 +257,7 @@ function ChipTextField({...props})
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
ChipTextField.defaultProps = {
|
||||
chipData: []
|
||||
};
|
||||
@@ -166,4 +266,4 @@ ChipTextField.propTypes = {
|
||||
chipData: arrayOf(string)
|
||||
};
|
||||
|
||||
export default ChipTextField
|
||||
export default ChipTextField;
|
||||
@@ -19,62 +19,376 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||
import {colors, Icon, InputLabel} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {useFormikContext} from "formik";
|
||||
import React, {useState} from "react";
|
||||
import {AdornmentType} from "@qrunio/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QTableSection} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableSection";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {FormikErrors, FormikTouched, FormikValues, useFormikContext} from "formik";
|
||||
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
|
||||
import DynamicFormUtils, {DynamicFormFieldDefinition} from "qqq/components/forms/DynamicFormUtils";
|
||||
import DynamicSelect from "qqq/components/forms/DynamicSelect";
|
||||
import FileInputField from "qqq/components/forms/FileInputField";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import HelpContent from "qqq/components/misc/HelpContent";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// definition of the 'formData' param to this, the QDynamicForm component //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
export interface DynamicFormDataDefinition
|
||||
{
|
||||
formFields: Record<string, DynamicFormFieldDefinition>;
|
||||
errors: Record<string, string> | FormikErrors<FormikValues>;
|
||||
touched: Record<string, boolean> | FormikTouched<FormikValues>;
|
||||
}
|
||||
|
||||
interface Props
|
||||
{
|
||||
////////////////////////////////////////////////////////
|
||||
// an optional h5 to be output at the top of the form //
|
||||
////////////////////////////////////////////////////////
|
||||
formLabel?: string;
|
||||
formData: any;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the fields (as QFMD dynamic-form objects) along with formik errors & touched states //
|
||||
// by default, all fields given here will be rendered - but - if fieldNamesToInclude //
|
||||
// is given, then only that subset of fields are rendered. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
formData: DynamicFormDataDefinition;
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
// optional array of which field names to include //
|
||||
////////////////////////////////////////////////////
|
||||
fieldNamesToInclude?: string[];
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// indicator of whether or not the form is in bulkEditMode (default false) //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
bulkEditMode?: boolean;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// when in bulkEditMode, a 'switch change' callback, to tell parent that a bulk-edit switch has been flipped //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
bulkEditSwitchChangeHandler?: any;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a QRecord with values for the form - although - those values (apparently?) are only used in file-type inputs. //
|
||||
// todo - eliminate this usage if we can? //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
record?: QRecord;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// array of "roles" used by helpContent system, to decide which help content in the field to display on this screen. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
helpRoles?: string[];
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// prefix put in front of field name for generating "helpHelp" (e.g., clue to user about what help content key to use) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
helpContentKeyPrefix?: string;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// optional callback for changing the definitions of fields, e.g., if they're owned by the parent component //
|
||||
// e.g., for use-case where a single page has multiple instances of this component, and where an on-change //
|
||||
// handler in one might impact another. for simple use-case (single form on page), omit this. //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
setFormFields?: (formFields: { [key: string]: DynamicFormFieldDefinition }) => void;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// optional callback for changing the definitions of sections - similar in spirit to setFormFields //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
updateSections?: (sections: Record<string, QTableSection>) => void;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// unique identifier for the process (if being used within a process) //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
processUUID?: string;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
// optional callback for if a field value changes, to let the component see it //
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
valueChangedCallback?: (fieldName: string, newValue: any) => void;
|
||||
}
|
||||
|
||||
function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHandler, record, helpRoles, helpContentKeyPrefix}: Props): JSX.Element
|
||||
/***************************************************************************
|
||||
* Standard form component used in QFMD.
|
||||
***************************************************************************/
|
||||
function QDynamicForm({formData, formLabel, fieldNamesToInclude, bulkEditMode, bulkEditSwitchChangeHandler, record, helpRoles, helpContentKeyPrefix, setFormFields, updateSections, processUUID, valueChangedCallback}: Props): JSX.Element
|
||||
{
|
||||
const {formFields, values, errors, touched} = formData;
|
||||
const {formFields: origFormFields, errors, touched} = formData;
|
||||
const {setFieldValue, values} = useFormikContext<Record<string, any>>();
|
||||
|
||||
const formikProps = useFormikContext();
|
||||
const [fileName, setFileName] = useState(null as string);
|
||||
const [formAdjustmentCounter, setFormAdjustmentCounter] = useState(0);
|
||||
|
||||
const fileChanged = (event: React.FormEvent<HTMLInputElement>, field: any) =>
|
||||
const [formFields, localSetFormFields] = useState(origFormFields as { [key: string]: DynamicFormFieldDefinition });
|
||||
if (!setFormFields)
|
||||
{
|
||||
setFileName(null);
|
||||
if (event.currentTarget.files && event.currentTarget.files[0])
|
||||
{
|
||||
setFileName(event.currentTarget.files[0].name);
|
||||
}
|
||||
|
||||
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
|
||||
};
|
||||
|
||||
const removeFile = (fieldName: string) =>
|
||||
{
|
||||
setFileName(null);
|
||||
formikProps.setFieldValue(fieldName, null);
|
||||
record?.values.delete(fieldName)
|
||||
record?.displayValues.delete(fieldName)
|
||||
};
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// if caller did not supply a setFormFields callback, then use our own local one //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
setFormFields = localSetFormFields;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* event handler for bulk-edit switch - passes through to prop callback
|
||||
***************************************************************************/
|
||||
const bulkEditSwitchChanged = (name: string, value: boolean) =>
|
||||
{
|
||||
bulkEditSwitchChangeHandler(name, value);
|
||||
if (bulkEditSwitchChangeHandler)
|
||||
{
|
||||
bulkEditSwitchChangeHandler(name, value);
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log("No prop bulkEditSwitchChangeHandler was given.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* test if a field should be included on this form. true if the prop
|
||||
* fieldNamesToInclude is not given, or if it includes this name.
|
||||
***************************************************************************/
|
||||
const includeField = (name: string): boolean =>
|
||||
{
|
||||
return (fieldNamesToInclude == undefined || fieldNamesToInclude.indexOf(name) > -1);
|
||||
};
|
||||
|
||||
|
||||
/////////////////////////////////////////
|
||||
// run on-load handlers if we have any //
|
||||
/////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
for (let fieldName in formFields)
|
||||
{
|
||||
if (!includeField(fieldName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = formFields[fieldName];
|
||||
const materialDashboardFieldMetaData = field.fieldMetaData?.supplementalFieldMetaData?.get("materialDashboard");
|
||||
if (materialDashboardFieldMetaData?.onLoadFormAdjuster)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// todo consider cases with multiple - do they need to list a sequenceNo? do they need to run serially? //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
considerRunningFormAdjuster("onLoad", fieldName, values[fieldName]);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const handleFieldChange = async (fieldName: string, newValue: any) =>
|
||||
{
|
||||
const field = formFields[fieldName];
|
||||
if (!field)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// map possible-value objects to ids - also capture their labels... //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
let actualNewValue = newValue;
|
||||
let possibleValueLabel: string = null;
|
||||
if (field.possibleValueProps)
|
||||
{
|
||||
actualNewValue = newValue ? newValue.id : null;
|
||||
possibleValueLabel = newValue ? newValue.label : null;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// make sure formik has the value - and that we capture the possible-value label if needed //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
setFieldValue(fieldName, actualNewValue);
|
||||
if (field.possibleValueProps)
|
||||
{
|
||||
field.possibleValueProps.initialDisplayValue = possibleValueLabel;
|
||||
}
|
||||
|
||||
//////////////////////////////
|
||||
// call callback if defined //
|
||||
//////////////////////////////
|
||||
if (valueChangedCallback)
|
||||
{
|
||||
valueChangedCallback(fieldName, newValue);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// for files, checkboxes, and possible values, consider form-adjuster on-change. //
|
||||
// for texts (the "else"), do it via a blur handler //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
if (field.type == "file" || field.type == "checkbox" || field.possibleValueProps)
|
||||
{
|
||||
considerRunningFormAdjuster("onChange", fieldName, actualNewValue);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* for text inputs, don't run form adjuster on-change - just do on blur.
|
||||
***************************************************************************/
|
||||
const considerRunningFormAdjusterOnBlur = (fieldName: string, event: React.SyntheticEvent<HTMLInputElement, Event>): void =>
|
||||
{
|
||||
considerRunningFormAdjuster("onChange", fieldName, event.currentTarget.value);
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const considerRunningFormAdjuster = async (event: "onChange" | "onLoad", fieldName: string, newValue: any) =>
|
||||
{
|
||||
const field = formFields[fieldName];
|
||||
if (!field)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const materialDashboardFieldMetaData = field.fieldMetaData?.supplementalFieldMetaData?.get("materialDashboard");
|
||||
const adjuster = event == "onChange" ? materialDashboardFieldMetaData?.onChangeFormAdjuster : materialDashboardFieldMetaData?.onLoadFormAdjuster;
|
||||
if (!adjuster)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Running form adjuster for field ${fieldName} ${event} (value is: ${newValue})`);
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// disable fields temporarily while waiting on backend response //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
const fieldNamesToTempDisable: string[] = materialDashboardFieldMetaData?.fieldsToDisableWhileRunningAdjusters ?? [];
|
||||
const previousIsEditableValues: { [key: string]: boolean } = {};
|
||||
if (fieldNamesToTempDisable.length > 0)
|
||||
{
|
||||
for (let oldFieldName in formFields)
|
||||
{
|
||||
if (fieldNamesToTempDisable.indexOf(oldFieldName) > -1)
|
||||
{
|
||||
previousIsEditableValues[oldFieldName] = formFields[oldFieldName].isEditable;
|
||||
formFields[oldFieldName].isEditable = false;
|
||||
}
|
||||
}
|
||||
|
||||
setFormAdjustmentCounter(formAdjustmentCounter + 1);
|
||||
setFormFields({...formFields});
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
// build request to backend for field adjustments //
|
||||
////////////////////////////////////////////////////
|
||||
const postBody = new FormData();
|
||||
postBody.append("event", event);
|
||||
postBody.append("fieldName", fieldName);
|
||||
postBody.append("newValue", newValue);
|
||||
postBody.append("allValues", JSON.stringify(values));
|
||||
const response = await qController.axiosRequest(
|
||||
{
|
||||
method: "post",
|
||||
url: `/material-dashboard-backend/form-adjuster/${encodeURIComponent(materialDashboardFieldMetaData.formAdjusterIdentifier)}/${event}`,
|
||||
data: postBody,
|
||||
headers: qController.defaultMultipartFormDataHeaders()
|
||||
});
|
||||
console.debug("Form adjuster response: " + JSON.stringify(response));
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
// un-disable any temp disabled fields from above //
|
||||
////////////////////////////////////////////////////
|
||||
if (fieldNamesToTempDisable.length > 0)
|
||||
{
|
||||
for (let oldFieldName in formFields)
|
||||
{
|
||||
if (fieldNamesToTempDisable.indexOf(oldFieldName) > -1)
|
||||
{
|
||||
formFields[oldFieldName].isEditable = previousIsEditableValues[oldFieldName];
|
||||
}
|
||||
}
|
||||
setFormFields({...formFields});
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// replace field definitions, if we have updates //
|
||||
///////////////////////////////////////////////////
|
||||
const updatedFields: { [fieldName: string]: QFieldMetaData } = response.updatedFieldMetaData;
|
||||
if (updatedFields)
|
||||
{
|
||||
for (let updatedFieldName in updatedFields)
|
||||
{
|
||||
const updatedField = new QFieldMetaData(updatedFields[updatedFieldName]);
|
||||
const dynamicField = DynamicFormUtils.getDynamicField(updatedField); // todo dynamicallyDisabledFields? second param...
|
||||
|
||||
const dynamicFieldInObject: Record<string, DynamicFormFieldDefinition> = {};
|
||||
dynamicFieldInObject[updatedFieldName] = dynamicField;
|
||||
let tableName = null;
|
||||
let processName = null;
|
||||
let displayValues = new Map();
|
||||
|
||||
DynamicFormUtils.addPossibleValueProps(dynamicFieldInObject, [updatedFields[updatedFieldName]], tableName, processName, displayValues);
|
||||
for (let oldFieldName in formFields)
|
||||
{
|
||||
if (oldFieldName == updatedFieldName)
|
||||
{
|
||||
formFields[updatedFieldName] = dynamicField;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFormAdjustmentCounter(formAdjustmentCounter + 2);
|
||||
setFormFields({...formFields});
|
||||
}
|
||||
|
||||
/////////////////////////////////////
|
||||
// update sections, if we got them //
|
||||
/////////////////////////////////////
|
||||
const updatedSections: Record<string, QTableSection> = response.updatedSectionMetaData;
|
||||
if (updatedSections)
|
||||
{
|
||||
updateSections?.(updatedSections);
|
||||
}
|
||||
|
||||
/////////////////////////
|
||||
// update field values //
|
||||
/////////////////////////
|
||||
const updatedFieldValues: { [fieldName: string]: any } = response?.updatedFieldValues ?? {};
|
||||
for (let fieldNameToUpdate in updatedFieldValues)
|
||||
{
|
||||
setFieldValue(fieldNameToUpdate, updatedFieldValues[fieldNameToUpdate]);
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// todo - track if a pvs field gets a value, but not a display value, and fetch it?? //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////
|
||||
// set display values in PVS's if we have them //
|
||||
/////////////////////////////////////////////////
|
||||
const updatedFieldDisplayValues: { [fieldName: string]: any } = response?.updatedFieldDisplayValues ?? {};
|
||||
for (let fieldNameToUpdate in updatedFieldDisplayValues)
|
||||
{
|
||||
const fieldToUpdate = formFields[fieldNameToUpdate];
|
||||
if (fieldToUpdate?.possibleValueProps)
|
||||
{
|
||||
fieldToUpdate.possibleValueProps.initialDisplayValue = updatedFieldDisplayValues[fieldNameToUpdate];
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////
|
||||
// clear field values if we have them //
|
||||
////////////////////////////////////////
|
||||
const fieldsToClear: string[] = response?.fieldsToClear ?? [];
|
||||
for (let fieldToClear of fieldsToClear)
|
||||
{
|
||||
setFieldValue(fieldToClear, "");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -82,94 +396,78 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
<Box>
|
||||
<Box lineHeight={0}>
|
||||
<MDTypography variant="h5">{formLabel}</MDTypography>
|
||||
{/* TODO - help text
|
||||
<MDTypography variant="button" color="text">
|
||||
Mandatory information
|
||||
</MDTypography>
|
||||
*/}
|
||||
</Box>
|
||||
<Box mt={1.625}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid container lg={12} display="flex" spacing={3}>
|
||||
{formFields
|
||||
&& Object.keys(formFields).length > 0
|
||||
&& Object.keys(formFields).map((fieldName: any) =>
|
||||
{
|
||||
if (!includeField(fieldName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const field = formFields[fieldName];
|
||||
if (field.omitFromQDynamicForm)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibilityClassName = "field-wrapper " + (field.fieldMetaData?.isHidden ? "is-hidden" : "is-visible");
|
||||
|
||||
if (values[fieldName] === undefined)
|
||||
{
|
||||
values[fieldName] = "";
|
||||
}
|
||||
|
||||
let formattedHelpContent = <HelpContent helpContents={field.fieldMetaData.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
|
||||
if(formattedHelpContent)
|
||||
let formattedHelpContent = <HelpContent helpContents={field?.fieldMetaData?.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
|
||||
if (formattedHelpContent)
|
||||
{
|
||||
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>
|
||||
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>;
|
||||
}
|
||||
|
||||
const labelElement = <Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
|
||||
<label htmlFor={field.name}>{field.label}</label>
|
||||
</Box>
|
||||
const labelElement = <DynamicFormFieldLabel name={fieldName} label={field.label} />;
|
||||
|
||||
let itemLG = (field?.fieldMetaData?.gridColumns && field?.fieldMetaData?.gridColumns > 0) ? field.fieldMetaData.gridColumns : 6;
|
||||
let itemXS = 12;
|
||||
let itemSM = 6;
|
||||
|
||||
/////////////
|
||||
// files!! //
|
||||
/////////////
|
||||
if (field.type === "file")
|
||||
{
|
||||
const pseudoField = new QFieldMetaData({name: fieldName, type: QFieldType.BLOB});
|
||||
return (
|
||||
<Grid item xs={12} sm={6} key={fieldName}>
|
||||
<Box mb={1.5}>
|
||||
{labelElement}
|
||||
{
|
||||
record && record.values.get(fieldName) && <Box fontSize="0.875rem" pb={1}>
|
||||
Current File:
|
||||
<Box display="inline-flex" pl={1}>
|
||||
{ValueUtils.getDisplayValue(pseudoField, record, "view")}
|
||||
<Tooltip placement="bottom" title="Remove current file">
|
||||
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(fieldName)}>delete</Icon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
<Box display="flex" alignItems="center">
|
||||
<Button variant="outlined" component="label">
|
||||
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
|
||||
<input
|
||||
id={fieldName}
|
||||
name={fieldName}
|
||||
type="file"
|
||||
hidden
|
||||
onChange={(event: React.FormEvent<HTMLInputElement>) => fileChanged(event, field)}
|
||||
/>
|
||||
</Button>
|
||||
<Box ml={1} fontSize={"1rem"}>
|
||||
{fileName}
|
||||
</Box>
|
||||
</Box>
|
||||
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
|
||||
const width = fileUploadAdornment?.values?.get("width") ?? "half";
|
||||
|
||||
<Box mt={0.75}>
|
||||
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
|
||||
{errors[fieldName] && <span>You must select a file to proceed</span>}
|
||||
</MDTypography>
|
||||
</Box>
|
||||
</Box>
|
||||
if (width == "full")
|
||||
{
|
||||
itemSM = 12;
|
||||
itemLG = 12;
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid item className={visibilityClassName} lg={itemLG} xs={itemXS} sm={itemSM} flexDirection="column" key={fieldName + "-" + formAdjustmentCounter}>
|
||||
{labelElement}
|
||||
<FileInputField field={field} record={record} errorMessage={errors[fieldName]} />
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
// possible values!!
|
||||
if (field.possibleValueProps)
|
||||
else if (field.possibleValueProps)
|
||||
{
|
||||
///////////////////////
|
||||
// possible values!! //
|
||||
///////////////////////
|
||||
const otherValuesMap = field.possibleValueProps.otherValues ?? new Map<string, any>();
|
||||
Object.keys(values).forEach((key) =>
|
||||
{
|
||||
otherValuesMap.set(key, values[key]);
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} key={fieldName}>
|
||||
<Grid item className={visibilityClassName} lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName + "-" + formAdjustmentCounter}>
|
||||
{labelElement}
|
||||
<DynamicSelect
|
||||
fieldPossibleValueProps={field.possibleValueProps}
|
||||
@@ -180,19 +478,22 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
bulkEditSwitchChangeHandler={bulkEditSwitchChanged}
|
||||
otherValues={otherValuesMap}
|
||||
useCase="form"
|
||||
onChange={(newValue: any) => handleFieldChange(fieldName, newValue)}
|
||||
processUUID={processUUID}
|
||||
/>
|
||||
{formattedHelpContent}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
// todo? inputProps={{ autoComplete: "" }}
|
||||
// todo? placeholder={password.placeholder}
|
||||
///////////////////////
|
||||
// everything else!! //
|
||||
///////////////////////
|
||||
return (
|
||||
<Grid item xs={12} sm={6} key={fieldName}>
|
||||
<Grid item className={visibilityClassName} lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName + "-" + formAdjustmentCounter}>
|
||||
{labelElement}
|
||||
<QDynamicFormField
|
||||
id={field.name}
|
||||
id={fieldName}
|
||||
type={field.type}
|
||||
label=""
|
||||
isEditable={field.isEditable}
|
||||
@@ -204,6 +505,9 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
bulkEditSwitchChangeHandler={bulkEditSwitchChanged}
|
||||
success={`${values[fieldName]}` !== "" && !errors[fieldName] && touched[fieldName]}
|
||||
formFieldObject={field}
|
||||
onChangeCallback={(newValue) => handleFieldChange(fieldName, newValue)}
|
||||
additionalCallbacks={{onBlur: (event) => considerRunningFormAdjusterOnBlur(fieldName, event)}}
|
||||
otherValues={values}
|
||||
/>
|
||||
{formattedHelpContent}
|
||||
</Grid>
|
||||
@@ -224,4 +528,19 @@ QDynamicForm.defaultProps = {
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
interface DynamicFormFieldLabelProps
|
||||
{
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DynamicFormFieldLabel({name, label}: DynamicFormFieldLabelProps): JSX.Element
|
||||
{
|
||||
return (<Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
|
||||
<label htmlFor={name}>{label}</label>
|
||||
</Box>);
|
||||
}
|
||||
|
||||
|
||||
export default QDynamicForm;
|
||||
@@ -19,39 +19,69 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Box, InputAdornment, InputLabel} from "@mui/material";
|
||||
import {InputAdornment, InputLabel} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import {AdornmentType} from "@qrunio/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QPossibleValue} from "@qrunio/qqq-frontend-core/lib/model/QPossibleValue";
|
||||
import {ErrorMessage, Field, useFormikContext} from "formik";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import React, {useMemo, useState} from "react";
|
||||
import AceEditor from "react-ace";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
|
||||
import DynamicFormFieldAsWidget from "qqq/components/forms/DynamicFormFieldAsWidget";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import DynamicSelect from "qqq/components/forms/DynamicSelect";
|
||||
import MDInput from "qqq/components/legacy/MDInput";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import React, {useMemo, useState} from "react";
|
||||
import AceEditor from "react-ace";
|
||||
import {flushSync} from "react-dom";
|
||||
import {generateInputId} from "qqq/utils/qqqIdUtils";
|
||||
|
||||
// Declaring props types for FormField
|
||||
interface Props
|
||||
{
|
||||
label: string;
|
||||
name: string;
|
||||
displayFormat: string;
|
||||
value: any;
|
||||
type: string;
|
||||
isEditable?: boolean;
|
||||
placeholder?: string;
|
||||
backgroundColor?: string;
|
||||
label: string,
|
||||
name: string,
|
||||
displayFormat: string,
|
||||
value: any,
|
||||
type: string,
|
||||
isEditable?: boolean,
|
||||
placeholder?: string,
|
||||
backgroundColor?: string,
|
||||
processUUID?: string,
|
||||
onChangeCallback?: (newValue: any) => void,
|
||||
additionalCallbacks?:
|
||||
{
|
||||
onTextSelect?: (event: React.SyntheticEvent<HTMLInputElement>) => void;
|
||||
onFocus?: (event: React.SyntheticEvent<HTMLInputElement>) => void;
|
||||
onBlur?: (event: React.SyntheticEvent<HTMLInputElement>) => void;
|
||||
},
|
||||
|
||||
[key: string]: any;
|
||||
[key: string]: any,
|
||||
|
||||
bulkEditMode?: boolean;
|
||||
bulkEditSwitchChangeHandler?: any;
|
||||
formFieldObject: any; // is the type returned by DynamicFormUtils.getDynamicField
|
||||
bulkEditMode?: boolean,
|
||||
bulkEditSwitchChangeHandler?: any,
|
||||
formFieldObject: any,
|
||||
otherValues?: Record<string, any>
|
||||
}
|
||||
|
||||
function QDynamicFormField({
|
||||
label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, placeholder, backgroundColor, formFieldObject, ...rest
|
||||
label,
|
||||
name,
|
||||
displayFormat,
|
||||
value,
|
||||
bulkEditMode,
|
||||
bulkEditSwitchChangeHandler,
|
||||
type,
|
||||
isEditable,
|
||||
placeholder,
|
||||
backgroundColor,
|
||||
formFieldObject,
|
||||
onChangeCallback,
|
||||
additionalCallbacks,
|
||||
processUUID,
|
||||
otherValues,
|
||||
...rest
|
||||
}: Props): JSX.Element
|
||||
{
|
||||
const [switchChecked, setSwitchChecked] = useState(false);
|
||||
@@ -79,10 +109,10 @@ function QDynamicFormField({
|
||||
|
||||
if (placeholder)
|
||||
{
|
||||
inputProps.placeholder = placeholder
|
||||
inputProps.placeholder = placeholder;
|
||||
}
|
||||
|
||||
if(backgroundColor)
|
||||
if (backgroundColor)
|
||||
{
|
||||
inputProps.sx = {
|
||||
"&.MuiInputBase-root": {
|
||||
@@ -116,42 +146,80 @@ function QDynamicFormField({
|
||||
// put the onChange in an object and assign it with a spread //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
let onChange: any = {};
|
||||
if (isToUpperCase || isToLowerCase)
|
||||
if (isToUpperCase || isToLowerCase || onChangeCallback)
|
||||
{
|
||||
onChange.onChange = (e: any) =>
|
||||
{
|
||||
const beforeStart = e.target.selectionStart;
|
||||
const beforeEnd = e.target.selectionEnd;
|
||||
|
||||
flushSync(() =>
|
||||
if (isToUpperCase || isToLowerCase)
|
||||
{
|
||||
let newValue = e.currentTarget.value;
|
||||
if (isToUpperCase)
|
||||
{
|
||||
newValue = newValue.toUpperCase();
|
||||
}
|
||||
if (isToLowerCase)
|
||||
{
|
||||
newValue = newValue.toLowerCase();
|
||||
}
|
||||
setFieldValue(name, newValue);
|
||||
});
|
||||
const beforeStart = e.target.selectionStart;
|
||||
const beforeEnd = e.target.selectionEnd;
|
||||
|
||||
const input = document.getElementById(name) as HTMLInputElement;
|
||||
if (input)
|
||||
flushSync(() =>
|
||||
{
|
||||
let newValue = e.currentTarget.value;
|
||||
if (isToUpperCase)
|
||||
{
|
||||
newValue = newValue.toUpperCase();
|
||||
}
|
||||
if (isToLowerCase)
|
||||
{
|
||||
newValue = newValue.toLowerCase();
|
||||
}
|
||||
setFieldValue(name, newValue);
|
||||
if (onChangeCallback)
|
||||
{
|
||||
onChangeCallback(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
const input = document.getElementById(name) as HTMLInputElement;
|
||||
if (input)
|
||||
{
|
||||
input.setSelectionRange(beforeStart, beforeEnd);
|
||||
}
|
||||
}
|
||||
else if (onChangeCallback)
|
||||
{
|
||||
input.setSelectionRange(beforeStart, beforeEnd);
|
||||
onChangeCallback(e.currentTarget.value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function dynamicSelectOnChange(newValue?: QPossibleValue)
|
||||
{
|
||||
if (onChangeCallback)
|
||||
{
|
||||
onChangeCallback(newValue == null ? null : newValue.id);
|
||||
}
|
||||
}
|
||||
|
||||
let field;
|
||||
let getsBulkEditHtmlLabel = true;
|
||||
if (type === "checkbox")
|
||||
if (formFieldObject.possibleValueProps)
|
||||
{
|
||||
field = (<DynamicSelect
|
||||
name={name}
|
||||
fieldPossibleValueProps={formFieldObject.possibleValueProps}
|
||||
isEditable={!isDisabled}
|
||||
fieldLabel={label}
|
||||
initialValue={value}
|
||||
bulkEditMode={bulkEditMode}
|
||||
bulkEditSwitchChangeHandler={bulkEditSwitchChangeHandler}
|
||||
onChange={dynamicSelectOnChange}
|
||||
// otherValues={otherValuesMap}
|
||||
useCase="form"
|
||||
processUUID={processUUID}
|
||||
/>);
|
||||
}
|
||||
else if (type === "checkbox")
|
||||
{
|
||||
getsBulkEditHtmlLabel = false;
|
||||
field = (<>
|
||||
<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} />
|
||||
<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} onChangeCallback={onChangeCallback} />
|
||||
<Box mt={0.75}>
|
||||
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
|
||||
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={name} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
|
||||
@@ -179,6 +247,10 @@ function QDynamicFormField({
|
||||
onChange={(value: string, event: any) =>
|
||||
{
|
||||
setFieldValue(name, value, false);
|
||||
if (onChangeCallback)
|
||||
{
|
||||
onChangeCallback(value);
|
||||
}
|
||||
}}
|
||||
setOptions={{useWorker: false}}
|
||||
width="100%"
|
||||
@@ -189,11 +261,31 @@ function QDynamicFormField({
|
||||
</>
|
||||
);
|
||||
}
|
||||
else if (formFieldObject.fieldMetaData.getAdornment(AdornmentType.WIDGET))
|
||||
{
|
||||
field = (<DynamicFormFieldAsWidget
|
||||
name={name ?? formFieldObject.fieldMetaData.name}
|
||||
fieldMetaData={formFieldObject?.fieldMetaData}
|
||||
setValueCallback={(fieldName: string, value: any) =>
|
||||
{
|
||||
if(fieldName == name)
|
||||
{
|
||||
onChangeCallback(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log(`Discarding a changed value from a DynamicFormFieldAsWidget: [${fieldName}][${value}]`);
|
||||
}
|
||||
}}
|
||||
otherValues={otherValues}
|
||||
/>);
|
||||
}
|
||||
else
|
||||
{
|
||||
const dataQqqId = generateInputId(undefined, name, label);
|
||||
field = (
|
||||
<>
|
||||
<Field {...rest} {...onChange} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="outlined" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled}
|
||||
<Field {...rest} {...onChange} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="outlined" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled} data-qqq-id={dataQqqId}
|
||||
onKeyPress={(e: any) =>
|
||||
{
|
||||
if (e.key === "Enter")
|
||||
@@ -201,6 +293,18 @@ function QDynamicFormField({
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onSelect={(event: React.SyntheticEvent<HTMLInputElement>) =>
|
||||
{
|
||||
additionalCallbacks?.onTextSelect?.(event);
|
||||
}}
|
||||
onFocus={(event: React.SyntheticEvent<HTMLInputElement>) =>
|
||||
{
|
||||
additionalCallbacks?.onFocus?.(event);
|
||||
}}
|
||||
onBlur={(event: React.SyntheticEvent<HTMLInputElement>) =>
|
||||
{
|
||||
additionalCallbacks?.onBlur?.(event);
|
||||
}}
|
||||
/>
|
||||
<Box mt={0.75}>
|
||||
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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 {Alert, Skeleton} from "@mui/material";
|
||||
import {AdornmentType} from "@qrunio/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QWidgetMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import FormData from "form-data";
|
||||
import FilterAndColumnsSetupWidget from "qqq/components/widgets/misc/FilterAndColumnsSetupWidget";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
interface DynamicFormFieldAsWidgetProps
|
||||
{
|
||||
name: string
|
||||
fieldMetaData: QFieldMetaData,
|
||||
setValueCallback?: (fieldName: string, value: any, shouldValidate?: boolean) => void,
|
||||
otherValues?: Record<string, any>,
|
||||
}
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
export default function DynamicFormFieldAsWidget({name, fieldMetaData, setValueCallback, otherValues}: DynamicFormFieldAsWidgetProps): JSX.Element
|
||||
{
|
||||
const [widgetName, setWidgetName] = useState(fieldMetaData.getAdornment(AdornmentType.WIDGET).getValue("widgetName"));
|
||||
const [widgetMetaData, setWidgetMetaData] = useState(null as QWidgetMetaData);
|
||||
const [widgetData, setWidgetData] = useState(null as any);
|
||||
|
||||
const [formValues, setFormValues] = useState({} as { [name: string]: any });
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
// upon initial load (or if the widget name ever changed??), fetch widget meta data //
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!widgetMetaData || widgetMetaData.name != widgetName)
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
const metaData = await qController.loadMetaData();
|
||||
const widgetMetaData = metaData.widgets.get(widgetName);
|
||||
setWidgetMetaData(widgetMetaData);
|
||||
})();
|
||||
}
|
||||
}, [widgetName]);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// upon widget meta data change, fetch widget data / render the widget //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!widgetMetaData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const formData: FormData = new FormData();
|
||||
if (otherValues)
|
||||
{
|
||||
for (let key in otherValues)
|
||||
{
|
||||
formData.append(key, otherValues[key]);
|
||||
}
|
||||
}
|
||||
|
||||
formData.append("__formFieldAsWidget_FieldName", name);
|
||||
|
||||
const widgetData = await qController.widget(widgetMetaData.name, formData, `${widgetMetaData.name}-${name}`);
|
||||
if (widgetData)
|
||||
{
|
||||
setWidgetData(widgetData);
|
||||
}
|
||||
})();
|
||||
|
||||
}, [widgetMetaData]);
|
||||
|
||||
/////////////////////////////////////////
|
||||
// hooks above here //
|
||||
// callbacks and other functions below //
|
||||
/////////////////////////////////////////
|
||||
|
||||
/*******************************************************************************
|
||||
* callback passed in to the rendering of widgets, to pass values out of here
|
||||
* back to the containing form
|
||||
*******************************************************************************/
|
||||
function setFormFieldValuesFromWidget(values: { [name: string]: any })
|
||||
{
|
||||
for (let key in values)
|
||||
{
|
||||
setValueCallback(key, values[key]);
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
// callbacks and other functions above //
|
||||
// returns below here //
|
||||
/////////////////////////////////////////
|
||||
|
||||
if (!widgetData || !widgetMetaData)
|
||||
{
|
||||
return (<Skeleton></Skeleton>);
|
||||
}
|
||||
|
||||
/////////////////////////////////
|
||||
// switch based on widget type //
|
||||
/////////////////////////////////
|
||||
if (widgetMetaData.type == "filterAndColumnsSetup")
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the widget metadata specifies a table name, set form values to that so widget knows which to use //
|
||||
// (for the case when it is not being specified by a separate field in the record) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (widgetData?.tableName)
|
||||
{
|
||||
formValues["tableName"] = widgetData?.tableName;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// added this for row-builder use-case, where the field name has an index appended to it //
|
||||
// but seems safe for general use case as well. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
widgetData.filterFieldName = name;
|
||||
|
||||
return <FilterAndColumnsSetupWidget
|
||||
isEditable={true}
|
||||
widgetMetaData={widgetMetaData}
|
||||
widgetData={widgetData}
|
||||
recordValues={otherValues}
|
||||
label={widgetData.label ?? widgetMetaData.label ?? "Filter"}
|
||||
onSaveCallback={setFormFieldValuesFromWidget}
|
||||
widgetComponentProps={{
|
||||
omitLabel: true,
|
||||
additionalCSS: {
|
||||
display: "inline-block",
|
||||
position: "relative",
|
||||
top: "-2rem",
|
||||
marginBottom: "-2rem",
|
||||
padding: "0"
|
||||
}
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////
|
||||
// error for unsupported widgets //
|
||||
///////////////////////////////////
|
||||
return (<Alert severity="error">Error: Unsupported widget type [{widgetMetaData.type}] in DynamicFormFieldAsWidget</Alert>);
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,42 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {AdornmentType} from "@qrunio/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
|
||||
import * as Yup from "yup";
|
||||
|
||||
|
||||
type DisabledFields = { [fieldName: string]: boolean } | string[];
|
||||
|
||||
type DynamicFormFieldType = "number" | "datetime-local" | "password" | "time" | "date" | "file" | "checkbox" | "text" | "ace"
|
||||
|
||||
//////////////////////////////////////
|
||||
// type returned by getDynamicField //
|
||||
//////////////////////////////////////
|
||||
export interface DynamicFormFieldDefinition
|
||||
{
|
||||
fieldMetaData: QFieldMetaData;
|
||||
name: string;
|
||||
label: string;
|
||||
isRequired: boolean;
|
||||
isEditable: boolean;
|
||||
type: DynamicFormFieldType;
|
||||
displayFormat?: string;
|
||||
possibleValueProps?: FieldPossibleValueProps;
|
||||
omitFromQDynamicForm?: boolean;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// type returned by getFormData: associative arrays of field definitions and yup validations //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
export interface FormDataDefinition
|
||||
{
|
||||
dynamicFormFields: Record<string, DynamicFormFieldDefinition>,
|
||||
formValidations: Record<string, Yup.BaseSchema>
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Meta-data to represent a single field in a table.
|
||||
**
|
||||
@@ -38,10 +65,10 @@ class DynamicFormUtils
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static getFormData(qqqFormFields: QFieldMetaData[], disabledFields?: DisabledFields)
|
||||
public static getFormData(qqqFormFields: QFieldMetaData[], disabledFields?: DisabledFields): FormDataDefinition
|
||||
{
|
||||
const dynamicFormFields: any = {};
|
||||
const formValidations: any = {};
|
||||
const dynamicFormFields: Record<string, DynamicFormFieldDefinition> = {};
|
||||
const formValidations: Record<string, Yup.BaseSchema> = {};
|
||||
|
||||
qqqFormFields.forEach((field) =>
|
||||
{
|
||||
@@ -56,7 +83,7 @@ class DynamicFormUtils
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static getDynamicField(field: QFieldMetaData, disabledFields?: DisabledFields)
|
||||
public static getDynamicField(field: QFieldMetaData, disabledFields?: DisabledFields): DynamicFormFieldDefinition
|
||||
{
|
||||
let fieldType: string;
|
||||
switch (field.type.toString())
|
||||
@@ -123,7 +150,7 @@ class DynamicFormUtils
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static getValidationForField(field: QFieldMetaData, disabledFields?: DisabledFields)
|
||||
public static getValidationForField(field: QFieldMetaData, disabledFields?: DisabledFields): Yup.BaseSchema
|
||||
{
|
||||
const effectiveIsEditable = field.isEditable && !this.isFieldDynamicallyDisabled(field.name, disabledFields);
|
||||
const effectivelyIsRequired = field.isRequired && effectiveIsEditable;
|
||||
@@ -141,9 +168,22 @@ class DynamicFormUtils
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
* wrapper around addPossibleValueProps to do it for a single field.
|
||||
*******************************************************************************/
|
||||
public static addPossibleValueProps(dynamicFormFields: any, qFields: QFieldMetaData[], tableName: string, processName: string, displayValues: Map<string, string>)
|
||||
public static addPossibleValuePropsToSingleField(dynamicFormField: DynamicFormFieldDefinition, qField: QFieldMetaData, tableName: string, processName: string, displayValues: Map<string, string>)
|
||||
{
|
||||
const dynamicFormFields: Record<string, DynamicFormFieldDefinition> = {};
|
||||
dynamicFormFields[dynamicFormField.name] = dynamicFormField;
|
||||
const qFields = [qField];
|
||||
DynamicFormUtils.addPossibleValueProps(dynamicFormFields, qFields, tableName, processName, displayValues);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* update several DynamicFormFieldDefinition's with properties that make them
|
||||
* behave like possible value objects, based on attributes in QFieldMetaData.
|
||||
*******************************************************************************/
|
||||
public static addPossibleValueProps(dynamicFormFields: Record<string, DynamicFormFieldDefinition>, qFields: QFieldMetaData[], tableName: string, processName: string, displayValues: Map<string, string>)
|
||||
{
|
||||
for (let i = 0; i < qFields.length; i++)
|
||||
{
|
||||
@@ -191,6 +231,11 @@ class DynamicFormUtils
|
||||
props.possibleValueSourceName = field.possibleValueSourceName;
|
||||
}
|
||||
|
||||
if(field.possibleValueSourceFilter)
|
||||
{
|
||||
props.possibleValueSourceFilter = field.possibleValueSourceFilter;
|
||||
}
|
||||
|
||||
dynamicFormFields[field.name].possibleValueProps = props;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,25 +19,27 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
|
||||
import {Checkbox, Chip, CircularProgress, FilterOptionsState, Icon} from "@mui/material";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Box from "@mui/material/Box";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {AdornmentType} from "@qrunio/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QPossibleValue} from "@qrunio/qqq-frontend-core/lib/model/QPossibleValue";
|
||||
import {ErrorMessage, useFormikContext} from "formik";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {generateSelectId} from "qqq/utils/qqqIdUtils";
|
||||
|
||||
interface Props
|
||||
{
|
||||
fieldPossibleValueProps: FieldPossibleValueProps;
|
||||
overrideId?: string;
|
||||
name?: string;
|
||||
fieldLabel: string;
|
||||
inForm: boolean;
|
||||
initialValue?: any;
|
||||
@@ -45,12 +47,14 @@ interface Props
|
||||
onChange?: any;
|
||||
isEditable?: boolean;
|
||||
isMultiple?: boolean;
|
||||
limitTags?: number;
|
||||
bulkEditMode?: boolean;
|
||||
bulkEditSwitchChangeHandler?: any;
|
||||
otherValues?: Map<string, any>;
|
||||
variant: "standard" | "outlined";
|
||||
initiallyOpen: boolean;
|
||||
useCase: "form" | "filter";
|
||||
processUUID?: string | null;
|
||||
}
|
||||
|
||||
DynamicSelect.defaultProps = {
|
||||
@@ -60,6 +64,7 @@ DynamicSelect.defaultProps = {
|
||||
onChange: null,
|
||||
isEditable: true,
|
||||
isMultiple: false,
|
||||
limitTags: 5,
|
||||
bulkEditMode: false,
|
||||
otherValues: new Map<string, any>(),
|
||||
variant: "outlined",
|
||||
@@ -95,7 +100,7 @@ export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm, initialValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen, useCase}: Props)
|
||||
function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, inForm, initialValue, initialValues, onChange, isEditable, isMultiple, limitTags, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen, useCase, processUUID}: Props)
|
||||
{
|
||||
const {fieldName, initialDisplayValue, possibleValueSourceName, possibleValues, processName, tableName} = fieldPossibleValueProps;
|
||||
|
||||
@@ -172,8 +177,16 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm,
|
||||
*******************************************************************************/
|
||||
const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] =>
|
||||
{
|
||||
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm));
|
||||
}
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// sometimes search term is null - which we'd like to treat the same as empty (e.g., return all results) //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(searchTerm == null || searchTerm == undefined)
|
||||
{
|
||||
searchTerm = "";
|
||||
}
|
||||
|
||||
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm.toLowerCase()));
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
@@ -181,15 +194,25 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm,
|
||||
***************************************************************************/
|
||||
const loadResults = async (): Promise<QPossibleValue[]> =>
|
||||
{
|
||||
if(possibleValues)
|
||||
if (possibleValues)
|
||||
{
|
||||
return filterInlinePossibleValues(searchTerm, possibleValues)
|
||||
return filterInlinePossibleValues(searchTerm, possibleValues);
|
||||
}
|
||||
else
|
||||
{
|
||||
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues, useCase);
|
||||
return await qController.possibleValues(
|
||||
{
|
||||
tableName,
|
||||
processName,
|
||||
fieldNameOrPossibleValueSourceName: possibleValueSourceName ?? fieldName,
|
||||
searchTerm: searchTerm ?? "",
|
||||
values: otherValues,
|
||||
useCase,
|
||||
possibleValueSourceFilter: fieldPossibleValueProps.possibleValueSourceFilter,
|
||||
processUUID
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
@@ -201,6 +224,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm,
|
||||
{
|
||||
// console.log("First render, so not searching...");
|
||||
setFirstRender(false);
|
||||
return;
|
||||
|
||||
/*
|
||||
if(!initiallyOpen)
|
||||
@@ -293,9 +317,14 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm,
|
||||
***************************************************************************/
|
||||
const handleChanged = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
|
||||
{
|
||||
// console.log("handleChanged. value is:");
|
||||
// console.log(value);
|
||||
setSearchTerm(null);
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// for multiple mode, keep the searchTerm - but else, clear it //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
if(!isMultiple)
|
||||
{
|
||||
setSearchTerm(null);
|
||||
}
|
||||
|
||||
|
||||
if (onChange)
|
||||
{
|
||||
@@ -400,15 +429,19 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm,
|
||||
autocompleteSX = getAutocompleteOutlinedStyle(isDisabled);
|
||||
}
|
||||
|
||||
const dataQqqId = generateSelectId(undefined, name ?? fieldName, fieldLabel);
|
||||
const autocomplete = (
|
||||
<Box>
|
||||
<Autocomplete
|
||||
id={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"}
|
||||
name={name}
|
||||
data-qqq-id={dataQqqId}
|
||||
sx={autocompleteSX}
|
||||
open={open}
|
||||
fullWidth
|
||||
onOpen={() =>
|
||||
{
|
||||
reloadIfOtherValuesAreChanged();
|
||||
setOpen(true);
|
||||
// console.log("setting open...");
|
||||
if (options.length == 0)
|
||||
@@ -440,6 +473,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm,
|
||||
}}
|
||||
options={options}
|
||||
loading={loading}
|
||||
inputValue={isMultiple ? searchTerm : undefined}
|
||||
onInputChange={inputChanged}
|
||||
onBlur={handleBlur}
|
||||
defaultValue={defaultValue}
|
||||
@@ -458,7 +492,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm,
|
||||
disabled={isDisabled}
|
||||
multiple={isMultiple}
|
||||
disableCloseOnSelect={isMultiple}
|
||||
limitTags={5}
|
||||
limitTags={limitTags}
|
||||
slotProps={{popper: {className: "DynamicSelectPopper"}}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
@@ -520,7 +554,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm,
|
||||
else
|
||||
{
|
||||
return (
|
||||
<Box>
|
||||
<Box className="DynamicSelectAutoCompleteWrapper">
|
||||
{autocomplete}
|
||||
</Box>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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 {AdornmentType} from "@qrunio/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {Button, colors, Icon} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {useFormikContext} from "formik";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React, {useCallback, useState} from "react";
|
||||
import {useDropzone} from "react-dropzone";
|
||||
|
||||
interface FileInputFieldProps
|
||||
{
|
||||
field: any,
|
||||
record?: QRecord,
|
||||
errorMessage?: any
|
||||
}
|
||||
|
||||
export default function FileInputField({field, record, errorMessage}: FileInputFieldProps): JSX.Element
|
||||
{
|
||||
const [fileName, setFileName] = useState(null as string);
|
||||
|
||||
const formikProps = useFormikContext();
|
||||
|
||||
const fileChanged = (event: React.FormEvent<HTMLInputElement>, field: any) =>
|
||||
{
|
||||
setFileName(null);
|
||||
if (event.currentTarget.files && event.currentTarget.files[0])
|
||||
{
|
||||
setFileName(event.currentTarget.files[0].name);
|
||||
}
|
||||
|
||||
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
|
||||
};
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: any) =>
|
||||
{
|
||||
setFileName(null);
|
||||
if (acceptedFiles.length && acceptedFiles[0])
|
||||
{
|
||||
setFileName(acceptedFiles[0].name);
|
||||
}
|
||||
|
||||
formikProps.setFieldValue(field.name, acceptedFiles[0]);
|
||||
}, []);
|
||||
const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop});
|
||||
|
||||
|
||||
const removeFile = (fieldName: string) =>
|
||||
{
|
||||
setFileName(null);
|
||||
formikProps.setFieldValue(fieldName, null);
|
||||
record?.values.delete(fieldName);
|
||||
record?.displayValues.delete(fieldName);
|
||||
};
|
||||
|
||||
const pseudoField = new QFieldMetaData({name: field.name, type: QFieldType.BLOB});
|
||||
|
||||
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
|
||||
const format = fileUploadAdornment?.values?.get("format") ?? "button";
|
||||
|
||||
return (
|
||||
<Box mb={1.5}>
|
||||
{
|
||||
record && record.values.get(field.name) && <Box fontSize="0.875rem" pb={1}>
|
||||
Current File:
|
||||
<Box display="inline-flex" pl={1}>
|
||||
{ValueUtils.getDisplayValue(pseudoField, record, "view")}
|
||||
<Tooltip placement="bottom" title="Remove current file">
|
||||
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(field.name)}>delete</Icon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
|
||||
{
|
||||
format == "button" &&
|
||||
<Box display="flex" alignItems="center">
|
||||
<Button variant="outlined" component="label">
|
||||
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="file"
|
||||
hidden
|
||||
onChange={(event: React.FormEvent<HTMLInputElement>) => fileChanged(event, field)}
|
||||
/>
|
||||
</Button>
|
||||
<Box ml={1} fontSize={"1rem"}>
|
||||
{fileName}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
|
||||
{
|
||||
format == "dragAndDrop" &&
|
||||
<>
|
||||
<Box {...getRootProps()} sx={
|
||||
{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "300px",
|
||||
borderRadius: "2rem",
|
||||
backgroundColor: isDragActive ? colors.lightBlue[50] : "transparent",
|
||||
border: `2px ${isDragActive ? "solid" : "dashed"} ${colors.lightBlue[500]}`
|
||||
}}>
|
||||
<input {...getInputProps()} />
|
||||
<Box display="flex" alignItems="center" flexDirection="column">
|
||||
<Icon sx={{fontSize: "4rem !important", color: colors.lightBlue[500]}}>upload_file</Icon>
|
||||
<Box>Drag and drop a file</Box>
|
||||
<Box fontSize="1rem" m="0.5rem">or</Box>
|
||||
<Box border={`2px solid ${colors.lightBlue[500]}`} mt="0.25rem" padding="0.25rem 1rem" borderRadius="0.5rem" sx={{cursor: "pointer"}} fontSize="1rem">
|
||||
Browse files
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box fontSize={"1rem"} mt="0.25rem">
|
||||
{fileName}
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
|
||||
<Box mt={0.75}>
|
||||
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
|
||||
{errorMessage && <span>{errorMessage}</span>}
|
||||
</MDTypography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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 {QThemeMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QThemeMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import React from "react";
|
||||
|
||||
interface Props
|
||||
{
|
||||
theme?: QThemeMetaData | null;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** BrandedHeaderBar - renders a branded header bar when enabled via theme.
|
||||
** Uses CSS variables injected from QThemeMetaData for styling.
|
||||
*******************************************************************************/
|
||||
export default function BrandedHeaderBar({theme}: Props): JSX.Element | null
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Check if branded header is enabled via theme prop //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
if (!theme?.brandedHeaderEnabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="header"
|
||||
data-branded-header="true"
|
||||
className="qqq-branded-header-bar"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 1rem",
|
||||
backgroundColor: "var(--qqq-branded-header-background-color)",
|
||||
color: "var(--qqq-branded-header-text-color)",
|
||||
height: "var(--qqq-branded-header-height, 48px)",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1200,
|
||||
}}
|
||||
>
|
||||
<Box sx={{display: "flex", alignItems: "center", gap: 2}}>
|
||||
{theme.brandedHeaderLogoPath && (
|
||||
<Box
|
||||
component="img"
|
||||
src={theme.brandedHeaderLogoPath}
|
||||
alt={theme.brandedHeaderLogoAltText || "Logo"}
|
||||
sx={{
|
||||
height: "calc(var(--qqq-branded-header-height, 48px) - 8px)",
|
||||
maxWidth: "200px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onError={(e: React.SyntheticEvent<HTMLImageElement>) =>
|
||||
{
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{theme.brandedHeaderTagline && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: "var(--qqq-branded-header-text-color)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{theme.brandedHeaderTagline}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -270,7 +270,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
{
|
||||
pageHeader &&
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<MDTypography pb="0.5rem" textTransform="capitalize" variant="h3" color={light ? "white" : "dark"} noWrap>
|
||||
<MDTypography pb="0.5rem" variant="h3" color={light ? "white" : "dark"}>
|
||||
{pageHeader}
|
||||
</MDTypography>
|
||||
</Box>
|
||||
|
||||
@@ -19,22 +19,24 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
||||
import {QBrandingMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
||||
import {Button} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import Link from "@mui/material/Link";
|
||||
import List from "@mui/material/List";
|
||||
import {ReactNode, useEffect, useReducer, useState} from "react";
|
||||
import {NavLink, useLocation} from "react-router-dom";
|
||||
import AuthenticationButton from "qqq/components/buttons/AuthenticationButton";
|
||||
import SideNavCollapse from "qqq/components/horseshoe/sidenav/SideNavCollapse";
|
||||
import SideNavItem from "qqq/components/horseshoe/sidenav/SideNavItem";
|
||||
import SideNavList from "qqq/components/horseshoe/sidenav/SideNavList";
|
||||
import SidenavRoot from "qqq/components/horseshoe/sidenav/SideNavRoot";
|
||||
import sidenavLogoLabel from "qqq/components/horseshoe/sidenav/styles/SideNav";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
|
||||
import {setMiniSidenav, setTransparentSidenav, setWhiteSidenav, useMaterialUIController,} from "qqq/context";
|
||||
import {sanitizeId} from "qqq/utils/qqqIdUtils";
|
||||
import {ReactNode, useEffect, useReducer, useState} from "react";
|
||||
import {NavLink, useLocation} from "react-router-dom";
|
||||
|
||||
|
||||
interface Props
|
||||
@@ -44,6 +46,7 @@ interface Props
|
||||
logo?: string;
|
||||
appName?: string;
|
||||
branding?: QBrandingMetaData;
|
||||
logout: () => void;
|
||||
routes: {
|
||||
[key: string]:
|
||||
| ReactNode
|
||||
@@ -66,7 +69,7 @@ interface Props
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props): JSX.Element
|
||||
function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}: Props): JSX.Element
|
||||
{
|
||||
const [openCollapse, setOpenCollapse] = useState<boolean | string>(false);
|
||||
const [openNestedCollapse, setOpenNestedCollapse] = useState<boolean | string>(false);
|
||||
@@ -257,7 +260,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
|
||||
active={key === collapseName}
|
||||
open={openCollapse === key}
|
||||
noCollapse={noCollapse}
|
||||
onClick={() => (! noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null) }
|
||||
onClick={() => (!noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null)}
|
||||
>
|
||||
{collapse ? renderCollapse(collapse) : null}
|
||||
</SideNavCollapse>
|
||||
@@ -300,13 +303,38 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
|
||||
}
|
||||
);
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function EnvironmentBanner({branding}: { branding: QBrandingMetaData }): JSX.Element | null
|
||||
{
|
||||
// deprecated!
|
||||
if (branding && branding.environmentBannerText)
|
||||
{
|
||||
return <Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
|
||||
{branding.environmentBannerText}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
const banner = getBanner(branding, "QFMD_SIDE_NAV_UNDER_LOGO");
|
||||
if (banner)
|
||||
{
|
||||
return <Box className={getBannerClassName(banner)} mt={2} borderRadius={2} sx={getBannerStyles(banner)}>
|
||||
{makeBannerContent(banner)}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidenavRoot
|
||||
{...rest}
|
||||
variant="permanent"
|
||||
ownerState={{transparentSidenav, whiteSidenav, miniSidenav, darkMode}}
|
||||
data-qqq-id="sidenav-root"
|
||||
>
|
||||
<Box pt={3} mr={1} pb={0} px={4} textAlign="center">
|
||||
<Box pt={3} mr={1} pb={0} px={4} textAlign="center" data-qqq-id="sidenav-logo-area">
|
||||
<Box
|
||||
display={{xs: "block", xl: "none"}}
|
||||
position="absolute"
|
||||
@@ -321,8 +349,14 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
|
||||
</MDTypography>
|
||||
</Box>
|
||||
<Box component={NavLink} to="/" display="flex" alignItems="center">
|
||||
{!miniSidenav && logo && <Box component="img" src={logo} alt={appName} title={appName} width="100%" />}
|
||||
{miniSidenav && icon && <Box component="img" src={icon} alt={appName} title={appName} width="160%" />}
|
||||
{!miniSidenav && logo && <Box component="img" src={logo} alt={appName} title={appName} width="100%" onError={(e: any) =>
|
||||
{
|
||||
e.target.style.display = "none";
|
||||
}} />}
|
||||
{miniSidenav && icon && <Box component="img" src={icon} alt={appName} title={appName} width="160%" onError={(e: any) =>
|
||||
{
|
||||
e.target.style.display = "none";
|
||||
}} />}
|
||||
{!miniSidenav && !logo && appName && <Box width={appName && "100%"} sx={(theme: any) => sidenavLogoLabel(theme, {miniSidenav})}>
|
||||
<MDTypography component="h6" variant="button" fontWeight="medium" color={textColor}>
|
||||
{appName}
|
||||
@@ -330,12 +364,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
{
|
||||
branding && branding.environmentBannerText &&
|
||||
<Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
|
||||
{branding.environmentBannerText}
|
||||
</Box>
|
||||
}
|
||||
<EnvironmentBanner branding={branding} />
|
||||
</Box>
|
||||
<Divider
|
||||
light={
|
||||
@@ -343,14 +372,14 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
|
||||
(darkMode && !transparentSidenav && whiteSidenav)
|
||||
}
|
||||
/>
|
||||
<List>{renderRoutes}</List>
|
||||
<List data-qqq-id="sidenav-menu-list">{renderRoutes}</List>
|
||||
<Divider
|
||||
light={
|
||||
(!darkMode && !whiteSidenav && !transparentSidenav) ||
|
||||
(darkMode && !transparentSidenav && whiteSidenav)
|
||||
}
|
||||
/>
|
||||
<AuthenticationButton />
|
||||
<Button onClick={logout} data-qqq-id="sidenav-logout-button">Log Out</Button>
|
||||
</SidenavRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import ListItemText from "@mui/material/ListItemText";
|
||||
import {ReactNode} from "react";
|
||||
import {collapseArrow, collapseIcon, collapseIconBox, collapseItem, collapseText,} from "qqq/components/horseshoe/sidenav/styles/SideNavCollapse";
|
||||
import {useMaterialUIController} from "qqq/context";
|
||||
import {generateNavItemId} from "qqq/utils/qqqIdUtils";
|
||||
|
||||
// Declaring props types for SideNavCollapse
|
||||
interface Props {
|
||||
@@ -53,11 +54,14 @@ function SideNavCollapse({
|
||||
const [controller] = useMaterialUIController();
|
||||
const {miniSidenav, transparentSidenav, whiteSidenav, darkMode} = controller;
|
||||
|
||||
const dataQqqId = generateNavItemId(undefined, name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem component="li">
|
||||
<ListItem component="li" data-qqq-id={dataQqqId}>
|
||||
<Box
|
||||
{...rest}
|
||||
className={active ? "qqq-sidebar-active" : ""}
|
||||
sx={(theme: any) =>
|
||||
collapseItem(theme, {active, transparentSidenav, whiteSidenav, darkMode})
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {Theme} from "@mui/material/styles";
|
||||
import {ReactNode} from "react";
|
||||
import {item, itemArrow, itemContent} from "qqq/components/horseshoe/sidenav/styles/SideNavItem";
|
||||
import {useMaterialUIController} from "qqq/context";
|
||||
import {generateNavItemId} from "qqq/utils/qqqIdUtils";
|
||||
|
||||
// Declaring props types for SideNavCollapse
|
||||
interface Props {
|
||||
@@ -45,11 +46,15 @@ function SideNavItem({color, name, active, nested, children, open, ...rest}: Pro
|
||||
const [controller] = useMaterialUIController();
|
||||
const {miniSidenav, transparentSidenav, whiteSidenav, darkMode} = controller;
|
||||
|
||||
// Generate data-qqq-id for CSS targeting
|
||||
const dataQqqId = generateNavItemId(undefined, name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
{...rest}
|
||||
component="li"
|
||||
data-qqq-id={dataQqqId}
|
||||
sx={(theme) => item(theme, {active, color, transparentSidenav, whiteSidenav, darkMode})}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -96,7 +96,9 @@ export default styled(Drawer)(({theme, ownerState}: { theme?: Theme | any; owner
|
||||
border: "none",
|
||||
margin: "0",
|
||||
borderRadius: "0",
|
||||
height: "100%",
|
||||
// Account for branded header height when present
|
||||
top: "var(--qqq-branded-header-height, 0px)",
|
||||
height: "calc(100vh - var(--qqq-branded-header-height, 0px))",
|
||||
|
||||
...(miniSidenav ? drawerCloseStyles() : drawerOpenStyles()),
|
||||
},
|
||||
|
||||
@@ -21,41 +21,41 @@
|
||||
|
||||
import {Theme} from "@mui/material/styles";
|
||||
|
||||
function collapseItem(theme: Theme, ownerState: any)
|
||||
function collapseItem(theme: Theme, ownerState: any)
|
||||
{
|
||||
const {palette, transitions, breakpoints, boxShadows, borders, functions} = theme;
|
||||
const {active, transparentSidenav, whiteSidenav, darkMode} = ownerState;
|
||||
|
||||
const {white, transparent, dark, grey} = palette;
|
||||
const {white, transparent, grey} = palette;
|
||||
const {md} = boxShadows;
|
||||
const {borderRadius} = borders;
|
||||
const {pxToRem, rgba} = functions;
|
||||
|
||||
return {
|
||||
background: () =>
|
||||
background: () =>
|
||||
{
|
||||
let backgroundValue;
|
||||
|
||||
if (transparentSidenav && darkMode)
|
||||
if (transparentSidenav && darkMode)
|
||||
{
|
||||
backgroundValue = active ? rgba(white.main, 0.2) : transparent.main;
|
||||
}
|
||||
else if (transparentSidenav && !darkMode)
|
||||
else if (transparentSidenav && !darkMode)
|
||||
{
|
||||
backgroundValue = active ? grey[300] : transparent.main;
|
||||
}
|
||||
else if (whiteSidenav)
|
||||
else if (whiteSidenav)
|
||||
{
|
||||
backgroundValue = active ? grey[200] : transparent.main;
|
||||
}
|
||||
else
|
||||
else
|
||||
{
|
||||
backgroundValue = active ? rgba(white.main, 0.2) : transparent.main;
|
||||
}
|
||||
|
||||
return backgroundValue;
|
||||
},
|
||||
color: (transparentSidenav && !darkMode) || whiteSidenav ? dark.main : white.main,
|
||||
color: "var(--qqq-sidebar-text-color)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
@@ -79,27 +79,22 @@ function collapseItem(theme: Theme, ownerState: any)
|
||||
},
|
||||
|
||||
"&:hover, &:focus": {
|
||||
backgroundColor:
|
||||
transparentSidenav && !darkMode
|
||||
? grey[300]
|
||||
: rgba(whiteSidenav ? grey[400] : white.main, 0.2),
|
||||
backgroundColor: "var(--qqq-sidebar-hover-background-color)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collapseIconBox(theme: Theme, ownerState: any)
|
||||
function collapseIconBox(theme: Theme, ownerState: any)
|
||||
{
|
||||
const {palette, transitions, borders, functions} = theme;
|
||||
const {transparentSidenav, whiteSidenav, darkMode} = ownerState;
|
||||
const {transitions, borders, functions} = theme;
|
||||
|
||||
const {white, dark} = palette;
|
||||
const {borderRadius} = borders;
|
||||
const {pxToRem} = functions;
|
||||
|
||||
return {
|
||||
minWidth: pxToRem(32),
|
||||
minHeight: pxToRem(32),
|
||||
color: (transparentSidenav && !darkMode) || whiteSidenav ? dark.main : white.main,
|
||||
color: "var(--qqq-sidebar-icon-color)",
|
||||
borderRadius: borderRadius.md,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
@@ -109,13 +104,13 @@ function collapseIconBox(theme: Theme, ownerState: any)
|
||||
}),
|
||||
|
||||
"& svg, svg g": {
|
||||
color: transparentSidenav || whiteSidenav ? dark.main : white.main,
|
||||
color: "var(--qqq-sidebar-icon-color)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const collapseIcon = ({palette: {white, gradients}}: Theme, {active}: any) => ({
|
||||
color: active ? white.main : gradients.dark.state,
|
||||
const collapseIcon = (_theme: Theme, {active}: any) => ({
|
||||
color: active ? "var(--qqq-sidebar-selected-text-color)" : "var(--qqq-sidebar-icon-color)",
|
||||
});
|
||||
|
||||
function collapseText(theme: any, ownerState: any)
|
||||
@@ -147,40 +142,21 @@ function collapseText(theme: any, ownerState: any)
|
||||
};
|
||||
}
|
||||
|
||||
function collapseArrow(theme: Theme, ownerState: any)
|
||||
function collapseArrow(theme: Theme, ownerState: any)
|
||||
{
|
||||
const {palette, typography, transitions, breakpoints, functions} = theme;
|
||||
const {noCollapse, transparentSidenav, whiteSidenav, miniSidenav, open, active, darkMode} =
|
||||
ownerState;
|
||||
const {typography, transitions, breakpoints, functions} = theme;
|
||||
const {noCollapse, miniSidenav, transparentSidenav, open, active} = ownerState;
|
||||
|
||||
const {white, dark} = palette;
|
||||
const {size} = typography;
|
||||
const {pxToRem, rgba} = functions;
|
||||
const {pxToRem} = functions;
|
||||
|
||||
return {
|
||||
fontSize: `${size.lg} !important`,
|
||||
fontWeight: 700,
|
||||
marginBottom: pxToRem(-1),
|
||||
transform: open ? "rotate(0)" : "rotate(-180deg)",
|
||||
color: () =>
|
||||
{
|
||||
let colorValue;
|
||||
|
||||
if (transparentSidenav && darkMode)
|
||||
{
|
||||
colorValue = open || active ? white.main : rgba(white.main, 0.25);
|
||||
}
|
||||
else if (transparentSidenav || whiteSidenav)
|
||||
{
|
||||
colorValue = open || active ? dark.main : rgba(dark.main, 0.25);
|
||||
}
|
||||
else
|
||||
{
|
||||
colorValue = open || active ? white.main : rgba(white.main, 0.5);
|
||||
}
|
||||
|
||||
return colorValue;
|
||||
},
|
||||
color: open || active ? "var(--qqq-sidebar-selected-text-color)" : "var(--qqq-sidebar-icon-color)",
|
||||
opacity: open || active ? 1 : 0.6,
|
||||
transition: transitions.create(["color", "transform", "opacity"], {
|
||||
easing: transitions.easing.easeInOut,
|
||||
duration: transitions.duration.shorter,
|
||||
|
||||
@@ -23,12 +23,11 @@ import {Theme} from "@mui/material/styles";
|
||||
|
||||
function item(theme: Theme | any, ownerState: any)
|
||||
{
|
||||
const {palette, borders, functions, transitions} = theme;
|
||||
const {active, color, transparentSidenav, whiteSidenav, darkMode} = ownerState;
|
||||
const {palette, borders, transitions} = theme;
|
||||
const {active, color} = ownerState;
|
||||
|
||||
const {transparent, white, grey} = palette;
|
||||
const {transparent} = palette;
|
||||
const {borderRadius} = borders;
|
||||
const {rgba} = functions;
|
||||
|
||||
return {
|
||||
pl: 3,
|
||||
@@ -39,29 +38,16 @@ function item(theme: Theme | any, ownerState: any)
|
||||
cursor: "pointer",
|
||||
backgroundColor: () =>
|
||||
{
|
||||
let backgroundValue = transparent.main;
|
||||
|
||||
if (
|
||||
(active === "isParent" && !transparentSidenav && !whiteSidenav) ||
|
||||
(active === "isParent" && transparentSidenav && darkMode)
|
||||
)
|
||||
if (active === "isParent")
|
||||
{
|
||||
backgroundValue = rgba(white.main, 0.2);
|
||||
}
|
||||
else if (active === "isParent" && transparentSidenav)
|
||||
{
|
||||
backgroundValue = grey[300];
|
||||
}
|
||||
else if (active === "isParent" && whiteSidenav)
|
||||
{
|
||||
backgroundValue = grey[200];
|
||||
return "var(--qqq-sidebar-selected-background-color)";
|
||||
}
|
||||
else if (active)
|
||||
{
|
||||
backgroundValue = palette[color].main;
|
||||
return palette[color].main;
|
||||
}
|
||||
|
||||
return backgroundValue;
|
||||
return transparent.main;
|
||||
},
|
||||
transition: transitions.create("background-color", {
|
||||
easing: transitions.easing.easeInOut,
|
||||
@@ -69,19 +55,16 @@ function item(theme: Theme | any, ownerState: any)
|
||||
}),
|
||||
|
||||
"&:hover, &:focus": {
|
||||
backgroundColor:
|
||||
!active &&
|
||||
rgba((transparentSidenav && !darkMode) || whiteSidenav ? grey[400] : white.main, 0.2),
|
||||
backgroundColor: !active ? "var(--qqq-sidebar-hover-background-color)" : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function itemContent(theme: Theme, ownerState: any)
|
||||
{
|
||||
const {palette, typography, transitions, functions} = theme;
|
||||
const {miniSidenav, name, active, transparentSidenav, whiteSidenav, darkMode} = ownerState;
|
||||
const {typography, transitions, functions} = theme;
|
||||
const {miniSidenav, active} = ownerState;
|
||||
|
||||
const {white, dark} = palette;
|
||||
const {size, fontWeightRegular, fontWeightLight} = typography;
|
||||
const {pxToRem} = functions;
|
||||
|
||||
@@ -96,10 +79,7 @@ function itemContent(theme: Theme, ownerState: any)
|
||||
position: "relative",
|
||||
|
||||
"& span": {
|
||||
color:
|
||||
((transparentSidenav && !darkMode) || whiteSidenav) && (active === "isParent" || !active)
|
||||
? dark.main
|
||||
: white.main,
|
||||
color: active ? "var(--qqq-sidebar-selected-text-color)" : "var(--qqq-sidebar-text-color)",
|
||||
fontWeight: active ? fontWeightRegular : fontWeightLight,
|
||||
fontSize: size.sm,
|
||||
opacity: miniSidenav ? 0 : 1,
|
||||
@@ -110,10 +90,7 @@ function itemContent(theme: Theme, ownerState: any)
|
||||
},
|
||||
|
||||
"&::before": {
|
||||
color:
|
||||
((transparentSidenav && !darkMode) || whiteSidenav) && (active === "isParent" || !active)
|
||||
? dark.main
|
||||
: white.main,
|
||||
color: active ? "var(--qqq-sidebar-selected-text-color)" : "var(--qqq-sidebar-text-color)",
|
||||
fontWeight: fontWeightRegular,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -130,38 +107,19 @@ function itemContent(theme: Theme, ownerState: any)
|
||||
|
||||
function itemArrow(theme: Theme, ownerState: any)
|
||||
{
|
||||
const {palette, typography, transitions, breakpoints, functions} = theme;
|
||||
const {noCollapse, transparentSidenav, whiteSidenav, miniSidenav, open, active, darkMode} =
|
||||
ownerState;
|
||||
const {typography, transitions, breakpoints, functions} = theme;
|
||||
const {noCollapse, transparentSidenav, miniSidenav, open, active} = ownerState;
|
||||
|
||||
const {white, dark} = palette;
|
||||
const {size} = typography;
|
||||
const {pxToRem, rgba} = functions;
|
||||
const {pxToRem} = functions;
|
||||
|
||||
return {
|
||||
fontSize: `${size.lg} !important`,
|
||||
fontWeight: 700,
|
||||
marginBottom: pxToRem(-1),
|
||||
transform: open ? "rotate(0)" : "rotate(-180deg)",
|
||||
color: () =>
|
||||
{
|
||||
let colorValue;
|
||||
|
||||
if (transparentSidenav && darkMode)
|
||||
{
|
||||
colorValue = open || active ? white.main : rgba(white.main, 0.25);
|
||||
}
|
||||
else if (transparentSidenav || whiteSidenav)
|
||||
{
|
||||
colorValue = open || active ? dark.main : rgba(dark.main, 0.25);
|
||||
}
|
||||
else
|
||||
{
|
||||
colorValue = open || active ? white.main : rgba(white.main, 0.5);
|
||||
}
|
||||
|
||||
return colorValue;
|
||||
},
|
||||
color: open || active ? "var(--qqq-sidebar-selected-text-color)" : "var(--qqq-sidebar-icon-color)",
|
||||
opacity: open || active ? 1 : 0.6,
|
||||
transition: transitions.create(["color", "transform", "opacity"], {
|
||||
easing: transitions.easing.easeInOut,
|
||||
duration: transitions.duration.shorter,
|
||||
|
||||
@@ -91,12 +91,12 @@ const MDBadgeDot: FC<Props> = forwardRef(
|
||||
];
|
||||
|
||||
const colorValues = {
|
||||
"primary": "#e91e63",
|
||||
"primary": "var(--qqq-primary-color, #e91e63)",
|
||||
"secondary": "#7b809a",
|
||||
"info": accentColor,
|
||||
"success": "#4CAF50",
|
||||
"warning": "#fb8c00",
|
||||
"error": "#F44335",
|
||||
"success": "var(--qqq-success-color, #4CAF50)",
|
||||
"warning": "var(--qqq-warning-color, #fb8c00)",
|
||||
"error": "var(--qqq-error-color, #F44335)",
|
||||
"light": "#f0f2f5",
|
||||
"dark": "#344767",
|
||||
"custom1": "#8c28c2",
|
||||
|
||||
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user