Compare commits

..

159 Commits

Author SHA1 Message Date
501b8b34c9 Merge branch 'release/0.23.0' 2024-11-22 16:18:57 -06:00
efd1922ee3 Update versions for release 2024-11-22 15:59:20 -06:00
b4f8fb2e18 CE-1946: minor updates to padding, fixes, etc 2024-11-22 11:41:29 -06:00
204025c2a6 Merged feature/CE-1772-generate-labels-poc into dev 2024-11-21 12:31:44 -06:00
6dfc839c30 CE-1946: added child record widget data, minor divider styles 2024-11-19 20:40:16 -06:00
726906061d CE-1946: checkpoint initial commit 2024-11-19 15:07:10 -06:00
2514c463a6 Merge pull request #73 from Kingsrook/feature/CE-1727-mobile-first-uiux
Feature/ce 1727 mobile first uiux
2024-11-12 11:45:46 -06:00
b41a9a6fe6 CE-1960 Pass use-cases through more calls to qController.possibleValues - to fix filter-as-widget dropping possible-value fields w/ a defaultFilter 2024-11-07 10:19:55 -06:00
cfca47054e CE-1727 Remove unused (uncommitted) RevealBlock 2024-11-04 08:57:07 -06:00
b48ef70c5e Update to qqq-frontend-core 1.0.110 2024-11-04 08:52:55 -06:00
81efb7e18d CE-1727 Updates to processes rendering block-widgets, to get up to compatible with the android app 2024-11-04 08:48:46 -06:00
387f09f4ad CE-1772: updated value utils for non blob file downloads 2024-11-03 22:18:27 -06:00
4fd50936ea CE-1727 Add proxy for /qqq/*, for new versioned middleware 2024-10-31 16:07:10 -05:00
3adb8ab4ba CE-1727 - Improve (fix?) next/submit handling for validation screen and linear flow. 2024-09-20 12:17:30 -05:00
98a02cda96 CE-1727 - Add support for ad-hoc, composite/block widgets; support processFlow (e.g., non-linear); some mobile-style adjustments 2024-09-20 11:07:22 -05:00
aee4becda5 CE-1727 - Initial checkin 2024-09-20 11:06:29 -05:00
f13c2c276f CE-1727 - mobile style adjust 2024-09-20 11:06:14 -05:00
a99272767b CE-1727 - Update qfc to 1.0.107 (add process.stepFlow) 2024-09-20 11:05:54 -05:00
a3236b426e CE-1727 - minor mobile placement adjustments 2024-09-20 10:44:41 -05:00
597fde977f CE-1727 - in Yup required message, show 'This field' instead of 'undefined' if field doesn't have a label 2024-09-20 10:44:26 -05:00
e303ed0b43 CE-1727 - Initial checkin 2024-09-20 10:43:57 -05:00
2b057768b3 CE-1727 - Style adjustments re: mobile processes 2024-09-20 10:43:37 -05:00
504a43d9c3 CE-1727 - Add layout FLEX_ROW_CENTER; Add actionCallback 2024-09-20 10:43:19 -05:00
33e56f823d CE-1727 - Add BlockData.conditional attribute; Add actionCallback to StandardBlockComponentProps 2024-09-20 10:42:55 -05:00
dc8fdb33dc CE-1727 - New blocks and styles for (mobile-style) widgets in processes, plus callbacks and contextValues 2024-09-20 10:42:22 -05:00
efa67da7f9 CE-1727 - Switch from use of updatedFrontendStepList to processMetaDataAdjustment, which can include updatedFields (specifically adding to change a dynamic possible value source, but, seems more flexible than just that) 2024-09-09 16:04:16 -05:00
3dc92aec88 CE-1727 - Add inlinePossibleValueSources option to fields; pass more data into DynamicSelect in a named object, so it's a little less loosely defined. 2024-09-09 16:01:30 -05:00
d2705c3aed Avoid null-pointer in TableUtils.getSectionsForRecordSidebar callback, if instance has no widgets 2024-09-05 19:05:40 -05:00
1d965bcdee Merge tag 'version-0.22.0' into dev
Tag release
2024-09-04 20:15:14 -05:00
894a9c2afc Merge branch 'release/0.22.0' 2024-09-04 20:15:14 -05:00
d25f124d87 Update for next development version 2024-09-04 20:00:14 -05:00
fd5055e502 Update versions for release 2024-09-04 20:00:10 -05:00
326367fbe0 Merge pull request #72 from Kingsrook/feature/CE-1647-update-filter-widget-to-show-preview
CE-1647: added preview to query filter widget
2024-09-04 16:17:40 -05:00
bb6f818457 Merge pull request #71 from Kingsrook/feature/CE-1646-possible-value-filter-bug
Feature/ce 1646 possible value filter bug
2024-09-04 16:17:25 -05:00
1cd6e07907 Merge pull request #70 from Kingsrook/feature/CE-1643-query-date-bugs
Feature/ce 1643 query date bugs
2024-09-04 16:16:59 -05:00
e839da6123 CE-1405 - Put margin-left on this, so if its used in a big number block, and it wraps, it does right, i think 2024-08-29 16:03:26 -05:00
34a4fc19b4 CE-1647: added preview to query filter widget 2024-08-27 17:52:34 -05:00
2cc7e9ebe1 CE-1643 Add a timezone conversion to the formatDate function for the case where it took a string rather than a Date as input, in which case, the new Date() call would be appying a timezone, and making us off-by-one (for some side of the prime merdian i think) 2024-08-23 15:13:44 -05:00
128a748b63 CE-1643 Add fontVariantNumeric: "tabular-nums" to the thing with numbers that count up, so it's awesome. 2024-08-23 15:11:52 -05:00
1284e3a22c CE-1643 change default operator for DATEs to be equals 2024-08-23 15:11:26 -05:00
ae358b9067 Merge tag 'version-0.21.0' into dev
Tag release
2024-08-23 14:42:25 -05:00
dc20c3d5ec Turn off postReleaseGoals=install in gitflow-maven-plugin 2024-08-23 14:41:55 -05:00
71a9c6470a Merge branch 'release/0.21.0' 2024-08-23 14:39:48 -05:00
765d40aef1 Add skipTestProject to gitflow-maven-plugin 2024-08-23 14:39:39 -05:00
d9f1642f0a Update for next development version 2024-08-23 14:10:54 -05:00
858540427d Update versions for release 2024-08-23 14:10:51 -05:00
eecb2d4489 Update qqq-backend-core dep to 0.21.0 2024-08-23 14:10:29 -05:00
5a6293cfdf Merged dev into feature/CE-1646-possible-value-filter-bug 2024-08-23 10:27:45 -05:00
868022408c Merge pull request #68 from Kingsrook/feature/CE-1555-ops-overview-fix-accordion
Feature/ce 1555 ops overview fix accordion
2024-08-23 10:26:53 -05:00
d090a665ff Merge pull request #69 from Kingsrook/feature/CE-1556-ops-overview-enhanced-tooltips
Feature/ce 1556 ops overview enhanced tooltips
2024-08-23 10:24:15 -05:00
f112cf5543 Remove sold border 2024-08-23 10:24:00 -05:00
0c2dcb1215 Update qqq-frontend-core to 1.0.105 2024-08-23 10:07:18 -05:00
418f7957a2 CE-1646 pass useCase (filter or form) into DynamicSelect and down to possibleValue backend calls 2024-08-23 08:51:24 -05:00
8be8bf367a CE-1405 / CE-1479 - Add missing ? 2024-08-21 08:52:57 -05:00
1ca1313a25 CE-1405 / CE-1479 - Let widget meta data default values set more grid cols per size classes 2024-08-21 08:35:35 -05:00
4533815535 CE-1554: added ability to overlay html over a block widget 2024-08-20 15:42:58 -05:00
4230f34b15 Only output Link if link has an href (else page blows up) 2024-08-20 10:07:45 -05:00
e08e37222b CE-1556: updated to try to use composite block data within tooltips 2024-08-13 16:22:12 -05:00
0ffada6aec CE-1555: updates 'accordian' behavior of tables 2024-08-12 12:09:29 -05:00
9f04d897a1 Merge branch 'feature/style-cleanups-20240725' into feature/CE-1555-ops-overview-fix-accordion 2024-08-09 13:44:52 -05:00
e604f47231 Fixes for data table css redo (a z-index on headers, and use background color (as sx prop) in body cells 2024-07-26 10:34:18 -05:00
93f5bb688c Merge pull request #66 from Kingsrook/feature/CE-1460-export-and-join-bugs
Feature/ce 1460 export and join bugs
2024-07-25 11:53:35 -05:00
3fa017e8b9 Update selector and widths per css change 2024-07-25 09:29:17 -05:00
9d5af539b9 Re-do css on table skeleton, per changes in the included DataTable*Cell components 2024-07-25 09:29:13 -05:00
97bab57974 Re-do css on tables, to do the whole table as divs with display: grid 2024-07-25 08:37:37 -05:00
d9de96ea7f Make whole top-right bar display:none at under md breakpoints 2024-07-25 08:36:13 -05:00
ff839d85fd Merged dev into feature/CE-1460-export-and-join-bugs 2024-07-09 11:36:32 -05:00
d31215f6c0 Added dev for build, not test 2024-07-09 10:09:25 -05:00
262855b9c0 Increase version to 0.21.0-SNAPSHOT 2024-07-09 10:08:32 -05:00
4d082c3c57 Update revision to 0.20.0, to publish that release version 2024-07-05 20:38:36 -05:00
45b6b42836 Add a ? in case no valueCount records came back - which, can happen for a join-field where there were no matching join records. 2024-07-05 12:42:42 -05:00
47fb7cc2e3 Merge pull request #65 from Kingsrook/feature/CE-1402-field-case-change-behaviors
Feature/ce 1402 field case change behaviors
2024-07-03 16:29:05 -05:00
647c63f5a3 Add ErrorBoundary, and wrap HelpContent with it 2024-06-25 13:32:13 -05:00
f545649882 CE-1402 Make consistent naming 'behaviors', not 'fieldBehaviors' 2024-06-25 08:40:30 -05:00
4d4610801f Add "&& npm dedupe --force" to clean-and-install 2024-06-25 07:58:29 -05:00
3ec43fbbd3 CE-1402 Only do flushSync and setSelectionRange after a toUpper/Lower and add a try-catch, just in case (specifically, because failed on input type=number) 2024-06-25 07:58:12 -05:00
28bc07cce4 CE-1402 Look for toUpperCase/toLowerCase behaviors on fields, and apply those transforms (plus cursor adjustments) in input fields and filter criteria values 2024-06-24 20:44:15 -05:00
c7d31fa39e Better matching for multi-word search terms ("one th" now matches "one two three") 2024-06-19 16:43:26 -05:00
69f1cfe92f Merge pull request #64 from Kingsrook/feature/fix-process-pvs-display-value
Fix to fetch possible-values when switching screens, to display label…
2024-06-19 16:32:38 -05:00
2ed95ff77a Update to not submit 'undefined' values to backend 2024-06-14 11:38:55 -05:00
66336a28ed Fix to fetch possible-values when switching screens, to display labels properly. 2024-06-14 09:11:15 -05:00
826bed4537 Add iconButton to open dot menu 2024-06-06 10:25:39 -05:00
40bd83cd96 attempt to fix scrollbar issue in 'is any of' mode 2024-06-05 14:06:18 -05:00
ca460e65e1 Merge pull request #63 from Kingsrook/feature/CE-938-order-release-automation
CE-938: fixed issue where modal record query was accepting shortcut k…
2024-06-05 10:49:23 -05:00
122fef152c CE-938: fixed issue where modal record query was accepting shortcut keys for new/copy/etc. 2024-06-05 10:38:30 -05:00
d0ed0ce949 Merge pull request #62 from Kingsrook/feature/CE-938-order-release-automation
Feature/ce 938 order release automation
2024-06-04 19:57:59 -05:00
b8aa36455d Merge pull request #61 from Kingsrook/feature/dot-menu-sort-filter-change
Feature/dot menu sort filter change
2024-06-04 19:56:48 -05:00
a778b7497a CE-938: updated to get filter and column setup values from widget data, rather than 'default values' 2024-06-04 13:43:39 -05:00
c3503a719f CE-938 - Add class & rule for margin of alert-widgets inside processes 2024-06-04 10:54:43 -05:00
2afa82c770 Fix 'Saved View' showing up in breadcrumb when it shouldn't 2024-06-04 10:54:43 -05:00
d03e908a9d CE-938: fixed bug on deletion of associated child records 2024-06-03 15:25:53 -05:00
dc62f97219 CE-938: updates from code review feedback 2024-06-03 11:28:29 -05:00
fe9e20715a CE-938: fixed bug where editing a record was not updating filter fields, fixed padding issue 2024-05-31 14:42:35 -05:00
71a1bfaa6b CE-938: fixed filter display text, dashboard widget style change 2024-05-29 12:58:06 -05:00
d9e9a0be08 CE-938 Add calls to processCancel 2024-05-28 16:27:51 -05:00
aefb282a0e CE-938 update qfc to 1.0.102 - adding processCancel 2024-05-28 16:27:03 -05:00
fb57718c1c Add some ?.'s around metaData.widgets (in case an instance has no widgets) 2024-05-28 16:26:24 -05:00
ba213b038b Removing cypress 2024-05-28 16:25:56 -05:00
69daf47021 CE-938: renamed reportSetup widget to filterAndColumns 2024-05-22 15:30:13 -05:00
1d24b9b40c CE-938: updated 'flashing' occurring in child widget whenever any form fields are changed, instead of only the data in the widget 2024-05-22 12:38:16 -05:00
f44ba8d6d3 CE-938: improvements to the report setup widget 2024-05-21 18:26:35 -05:00
7b562aea50 Slightly better sort for multi-word search terms 2024-05-17 17:11:35 -05:00
3bf1cea9dd Do custom sort & filter 2024-05-17 12:55:24 -05:00
dc131d5189 qqq-frontend-core 1.0.101 2024-05-15 19:44:12 -05:00
2b5cc1610f For CE-1280 - add helpContent to process steps, along with css for help content to do standard app style alerts 2024-05-15 19:42:25 -05:00
a36bdb1474 Merge pull request #60 from Kingsrook/feature/CE-1240-out-of-stock-summary-page
Feature/ce 1240 out of stock summary page
2024-05-15 19:20:59 -05:00
c2926d26e8 Merge pull request #59 from Kingsrook/feature/CE-1180-order-address-validation
Feature/ce 1180 order address validation
2024-05-15 19:17:49 -05:00
eb42a86655 CE-1180 Update to only set formik values for fields that are in the form (to avoid setting, e.g., 'backend only' type fields, like the extract code-reference 2024-05-15 09:01:38 -05:00
b7f715f832 Change to javascript-scroll into view, rather than use anchors (they don't work upon reload anyway, due to async loading, and they broke record-view-by-key); also move overflow down an element in the stack, to make border-radius look better 2024-05-14 22:29:22 -05:00
16a08cfd42 Fix, don't push record-view/process urls into history 2024-05-14 22:28:25 -05:00
f5919c66ab Add whitespace nowrap to goto button 2024-05-14 20:29:21 -05:00
0831a87674 CE-1180 Add joins to request for the record 2024-05-13 08:46:46 -05:00
dd5cd459ce CE-1180 reset formik form values (to latest values from backend) after each backend step 2024-05-13 08:46:11 -05:00
c200cc9fab CE-1180 Add a null-check in getFieldandTable 2024-05-10 15:51:54 -05:00
17f378131d CE-1180 Add a null-check in ensureOrderBysFromJoinTablesAreVisibleTables (not sure why, but feels safe and good) 2024-05-10 15:51:37 -05:00
376a7a342e do toLowerCase on both sides of a contains check... also better fail message for not-contains text. 2024-05-10 15:50:29 -05:00
fcadea3192 CE-1180 Better support for processes w/ dynamic flows, by using updtedFrontnedStepList;
- fixed min-height on the bar w/ steps (e.g., before they're known)
- updated EDIT_FORM to support values: "includeFieldNames" - to do a sub-set of field names (so you can organize them a bit across multiple EDIT_FORM's) and "sectionLabel"
2024-05-10 15:49:39 -05:00
086ab775fc CE-1180 Update qqq-frontend-core to 1.0.100 💯 🎉 - updatedFrontendStepList in process output 2024-05-10 15:46:43 -05:00
5693661d20 CE-1180 Updated to support multi-value keys. also, support tables (e.g., api tables) w/o a primary key, by redirecting a successful UK lookup to the (new) /key?queryString record-view screen 2024-05-10 15:45:44 -05:00
8c9224aceb CE-1180 Add new table/key?queryString endpoint, to look up a record by a (unique) key and then view it 2024-05-10 15:33:56 -05:00
d750ef0930 CE-1240: made link font slightly larger 2024-05-06 15:30:27 -05:00
267ead925b CE-1240: added support for table link 2024-05-06 11:58:57 -05:00
f925ad9116 CE-1240: updated composite widget to have flex column ability, support for 'multi table' widget, 2024-05-03 20:26:36 -05:00
1859dd603d Merge pull request #58 from Kingsrook/integration/sprint-41
Integration/sprint 41
2024-05-02 08:38:09 -05:00
74f8f11737 Merge pull request #57 from Kingsrook/feature/CE-1068-add-basic-functionality-of
Feature/ce 1068 add basic functionality of
2024-05-02 08:36:30 -05:00
0629172270 Merged feature/CE-1068-add-basic-functionality-of into integration/sprint-41 2024-05-01 16:59:24 -05:00
1bf1f09e9d CE-1068 - scroll-top-top to show alerts in modals 2024-05-01 15:38:14 -05:00
e0f689544d CE-1068: fixed bug around filter variables on possible value 2024-05-01 13:43:41 -05:00
f3d08ef683 Merged feature/CE-882-add-functionality-of-sharing into feature/CE-1068-add-basic-functionality-of 2024-05-01 10:54:38 -05:00
1aff749f72 CE-1068: made width for date times a little wider 2024-05-01 10:54:33 -05:00
ccc622e0e9 Merge branch 'feature/CE-1068-add-basic-functionality-of' into integration/sprint-41 2024-05-01 10:21:54 -05:00
a6662eeb07 CE-1068: bug fixes 2024-05-01 10:04:20 -05:00
c8b673fb46 Merged feature/CE-1068-add-basic-functionality-of into integration/sprint-41 2024-04-30 19:51:10 -05:00
f19e36a6bf CE-882 - Better avoidance of savedView under a table query screen 2024-04-30 19:42:48 -05:00
c708ec3b9a CE-882 - Better handling of stupidly long titles 2024-04-30 19:42:34 -05:00
7e40fa90e9 CE-1068: updates to fix selenium tests 2024-04-30 19:28:35 -05:00
680d185eb5 Merged feature/CE-1068-add-basic-functionality-of into integration/sprint-41 2024-04-30 19:21:02 -05:00
4f37488d37 Merged feature/CE-882-add-functionality-of-sharing into integration/sprint-41 2024-04-30 19:20:50 -05:00
d20700edb1 CE-882 - Turn off 'scope' for time being 2024-04-30 19:20:22 -05:00
d17c7f6990 CE-1068: more updates for other operators to support variables 2024-04-30 19:04:34 -05:00
0d7849b7dc Merged feature/CE-882-add-functionality-of-sharing into integration/sprint-41 2024-04-30 16:42:04 -05:00
57098b5f05 CE-882 - Add awareness of shared views 2024-04-30 15:28:22 -05:00
7316b6141b CE-1068 - possible-values working in dynamic form (i think!) 2024-04-30 14:42:20 -05:00
8bc2479716 CE-1068: added passing of allowVariables to date criteria 2024-04-30 14:04:38 -05:00
010f80def3 Merged feature/CE-1068-add-basic-functionality-of into integration/sprint-41 2024-04-30 11:45:38 -05:00
13d7cc6825 Merged feature/CE-882-add-functionality-of-sharing into feature/CE-1068-add-basic-functionality-of 2024-04-30 10:30:45 -05:00
ca715af84a Merged feature/CE-1179-add-user-defined-inputs-to into feature/CE-1068-add-basic-functionality-of 2024-04-30 10:29:57 -05:00
65aaf4fce1 CE-1068 - add RELOAD_WIDGET action, and targetWidget to FieldRules 2024-04-30 10:18:13 -05:00
8dc8ae0b6d CE-1068 - Add dynamic form widget; add widgets on processes 2024-04-30 10:17:38 -05:00
8707aa8a94 CE-1179: checkpoint commit for integrations 2024-04-30 10:06:21 -05:00
e7d870a7fa CE-1068 - Add dumping console logs upon error - could help diagnose test fails faster hopefully 2024-04-29 12:52:29 -05:00
38b8f47409 Merged feature/CE-882-add-functionality-of-sharing into integration/sprint-41 2024-04-29 10:41:29 -05:00
de8594bfe1 CE-882 Try to fix userId / user.email state 2024-04-29 09:39:20 -05:00
3c8180cf51 try committing this to fix inexplicable failed tests 2024-04-29 08:49:57 -05:00
2e48aa3eba CE-882 Data from ShareableTableMetaData; dynamic select for audiences; 2024-04-28 20:37:22 -05:00
feb1cc5c86 CE-882 Add /possibleValues/ 2024-04-28 20:35:30 -05:00
c2ad1c34be CE-882 Remove hard-coded table names for sharing; Disable sharing except for owner; 2024-04-28 20:35:18 -05:00
4c6955b6ed Merge pull request #56 from Kingsrook/feature/report-setup-selenium
the missing selenium test for the report setup screen from last sprint
2024-04-19 07:55:04 -05:00
11f1250d73 the missing selenium test for the report setup screen from last sprint 2024-04-18 20:48:26 -05:00
94 changed files with 26067 additions and 6972 deletions

View File

@ -115,7 +115,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters:
branches:
ignore: /(main|integration.*)/
ignore: /(main|dev|integration.*)/
tags:
ignore: /(version|snapshot)-.*/
deploy:
@ -124,7 +124,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters:
branches:
only: /(main|integration.*)/
only: /(main|dev|integration.*)/
tags:
only: /(version|snapshot)-.*/

View File

@ -1,12 +0,0 @@
import {defineConfig} from "cypress";
export default defineConfig({
e2e: {
viewportHeight: 1000,
viewportWidth: 1200,
setupNodeEvents(on, config)
{
// implement node event listeners here
},
},
});

25271
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.97",
"@kingsrook/qqq-frontend-core": "1.0.110",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",
@ -59,7 +59,7 @@
"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",
"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",

View File

@ -29,7 +29,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.20.0-SNAPSHOT</revision>
<revision>0.23.0</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@ -66,7 +66,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>0.20.0-20240308.165846-65</version>
<version>0.21.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
@ -154,11 +154,11 @@
<versionTagPrefix>version-</versionTagPrefix>
</gitFlowConfig>
<skipFeatureVersion>true</skipFeatureVersion> <!-- Keep feature names out of versions -->
<postReleaseGoals>install</postReleaseGoals> <!-- Let CI run deploys -->
<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>

View File

@ -49,6 +49,7 @@ import EntityEdit from "qqq/pages/records/edit/RecordEdit";
import RecordQuery from "qqq/pages/records/query/RecordQuery";
import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView";
import RecordView from "qqq/pages/records/view/RecordView";
import RecordViewByUniqueKey from "qqq/pages/records/view/RecordViewByUniqueKey";
import GoogleAnalyticsUtils, {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import Client from "qqq/utils/qqq/Client";
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
@ -392,6 +393,13 @@ export default function App()
component: <RecordView table={table} />,
});
routeList.push({
name: `${app.label} View`,
key: `${app.name}.view`,
route: `${path}/key`,
component: <RecordViewByUniqueKey table={table} />,
});
routeList.push({
name: `${app.label}`,
key: `${app.name}.edit`,
@ -664,8 +672,14 @@ export default function App()
const [dotMenuOpen, setDotMenuOpen] = useState(false);
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
const [userId] = useState(user.email);
const [userId, setUserId] = useState(user?.email);
useEffect(() =>
{
setUserId(user?.email)
}, [user]);
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
/*******************************************************************************

View File

@ -36,7 +36,7 @@ import Icon from "@mui/material/Icon";
import Typography from "@mui/material/Typography";
import {makeStyles} from "@mui/styles";
import {Command} from "cmdk";
import React, {useContext, useEffect, useRef} from "react";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useNavigate} from "react-router-dom";
import QContext from "QContext";
import HistoryUtils, {QHistoryEntry} from "qqq/utils/HistoryUtils";
@ -62,16 +62,21 @@ const useStyles = makeStyles((theme: any) => ({
}
}));
const A_FIRST = -1;
const B_FIRST = 1;
const CommandMenu = ({metaData}: Props) =>
{
const [searchString, setSearchString] = useState("");
const navigate = useNavigate();
const pathParts = location.pathname.replace(/\/+$/, "").split("/");
const {accentColor, tableMetaData, dotMenuOpen, setDotMenuOpen, keyboardHelpOpen, setKeyboardHelpOpen, setTableMetaData, tableProcesses} = useContext(QContext);
const {accentColor, tableMetaData, dotMenuOpen, setDotMenuOpen, keyboardHelpOpen, setKeyboardHelpOpen, setTableMetaData, tableProcesses, recordAnalytics} = useContext(QContext);
const classes = useStyles();
function evalueKeyPress(e: KeyboardEvent)
function evaluateKeyPress(e: KeyboardEvent)
{
///////////////////////////////////////////////////////////////////////////
// if a dot pressed, not from a "text" element, then toggle command menu //
@ -82,6 +87,7 @@ const CommandMenu = ({metaData}: Props) =>
if (e.key === "." && !keyboardHelpOpen)
{
e.preventDefault();
recordAnalytics({category: "globalEvents", action: "dotMenuKeyboardShortcut"});
setDotMenuOpen(true);
}
else if (e.key === "?" && !dotMenuOpen)
@ -107,20 +113,20 @@ const CommandMenu = ({metaData}: Props) =>
const down = (e: KeyboardEvent) =>
{
evalueKeyPress(e);
}
evaluateKeyPress(e);
};
document.addEventListener("keydown", down)
document.addEventListener("keydown", down);
return () =>
{
document.removeEventListener("keydown", down)
}
}, [tableMetaData, dotMenuOpen, keyboardHelpOpen])
document.removeEventListener("keydown", down);
};
}, [tableMetaData, dotMenuOpen, keyboardHelpOpen]);
useEffect(() =>
{
setDotMenuOpen(false);
}, [location.pathname])
}, [location.pathname]);
function goToItem(path: string)
{
@ -162,73 +168,117 @@ const CommandMenu = ({metaData}: Props) =>
return (null);
}
/*******************************************************************************
** sort a section (e.g, tables, apps).
**
** put labels that start-with the search word first.
*******************************************************************************/
function comparator(labelA: string, labelB: string)
{
if (searchString != "")
{
let aStartsWith = labelA.toLowerCase().startsWith(searchString.toLowerCase());
let bStartsWith = labelB.toLowerCase().startsWith(searchString.toLowerCase());
if (aStartsWith && !bStartsWith)
{
return A_FIRST;
}
else if (bStartsWith && !aStartsWith)
{
return B_FIRST;
}
const indexOfSpace = searchString.indexOf(" ");
if (indexOfSpace > 0)
{
aStartsWith = labelA.toLowerCase().startsWith(searchString.substring(0, indexOfSpace).toLowerCase());
bStartsWith = labelB.toLowerCase().startsWith(searchString.substring(0, indexOfSpace).toLowerCase());
if (aStartsWith && !bStartsWith)
{
return A_FIRST;
}
else if (bStartsWith && !aStartsWith)
{
return B_FIRST;
}
}
}
return (labelA.localeCompare(labelB));
}
/*******************************************************************************
**
*******************************************************************************/
function ActionsSection()
{
let tableNames : string[]= [];
let tableNames: string[] = [];
metaData.tables.forEach((value: QTableMetaData, key: string) =>
{
tableNames.push(value.name);
})
tableNames = tableNames.sort((a: string, b:string) =>
});
tableNames = tableNames.sort((a: string, b: string) =>
{
const labelA = metaData.tables.get(a).label ?? "";
const labelB = metaData.tables.get(b).label ?? "";
return (labelA.localeCompare(labelB));
})
return comparator(labelA, labelB);
});
const path = location.pathname;
return tableMetaData && !path.endsWith("/edit") && !path.endsWith("/create") && !path.endsWith("#audit") && ! path.endsWith("copy") &&
return tableMetaData && !path.endsWith("/edit") && !path.endsWith("/create") && !path.endsWith("#audit") && !path.endsWith("copy") &&
(
<Command.Group heading={`${tableMetaData.label} Actions`}>
{
tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission &&
<Command.Item onSelect={() => goToItem(`${pathParts.slice(0, -1).join("/")}/create`)} key={`${tableMetaData.label}-new`} value="New"><Icon sx={{color: accentColor}}>add</Icon>New</Command.Item>
<Command.Item onSelect={() => goToItem(`${pathParts.slice(0, -1).join("/")}/create`)} key={`${tableMetaData.label}-new`} value="New"><Icon sx={{color: accentColor}}>add</Icon>New</Command.Item>
}
{
tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission &&
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/copy`)} key={`${tableMetaData.label}-copy`} value="Copy"><Icon sx={{color: accentColor}}>copy</Icon>Copy</Command.Item>
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/copy`)} key={`${tableMetaData.label}-copy`} value="Copy"><Icon sx={{color: accentColor}}>copy</Icon>Copy</Command.Item>
}
{
tableMetaData.capabilities.has(Capability.TABLE_UPDATE) && tableMetaData.editPermission &&
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/edit`)} key={`${tableMetaData.label}-edit`} value="Edit"><Icon sx={{color: accentColor}}>edit</Icon>Edit</Command.Item>
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/edit`)} key={`${tableMetaData.label}-edit`} value="Edit"><Icon sx={{color: accentColor}}>edit</Icon>Edit</Command.Item>
}
{
metaData && metaData.tables.has("audit") &&
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}#audit`)} key={`${tableMetaData.label}-audit`} value="Audit"><Icon sx={{color: accentColor}}>checklist</Icon>Audit</Command.Item>
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}#audit`)} key={`${tableMetaData.label}-audit`} value="Audit"><Icon sx={{color: accentColor}}>checklist</Icon>Audit</Command.Item>
}
{
tableProcesses && tableProcesses.length > 0 &&
(
tableProcesses.map((process) => (
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/${process.name}`)} key={`${process.name}`} value={`${process.label}`}><Icon sx={{color: accentColor}}>{getIconName(process.iconName, "play_arrow")}</Icon>{process.label}</Command.Item>
))
)
(
tableProcesses.map((process) => (
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/${process.name}`)} key={`${process.name}`} value={`${process.label}`}><Icon sx={{color: accentColor}}>{getIconName(process.iconName, "play_arrow")}</Icon>{process.label}</Command.Item>
))
)
}
<Command.Separator />
</Command.Group>
);
}
/*******************************************************************************
**
*******************************************************************************/
function TablesSection()
{
let tableNames : string[]= [];
let tableNames: string[] = [];
metaData.tables.forEach((value: QTableMetaData, key: string) =>
{
tableNames.push(value.name);
})
tableNames = tableNames.sort((a: string, b:string) =>
});
tableNames = tableNames.sort((a: string, b: string) =>
{
const labelA = metaData.tables.get(a).label ?? "";
const labelB = metaData.tables.get(b).label ?? "";
return (labelA.localeCompare(labelB));
})
return(
return comparator(labelA, labelB);
});
return (
<Command.Group heading="Tables">
{
tableNames.map((tableName: string, index: number) =>
@ -243,6 +293,7 @@ const CommandMenu = ({metaData}: Props) =>
);
}
/*******************************************************************************
**
*******************************************************************************/
@ -252,16 +303,16 @@ const CommandMenu = ({metaData}: Props) =>
metaData.apps.forEach((value: QAppMetaData, key: string) =>
{
appNames.push(value.name);
})
});
appNames = appNames.sort((a: string, b:string) =>
appNames = appNames.sort((a: string, b: string) =>
{
const labelA = getFullAppLabel(metaData.appTree, a, 1, "") ?? "";
const labelB = getFullAppLabel(metaData.appTree, b, 1, "") ?? "";
return (labelA.localeCompare(labelB));
})
return comparator(labelA, labelB);
});
return(
return (
<Command.Group heading="Apps">
{
appNames.map((appName: string, index: number) =>
@ -276,33 +327,37 @@ const CommandMenu = ({metaData}: Props) =>
);
}
/*******************************************************************************
**
*******************************************************************************/
function RecentlyViewedSection()
{
const history = HistoryUtils.get();
const options = [] as any;
history.entries.reverse().forEach((entry, index) =>
options.push({label: `${entry.label} index`, id: index, key: index, path: entry.path, iconName: entry.iconName})
)
);
let appNames: string[] = [];
metaData.apps.forEach((value: QAppMetaData, key: string) =>
{
appNames.push(value.name);
})
});
appNames = appNames.sort((a: string, b:string) =>
appNames = appNames.sort((a: string, b: string) =>
{
const labelA = metaData.apps.get(a).label ?? "";
const labelB = metaData.apps.get(b).label ?? "";
return (labelA.localeCompare(labelB));
})
return comparator(labelA, labelB);
});
const entryMap = new Map<string, boolean>();
return(
return (
<Command.Group heading="Recently Viewed Records">
{
history.entries.reverse().map((entry: QHistoryEntry, index: number) =>
! entryMap.has(entry.label) && entryMap.set(entry.label, true) && (
!entryMap.has(entry.label) && entryMap.set(entry.label, true) && (
<Command.Item onSelect={() => goToItem(`${entry.path}`)} key={`${entry.label}-${index}`} value={entry.label}><Icon sx={{color: accentColor}}>{entry.iconName}</Icon>{entry.label}</Command.Item>
)
)
@ -311,29 +366,101 @@ const CommandMenu = ({metaData}: Props) =>
);
}
const containerElement = useRef(null)
const containerElement = useRef(null);
/*******************************************************************************
**
*******************************************************************************/
function closeKeyboardHelp()
{
setKeyboardHelpOpen(false);
}
/*******************************************************************************
**
*******************************************************************************/
function closeDotMenu()
{
setDotMenuOpen(false);
}
/*******************************************************************************
** filter function for cmd-k library
**
*******************************************************************************/
function doFilter(value: string, search: string)
{
setSearchString(search);
/////////////////////
// split on spaces //
/////////////////////
const searchParts = search.toLowerCase().split(" ");
if (searchParts.length == 1)
{
//////////////////////////////////////////////
// if only 1 word, just do an includes test //
//////////////////////////////////////////////
return (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0);
}
else
{
////////////////////////////////////////
// else split the value on spaces too //
////////////////////////////////////////
const valueParts = value.toLowerCase().split(" ");
if (searchParts.length > valueParts.length)
{
//////////////////////////////////////////////////////////////////////////////////
// if there are more words in the search than in the value, then it can't match //
// e.g. "order c" can't ever match, say "order" //
//////////////////////////////////////////////////////////////////////////////////
return (0);
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// iterate over the search parts - if any don't match the corresponding value parts, then it's a non-match //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
let valueIndex = 0;
for (let i = 0; i < searchParts.length; i++)
{
let foundMatch = false;
for (; valueIndex < valueParts.length; valueIndex++)
{
if (valueParts[valueIndex].includes(searchParts[i]))
{
foundMatch = true;
break;
}
}
if (!foundMatch)
{
return (0);
}
}
/////////////////////////////////
// if no failure, return a hit //
/////////////////////////////////
return (1);
}
}
return (
<React.Fragment>
<Box ref={containerElement} className="raycast" sx={{position: "relative", zIndex: 10_000}}>
{
<Dialog open={dotMenuOpen} onClose={closeDotMenu}>
<Command.Dialog open={dotMenuOpen} onOpenChange={setDotMenuOpen} container={containerElement.current} label="Test Global Command Menu">
<Command.Dialog open={dotMenuOpen} onOpenChange={setDotMenuOpen} container={containerElement.current} filter={(value, search) => doFilter(value, search)}>
<Box sx={{display: "flex"}}>
<Command.Input placeholder="Search for Tables, Actions, or Recently Viewed Items..."/>
<Command.Input placeholder="Search for Tables, Actions, or Recently Viewed Items..." />
<Button onClick={closeDotMenu}><Icon>close</Icon></Button>
</Box>
<Command.Loading />
<Command.Loading />
<Command.Separator />
<Command.List>
<Command.Empty>No results found.</Command.Empty>
@ -381,6 +508,6 @@ const CommandMenu = ({metaData}: Props) =>
</Dialog>
}
</React.Fragment>
)
}
);
};
export default CommandMenu;

View File

@ -134,17 +134,36 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
for(FieldRule fieldRule : CollectionUtils.nonNullList(fieldRules))
{
qInstanceValidator.assertCondition(fieldRule.getTrigger() != null, prefix + "has a fieldRule without a trigger");
qInstanceValidator.assertCondition(fieldRule.getAction() != null, prefix + "has a fieldRule without an action");
validateFieldRule(qInstance, tableMetaData, qInstanceValidator, fieldRule, prefix);
}
}
if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getSourceField()), prefix + "has a fieldRule without a sourceField"))
{
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getSourceField()), prefix + "has a fieldRule with an unrecognized sourceField: " + fieldRule.getSourceField());
}
if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getTargetField()), prefix + "has a fieldRule without a targetField"))
/*******************************************************************************
**
*******************************************************************************/
static void validateFieldRule(QInstance qInstance, QTableMetaData tableMetaData, QInstanceValidator qInstanceValidator, FieldRule fieldRule, String prefix)
{
qInstanceValidator.assertCondition(fieldRule.getTrigger() != null, prefix + "has a fieldRule without a trigger");
qInstanceValidator.assertCondition(fieldRule.getAction() != null, prefix + "has a fieldRule without an action");
if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getSourceField()), prefix + "has a fieldRule without a sourceField"))
{
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getSourceField()), prefix + "has a fieldRule with an unrecognized sourceField: " + fieldRule.getSourceField());
}
if(StringUtils.hasContent(fieldRule.getTargetField()))
{
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getTargetField()), prefix + "has a fieldRule with an unrecognized targetField: " + fieldRule.getTargetField());
}
if(StringUtils.hasContent(fieldRule.getTargetWidget()))
{
if(qInstanceValidator.assertCondition(qInstance.getWidget(fieldRule.getTargetWidget()) != null, prefix + "has a widgetRule with an unrecognized targetWidget: " + fieldRule.getTargetWidget()))
{
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getTargetField()), prefix + "has a fieldRule with an unrecognized targetField: " + fieldRule.getTargetField());
qInstanceValidator.assertCondition(CollectionUtils.nonNullList(tableMetaData.getSections()).stream().anyMatch(s -> fieldRule.getTargetWidget().equals(s.getWidgetName())),
prefix + "has a widgetRule with a targetWidget which is not used in any sections on the table");
}
}
}

View File

@ -38,6 +38,8 @@ public class FieldRule implements Serializable
private FieldRuleAction action;
private String targetField;
private String targetWidget;
/*******************************************************************************
@ -162,4 +164,35 @@ public class FieldRule implements Serializable
return (this);
}
/*******************************************************************************
** Getter for targetWidget
*******************************************************************************/
public String getTargetWidget()
{
return (this.targetWidget);
}
/*******************************************************************************
** Setter for targetWidget
*******************************************************************************/
public void setTargetWidget(String targetWidget)
{
this.targetWidget = targetWidget;
}
/*******************************************************************************
** Fluent setter for targetWidget
*******************************************************************************/
public FieldRule withTargetWidget(String targetWidget)
{
this.targetWidget = targetWidget;
return (this);
}
}

View File

@ -27,5 +27,6 @@ package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules;
*******************************************************************************/
public enum FieldRuleAction
{
CLEAR_TARGET_FIELD
CLEAR_TARGET_FIELD,
RELOAD_WIDGET
}

View File

@ -30,14 +30,17 @@ import MDButton from "qqq/components/legacy/MDButton";
export const standardWidth = "150px";
const standardML = {xs: 1, md: 3};
interface QCreateNewButtonProps
{
tablePath: string;
}
export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element
{
return (
<Box display="inline-block" ml={3} mr={0} width={standardWidth}>
<Box display="inline-block" ml={standardML} mr={0} width={standardWidth}>
<Link to={`${tablePath}/create`}>
<MDButton variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
Create New
@ -54,6 +57,7 @@ interface QSaveButtonProps
onClickHandler?: any,
disabled: boolean
}
QSaveButton.defaultProps = {
label: "Save",
iconName: "save"
@ -62,7 +66,7 @@ QSaveButton.defaultProps = {
export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveButtonProps): JSX.Element
{
return (
<Box ml={3} width={standardWidth}>
<Box ml={standardML} width={standardWidth}>
<MDButton type="submit" variant="gradient" color="info" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
{label}
</MDButton>
@ -72,17 +76,18 @@ export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveBu
interface QDeleteButtonProps
{
onClickHandler: any
disabled?: boolean
onClickHandler: any;
disabled?: boolean;
}
QDeleteButton.defaultProps = {
disabled: false
};
export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): JSX.Element
{
return (
<Box ml={3} width={standardWidth}>
<Box ml={standardML} width={standardWidth}>
<MDButton variant="gradient" color="primary" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>delete</Icon>} disabled={disabled}>
Delete
</MDButton>
@ -93,7 +98,7 @@ export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): J
export function QEditButton(): JSX.Element
{
return (
<Box ml={3} width={standardWidth}>
<Box ml={standardML} width={standardWidth}>
<Link to="edit">
<MDButton variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>}>
Edit
@ -132,7 +137,7 @@ interface QCancelButtonProps
onClickHandler: any;
disabled: boolean;
label?: string;
iconName?: string
iconName?: string;
}
export function QCancelButton({
@ -140,7 +145,7 @@ export function QCancelButton({
}: QCancelButtonProps): JSX.Element
{
return (
<Box ml="auto" width={standardWidth}>
<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}>
{label}
</MDButton>
@ -155,15 +160,15 @@ QCancelButton.defaultProps = {
interface QSubmitButtonProps
{
label?: string
iconName?: string
disabled: boolean
label?: string;
iconName?: string;
disabled: boolean;
}
export function QSubmitButton({label, iconName, disabled}: QSubmitButtonProps): JSX.Element
{
return (
<Box ml={3} width={standardWidth}>
<Box ml={standardML} width={standardWidth}>
<MDButton type="submit" variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
{label}
</MDButton>

View File

@ -172,16 +172,14 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
<Grid item xs={12} sm={6} key={fieldName}>
{labelElement}
<DynamicSelect
tableName={field.possibleValueProps.tableName}
processName={field.possibleValueProps.processName}
fieldName={fieldName}
fieldPossibleValueProps={field.possibleValueProps}
isEditable={field.isEditable}
fieldLabel=""
initialValue={values[fieldName]}
initialDisplayValue={field.possibleValueProps.initialDisplayValue}
bulkEditMode={bulkEditMode}
bulkEditSwitchChangeHandler={bulkEditSwitchChanged}
otherValues={otherValuesMap}
useCase="form"
/>
{formattedHelpContent}
</Grid>

View File

@ -19,16 +19,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {InputAdornment, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import {Box, InputAdornment, InputLabel} from "@mui/material";
import Switch from "@mui/material/Switch";
import {ErrorMessage, Field, useFormikContext} from "formik";
import React, {useState} from "react";
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 MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography";
import {flushSync} from "react-dom";
// Declaring props types for FormField
interface Props
@ -39,6 +40,8 @@ interface Props
value: any;
type: string;
isEditable?: boolean;
placeholder?: string;
backgroundColor?: string;
[key: string]: any;
@ -48,7 +51,7 @@ interface Props
}
function QDynamicFormField({
label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, formFieldObject, ...rest
label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, placeholder, backgroundColor, formFieldObject, ...rest
}: Props): JSX.Element
{
const [switchChecked, setSwitchChecked] = useState(false);
@ -64,18 +67,30 @@ function QDynamicFormField({
inputLabelProps.shrink = true;
}
const inputProps = {};
const inputProps: any = {};
if (displayFormat && displayFormat.startsWith("$"))
{
// @ts-ignore
inputProps.startAdornment = <InputAdornment position="start">$</InputAdornment>;
}
if (displayFormat && displayFormat.endsWith("%%"))
{
// @ts-ignore
inputProps.endAdornment = <InputAdornment position="end">%</InputAdornment>;
}
if (placeholder)
{
inputProps.placeholder = placeholder
}
if(backgroundColor)
{
inputProps.sx = {
"&.MuiInputBase-root": {
backgroundColor: backgroundColor
}
};
}
// @ts-ignore
const handleOnWheel = (e) =>
{
@ -85,6 +100,51 @@ function QDynamicFormField({
}
};
///////////////////////////////////////////////////////////////////////////////////////
// check the field meta data for behavior that says to do toUpperCase or toLowerCase //
///////////////////////////////////////////////////////////////////////////////////////
let isToUpperCase = useMemo(() => DynamicFormUtils.isToUpperCase(formFieldObject?.fieldMetaData), [formFieldObject]);
let isToLowerCase = useMemo(() => DynamicFormUtils.isToLowerCase(formFieldObject?.fieldMetaData), [formFieldObject]);
////////////////////////////////////////////////////////////////////////
// if the field has a toUpperCase or toLowerCase behavior on it, then //
// apply that rule. But also, to avoid the cursor always jumping to //
// the end of the input, do some manipulation of the selection. //
// See: https://giacomocerquone.com/blog/keep-input-cursor-still //
// Note, we only want an onChange handle if we're doing one of these //
// behaviors, (because teh flushSync is potentially slow). hence, we //
// put the onChange in an object and assign it with a spread //
////////////////////////////////////////////////////////////////////////
let onChange: any = {};
if (isToUpperCase || isToLowerCase)
{
onChange.onChange = (e: any) =>
{
const beforeStart = e.target.selectionStart;
const beforeEnd = e.target.selectionEnd;
flushSync(() =>
{
let newValue = e.currentTarget.value;
if (isToUpperCase)
{
newValue = newValue.toUpperCase();
}
if (isToLowerCase)
{
newValue = newValue.toLowerCase();
}
setFieldValue(name, newValue);
});
const input = document.getElementById(name) as HTMLInputElement;
if (input)
{
input.setSelectionRange(beforeStart, beforeEnd);
}
};
}
let field;
let getsBulkEditHtmlLabel = true;
if (type === "checkbox")
@ -102,7 +162,7 @@ function QDynamicFormField({
else if (type === "ace")
{
let mode = "text";
if(formFieldObject && formFieldObject.languageMode)
if (formFieldObject && formFieldObject.languageMode)
{
mode = formFieldObject.languageMode;
}
@ -133,7 +193,7 @@ function QDynamicFormField({
{
field = (
<>
<Field {...rest} 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}
onKeyPress={(e: any) =>
{
if (e.key === "Enter")
@ -173,7 +233,8 @@ function QDynamicFormField({
id={`bulkEditSwitch-${name}`}
checked={switchChecked}
onClick={bulkEditSwitchChanged}
sx={{top: "-4px",
sx={{
top: "-4px",
"& .MuiSwitch-track": {
height: 20,
borderRadius: 10,

View File

@ -22,6 +22,7 @@
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 {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
import * as Yup from "yup";
@ -129,18 +130,11 @@ class DynamicFormUtils
if (effectivelyIsRequired)
{
if (field.possibleValueSourceName)
{
////////////////////////////////////////////////////////////////////////////////////////////
// the "nullable(true)" here doesn't mean that you're allowed to set the field to null... //
// rather, it's more like "null is how empty will be treated" or some-such... //
////////////////////////////////////////////////////////////////////////////////////////////
return (Yup.string().required(`${field.label} is required.`).nullable(true));
}
else
{
return (Yup.string().required(`${field.label} is required.`));
}
////////////////////////////////////////////////////////////////////////////////////////////
// the "nullable(true)" here doesn't mean that you're allowed to set the field to null... //
// rather, it's more like "null is how empty will be treated" or some-such... //
////////////////////////////////////////////////////////////////////////////////////////////
return (Yup.string().required(`${field.label ?? "This field"} is required.`).nullable(true));
}
return (null);
}
@ -155,35 +149,49 @@ class DynamicFormUtils
{
const field = qFields[i];
if(!dynamicFormFields[field.name])
{
continue;
}
/////////////////////////////////////////
// add props for possible value fields //
/////////////////////////////////////////
if (field.possibleValueSourceName && dynamicFormFields[field.name])
if (field.possibleValueSourceName || field.inlinePossibleValueSource)
{
let initialDisplayValue = null;
let props: FieldPossibleValueProps =
{
isPossibleValue: true,
fieldName: field.name,
initialDisplayValue: null
}
if (displayValues)
{
initialDisplayValue = displayValues.get(field.name);
props.initialDisplayValue = displayValues.get(field.name);
}
if (tableName)
if(field.inlinePossibleValueSource)
{
dynamicFormFields[field.name].possibleValueProps =
{
isPossibleValue: true,
tableName: tableName,
initialDisplayValue: initialDisplayValue,
};
//////////////////////////////////////////////////////////////////////
// handle an inline PVS - which is a list of possible value objects //
//////////////////////////////////////////////////////////////////////
props.possibleValues = field.inlinePossibleValueSource;
}
else if (tableName)
{
props.tableName = tableName;
}
else if (processName)
{
props.processName = processName;
}
else
{
dynamicFormFields[field.name].possibleValueProps =
{
isPossibleValue: true,
processName: processName,
initialDisplayValue: initialDisplayValue,
};
props.possibleValueSourceName = field.possibleValueSourceName;
}
dynamicFormFields[field.name].possibleValueProps = props;
}
}
}
@ -202,7 +210,7 @@ class DynamicFormUtils
if (Array.isArray(disabledFields))
{
return (disabledFields.indexOf(fieldName) > -1)
return (disabledFields.indexOf(fieldName) > -1);
}
else
{
@ -210,6 +218,44 @@ class DynamicFormUtils
}
}
/***************************************************************************
* check if a field has the TO_UPPER_CASE behavior on it.
***************************************************************************/
public static isToUpperCase(fieldMetaData: QFieldMetaData): boolean
{
return this.hasBehavior(fieldMetaData, "TO_UPPER_CASE");
}
/***************************************************************************
* check if a field has the TO_LOWER_CASE behavior on it.
***************************************************************************/
public static isToLowerCase(fieldMetaData: QFieldMetaData): boolean
{
return this.hasBehavior(fieldMetaData, "TO_LOWER_CASE");
}
/***************************************************************************
* check if a field has a specific behavior name on it.
***************************************************************************/
private static hasBehavior(fieldMetaData: QFieldMetaData, behaviorName: string): boolean
{
if (fieldMetaData && fieldMetaData.behaviors)
{
for (let i = 0; i < fieldMetaData.behaviors.length; i++)
{
if (fieldMetaData.behaviors[i] == behaviorName)
{
return (true);
}
}
}
return (false);
}
}
export default DynamicFormUtils;

View File

@ -30,20 +30,17 @@ import TextField from "@mui/material/TextField";
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";
interface Props
{
tableName?: string;
processName?: string;
fieldName?: string;
possibleValueSourceName?: string;
fieldPossibleValueProps: FieldPossibleValueProps;
overrideId?: string;
fieldLabel: string;
inForm: boolean;
initialValue?: any;
initialDisplayValue?: string;
initialValues?: QPossibleValue[];
onChange?: any;
isEditable?: boolean;
@ -53,16 +50,12 @@ interface Props
otherValues?: Map<string, any>;
variant: "standard" | "outlined";
initiallyOpen: boolean;
useCase: "form" | "filter";
}
DynamicSelect.defaultProps = {
tableName: null,
processName: null,
fieldName: null,
possibleValueSourceName: null,
inForm: true,
initialValue: null,
initialDisplayValue: null,
initialValues: undefined,
onChange: null,
isEditable: true,
@ -97,51 +90,49 @@ export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
borderColor: inputBorderColor
}
});
}
};
const qController = Client.getInstance();
function DynamicSelect({tableName, processName, fieldName, possibleValueSourceName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen}: Props)
function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm, initialValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen, useCase}: Props)
{
const {fieldName, initialDisplayValue, possibleValueSourceName, possibleValues, processName, tableName} = fieldPossibleValueProps;
const [open, setOpen] = useState(initiallyOpen);
const [options, setOptions] = useState<readonly QPossibleValue[]>([]);
const [searchTerm, setSearchTerm] = useState(null);
const [firstRender, setFirstRender] = useState(true);
const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues))))
const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues))));
useEffect(() =>
{
if(tableName && processName)
if (tableName && processName)
{
console.log("DynamicSelect - you may not provide both a tableName and a processName")
console.log("DynamicSelect - you may not provide both a tableName and a processName");
}
if(tableName && !fieldName)
if (tableName && !fieldName)
{
console.log("DynamicSelect - if you provide a tableName, you must also provide a fieldName");
}
if(processName && !fieldName)
if (processName && !fieldName)
{
console.log("DynamicSelect - if you provide a processName, you must also provide a fieldName");
}
if(fieldName && possibleValueSourceName)
{
console.log("DynamicSelect - if you provide a fieldName and a possibleValueSourceName, the possibleValueSourceName will be ignored");
}
if(!fieldName && !possibleValueSourceName)
if (!fieldName && !possibleValueSourceName)
{
console.log("DynamicSelect - you must provide either a fieldName (and a tableName or processName) or a possibleValueSourceName");
}
if(fieldName)
if (fieldName && !possibleValueSourceName)
{
if(!tableName || !processName)
if (!tableName || !processName)
{
console.log("DynamicSelect - if you provide a fieldName, you must also provide a tableName or processName");
console.log("DynamicSelect - if you provide a fieldName and not a possibleValueSourceName, then you must also provide a tableName or processName");
}
}
if(possibleValueSourceName)
if (possibleValueSourceName)
{
if(tableName || processName)
if (tableName || processName)
{
console.log("DynamicSelect - if you provide a possibleValueSourceName, you should not also provide a tableName or processName");
}
@ -175,9 +166,38 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
setFieldValueRef = setFieldValue;
}
/*******************************************************************************
**
*******************************************************************************/
const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] =>
{
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm));
}
/***************************************************************************
**
***************************************************************************/
const loadResults = async (): Promise<QPossibleValue[]> =>
{
if(possibleValues)
{
return filterInlinePossibleValues(searchTerm, possibleValues)
}
else
{
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues, useCase);
}
}
/***************************************************************************
**
***************************************************************************/
useEffect(() =>
{
if(firstRender)
if (firstRender)
{
// console.log("First render, so not searching...");
setFirstRender(false);
@ -198,9 +218,9 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
(async () =>
{
// console.log(`doing a search with ${searchTerm}`);
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, fieldName ?? possibleValueSourceName, searchTerm ?? "", null, otherValues);
const results: QPossibleValue[] = await loadResults();
if(tableMetaData == null && tableName)
if (tableMetaData == null && tableName)
{
let tableMetaData: QTableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
@ -211,7 +231,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
// console.log(`${results}`);
if (active)
{
setOptions([ ...results ]);
setOptions([...results]);
}
})();
@ -219,50 +239,67 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
{
active = false;
};
}, [ searchTerm ]);
}, [searchTerm]);
// todo - finish... call it in onOpen?
/***************************************************************************
** todo - finish... call it in onOpen?
***************************************************************************/
const reloadIfOtherValuesAreChanged = () =>
{
if(JSON.stringify(Object.fromEntries(otherValues)) != otherValuesWhenResultsWereLoaded)
if (JSON.stringify(Object.fromEntries(otherValues)) != otherValuesWhenResultsWereLoaded)
{
(async () =>
{
setLoading(true);
setOptions([]);
console.log("Refreshing possible values...");
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, fieldName ?? possibleValueSourceName, searchTerm ?? "", null, otherValues);
const results: QPossibleValue[] = await loadResults();
setLoading(false);
setOptions([ ...results ]);
setOptions([...results]);
setOtherValuesWhenResultsWereLoaded(JSON.stringify(Object.fromEntries(otherValues)));
})();
}
}
};
/***************************************************************************
**
***************************************************************************/
const inputChanged = (event: React.SyntheticEvent, value: string, reason: string) =>
{
// console.log(`input changed. Reason: ${reason}, setting search term to ${value}`);
if(reason !== "reset")
if (reason !== "reset")
{
// console.log(` -> setting search term to ${value}`);
setSearchTerm(value);
}
};
/***************************************************************************
**
***************************************************************************/
const handleBlur = (x: any) =>
{
setSearchTerm(null);
}
};
/***************************************************************************
**
***************************************************************************/
const handleChanged = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
{
// console.log("handleChanged. value is:");
// console.log(value);
setSearchTerm(null);
if(onChange)
if (onChange)
{
if(isMultiple)
if (isMultiple)
{
onChange(value);
}
@ -271,12 +308,16 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
onChange(value ? new QPossibleValue(value) : null);
}
}
else if(setFieldValueRef && fieldName)
else if (setFieldValueRef && fieldName)
{
setFieldValueRef(fieldName, value ? value.id : null);
}
};
/***************************************************************************
**
***************************************************************************/
const filterOptions = (options: { id: any; label: string; }[], state: FilterOptionsState<{ id: any; label: string; }>): { id: any; label: string; }[] =>
{
/////////////////////////////////////////////////////////////////////////////////
@ -284,8 +325,12 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
// get options whose text/label matches the input (e.g., not ids that match) //
/////////////////////////////////////////////////////////////////////////////////
return (options);
}
};
/***************************************************************************
**
***************************************************************************/
// @ts-ignore
const renderOption = (props: Object, option: any, {selected}) =>
{
@ -293,23 +338,24 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
try
{
const field = tableMetaData?.fields.get(fieldName)
if(field)
const field = tableMetaData?.fields.get(fieldName);
if (field)
{
const adornment = field.getAdornment(AdornmentType.CHIP);
if(adornment)
if (adornment)
{
const color = adornment.getValue("color." + option.id) ?? "default"
const color = adornment.getValue("color." + option.id) ?? "default";
const iconName = adornment.getValue("icon." + option.id) ?? null;
const iconElement = iconName ? <Icon>{iconName}</Icon> : null;
content = (<Chip label={option.label} color={color} icon={iconElement} size="small" variant="outlined" sx={{fontWeight: 500}} />);
}
}
}
catch(e)
{ }
catch (e)
{
}
if(isMultiple)
if (isMultiple)
{
content = (
<>
@ -331,8 +377,12 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
{content}
</li>
);
}
};
/***************************************************************************
**
***************************************************************************/
const bulkEditSwitchChanged = () =>
{
const newSwitchValue = !switchChecked;
@ -353,7 +403,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
const autocomplete = (
<Box>
<Autocomplete
id={overrideId ?? fieldName ?? possibleValueSourceName}
id={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"}
sx={autocompleteSX}
open={open}
fullWidth
@ -361,7 +411,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
{
setOpen(true);
// console.log("setting open...");
if(options.length == 0)
if (options.length == 0)
{
// console.log("no options yet, so setting search term to ''...");
setSearchTerm("");
@ -374,19 +424,19 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
isOptionEqualToValue={(option, value) => value !== null && value !== undefined && option.id === value.id}
getOptionLabel={(option) =>
{
if(option === null || option === undefined)
if (option === null || option === undefined)
{
return ("");
}
// @ts-ignore
if(option && option.length)
if (option && option.length)
{
// @ts-ignore
option = option[0];
}
// @ts-ignore
return option.label
return option.label;
}}
options={options}
loading={loading}
@ -433,7 +483,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
inForm &&
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={fieldName ?? possibleValueSourceName} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
</MDTypography>
</Box>
}
@ -450,7 +500,8 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
id={`bulkEditSwitch-${fieldName}`}
checked={switchChecked}
onClick={bulkEditSwitchChanged}
sx={{top: "-4px",
sx={{
top: "-4px",
"& .MuiSwitch-track": {
height: 20,
borderRadius: 10,
@ -469,7 +520,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
else
{
return (
<Box mb={1.5}>
<Box>
{autocomplete}
</Box>
);

View File

@ -43,9 +43,10 @@ import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
import DynamicFormWidget from "qqq/components/widgets/misc/DynamicFormWidget";
import FilterAndColumnsSetupWidget from "qqq/components/widgets/misc/FilterAndColumnsSetupWidget";
import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget";
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget";
import {FieldRule, FieldRuleAction, FieldRuleTrigger} from "qqq/models/fields/FieldRules";
import HtmlUtils from "qqq/utils/HtmlUtils";
import Client from "qqq/utils/qqq/Client";
@ -87,7 +88,7 @@ EntityForm.defaultProps = {
////////////////////////////////////////////////////////////////////////////
let formikSetFieldValueFunction = (field: string, value: any, shouldValidate?: boolean): void =>
{
}
};
function EntityForm(props: Props): JSX.Element
{
@ -118,11 +119,12 @@ function EntityForm(props: Props): JSX.Element
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [showEditChildForm, setShowEditChildForm] = useState(null as any);
const [modalDataChangedCounter, setModalDataChangedCount] = useState(0);
const [notAllowedError, setNotAllowedError] = useState(null as string);
const [formValuesJSON, setFormValuesJSON] = useState("");
const [formValues, setFormValues] = useState({} as {[name: string]: any});
const [formValues, setFormValues] = useState({} as { [name: string]: any });
const {pageHeader, setPageHeader} = useContext(QContext);
@ -203,7 +205,7 @@ function EntityForm(props: Props): JSX.Element
/*******************************************************************************
**
*******************************************************************************/
const deleteChildRecord = (name: string, widgetData: any, rowIndex: number) =>
function deleteChildRecord(name: string, widgetData: any, rowIndex: number)
{
updateChildRecordList(name, "delete", rowIndex);
};
@ -281,6 +283,8 @@ function EntityForm(props: Props): JSX.Element
setRenderedWidgetSections(newRenderedWidgetSections);
forceUpdate();
setModalDataChangedCount(modalDataChangedCounter + 1);
setShowEditChildForm(null);
}
@ -290,7 +294,7 @@ function EntityForm(props: Props): JSX.Element
*******************************************************************************/
useEffect(() =>
{
const newRenderedWidgetSections: {[name: string]: JSX.Element} = {};
const newRenderedWidgetSections: { [name: string]: JSX.Element } = {};
for (let widgetName in renderedWidgetSections)
{
const widgetMetaData = metaData.widgets.get(widgetName);
@ -350,12 +354,11 @@ function EntityForm(props: Props): JSX.Element
}
/*******************************************************************************
** if we have a widget that wants to set form-field values, they can take this
** function in as a callback, and then call it with their values.
*******************************************************************************/
function setFormFieldValuesFromWidget(values: {[name: string]: any})
function setFormFieldValuesFromWidget(values: { [name: string]: any })
{
for (let key in values)
{
@ -369,13 +372,13 @@ function EntityForm(props: Props): JSX.Element
*******************************************************************************/
function getWidgetSection(widgetMetaData: QWidgetMetaData, widgetData: any): JSX.Element
{
if(widgetMetaData.type == "childRecordList")
if (widgetMetaData.type == "childRecordList")
{
widgetData.viewAllLink = null;
widgetMetaData.showExportButton = false;
return <RecordGridWidget
key={new Date().getTime()} // added so that editing values actually re-renders...
return Object.keys(childListWidgetData).length > 0 && (<RecordGridWidget
key={`${formValues["tableName"]}-${modalDataChangedCounter}`}
widgetMetaData={widgetMetaData}
data={widgetData}
disableRowClick
@ -384,21 +387,31 @@ function EntityForm(props: Props): JSX.Element
addNewRecordCallback={() => openAddChildRecord(widgetMetaData.name, widgetData)}
editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData, rowIndex)}
deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, widgetData, rowIndex)}
/>;
/>);
}
if(widgetMetaData.type == "reportSetup")
if (widgetMetaData.type == "filterAndColumnsSetup")
{
return <ReportSetupWidget
/////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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;
}
return <FilterAndColumnsSetupWidget
key={formValues["tableName"]} // todo, is this good? it was added so that editing values actually re-renders...
isEditable={true}
widgetMetaData={widgetMetaData}
widgetData={widgetData}
recordValues={formValues}
onSaveCallback={setFormFieldValuesFromWidget}
/>
/>;
}
if(widgetMetaData.type == "pivotTableSetup")
if (widgetMetaData.type == "pivotTableSetup")
{
return <PivotTableSetupWidget
key={formValues["tableName"]} // todo, is this good? it was added so that editing values actually re-renders...
@ -406,10 +419,23 @@ function EntityForm(props: Props): JSX.Element
widgetMetaData={widgetMetaData}
recordValues={formValues}
onSaveCallback={setFormFieldValuesFromWidget}
/>
/>;
}
return (<Box>Unsupported widget type: {widgetMetaData.type}</Box>)
if (widgetMetaData.type == "dynamicForm")
{
return <DynamicFormWidget
key={formValues["savedReportId"]} // todo - pull this from the metaData (could do so above too...)
isEditable={true}
widgetMetaData={widgetMetaData}
widgetData={widgetData}
recordValues={formValues}
record={record}
onSaveCallback={setFormFieldValuesFromWidget}
/>;
}
return (<Box>Unsupported widget type: {widgetMetaData.type}</Box>);
}
@ -435,12 +461,12 @@ function EntityForm(props: Props): JSX.Element
function setupFieldRules(tableMetaData: QTableMetaData)
{
const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
if(!mdbMetaData)
if (!mdbMetaData)
{
return;
}
if(mdbMetaData.fieldRules)
if (mdbMetaData.fieldRules)
{
const newFieldRules: FieldRule[] = [];
for (let i = 0; i < mdbMetaData.fieldRules.length; i++)
@ -455,83 +481,164 @@ function EntityForm(props: Props): JSX.Element
//////////////////
// initial load //
//////////////////
if (!asyncLoadInited)
useEffect(() =>
{
setAsyncLoadInited(true);
(async () =>
if (!asyncLoadInited)
{
const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
recordAnalytics({location: window.location, title: (props.isCopy ? "Copy" : props.id ? "Edit" : "New") + ": " + tableMetaData.label});
setupFieldRules(tableMetaData);
const metaData = await qController.loadMetaData();
setMetaData(metaData);
/////////////////////////////////////////////////
// define the sections, e.g., for the left-bar //
/////////////////////////////////////////////////
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) =>
setAsyncLoadInited(true);
(async () =>
{
const widget = metaData.widgets.get(section.widgetName);
if(widget)
const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
recordAnalytics({location: window.location, title: (props.isCopy ? "Copy" : props.id ? "Edit" : "New") + ": " + tableMetaData.label});
setupFieldRules(tableMetaData);
const metaData = await qController.loadMetaData();
setMetaData(metaData);
/////////////////////////////////////////////////
// define the sections, e.g., for the left-bar //
/////////////////////////////////////////////////
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) =>
{
if(widget.type == "childRecordList" && widget.defaultValues?.has("manageAssociationName"))
const widget = metaData?.widgets?.get(section.widgetName);
if (widget)
{
return (true);
if (widget.type == "childRecordList" && widget.defaultValues?.has("manageAssociationName"))
{
return (true);
}
if (widget.type == "filterAndColumnsSetup" || widget.type == "pivotTableSetup" || widget.type == "dynamicForm")
{
return (true);
}
}
if(widget.type == "reportSetup" || widget.type == "pivotTableSetup")
{
return (true);
}
}
return (false);
});
setTableSections(tableSections);
return (false);
});
setTableSections(tableSections);
const fieldArray = [] as QFieldMetaData[];
const sortedKeys = [...tableMetaData.fields.keys()].sort();
sortedKeys.forEach((key) =>
{
const fieldMetaData = tableMetaData.fields.get(key);
fieldArray.push(fieldMetaData);
});
/////////////////////////////////////////////////////////////////////////////////////////
// if doing an edit or copy, fetch the record and pre-populate the form values from it //
/////////////////////////////////////////////////////////////////////////////////////////
let record: QRecord = null;
let defaultDisplayValues = new Map<string, string>();
if (props.id !== null)
{
record = await qController.get(tableName, props.id);
setRecord(record);
recordAnalytics({category: "tableEvents", action: props.isCopy ? "copy" : "edit", label: tableMetaData?.label + " / " + record?.recordLabel});
const titleVerb = props.isCopy ? "Copy" : "Edit";
setFormTitle(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
if (!props.isModal)
const fieldArray = [] as QFieldMetaData[];
const sortedKeys = [...tableMetaData.fields.keys()].sort();
sortedKeys.forEach((key) =>
{
setPageHeader(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
}
tableMetaData.fields.forEach((fieldMetaData, key) =>
{
if (props.isCopy && fieldMetaData.name == tableMetaData.primaryKeyField)
{
return;
}
initialValues[key] = record.values.get(key);
const fieldMetaData = tableMetaData.fields.get(key);
fieldArray.push(fieldMetaData);
});
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// these checks are only for updating records, if copying, it is actually an insert, which is checked after this block //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (!props.isCopy)
/////////////////////////////////////////////////////////////////////////////////////////
// if doing an edit or copy, fetch the record and pre-populate the form values from it //
/////////////////////////////////////////////////////////////////////////////////////////
let record: QRecord = null;
let defaultDisplayValues = new Map<string, string>();
if (props.id !== null)
{
record = await qController.get(tableName, props.id);
setRecord(record);
recordAnalytics({category: "tableEvents", action: props.isCopy ? "copy" : "edit", label: tableMetaData?.label + " / " + record?.recordLabel});
const titleVerb = props.isCopy ? "Copy" : "Edit";
setFormTitle(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
if (!props.isModal)
{
setPageHeader(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
}
tableMetaData.fields.forEach((fieldMetaData, key) =>
{
if (props.isCopy && fieldMetaData.name == tableMetaData.primaryKeyField)
{
return;
}
initialValues[key] = record.values.get(key);
});
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// these checks are only for updating records, if copying, it is actually an insert, which is checked after this block //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (!props.isCopy)
{
if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE))
{
setNotAllowedError("Records may not be edited in this table");
}
else if (!tableMetaData.editPermission)
{
setNotAllowedError(`You do not have permission to edit ${tableMetaData.label} records`);
}
}
}
else
{
///////////////////////////////////////////
// else handle preparing to do an insert //
///////////////////////////////////////////
setFormTitle(`Creating New ${tableMetaData?.label}`);
recordAnalytics({category: "tableEvents", action: "new", label: tableMetaData?.label});
if (!props.isModal)
{
setPageHeader(`Creating New ${tableMetaData?.label}`);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// if default values were supplied for a new record, then populate initialValues, for formik. //
////////////////////////////////////////////////////////////////////////////////////////////////
for (let i = 0; i < fieldArray.length; i++)
{
const fieldMetaData = fieldArray[i];
const fieldName = fieldMetaData.name;
const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue;
if (defaultValue)
{
initialValues[fieldName] = defaultValue;
///////////////////////////////////////////////////////////////////////////////////////////
// we need to set the initialDisplayValue for possible value fields with a default value //
// so, look them up here now if needed //
///////////////////////////////////////////////////////////////////////////////////////////
if (fieldMetaData.possibleValueSourceName)
{
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], undefined, "form");
if (results && results.length > 0)
{
defaultDisplayValues.set(fieldName, results[0].label);
}
}
}
}
}
///////////////////////////////////////////////////
// if an override heading was passed in, use it. //
///////////////////////////////////////////////////
if (props.overrideHeading)
{
setFormTitle(props.overrideHeading);
if (!props.isModal)
{
setPageHeader(props.overrideHeading);
}
}
//////////////////////////////////////
// check capabilities & permissions //
//////////////////////////////////////
if (props.isCopy || !props.id)
{
if (!tableMetaData.capabilities.has(Capability.TABLE_INSERT))
{
setNotAllowedError("Records may not be created in this table");
}
else if (!tableMetaData.insertPermission)
{
setNotAllowedError(`You do not have permission to create ${tableMetaData.label} records`);
}
}
else
{
if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE))
{
@ -542,200 +649,123 @@ function EntityForm(props: Props): JSX.Element
setNotAllowedError(`You do not have permission to edit ${tableMetaData.label} records`);
}
}
}
else
{
///////////////////////////////////////////
// else handle preparing to do an insert //
///////////////////////////////////////////
setFormTitle(`Creating New ${tableMetaData?.label}`);
recordAnalytics({category: "tableEvents", action: "new", label: tableMetaData?.label});
if (!props.isModal)
{
setPageHeader(`Creating New ${tableMetaData?.label}`);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// if default values were supplied for a new record, then populate initialValues, for formik. //
////////////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////
// make sure all initialValues are properly formatted for the form //
/////////////////////////////////////////////////////////////////////
for (let i = 0; i < fieldArray.length; i++)
{
const fieldMetaData = fieldArray[i];
const fieldName = fieldMetaData.name;
const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue;
if (defaultValue)
if (fieldMetaData.type == QFieldType.DATE_TIME && initialValues[fieldMetaData.name])
{
initialValues[fieldName] = defaultValue;
initialValues[fieldMetaData.name] = ValueUtils.formatDateTimeValueForForm(initialValues[fieldMetaData.name]);
}
}
///////////////////////////////////////////////////////////////////////////////////////////
// we need to set the initialDisplayValue for possible value fields with a default value //
// so, look them up here now if needed //
///////////////////////////////////////////////////////////////////////////////////////////
if (fieldMetaData.possibleValueSourceName)
setInitialValues(initialValues);
/////////////////////////////////////////////////////////
// get formField and formValidation objects for Formik //
/////////////////////////////////////////////////////////
const {
dynamicFormFields,
formValidations,
} = DynamicFormUtils.getFormData(fieldArray, disabledFields);
DynamicFormUtils.addPossibleValueProps(dynamicFormFields, fieldArray, tableName, null, record ? record.displayValues : defaultDisplayValues);
/////////////////////////////////////
// group the formFields by section //
/////////////////////////////////////
const dynamicFormFieldsBySection = new Map<string, any>();
let t1sectionName;
let t1section;
const nonT1Sections: QTableSection[] = [];
const newRenderedWidgetSections: { [name: string]: JSX.Element } = {};
const newChildListWidgetData: { [name: string]: ChildRecordListData } = {};
for (let i = 0; i < tableSections.length; i++)
{
const section = tableSections[i];
const sectionDynamicFormFields: any[] = [];
if (section.isHidden)
{
continue;
}
const hasFields = section.fieldNames && section.fieldNames.length > 0;
if (hasFields)
{
for (let j = 0; j < section.fieldNames.length; j++)
{
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]]);
if (results && results.length > 0)
const fieldName = section.fieldNames[j];
const field = tableMetaData.fields.get(fieldName);
if (!field)
{
defaultDisplayValues.set(fieldName, results[0].label);
console.log(`Omitting un-found field ${fieldName} from form`);
continue;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if id !== null (and we're not copying) - means we're on the edit screen -- show all fields on the edit screen. //
// || (or) we're on the insert screen in which case, only show editable fields. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if ((props.id !== null && !props.isCopy) || field.isEditable)
{
sectionDynamicFormFields.push(dynamicFormFields[fieldName]);
}
}
}
}
}
///////////////////////////////////////////////////
// if an override heading was passed in, use it. //
///////////////////////////////////////////////////
if (props.overrideHeading)
{
setFormTitle(props.overrideHeading);
if (!props.isModal)
{
setPageHeader(props.overrideHeading);
}
}
//////////////////////////////////////
// check capabilities & permissions //
//////////////////////////////////////
if (props.isCopy || !props.id)
{
if (!tableMetaData.capabilities.has(Capability.TABLE_INSERT))
{
setNotAllowedError("Records may not be created in this table");
}
else if (!tableMetaData.insertPermission)
{
setNotAllowedError(`You do not have permission to create ${tableMetaData.label} records`);
}
}
else
{
if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE))
{
setNotAllowedError("Records may not be edited in this table");
}
else if (!tableMetaData.editPermission)
{
setNotAllowedError(`You do not have permission to edit ${tableMetaData.label} records`);
}
}
/////////////////////////////////////////////////////////////////////
// make sure all initialValues are properly formatted for the form //
/////////////////////////////////////////////////////////////////////
for (let i = 0; i < fieldArray.length; i++)
{
const fieldMetaData = fieldArray[i];
if (fieldMetaData.type == QFieldType.DATE_TIME && initialValues[fieldMetaData.name])
{
initialValues[fieldMetaData.name] = ValueUtils.formatDateTimeValueForForm(initialValues[fieldMetaData.name]);
}
}
setInitialValues(initialValues);
/////////////////////////////////////////////////////////
// get formField and formValidation objects for Formik //
/////////////////////////////////////////////////////////
const {
dynamicFormFields,
formValidations,
} = DynamicFormUtils.getFormData(fieldArray, disabledFields);
DynamicFormUtils.addPossibleValueProps(dynamicFormFields, fieldArray, tableName, null, record ? record.displayValues : defaultDisplayValues);
/////////////////////////////////////
// group the formFields by section //
/////////////////////////////////////
const dynamicFormFieldsBySection = new Map<string, any>();
let t1sectionName;
let t1section;
const nonT1Sections: QTableSection[] = [];
const newRenderedWidgetSections: { [name: string]: JSX.Element } = {};
const newChildListWidgetData: { [name: string]: ChildRecordListData } = {};
for (let i = 0; i < tableSections.length; i++)
{
const section = tableSections[i];
const sectionDynamicFormFields: any[] = [];
if (section.isHidden)
{
continue;
}
const hasFields = section.fieldNames && section.fieldNames.length > 0;
if(hasFields)
{
for (let j = 0; j < section.fieldNames.length; j++)
{
const fieldName = section.fieldNames[j];
const field = tableMetaData.fields.get(fieldName);
if (!field)
if (sectionDynamicFormFields.length === 0)
{
console.log(`Omitting un-found field ${fieldName} from form`);
////////////////////////////////////////////////////////////////////////////////////////////////
// in case there are no active fields in this section, remove it from the tableSections array //
////////////////////////////////////////////////////////////////////////////////////////////////
tableSections.splice(i, 1);
i--;
continue;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if id !== null (and we're not copying) - means we're on the edit screen -- show all fields on the edit screen. //
// || (or) we're on the insert screen in which case, only show editable fields. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if ((props.id !== null && !props.isCopy) || field.isEditable)
else
{
sectionDynamicFormFields.push(dynamicFormFields[fieldName]);
dynamicFormFieldsBySection.set(section.name, sectionDynamicFormFields);
}
}
if (sectionDynamicFormFields.length === 0)
{
////////////////////////////////////////////////////////////////////////////////////////////////
// in case there are no active fields in this section, remove it from the tableSections array //
////////////////////////////////////////////////////////////////////////////////////////////////
tableSections.splice(i, 1);
i--;
continue;
}
else
{
dynamicFormFieldsBySection.set(section.name, sectionDynamicFormFields);
const widgetMetaData = metaData?.widgets.get(section.widgetName);
const widgetData = await qController.widget(widgetMetaData.name, makeQueryStringWithIdAndObject(tableMetaData, defaultValues));
newRenderedWidgetSections[section.widgetName] = getWidgetSection(widgetMetaData, widgetData);
newChildListWidgetData[section.widgetName] = widgetData;
}
//////////////////////////////////////
// capture the tier1 section's name //
//////////////////////////////////////
if (section.tier === "T1")
{
t1sectionName = section.name;
t1section = section;
}
else
{
nonT1Sections.push(section);
}
}
else
{
const widgetMetaData = metaData.widgets.get(section.widgetName);
const widgetData = await qController.widget(widgetMetaData.name, props.id ? `${tableMetaData.primaryKeyField}=${props.id}` : "");
newRenderedWidgetSections[section.widgetName] = getWidgetSection(widgetMetaData, widgetData);
newChildListWidgetData[section.widgetName] = widgetData;
}
//////////////////////////////////////
// capture the tier1 section's name //
//////////////////////////////////////
if (section.tier === "T1")
{
t1sectionName = section.name;
t1section = section;
}
else
{
nonT1Sections.push(section);
}
}
setT1SectionName(t1sectionName);
setT1Section(t1section);
setNonT1Sections(nonT1Sections);
setFormFields(dynamicFormFieldsBySection);
setValidations(Yup.object().shape(formValidations));
setRenderedWidgetSections(newRenderedWidgetSections);
setChildListWidgetData(newChildListWidgetData);
setT1SectionName(t1sectionName);
setT1Section(t1section);
setNonT1Sections(nonT1Sections);
setFormFields(dynamicFormFieldsBySection);
setValidations(Yup.object().shape(formValidations));
setRenderedWidgetSections(newRenderedWidgetSections);
setChildListWidgetData(newChildListWidgetData);
forceUpdate();
})();
}
forceUpdate();
})();
}
}, []);
//////////////////////////////////////////////////////////////////
@ -788,9 +818,9 @@ function EntityForm(props: Props): JSX.Element
{
actions.setSubmitting(true);
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if there's a callback (e.g., for a modal nested on another create/edit screen), then just pass our data back there anre return. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if there's a callback (e.g., for a modal nested on another create/edit screen), then just pass our data back there and return. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (props.onSubmitCallback)
{
props.onSubmitCallback(values);
@ -855,16 +885,28 @@ function EntityForm(props: Props): JSX.Element
let haveAssociationsToPost = false;
for (let name of Object.keys(childListWidgetData))
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if cannot find association name, continue loop, since cannot tell backend which association this is for //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
const manageAssociationName = metaData.widgets.get(name)?.defaultValues?.get("manageAssociationName");
if (!manageAssociationName)
{
console.log(`Cannot send association data to backend - missing a manageAssociationName defaultValue in widget meta data for widget name ${name}`);
continue;
}
associationsToPost[manageAssociationName] = [];
haveAssociationsToPost = true;
for (let i = 0; i < childListWidgetData[name].queryOutput?.records?.length; i++)
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if the records array exists, add to associations to post - note: even if empty list, the backend will expect this //
// association name to be present if it is to act on it (for the case when all associations have been deleted) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (childListWidgetData[name].queryOutput.records)
{
associationsToPost[manageAssociationName].push(childListWidgetData[name].queryOutput.records[i].values);
associationsToPost[manageAssociationName] = [];
haveAssociationsToPost = true;
for (let i = 0; i < childListWidgetData[name].queryOutput?.records?.length; i++)
{
associationsToPost[manageAssociationName].push(childListWidgetData[name].queryOutput.records[i].values);
}
}
}
if (haveAssociationsToPost)
@ -912,7 +954,7 @@ function EntityForm(props: Props): JSX.Element
else
{
setAlertContent(error.message);
HtmlUtils.autoScroll(0);
scrollToTopToShowAlert();
}
});
}
@ -958,7 +1000,7 @@ function EntityForm(props: Props): JSX.Element
else
{
setAlertContent(error.message);
HtmlUtils.autoScroll(0);
scrollToTopToShowAlert();
}
});
}
@ -966,14 +1008,75 @@ function EntityForm(props: Props): JSX.Element
};
/*******************************************************************************
**
*******************************************************************************/
function scrollToTopToShowAlert()
{
if (props.isModal)
{
document.getElementById("modalTopReference")?.scrollIntoView();
}
else
{
HtmlUtils.autoScroll(0);
}
}
/*******************************************************************************
**
*******************************************************************************/
function makeQueryStringWithIdAndObject(tableMetaData: QTableMetaData, object: { [key: string]: any })
{
const queryParamsArray: string[] = [];
if (props.id)
{
queryParamsArray.push(`${tableMetaData.primaryKeyField}=${encodeURIComponent(props.id)}`);
}
if (object)
{
for (let key in object)
{
queryParamsArray.push(`${key}=${encodeURIComponent(object[key])}`);
}
}
return (queryParamsArray.join("&"));
}
/*******************************************************************************
**
*******************************************************************************/
async function reloadWidget(widgetName: string, additionalQueryParamsForWidget: { [key: string]: any })
{
const widgetData = await qController.widget(widgetName, makeQueryStringWithIdAndObject(tableMetaData, additionalQueryParamsForWidget));
const widgetMetaData = metaData.widgets.get(widgetName);
/////////////////////////////////////////////////////////////////////////////////////////////////////
// todo - rename this - it holds all widget dta, not just child-lists. also, the type is wrong... //
/////////////////////////////////////////////////////////////////////////////////////////////////////
const newChildListWidgetData: { [name: string]: ChildRecordListData } = Object.assign({}, childListWidgetData);
newChildListWidgetData[widgetName] = widgetData;
setChildListWidgetData(newChildListWidgetData);
const newRenderedWidgetSections = Object.assign({}, renderedWidgetSections);
newRenderedWidgetSections[widgetName] = getWidgetSection(widgetMetaData, widgetData);
setRenderedWidgetSections(newRenderedWidgetSections);
forceUpdate();
}
/*******************************************************************************
** process a form-field having a changed value (e.g., apply field rules).
*******************************************************************************/
function handleChangedFieldValue(fieldName: string, oldValue: any, newValue: any, valueChangesToMake: {[fieldName: string]: any})
function handleChangedFieldValue(fieldName: string, oldValue: any, newValue: any, valueChangesToMake: { [fieldName: string]: any })
{
for (let fieldRule of fieldRules)
{
if(fieldRule.trigger == FieldRuleTrigger.ON_CHANGE && fieldRule.sourceField == fieldName)
if (fieldRule.trigger == FieldRuleTrigger.ON_CHANGE && fieldRule.sourceField == fieldName)
{
switch (fieldRule.action)
{
@ -981,6 +1084,10 @@ function EntityForm(props: Props): JSX.Element
console.log(`Clearing value from [${fieldRule.targetField}] due to change in [${fieldName}]`);
valueChangesToMake[fieldRule.targetField] = null;
break;
case FieldRuleAction.RELOAD_WIDGET:
const additionalQueryParamsForWidget: { [key: string]: any } = {};
additionalQueryParamsForWidget[fieldRule.sourceField] = newValue;
reloadWidget(fieldRule.targetWidget, additionalQueryParamsForWidget);
}
}
}
@ -1045,11 +1152,11 @@ function EntityForm(props: Props): JSX.Element
<Grid container spacing={3}>
{
!props.isModal &&
<Grid item xs={12} lg={3}>
<Grid item xs={12} lg={3} className="recordSidebar">
<QRecordSidebar tableSections={tableSections} />
</Grid>
}
<Grid item xs={12} lg={props.isModal ? 12 : 9}>
<Grid item xs={12} lg={props.isModal ? 12 : 9} className={props.isModal ? "" : "recordWithSidebar"}>
<Formik
initialValues={initialValues}
@ -1068,21 +1175,21 @@ function EntityForm(props: Props): JSX.Element
/////////////////////////////////////////////////
// if we have values from formik, look at them //
/////////////////////////////////////////////////
if(values)
if (values)
{
////////////////////////////////////////////////////////////////////////
// use stringified values as cheap/easy way to see if any are changed //
////////////////////////////////////////////////////////////////////////
const newFormValuesJSON = JSON.stringify(values);
if(formValuesJSON != newFormValuesJSON)
if (formValuesJSON != newFormValuesJSON)
{
const valueChangesToMake: {[fieldName: string]: any} = {};
const valueChangesToMake: { [fieldName: string]: any } = {};
////////////////////////////////////////////////////////////////////
// if the form is dirty (e.g., we're not doing the initial load), //
// then process rules for any changed fields //
////////////////////////////////////////////////////////////////////
if(dirty)
if (dirty)
{
for (let fieldName in values)
{
@ -1114,7 +1221,7 @@ function EntityForm(props: Props): JSX.Element
setFieldValue(fieldName, valueChangesToMake[fieldName], false);
}
setFormValues(formValues)
setFormValues(formValues);
setFormValuesJSON(JSON.stringify(values));
}
}
@ -1183,7 +1290,7 @@ function EntityForm(props: Props): JSX.Element
table={showEditChildForm.table}
defaultValues={showEditChildForm.defaultValues}
disabledFields={showEditChildForm.disabledFields}
onSubmitCallback={submitEditChildForm}
onSubmitCallback={props.onSubmitCallback ? props.onSubmitCallback : submitEditChildForm}
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
/>
</div>
@ -1201,6 +1308,7 @@ function EntityForm(props: Props): JSX.Element
return (
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
<Card sx={{my: 5, mx: "auto", p: 6, pb: 0, maxWidth: "1024px"}}>
<span id="modalTopReference"></span>
{body}
</Card>
</Box>

View File

@ -63,7 +63,7 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
///////////////////////////////////////////////////////////////////////
// strip away empty elements of the route (e.g., trailing slash(es)) //
///////////////////////////////////////////////////////////////////////
if(route.length)
if (route.length)
{
// @ts-ignore
route = route.filter(r => r != "");
@ -74,18 +74,18 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
const fullPathToLabel = (fullPath: string, route: string): string =>
{
if(fullPath.endsWith("/"))
if (fullPath.endsWith("/"))
{
fullPath = fullPath.replace(/\/+$/, "");
}
if(pathToLabelMap && pathToLabelMap[fullPath])
if (pathToLabelMap && pathToLabelMap[fullPath])
{
return pathToLabelMap[fullPath];
}
return (routeToLabel(route));
}
};
let pageTitle = branding?.appName ?? "";
const fullRoutes: string[] = [];
@ -94,21 +94,24 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
{
////////////////////////////////////////////////////////
// avoid showing "saved view" as a breadcrumb element //
// e.g., if at /app/table/savedView/1 //
////////////////////////////////////////////////////////
if(routes[i] === "savedView")
if (routes[i] === "savedView" && i == routes.length - 1)
{
continue;
}
///////////////////////////////////////////////////////////////////////
// avoid showing the table name if it's the element before savedView //
// e.g., when at /app/table/savedView/1 (so where i==1) //
// we want to just be showing "App" //
///////////////////////////////////////////////////////////////////////
if(i < routes.length - 1 && routes[i+1] == "savedView")
if (i < routes.length - 1 && routes[i + 1] == "savedView" && i == 1)
{
continue;
}
if(routes[i] === "")
if (routes[i] === "")
{
continue;
}

View File

@ -64,13 +64,14 @@ function Footer({company, links}: Props): JSX.Element
<Box
width="100%"
display="flex"
flexDirection={{xs: "column", lg: "row"}}
flexDirection={{xs: "column", md: "row"}}
justifyContent="space-between"
alignItems="center"
px={1.5}
style={{
position: "fixed", bottom: "0px", zIndex: -1, marginBottom: "10px",
}}
left={{xs: "0", xl: "auto"}}
>
{
href && name &&

View File

@ -19,16 +19,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Popper, InputAdornment} from "@mui/material";
import {Popper, InputAdornment, Box} from "@mui/material";
import AppBar from "@mui/material/AppBar";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import {Theme} from "@mui/material/styles";
import TextField from "@mui/material/TextField";
import Toolbar from "@mui/material/Toolbar";
import React, {useContext, useEffect, useState} from "react";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
import QContext from "QContext";
import QBreadcrumbs, {routeToLabel} from "qqq/components/horseshoe/Breadcrumbs";
@ -45,7 +45,8 @@ interface Props
isMini?: boolean;
}
interface HistoryEntry {
interface HistoryEntry
{
id: number;
path: string;
label: string;
@ -64,7 +65,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const route = useLocation().pathname.split("/").slice(1);
const navigate = useNavigate();
const {pageHeader} = useContext(QContext);
const {pageHeader, setDotMenuOpen} = useContext(QContext);
useEffect(() =>
{
@ -99,7 +100,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const options = [] as any;
history.entries.reverse().forEach((entry, index) =>
options.push({label: `${entry.label} index`, id: index, key: index, path: entry.path, iconName: entry.iconName})
)
);
setHistory(options);
// Remove event listener on cleanup
@ -111,7 +112,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const goToHistory = (path: string) =>
{
navigate(path);
}
};
function buildHistoryEntries()
{
@ -119,7 +120,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const options = [] as any;
history.entries.reverse().forEach((entry, index) =>
options.push({label: entry.label, id: index, key: index, path: entry.path, iconName: entry.iconName})
)
);
setHistory(options);
}
@ -133,12 +134,12 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const handleAutocompleteOnChange = (event: any, value: any, reason: any, details: any) =>
{
if(value)
if (value)
{
goToHistory(value.path);
}
setAutocompleteValue(null);
}
};
const CustomPopper = function (props: any)
{
@ -146,8 +147,8 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
{...props}
style={{whiteSpace: "nowrap", width: "auto"}}
placement="bottom-end"
/>)
}
/>);
};
const renderHistory = () =>
{
@ -166,7 +167,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
PopperComponent={CustomPopper}
isOptionEqualToValue={(option, value) => option.id === value.id}
sx={recentlyViewedMenu}
renderInput={(params) => <TextField {...params} label="Recently Viewed Records" InputProps={{
renderInput={(params) => <TextField {...params} label="Recently Viewed Records" InputProps={{
...params.InputProps,
endAdornment: (
<InputAdornment position="end">
@ -184,7 +185,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
)}
/>
);
}
};
// Styles for the navbar icons
const iconsStyle = ({
@ -210,21 +211,34 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const {pathToLabelMap} = useContext(QContext);
const fullPathToLabel = (fullPath: string, route: string): string =>
{
if(fullPath.endsWith("/"))
if (fullPath.endsWith("/"))
{
fullPath = fullPath.replace(/\/+$/, "");
}
if(pathToLabelMap && pathToLabelMap[fullPath])
if (pathToLabelMap && pathToLabelMap[fullPath])
{
return pathToLabelMap[fullPath];
}
return (routeToLabel(route));
}
};
const breadcrumbTitle = fullPathToLabel(fullPath, route[route.length - 1]);
///////////////////////////////////////////////////////////////////////////////////////////////
// set the right-half of the navbar up so that below the 'md' breakpoint, it just disappears //
///////////////////////////////////////////////////////////////////////////////////////////////
const navbarRowRight = (theme: Theme, {isMini}: any) =>
{
return {
[theme.breakpoints.down("md")]: {
display: "none",
},
...navbarRow(theme, isMini)
}
};
return (
<AppBar
position={absolute ? "absolute" : navbarType}
@ -241,10 +255,15 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
<QBreadcrumbs icon="home" title={breadcrumbTitle} route={route} light={light} />
</Box>
{isMini ? null : (
<Box sx={(theme) => navbarRow(theme, {isMini})}>
<Box pr={0} mr={-2}>
<Box sx={(theme) => navbarRowRight(theme, {isMini})}>
<Box mt={"-0.25rem"} pb={"0.75rem"} pr={2} mr={-2} sx={{"& *": {cursor: "pointer !important"}}}>
{renderHistory()}
</Box>
<Box mt={"-1rem"}>
<IconButton size="small" disableRipple color="inherit" onClick={() => setDotMenuOpen(true)}>
<Icon sx={iconsStyle} fontSize="small">search</Icon>
</IconButton>
</Box>
</Box>
)}
</Toolbar>

View File

@ -0,0 +1,74 @@
/*
* 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 React, {Component, ErrorInfo} from "react";
interface Props
{
errorElement?: React.ReactNode;
children: React.ReactNode;
}
interface State
{
hasError: boolean;
}
/*******************************************************************************
** Component that you can wrap around other components that might throw an error,
** to give some isolation, rather than breaking a whole page.
** Credit: https://medium.com/@bobjunior542/how-to-use-error-boundaries-in-react-js-with-typescript-ee90ec814bf1
*******************************************************************************/
class ErrorBoundary extends Component<Props, State>
{
/***************************************************************************
*
***************************************************************************/
constructor(props: Props)
{
super(props);
this.state = {hasError: false};
}
/***************************************************************************
*
***************************************************************************/
componentDidCatch(error: Error, errorInfo: ErrorInfo)
{
console.error("ErrorBoundary caught an error: ", error, errorInfo);
this.setState({hasError: true});
}
/***************************************************************************
*
***************************************************************************/
render()
{
if (this.state.hasError)
{
return this.props.errorElement ?? <span>(Error)</span>;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -35,6 +35,7 @@ import DialogTitle from "@mui/material/DialogTitle";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import TextField from "@mui/material/TextField";
import {any} from "prop-types";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
import {QCancelButton} from "qqq/components/buttons/DefaultButtons";
@ -71,7 +72,12 @@ function hasGotoFieldNames(tableMetaData: QTableMetaData): boolean
function GotoRecordDialog(props: Props): JSX.Element
{
const fields: QFieldMetaData[] = [];
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this is an array of array of fields. //
// that is - each entry in the top-level array is a set of fields that can be used together to goto a record //
// such as (pkey), (ukey-field1,ukey-field2). //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
const options: QFieldMetaData[][] = [];
let pkey = props?.tableMetaData?.fields.get(props?.tableMetaData?.primaryKeyField);
let addedPkey = false;
@ -82,31 +88,38 @@ function GotoRecordDialog(props: Props): JSX.Element
{
for (let i = 0; i < mdbMetaData.gotoFieldNames.length; i++)
{
// todo - multi-field keys!!
let fieldName = mdbMetaData.gotoFieldNames[i][0];
let field = props.tableMetaData.fields.get(fieldName);
if (field)
const option: QFieldMetaData[] = [];
options.push(option);
for (let j = 0; j < mdbMetaData.gotoFieldNames[i].length; j++)
{
fields.push(field);
if (field.name == pkey.name)
let fieldName = mdbMetaData.gotoFieldNames[i][j];
let field = props.tableMetaData.fields.get(fieldName);
if (field)
{
addedPkey = true;
option.push(field);
if (pkey != null && field.name == pkey.name)
{
addedPkey = true;
}
}
}
}
}
}
//////////////////////////////////////////////////////////////////////////////////////////
// if pkey wasn't in the gotoField options meta-data, go ahead add it as an option here //
//////////////////////////////////////////////////////////////////////////////////////////
if (pkey && !addedPkey)
{
fields.unshift(pkey);
options.unshift([pkey]);
}
const makeInitialValues = () =>
{
const rs = {} as { [field: string]: string };
fields.forEach((field) => rs[field.name] = "");
options.forEach((option) => option.forEach((field) => rs[field.name] = ""));
return (rs);
};
@ -141,11 +154,16 @@ function GotoRecordDialog(props: Props): JSX.Element
}
else if (e.key == "Enter" && targetId?.startsWith("gotoInput-"))
{
const index = targetId?.replaceAll("gotoInput-", "");
const parts = targetId?.split(/-/);
const index = parts[1];
document.getElementById("gotoButton-" + index).click();
}
};
/***************************************************************************
** event handler for close button
***************************************************************************/
const closeRequested = () =>
{
if (props.mayClose)
@ -154,10 +172,47 @@ function GotoRecordDialog(props: Props): JSX.Element
}
};
const goClicked = async (fieldName: string) =>
/*******************************************************************************
** function to say if an option's submit button should be disabled
*******************************************************************************/
const isOptionSubmitButtonDisabled = (optionIndex: number) =>
{
let anyFieldsInThisOptionHaveAValue = false;
options[optionIndex].forEach((field) =>
{
if(values[field.name])
{
anyFieldsInThisOptionHaveAValue = true;
}
})
if(!anyFieldsInThisOptionHaveAValue)
{
return (true);
}
return (false);
}
/***************************************************************************
** event handler for clicking an 'option's go/submit button
***************************************************************************/
const optionGoClicked = async (optionIndex: number) =>
{
setError("");
const filter = new QQueryFilter([new QFilterCriteria(fieldName, QCriteriaOperator.EQUALS, [values[fieldName]])], null, null, "AND", null, 10);
const criteria: QFilterCriteria[] = [];
const queryStringParts: string[] = [];
options[optionIndex].forEach((field) =>
{
criteria.push(new QFilterCriteria(field.name, QCriteriaOperator.EQUALS, [values[field.name]]))
queryStringParts.push(`${field.name}=${encodeURIComponent(values[field.name])}`)
})
const filter = new QQueryFilter(criteria, null, null, "AND", null, 10);
try
{
const queryResult = await qController.query(props.tableMetaData.name, filter, null, props.tableVariant);
@ -168,12 +223,26 @@ function GotoRecordDialog(props: Props): JSX.Element
}
else if (queryResult.length == 1)
{
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/${encodeURIComponent(queryResult[0].values.get(props.tableMetaData.primaryKeyField))}`);
if(options[optionIndex].length == 1 && options[optionIndex][0].name == pkey?.name)
{
/////////////////////////////////////////////////
// navigate by pkey, if that's how we searched //
/////////////////////////////////////////////////
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/${encodeURIComponent(queryResult[0].values.get(props.tableMetaData.primaryKeyField))}`);
}
else
{
/////////////////////////////////
// else navigate by unique-key //
/////////////////////////////////
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/key/?${queryStringParts.join("&")}`);
}
close();
}
else
{
setError("More than 1 record found...");
setError("More than 1 record was found...");
setTimeout(() => setError(""), 3000);
}
}
@ -187,7 +256,7 @@ function GotoRecordDialog(props: Props): JSX.Element
if (props.tableMetaData)
{
if (fields.length == 0 && !error)
if (options.length == 0 && !error)
{
setError("This table is not configured for this feature.");
}
@ -200,31 +269,38 @@ function GotoRecordDialog(props: Props): JSX.Element
<DialogContent>
{props.subHeader}
{
fields.map((field, index) =>
(
<Grid key={field.name} container alignItems="center" py={1}>
<Grid item xs={3} textAlign="right" pr={2}>
{field.label}
</Grid>
<Grid item xs={6}>
<TextField
id={`gotoInput-${index}`}
autoFocus={index == 0}
autoComplete="off"
inputProps={{width: "100%"}}
onChange={(e) => handleChange(field.name, e.target.value)}
value={values[field.name]}
sx={{width: "100%"}}
onFocus={event => event.target.select()}
/>
</Grid>
<Grid item xs={1} pl={2}>
<MDButton id={`gotoButton-${index}`} type="submit" variant="gradient" color="info" size="small" onClick={() => goClicked(field.name)} fullWidth startIcon={<Icon>double_arrow</Icon>} disabled={`${values[field.name]}`.length == 0}>
Go
</MDButton>
</Grid>
</Grid>
))
options.map((option, optionIndex) =>
<Box key={optionIndex}>
{
option.map((field, index) =>
(
<Grid key={field.name} container alignItems="center" py={1}>
<Grid item xs={3} textAlign="right" pr={2}>
{field.label}
</Grid>
<Grid item xs={6}>
<TextField
id={`gotoInput-${optionIndex}-${index}`}
autoFocus={optionIndex == 0 && index == 0}
autoComplete="off"
inputProps={{width: "100%"}}
onChange={(e) => handleChange(field.name, e.target.value)}
value={values[field.name]}
sx={{width: "100%"}}
onFocus={event => event.target.select()}
/>
</Grid>
<Grid item xs={1} pl={2}>
{
(index == option.length - 1) &&
<MDButton id={`gotoButton-${optionIndex}`} type="submit" variant="gradient" color="info" size="small" onClick={() => optionGoClicked(optionIndex)} fullWidth startIcon={<Icon>double_arrow</Icon>} disabled={isOptionSubmitButtonDisabled(optionIndex)}>Go</MDButton>
}
</Grid>
</Grid>
))
}
</Box>
)
}
{
error &&
@ -282,7 +358,7 @@ export function GotoRecordButton(props: GotoRecordButtonProps): JSX.Element
return (
<React.Fragment>
{
props.buttonVisible && hasGotoFieldNames(props.tableMetaData) && <Button onClick={openGoto}>Go To...</Button>
props.buttonVisible && hasGotoFieldNames(props.tableMetaData) && <Button onClick={openGoto} sx={{whiteSpace: "nowrap"}}>Go To...</Button>
}
<GotoRecordDialog metaData={props.metaData} tableMetaData={props.tableMetaData} tableVariant={props.tableVariant} isOpen={gotoIsOpen} closeHandler={closeGoto} mayClose={props.mayClose} subHeader={props.subHeader} />
</React.Fragment>

View File

@ -22,6 +22,7 @@
import {QHelpContent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QHelpContent";
import Box from "@mui/material/Box";
import parse from "html-react-parser";
import ErrorBoundary from "qqq/components/misc/ErrorBoundary";
import React, {useContext} from "react";
import Markdown from "react-markdown";
import QContext from "QContext";
@ -128,6 +129,7 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
let selectedHelpContent = getMatchingHelpContent(helpContentsArray, roles);
let content = null;
let errorContent = "Error rendering help content.";
if (helpHelpActive)
{
if (!selectedHelpContent)
@ -135,6 +137,7 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
selectedHelpContent = new QHelpContent({content: ""});
}
content = selectedHelpContent.content + ` [${helpContentKey ?? "?"}]`;
errorContent += ` [${helpContentKey ?? "?"}]`;
}
else if(selectedHelpContent)
{
@ -148,7 +151,9 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
{
return <Box display="inline" className="helpContent">
{heading && <span className="header">{heading}</span>}
{formatHelpContent(content, selectedHelpContent.format)}
<ErrorBoundary errorElement={<i>{errorContent}</i>}>
{formatHelpContent(content, selectedHelpContent.format)}
</ErrorBoundary>
</Box>;
}

View File

@ -22,7 +22,7 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import Box from "@mui/material/Box";
import {Box} from "@mui/material";
import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon";
import {Theme} from "@mui/material/styles";
@ -76,12 +76,12 @@ function QRecordSidebar({tableSections, widgetMetaDataList, light, stickyTop}: P
return (
<Card sx={{borderRadius: "0.75rem", position: "sticky", top: stickyTop, overflow: "auto", maxHeight: "calc(100vh - 2rem)"}}>
<Box component="ul" display="flex" flexDirection="column" p={2} m={0} sx={{listStyle: "none"}}>
<Card sx={{borderRadius: "0.75rem", position: "sticky", top: stickyTop, overflow: "hidden", maxHeight: "calc(100vh - 2rem)"}}>
<Box component="ul" display="flex" flexDirection="column" p={2} m={0} sx={{listStyle: "none", overflow: "auto", height: "100%"}}>
{
sidebarEntries ? sidebarEntries.map((entry: SidebarEntry, key: number) => (
<HashLink key={`section-link-${entry.name}`} to={`#${entry.name}`}>
<Box key={`section-link-${entry.name}`} onClick={() => document.getElementById(entry.name).scrollIntoView()} sx={{cursor: "pointer"}}>
<Box key={`section-${entry.name}`} component="li" pt={key === 0 ? 0 : 1}>
<MDTypography
variant="button"
@ -112,7 +112,7 @@ function QRecordSidebar({tableSections, widgetMetaDataList, light, stickyTop}: P
</MDTypography>
</Box>
</HashLink>
</Box>
)) : null
}
</Box>

View File

@ -60,7 +60,7 @@ interface Props
view?: RecordQueryView;
viewAsJson?: string;
viewOnChangeCallback?: (selectedSavedViewId: number) => void;
loadingSavedView: boolean
loadingSavedView: boolean;
queryScreenUsage: QueryScreenUsage;
}
@ -69,6 +69,8 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
const navigate = useNavigate();
const [savedViews, setSavedViews] = useState([] as QRecord[]);
const [yourSavedViews, setYourSavedViews] = useState([] as QRecord[]);
const [viewsSharedWithYou, setViewsSharedWithYou] = useState([] as QRecord[]);
const [savedViewsMenu, setSavedViewsMenu] = useState(null);
const [savedViewsHaveLoaded, setSavedViewsHaveLoaded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -91,14 +93,14 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
const CLEAR_OPTION = "New View";
const NEW_REPORT_OPTION = "Create Report from Current View";
const {accentColor, accentColorLight} = useContext(QContext);
const {accentColor, accentColorLight, userId: currentUserId} = useContext(QContext);
/////////////////////////////////////////////////////////////////////////////////////////
// this component is used by <RecordQuery> - but that component has different usages - //
// e.g., the full-fledged query screen, but also, within other screens (e.g., a modal //
// under the ReportSetupWidget). So, there are some behaviors we only want when we're //
// on the full-fledged query screen, such as changing the URL with saved view ids. //
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////////
// this component is used by <RecordQuery> - but that component has different usages - //
// e.g., the full-fledged query screen, but also, within other screens (e.g., a modal //
// under the FilterAndColumnsSetupWidget). So, there are some behaviors we only want when //
// we're on the full-fledged query screen, such as changing the URL with saved view ids. //
/////////////////////////////////////////////////////////////////////////////////////////////
const isQueryScreen = queryScreenUsage == "queryScreen";
const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget);
@ -114,13 +116,13 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
{
setSavedViewsHaveLoaded(true);
});
}, [location, tableMetaData])
}, [location, tableMetaData]);
const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView;
const viewDiffs = SavedViewUtils.diffViews(tableMetaData, baseView, view);
let viewIsModified = false;
if(viewDiffs.length > 0)
if (viewDiffs.length > 0)
{
viewIsModified = true;
}
@ -130,7 +132,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
*******************************************************************************/
async function loadSavedViews()
{
if (! tableMetaData)
if (!tableMetaData)
{
return;
}
@ -140,8 +142,24 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
let savedViews = await makeSavedViewRequest("querySavedView", formData);
setSavedViews(savedViews);
}
const yourSavedViews: QRecord[] = [];
const viewsSharedWithYou: QRecord[] = [];
for (let i = 0; i < savedViews.length; i++)
{
const record = savedViews[i];
if (record.values.get("userId") == currentUserId)
{
yourSavedViews.push(record);
}
else
{
viewsSharedWithYou.push(record);
}
}
setYourSavedViews(yourSavedViews);
setViewsSharedWithYou(viewsSharedWithYou);
}
/*******************************************************************************
@ -152,14 +170,13 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
setSaveFilterPopupOpen(false);
closeSavedViewsMenu();
viewOnChangeCallback(record.values.get("id"));
if(isQueryScreen)
if (isQueryScreen)
{
navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`);
}
};
/*******************************************************************************
** fired when a save option is selected from the save... button/dropdown combo
*******************************************************************************/
@ -171,12 +188,12 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
setSaveFilterPopupOpen(true);
setIsSaveFilterAs(false);
setIsRenameFilter(false);
setIsDeleteFilter(false)
setIsDeleteFilter(false);
switch(optionName)
switch (optionName)
{
case SAVE_OPTION:
if(currentSavedView == null)
if (currentSavedView == null)
{
setSavedViewNameInputValue("");
}
@ -186,28 +203,28 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
setIsSaveFilterAs(true);
break;
case CLEAR_OPTION:
setSaveFilterPopupOpen(false)
setSaveFilterPopupOpen(false);
viewOnChangeCallback(null);
if(isQueryScreen)
if (isQueryScreen)
{
navigate(metaData.getTablePathByName(tableMetaData.name));
}
break;
case RENAME_OPTION:
if(currentSavedView != null)
if (currentSavedView != null)
{
setSavedViewNameInputValue(currentSavedView.values.get("label"));
}
setIsRenameFilter(true);
break;
case DELETE_OPTION:
setIsDeleteFilter(true)
setIsDeleteFilter(true);
break;
case NEW_REPORT_OPTION:
createNewReport();
break;
}
}
};
/*******************************************************************************
@ -215,11 +232,11 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
*******************************************************************************/
function createNewReport()
{
const defaultValues: {[key: string]: any} = {};
const defaultValues: { [key: string]: any } = {};
defaultValues.tableName = tableMetaData.name;
let filterForBackend = JSON.parse(JSON.stringify(view.queryFilter));
filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, filterForBackend);
filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, filterForBackend);
defaultValues.queryFilterJson = JSON.stringify(filterForBackend);
defaultValues.columnsJson = JSON.stringify(view.queryColumns);
@ -227,7 +244,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
}
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
*******************************************************************************/
@ -247,7 +263,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
setSaveFilterPopupOpen(false);
setSaveOptionsOpen(false);
await(async() =>
await (async () =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
@ -267,14 +283,14 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
////////////////////////////////////////////////////////////////////////////
// strip away incomplete filters too, just for cleaner saved view filters //
////////////////////////////////////////////////////////////////////////////
FilterUtils.stripAwayIncompleteCriteria(viewObject.queryFilter)
FilterUtils.stripAwayIncompleteCriteria(viewObject.queryFilter);
formData.append("viewJson", JSON.stringify(viewObject));
if (isSaveFilterAs || isRenameFilter || currentSavedView == null)
{
formData.append("label", savedViewNameInputValue);
if(currentSavedView != null && isRenameFilter)
if (currentSavedView != null && isRenameFilter)
{
formData.append("id", currentSavedView.values.get("id"));
}
@ -285,7 +301,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
formData.append("label", currentSavedView?.values.get("label"));
}
const recordList = await makeSavedViewRequest("storeSavedView", formData);
await(async() =>
await (async () =>
{
if (recordList && recordList.length > 0)
{
@ -302,11 +318,11 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
catch (e: any)
{
let message = JSON.stringify(e);
if(typeof e == "string")
if (typeof e == "string")
{
message = e;
}
else if(typeof e == "object" && e.message)
else if (typeof e == "object" && e.message)
{
message = e.message;
}
@ -321,7 +337,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
}
/*******************************************************************************
** hides/shows the save options
*******************************************************************************/
@ -331,7 +346,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
};
/*******************************************************************************
** closes save options menu (on clickaway)
*******************************************************************************/
@ -346,7 +360,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
};
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
@ -356,7 +369,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
@ -366,7 +378,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
};
/*******************************************************************************
** make a request to the backend for various savedView processes
*******************************************************************************/
@ -375,7 +386,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
/////////////////////////
// fetch saved filters //
/////////////////////////
let savedViews = [] as QRecord[]
let savedViews = [] as QRecord[];
try
{
//////////////////////////////////////////////////////////////////
@ -386,12 +397,12 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
throw(jobError.error);
throw (jobError.error);
}
else
{
const result = processResult as QJobComplete;
if(result.values.savedViewList)
if (result.values.savedViewList)
{
for (let i = 0; i < result.values.savedViewList.length; i++)
{
@ -403,7 +414,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
}
catch (e)
{
throw(e);
throw (e);
}
return (savedViews);
@ -416,17 +427,27 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
const tooltipMaxWidth = (maxWidth: string) =>
{
return ({slotProps: {
tooltip: {
sx: {
maxWidth: maxWidth
return ({
slotProps: {
tooltip: {
sx: {
maxWidth: maxWidth
}
}
}
}})
}
});
};
const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps;
let disabledBecauseNotOwner = false;
let notOwnerTooltipText = null;
if (currentSavedView && currentSavedView.values.get("userId") != currentUserId)
{
disabledBecauseNotOwner = true;
notOwnerTooltipText = "You may not save changes to this view, because you are not its owner.";
}
const renderSavedViewsMenu = tableMetaData && (
<Menu
anchorEl={savedViewsMenu}
@ -443,75 +464,101 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
}
{
isQueryScreen && hasStorePermission &&
<Tooltip {...menuTooltipAttribs} title={<>Save your current filters, columns and settings, for quick re-use at a later time.<br /><br />You will be prompted to enter a name if you choose this option.</>}>
<MenuItem onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
<ListItemIcon><Icon>save</Icon></ListItemIcon>
{currentSavedView ? "Save..." : "Save As..."}
</MenuItem>
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? <>Save your current filters, columns and settings, for quick re-use at a later time.<br /><br />You will be prompted to enter a name if you choose this option.</>}>
<span>
<MenuItem disabled={disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
<ListItemIcon><Icon>save</Icon></ListItemIcon>
{currentSavedView ? "Save..." : "Save As..."}
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen && hasStorePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Change the name for this saved view.">
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
Rename...
</MenuItem>
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Change the name for this saved view."}>
<span>
<MenuItem disabled={currentSavedView === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
Rename...
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen && hasStorePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Save a new copy this view, with a different name, separate from the original.">
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
Save As...
</MenuItem>
<span>
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
Save As...
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen && hasDeletePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Delete this saved view.">
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
Delete...
</MenuItem>
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Delete this saved view."}>
<span>
<MenuItem disabled={currentSavedView === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
Delete...
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen &&
<Tooltip {...menuTooltipAttribs} title="Create a new view of this table, resetting the filters and columns to their defaults.">
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>monitor</Icon></ListItemIcon>
New View
</MenuItem>
<span>
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>monitor</Icon></ListItemIcon>
New View
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen && hasSavedReportsPermission &&
<Tooltip {...menuTooltipAttribs} title="Create a new Saved Report using your current view of this table as a starting point.">
<MenuItem onClick={() => handleDropdownOptionClick(NEW_REPORT_OPTION)}>
<ListItemIcon><Icon>article</Icon></ListItemIcon>
Create Report from Current View
</MenuItem>
<span>
<MenuItem onClick={() => handleDropdownOptionClick(NEW_REPORT_OPTION)}>
<ListItemIcon><Icon>article</Icon></ListItemIcon>
Create Report from Current View
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen && <Divider/>
isQueryScreen && <Divider />
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Views</b></MenuItem>
{
savedViews && savedViews.length > 0 ? (
savedViews.map((record: QRecord, index: number) =>
yourSavedViews && yourSavedViews.length > 0 ? (
yourSavedViews.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedViewRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
): (
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any saved views for this table.</i>
</MenuItem>
)
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Views Shared with you</b></MenuItem>
{
viewsSharedWithYou && viewsSharedWithYou.length > 0 ? (
viewsSharedWithYou.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedViewRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any views shared with you for this table.</i>
</MenuItem>
)
}
</Menu>
);
@ -520,7 +567,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
if(currentSavedView)
if (currentSavedView)
{
if (viewIsModified)
{
@ -548,23 +595,23 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
color: buttonColor,
backgroundColor: buttonBackground,
}
}
};
/*******************************************************************************
**
*******************************************************************************/
function isSaveButtonDisabled(): boolean
{
if(isSubmitting)
if (isSubmitting)
{
return (true);
}
const haveInputText = (savedViewNameInputValue != null && savedViewNameInputValue.trim() != "")
const haveInputText = (savedViewNameInputValue != null && savedViewNameInputValue.trim() != "");
if(isSaveFilterAs || isRenameFilter || currentSavedView == null)
if (isSaveFilterAs || isRenameFilter || currentSavedView == null)
{
if(!haveInputText)
if (!haveInputText)
{
return (true);
}
@ -593,7 +640,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
... buttonStyles
...buttonStyles
}}
>
<Icon sx={{mr: "0.5rem"}}>save</Icon>
@ -624,7 +671,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
</>
}
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset All Changes</Button>
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset All Changes</Button>
</>
}
{
@ -635,16 +682,20 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul></>}>
</ul>
{
notOwnerTooltipText && <i>{notOwnerTooltipText}</i>
}
</>}>
<Box display="inline" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>{viewDiffs.length} Unsaved Change{viewDiffs.length == 1 ? "" : "s"}</Box>
</Tooltip>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save&hellip;</Button>
{disabledBecauseNotOwner ? <>&nbsp;&nbsp;</> : <Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save&hellip;</Button>}
{/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset All Changes</Button>
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset All Changes</Button>
</>
}
{
@ -663,16 +714,17 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul></>}>
</ul>
</>}>
<Box display="inline" ml="0.25rem" mr="0.25rem" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>with {viewDiffs.length} Change{viewDiffs.length == 1 ? "" : "s"}</Box>
</Tooltip>
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset Changes</Button>
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset Changes</Button>
</>
}
{/* vertical rule */}
<Box display="inline-block" ml="0.25rem" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset to New View</Button>
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset to New View</Button>
</Box>
}
</Box>
@ -702,15 +754,15 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
) : (
isSaveFilterAs ? (
<DialogTitle id="alert-dialog-title">Save View As</DialogTitle>
):(
) : (
isRenameFilter ? (
<DialogTitle id="alert-dialog-title">Rename View</DialogTitle>
):(
) : (
<DialogTitle id="alert-dialog-title">Update Existing View</DialogTitle>
)
)
)
):(
) : (
<DialogTitle id="alert-dialog-title">Save New View</DialogTitle>
)
}
@ -721,12 +773,12 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
</Box>
) : ("")}
{
(! currentSavedView || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? (
(!currentSavedView || isSaveFilterAs || isRenameFilter) && !isDeleteFilter ? (
<Box>
{
isSaveFilterAs ? (
<Box mb={3}>Enter a name for this new saved view.</Box>
):(
) : (
<Box mb={3}>Enter a new name for this saved view.</Box>
)
}
@ -744,10 +796,10 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
}}
/>
</Box>
):(
) : (
isDeleteFilter ? (
<Box>Are you sure you want to delete the view {`'${currentSavedView?.values.get("label")}'`}?</Box>
):(
) : (
<Box>Are you sure you want to update the view {`'${currentSavedView?.values.get("label")}'`}?</Box>
)
)
@ -759,7 +811,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
isDeleteFilter ?
<QDeleteButton onClickHandler={handleFilterDialogButtonOnClick} disabled={isSubmitting} />
:
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={isSaveButtonDisabled()}/>
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={isSaveButtonDisabled()} />
}
</DialogActions>
</Dialog>

View File

@ -84,7 +84,7 @@ function ProcessSummaryResults({
);
return (
<Box m={3} mt={6}>
<Box m={{xs: 0, md: 3}} mt={"3rem!important"}>
<Grid container>
<Grid item xs={0} lg={2} />
<Grid item xs={12} lg={8}>

View File

@ -273,7 +273,7 @@ function ValidationReview({
);
return (
<Box m={3}>
<Box m={{xs: 0, md: 3}} mt={"3rem!important"}>
<Grid container spacing={2}>
<Grid item xs={12} lg={6}>
<MDTypography color="body" variant="button">

View File

@ -0,0 +1,66 @@
/*
* 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/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import Tooltip from "@mui/material/Tooltip";
import CriteriaDateField from "qqq/components/query/CriteriaDateField";
import React, {SyntheticEvent, useState} from "react";
export type Expression = FilterVariableExpression;
interface AssignFilterButtonProps
{
valueIndex: number;
field: QFieldMetaData;
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
}
CriteriaDateField.defaultProps = {
valueIndex: 0,
label: "Value",
idPrefix: "value-"
};
export default function AssignFilterVariable({valueIndex, field, valueChangeHandler}: AssignFilterButtonProps): JSX.Element
{
const [isValueAVariable, setIsValueAVariable] = useState(false);
const handleVariableButtonOnClick = () =>
{
setIsValueAVariable(!isValueAVariable);
const expression = new FilterVariableExpression({fieldName: field.name, valueIndex: valueIndex});
valueChangeHandler(null, valueIndex, expression);
};
return <Box display="flex" alignItems="flex-end">
<Box>
<Tooltip title={`Use a variable as the value for the ${field.label} field`} placement="bottom">
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={handleVariableButtonOnClick}>functions</Icon>
</Tooltip>
</Box>
</Box>;
}

View File

@ -79,6 +79,8 @@ interface BasicAndAdvancedQueryControlsProps
queryScreenUsage: QueryScreenUsage;
allowVariables?: boolean;
mode: string;
setMode: (mode: string) => void;
}
@ -114,7 +116,7 @@ export function getCurrentSortIndicator(queryFilter: QQueryFilter, tableMetaData
*******************************************************************************/
const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) =>
{
const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props;
const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode, queryScreenUsage} = props;
/////////////////////
// state variables //
@ -181,7 +183,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
{
// todo - sometimes i want contains instead of equals on strings (client.name, for example...)
let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS;
if (field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE)
if (field?.type == QFieldType.DATE_TIME)
{
defaultOperator = QCriteriaOperator.GREATER_THAN;
}
@ -676,12 +678,14 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
return (<QuickFilter
key={fieldName}
allowVariables={props.allowVariables}
fullFieldName={fieldName}
tableMetaData={tableMetaData}
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
defaultOperator={defaultOperator}
queryScreenUsage={queryScreenUsage}
handleRemoveQuickFilterField={null} />);
})
}
@ -700,7 +704,9 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
allowVariables={props.allowVariables}
defaultOperator={defaultOperator}
queryScreenUsage={queryScreenUsage}
handleRemoveQuickFilterField={handleRemoveQuickFilterField} />);
})
}

View File

@ -21,6 +21,7 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
import {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression";
import {NowWithOffsetExpression, NowWithOffsetOperator, NowWithOffsetUnit} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression";
import {ThisOrLastPeriodExpression, ThisOrLastPeriodOperator, ThisOrLastPeriodUnit} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression";
@ -34,14 +35,14 @@ import MenuItem from "@mui/material/MenuItem";
import {styled} from "@mui/material/styles";
import TextField from "@mui/material/TextField";
import Tooltip, {tooltipClasses, TooltipProps} from "@mui/material/Tooltip";
import React, {SyntheticEvent, useEffect, useReducer, useState} from "react";
import AdvancedDateTimeFilterValues from "qqq/components/query/AdvancedDateTimeFilterValues";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import {EvaluatedExpression} from "qqq/components/query/EvaluatedExpression";
import {makeTextField} from "qqq/components/query/FilterCriteriaRowValues";
import React, {SyntheticEvent, useReducer, useState} from "react";
export type Expression = NowWithOffsetExpression | ThisOrLastPeriodExpression | NowExpression;
export type Expression = NowWithOffsetExpression | ThisOrLastPeriodExpression | NowExpression | FilterVariableExpression;
interface CriteriaDateFieldProps
@ -52,6 +53,7 @@ interface CriteriaDateFieldProps
field: QFieldMetaData;
criteria: QFilterCriteriaWithId;
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
allowVariables?: boolean;
}
CriteriaDateField.defaultProps = {
@ -60,19 +62,30 @@ CriteriaDateField.defaultProps = {
idPrefix: "value-"
};
export default function CriteriaDateField({valueIndex, label, idPrefix, field, criteria, valueChangeHandler}: CriteriaDateFieldProps): JSX.Element
export const NoWrapTooltip = styled(({className, children, ...props}: TooltipProps) => (
<Tooltip {...props} classes={{popper: className}}>{children}</Tooltip>
))({
[`& .${tooltipClasses.tooltip}`]: {
whiteSpace: "nowrap"
},
});
export default function CriteriaDateField({valueIndex, label, idPrefix, field, criteria, valueChangeHandler, allowVariables}: CriteriaDateFieldProps): JSX.Element
{
const [relativeDateTimeOpen, setRelativeDateTimeOpen] = useState(false);
const [relativeDateTimeMenuAnchorElement, setRelativeDateTimeMenuAnchorElement] = useState(null);
const [forceAdvancedDateTimeDialogOpen, setForceAdvancedDateTimeDialogOpen] = useState(false)
const [forceAdvancedDateTimeDialogOpen, setForceAdvancedDateTimeDialogOpen] = useState(false);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const openRelativeDateTimeMenu = (event: React.MouseEvent<HTMLElement>) =>
{
setRelativeDateTimeOpen(true);
setRelativeDateTimeMenuAnchorElement(event.currentTarget);
};
const closeRelativeDateTimeMenu = () =>
{
setRelativeDateTimeOpen(false);
setRelativeDateTimeMenuAnchorElement(null);
};
@ -137,20 +150,12 @@ export default function CriteriaDateField({valueIndex, label, idPrefix, field, c
const isExpression = criteria.values && criteria.values[valueIndex] && criteria.values[valueIndex].type;
const currentExpression = isExpression ? criteria.values[valueIndex] : null;
const NoWrapTooltip = styled(({className, children, ...props}: TooltipProps) => (
<Tooltip {...props} classes={{popper: className}}>{children}</Tooltip>
))({
[`& .${tooltipClasses.tooltip}`]: {
whiteSpace: "nowrap"
},
});
const tooltipMenuItemFromExpression = (valueIndex: number, tooltipPlacement: "left" | "right", expression: Expression) =>
{
let startOfPrefix = "";
if(expression.type == "ThisOrLastPeriod")
if (expression.type == "ThisOrLastPeriod")
{
if(field.type == QFieldType.DATE_TIME || expression.timeUnit != "DAYS")
if (field.type == QFieldType.DATE_TIME || expression.timeUnit != "DAYS")
{
startOfPrefix = "start of ";
}
@ -191,84 +196,120 @@ export default function CriteriaDateField({valueIndex, label, idPrefix, field, c
setTimeout(() => setForceAdvancedDateTimeDialogOpen(false), 100);
}
const makeFilterVariableTextField = (expression: FilterVariableExpression, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
{
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
{
valueChangeHandler(event, index, "");
document.getElementById(`${idPrefix}${criteria.id}`).focus();
};
const inputProps2: any = {};
inputProps2.endAdornment = (
<InputAdornment position="end">
<IconButton sx={{visibility: expression ? "visible" : "hidden"}} onClick={(event) => clearValue(event, valueIndex)}>
<Icon>closer</Icon>
</IconButton>
</InputAdornment>
);
return <NoWrapTooltip title={<EvaluatedExpression field={field} expression={expression} />} placement="bottom" enterDelay={1000} sx={{marginLeft: "-75px !important", marginTop: "-8px !important"}}><TextField
id={`${idPrefix}${criteria.id}`}
label={label}
variant="standard"
autoComplete="off"
InputProps={{disabled: true, readOnly: true, unselectable: "off", ...inputProps2}}
InputLabelProps={{shrink: true}}
value="${VARIABLE}"
fullWidth
/></NoWrapTooltip>;
};
return <Box display="flex" alignItems="flex-end">
{
isExpression ? makeDateTimeExpressionTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
: makeTextField(field, criteria, valueChangeHandler, valueIndex, label, idPrefix)
isExpression ?
currentExpression?.type == "FilterVariableExpression" ? (
makeFilterVariableTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
) : (
makeDateTimeExpressionTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
)
: makeTextField(field, criteria, valueChangeHandler, valueIndex, label, idPrefix, allowVariables)
}
<Box>
<Tooltip title={`Choose a common relative ${field.type == QFieldType.DATE ? "date" : "date-time"} expression`} placement="bottom">
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={openRelativeDateTimeMenu}>date_range</Icon>
</Tooltip>
<Menu
open={relativeDateTimeMenuAnchorElement}
anchorEl={relativeDateTimeMenuAnchorElement}
transformOrigin={{horizontal: "left", vertical: "top"}}
onClose={closeRelativeDateTimeMenu}
>
{
field.type == QFieldType.DATE ?
<Box display="flex">
<Box>
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
<Divider />
<Tooltip title="Define a custom expression" placement="left">
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
</Tooltip>
{
(!isExpression || currentExpression?.type != "FilterVariableExpression") && (
<><Box>
<Tooltip title={`Choose a common relative ${field.type == QFieldType.DATE ? "date" : "date-time"} expression`} placement="bottom">
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={openRelativeDateTimeMenu}>date_range</Icon>
</Tooltip>
<Menu
open={relativeDateTimeOpen}
anchorEl={relativeDateTimeMenuAnchorElement}
transformOrigin={{horizontal: "left", vertical: "top"}}
onClose={closeRelativeDateTimeMenu}
>
{field.type == QFieldType.DATE ?
<Box display="flex">
<Box>
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
<Divider />
<Tooltip title="Define a custom expression" placement="left">
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
</Tooltip>
</Box>
<Box>
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
</Box>
</Box>
<Box>
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
</Box>
</Box>
:
<Box display="flex">
<Box>
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 12, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 24, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
<Divider />
<Tooltip title="Define a custom expression" placement="left">
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
</Tooltip>
</Box>
<Box>
{tooltipMenuItemFromExpression(valueIndex, "right", newNowExpression())}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
</Box>
</Box>
}
</Menu>
</Box>
<Box>
<AdvancedDateTimeFilterValues type={field.type} expression={currentExpression} onSave={(expression: any) => saveNewDateTimeExpression(valueIndex, expression)} forcedOpen={forceAdvancedDateTimeDialogOpen} />
</Box>
:
<Box display="flex">
<Box>
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 12, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 24, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
<Divider />
<Tooltip title="Define a custom expression" placement="left">
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
</Tooltip>
</Box>
<Box>
{tooltipMenuItemFromExpression(valueIndex, "right", newNowExpression())}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
</Box>
</Box>}
</Menu>
</Box><Box>
<AdvancedDateTimeFilterValues type={field.type} expression={currentExpression} onSave={(expression: any) => saveNewDateTimeExpression(valueIndex, expression)} forcedOpen={forceAdvancedDateTimeDialogOpen} />
</Box></>
)
}
</Box>;
}

View File

@ -21,7 +21,9 @@
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {Box, FormControlLabel, FormGroup} from "@mui/material";
import {FormControlLabel, FormGroup} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
@ -56,7 +58,7 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
const someRef = createRef();
const textRef = useRef(null);
const [didInitialFocus, setDidInitialFocus] = useState(false)
const [didInitialFocus, setDidInitialFocus] = useState(false);
const [openGroups, setOpenGroups] = useState(props.initialOpenedGroups || {});
const openGroupsBecauseOfFilter = {} as { [name: string]: boolean };
@ -71,9 +73,9 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
console.log(`Open groups: ${JSON.stringify(openGroups)}`);
if(!didInitialFocus)
if (!didInitialFocus)
{
if(textRef.current)
if (textRef.current)
{
textRef.current.select();
setDidInitialFocus(true);
@ -189,11 +191,11 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
///////////////////////////////////////////////////////////////////////////////////////////////////////
// always sort columns by label. note, in future may offer different sorts - here's where to do it. //
///////////////////////////////////////////////////////////////////////////////////////////////////////
const sortedColumns = [... columns];
const sortedColumns = [...columns];
sortedColumns.sort((a, b): number =>
{
return a.headerName.localeCompare(b.headerName);
})
});
for (let i = 0; i < sortedColumns.length; i++)
{
@ -361,7 +363,7 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
const changeFilterText = (newValue: string) =>
{
setFilterText(newValue);
props.filterTextChanger(newValue)
props.filterTextChanger(newValue);
};
const filterTextChanged = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>

View File

@ -28,8 +28,8 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button/Button";
import Icon from "@mui/material/Icon/Icon";
import {GridFilterPanelProps, GridSlotsComponentsProps} from "@mui/x-data-grid-pro";
import React, {forwardRef, useReducer} from "react";
import {FilterCriteriaRow, getDefaultCriteriaValue} from "qqq/components/query/FilterCriteriaRow";
import React, {forwardRef, useReducer} from "react";
declare module "@mui/x-data-grid"
@ -49,7 +49,7 @@ declare module "@mui/x-data-grid"
export class QFilterCriteriaWithId extends QFilterCriteria
{
id: number
id: number;
}
@ -62,6 +62,7 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const queryFilter = props.queryFilter;
// console.log(`CustomFilterPanel: filter: ${JSON.stringify(queryFilter)}`);
function focusLastField()
@ -124,7 +125,7 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
}
}
if(queryFilter.criteria.length == 1 && !queryFilter.criteria[0].fieldName)
if (queryFilter.criteria.length == 1 && !queryFilter.criteria[0].fieldName)
{
focusLastField();
}
@ -142,7 +143,7 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
{
queryFilter.criteria[index] = newCriteria;
clearTimeout(debounceTimeout)
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => props.updateFilter(queryFilter), needDebounce ? 500 : 1);
forceUpdate();
@ -178,6 +179,8 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
updateCriteria={(newCriteria, needDebounce) => updateCriteria(newCriteria, index, needDebounce)}
removeCriteria={() => removeCriteria(index)}
updateBooleanOperator={(newValue) => updateBooleanOperator(newValue)}
allowVariables={props.allowVariables}
queryScreenUsage={props.queryScreenUsage}
/>
{/*JSON.stringify(criteria)*/}
</Box>

View File

@ -21,9 +21,9 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import React, {useEffect, useState} from "react";
import {Expression} from "qqq/components/query/CriteriaDateField";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useEffect, useState} from "react";
/*******************************************************************************
** Helper component to show value inside tooltips that ticks up every second.
@ -50,13 +50,18 @@ export function EvaluatedExpression({field, expression}: EvaluatedExpressionProp
return () => clearInterval(interval);
}, []);
return <>{`${evaluateExpression(timeForEvaluations, field, expression)}`}</>;
return <span style={{fontVariantNumeric: "tabular-nums"}}>{`${evaluateExpression(timeForEvaluations, field, expression)}`}</span>;
}
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const evaluateExpression = (time: Date, field: QFieldMetaData, expression: Expression): string =>
{
if (expression.type == "FilterVariableExpression")
{
return (expression.toString());
}
let rs: Date = null;
if (expression.type == "NowWithOffset")
{

View File

@ -35,6 +35,7 @@ import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {ReactNode, SyntheticEvent, useState} from "react";
@ -72,7 +73,7 @@ export const getValueModeRequiredCount = (valueMode: ValueMode): number =>
case ValueMode.PVS_MULTI:
return (null);
}
}
};
export interface OperatorOption
{
@ -183,7 +184,7 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str
}
return (operatorOptions);
}
};
interface FilterCriteriaRowProps
@ -197,13 +198,14 @@ interface FilterCriteriaRowProps
updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean) => void;
removeCriteria: () => void;
updateBooleanOperator: (newValue: string) => void;
queryScreenUsage?: QueryScreenUsage;
allowVariables?: boolean;
}
FilterCriteriaRow.defaultProps =
{
};
{};
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string}
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): { criteriaIsValid: boolean, criteriaStatusTooltip: string }
{
let criteriaIsValid = true;
let criteriaStatusTooltip = "This condition is fully defined and is part of your filter.";
@ -213,7 +215,7 @@ export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValu
return (value === null || value == undefined || String(value).trim() === "");
}
if(!criteria)
if (!criteria)
{
criteriaIsValid = false;
criteriaStatusTooltip = "This condition is not defined.";
@ -266,7 +268,7 @@ export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValu
return {criteriaIsValid, criteriaStatusTooltip};
}
export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator}: FilterCriteriaRowProps): JSX.Element
export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator, queryScreenUsage, allowVariables}: FilterCriteriaRowProps): JSX.Element
{
// console.log(`FilterCriteriaRow: criteria: ${JSON.stringify(criteria)}`);
const [operatorSelectedValue, setOperatorSelectedValue] = useState(null as OperatorOption);
@ -284,7 +286,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
let defaultFieldValue;
let field = null;
let fieldTable = null;
if(criteria && criteria.fieldName)
if (criteria && criteria.fieldName)
{
[field, fieldTable] = FilterUtils.getField(tableMetaData, criteria.fieldName);
if (field && fieldTable)
@ -303,9 +305,9 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
let newOperatorSelectedValue = operatorOptions.filter(option =>
{
if(option.value == criteria.operator)
if (option.value == criteria.operator)
{
if(option.implicitValues)
if (option.implicitValues)
{
return (JSON.stringify(option.implicitValues) == JSON.stringify(criteria.values));
}
@ -316,7 +318,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
}
return (false);
})[0];
if(newOperatorSelectedValue?.label !== operatorSelectedValue?.label)
if (newOperatorSelectedValue?.label !== operatorSelectedValue?.label)
{
setOperatorSelectedValue(newOperatorSelectedValue);
setOperatorInputValue(newOperatorSelectedValue?.label);
@ -379,12 +381,12 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
{
criteria.operator = newValue ? newValue.value : null;
if(newValue)
if (newValue)
{
setOperatorSelectedValue(newValue);
setOperatorInputValue(newValue.label);
if(newValue.implicitValues)
if (newValue.implicitValues)
{
criteria.values = newValue.implicitValues;
}
@ -393,15 +395,15 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
// we've seen cases where switching operators can sometimes put a null in as the first value... //
// that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. //
//////////////////////////////////////////////////////////////////////////////////////////////////
if(criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
if (criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
{
criteria.values = [];
}
if(newValue.valueMode && !newValue.implicitValues)
if (newValue.valueMode && !newValue.implicitValues)
{
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
if (requiredValueCount != null && criteria.values.length > requiredValueCount)
{
criteria.values.splice(requiredValueCount);
}
@ -424,12 +426,12 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
// @ts-ignore
const value = newValue !== undefined ? newValue : event ? event.target.value : null;
if(!criteria.values)
if (!criteria.values)
{
criteria.values = [];
}
if(valueIndex == "all")
if (valueIndex == "all")
{
criteria.values = value;
}
@ -514,6 +516,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
field={field}
table={fieldTable}
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
queryScreenUsage={queryScreenUsage}
allowVariables={allowVariables}
/>
</Box>
<Box display="inline-block">

View File

@ -23,19 +23,25 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment/InputAdornment";
import TextField from "@mui/material/TextField";
import React, {SyntheticEvent, useReducer} from "react";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import CriteriaDateField from "qqq/components/query/CriteriaDateField";
import AssignFilterVariable from "qqq/components/query/AssignFilterVariable";
import CriteriaDateField, {NoWrapTooltip} from "qqq/components/query/CriteriaDateField";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import {EvaluatedExpression} from "qqq/components/query/EvaluatedExpression";
import FilterCriteriaPaster from "qqq/components/query/FilterCriteriaPaster";
import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {SyntheticEvent, useReducer} from "react";
import {flushSync} from "react-dom";
interface Props
{
@ -44,7 +50,9 @@ interface Props
field: QFieldMetaData;
table: QTableMetaData;
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
initiallyOpenMultiValuePvs?: boolean
initiallyOpenMultiValuePvs?: boolean;
queryScreenUsage?: QueryScreenUsage;
allowVariables?: boolean;
}
FilterCriteriaRowValues.defaultProps =
@ -52,6 +60,10 @@ FilterCriteriaRowValues.defaultProps =
initiallyOpenMultiValuePvs: false
};
/***************************************************************************
* get the type to use for an <input> from a QFieldMetaData
***************************************************************************/
export const getTypeForTextField = (field: QFieldMetaData): string =>
{
let type = "search";
@ -72,8 +84,15 @@ export const getTypeForTextField = (field: QFieldMetaData): string =>
return (type);
};
export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWithId, valueChangeHandler?: (event: (React.ChangeEvent | React.SyntheticEvent), valueIndex?: (number | "all"), newValue?: any) => void, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
/***************************************************************************
* Make an <input type=text> (actually, might be a different type, but that's
* the gist of it), for a field.
***************************************************************************/
export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWithId, valueChangeHandler?: (event: (React.ChangeEvent | React.SyntheticEvent), valueIndex?: (number | "all"), newValue?: any) => void, valueIndex: number = 0, label = "Value", idPrefix = "value-", allowVariables = false) =>
{
const isExpression = criteria.values && criteria.values[valueIndex] && criteria.values[valueIndex].type;
const inputId = `${idPrefix}${criteria.id}`;
let type = getTypeForTextField(field);
const inputLabelProps: any = {};
@ -88,14 +107,16 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
value = ValueUtils.formatDateTimeValueForForm(value);
}
/***************************************************************************
* Event handler for the clear 'x'.
***************************************************************************/
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
{
valueChangeHandler(event, index, "");
document.getElementById(`${idPrefix}${criteria.id}`).focus();
document.getElementById(inputId).focus();
};
/*******************************************************************************
** Event handler for key-down events - specifically added here, to stop pressing
** 'tab' in a date or date-time from closing the quick-filter...
@ -104,7 +125,7 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
{
if (field.type == QFieldType.DATE || field.type == QFieldType.DATE_TIME)
{
if(e.code == "Tab")
if (e.code == "Tab")
{
console.log("Tab on date or date-time - don't close me, just move to the next sub-field!...");
e.stopPropagation();
@ -112,6 +133,44 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
}
};
/***************************************************************************
* make a version of the text field for when the criteria's value is set to
* be a "variable"
***************************************************************************/
const makeFilterVariableTextField = (expression: FilterVariableExpression, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
{
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
{
valueChangeHandler(event, index, "");
document.getElementById(`${idPrefix}${criteria.id}`).focus();
};
const inputProps2: any = {};
inputProps2.endAdornment = (
<InputAdornment position="end">
<IconButton sx={{visibility: expression ? "visible" : "hidden"}} onClick={(event) => clearValue(event, valueIndex)}>
<Icon>closer</Icon>
</IconButton>
</InputAdornment>
);
return <NoWrapTooltip title={<EvaluatedExpression field={field} expression={expression} />} placement="bottom" enterDelay={1000} sx={{marginLeft: "-75px !important", marginTop: "-8px !important"}}><TextField
id={`${idPrefix}${criteria.id}`}
label={label}
variant="standard"
autoComplete="off"
InputProps={{disabled: true, readOnly: true, unselectable: "off", ...inputProps2}}
InputLabelProps={{shrink: true}}
value="${VARIABLE}"
fullWidth
/></NoWrapTooltip>;
};
///////////////////////////////////////////////////////////////////////////
// set up an 'x' icon as an end-adornment, to clear value from the field //
///////////////////////////////////////////////////////////////////////////
const inputProps: any = {};
inputProps.endAdornment = (
<InputAdornment position="end">
@ -121,23 +180,87 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
</InputAdornment>
);
return <TextField
id={`${idPrefix}${criteria.id}`}
label={label}
variant="standard"
autoComplete="off"
type={type}
onChange={(event) => valueChangeHandler(event, valueIndex)}
onKeyDown={handleKeyDown}
value={value}
InputLabelProps={inputLabelProps}
InputProps={inputProps}
fullWidth
autoFocus={true}
/>;
/***************************************************************************
* onChange event handler. deals with, if the field has a to upper/lower
* case rule on it, to apply that transform, and adjust the cursor.
* See: https://giacomocerquone.com/blog/keep-input-cursor-still
***************************************************************************/
function onChange(event: any)
{
const beforeStart = event.target.selectionStart;
const beforeEnd = event.target.selectionEnd;
let isToUpperCase = DynamicFormUtils.isToUpperCase(field);
let isToLowerCase = DynamicFormUtils.isToLowerCase(field);
if (isToUpperCase || isToLowerCase)
{
flushSync(() =>
{
let newValue = event.currentTarget.value;
if (isToUpperCase)
{
newValue = newValue.toUpperCase();
}
if (isToLowerCase)
{
newValue = newValue.toLowerCase();
}
event.currentTarget.value = newValue;
});
const input = document.getElementById(inputId);
if (input)
{
// @ts-ignore
input.setSelectionRange(beforeStart, beforeEnd);
}
}
valueChangeHandler(event, valueIndex);
}
////////////////////////
// return the element //
////////////////////////
return <Box sx={{margin: 0, padding: 0, display: "flex"}}>
{
isExpression ? (
makeFilterVariableTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
) : (
<TextField
id={inputId}
label={label}
variant="standard"
autoComplete="off"
type={type}
onChange={onChange}
onKeyDown={handleKeyDown}
value={value}
InputLabelProps={inputLabelProps}
InputProps={inputProps}
fullWidth
autoFocus={true}
/>
)
}
{
allowVariables && (
<AssignFilterVariable field={field} valueChangeHandler={valueChangeHandler} valueIndex={valueIndex} />
)
}
</Box>;
};
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs}: Props): JSX.Element
/***************************************************************************
* Component that is the "values" portion of a FilterCriteria Row in the
* advanced query filter editor.
***************************************************************************/
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs, queryScreenUsage, allowVariables}: Props): JSX.Element
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -146,6 +269,10 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
return null;
}
/***************************************************************************
* Callback for the Save button from the paste-values modal
***************************************************************************/
function saveNewPasterValues(newValues: any[])
{
if (criteria.values)
@ -169,33 +296,38 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
forceUpdate();
}
const isExpression = criteria.values && criteria.values[0] && criteria.values[0].type;
//////////////////////////////////////////////////////////////////////////////
// render different form element9s) based on operator option's "value mode" //
//////////////////////////////////////////////////////////////////////////////
switch (operatorOption.valueMode)
{
case ValueMode.NONE:
return null;
case ValueMode.SINGLE:
return makeTextField(field, criteria, valueChangeHandler);
return makeTextField(field, criteria, valueChangeHandler, 0, undefined, undefined, allowVariables);
case ValueMode.SINGLE_DATE:
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} />;
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} allowVariables={allowVariables} />;
case ValueMode.DOUBLE_DATE:
return <Box>
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" allowVariables={allowVariables} />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" allowVariables={allowVariables} />
</Box>;
case ValueMode.SINGLE_DATE_TIME:
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} />;
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} allowVariables={allowVariables} />;
case ValueMode.DOUBLE_DATE_TIME:
return <Box>
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" allowVariables={allowVariables} />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" allowVariables={allowVariables} />
</Box>;
case ValueMode.DOUBLE:
return <Box>
<Box width="50%" display="inline-block">
{makeTextField(field, criteria, valueChangeHandler, 0, "From", "from-")}
{makeTextField(field, criteria, valueChangeHandler, 0, "From", "from-", allowVariables)}
</Box>
<Box width="50%" display="inline-block">
{makeTextField(field, criteria, valueChangeHandler, 1, "To", "to-")}
{makeTextField(field, criteria, valueChangeHandler, 1, "To", "to-", allowVariables)}
</Box>
</Box>;
case ValueMode.MULTI:
@ -228,19 +360,29 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
{
selectedPossibleValue = criteria.values[0];
}
return <Box mb={-1.5}>
<DynamicSelect
tableName={table.name}
fieldName={field.name}
overrideId={field.name + "-single-" + criteria.id}
key={field.name + "-single-" + criteria.id}
fieldLabel="Value"
initialValue={selectedPossibleValue?.id}
initialDisplayValue={selectedPossibleValue?.label}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, 0, value)}
variant="standard"
/>
return <Box display="flex">
{
isExpression ? (
makeTextField(field, criteria, valueChangeHandler, 0, undefined, undefined, allowVariables)
) : (
<Box width={"100%"}>
<DynamicSelect
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: selectedPossibleValue?.label}}
overrideId={field.name + "-single-" + criteria.id}
key={field.name + "-single-" + criteria.id}
fieldLabel="Value"
initialValue={selectedPossibleValue?.id}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, 0, value)}
variant="standard"
useCase="filter"
/>
</Box>
)
}
{
allowVariables && !isExpression && <Box mt={2.0}><AssignFilterVariable field={field} valueChangeHandler={valueChangeHandler} valueIndex={0} /></Box>
}
</Box>;
case ValueMode.PVS_MULTI:
console.log("Doing pvs multi: " + criteria.values);
@ -256,10 +398,9 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
initialValues = criteria.values;
}
}
return <Box mb={-1.5}>
return <Box>
<DynamicSelect
tableName={table.name}
fieldName={field.name}
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
overrideId={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id}
isMultiple
@ -269,6 +410,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
inForm={false}
onChange={(value: any) => valueChangeHandler(null, "all", value)}
variant="standard"
useCase="filter"
/>
</Box>;
}
@ -276,4 +418,4 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
return (<br />);
}
export default FilterCriteriaRowValues;
export default FilterCriteriaRowValues;

View File

@ -30,14 +30,15 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import TextField from "@mui/material/TextField";
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
import QContext from "QContext";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import {getDefaultCriteriaValue, getOperatorOptions, getValueModeRequiredCount, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
import XIcon from "qqq/components/query/XIcon";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
@ -50,6 +51,8 @@ interface QuickFilterProps
updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean, doRemoveCriteria: boolean) => void;
defaultOperator?: QCriteriaOperator;
handleRemoveQuickFilterField?: (fieldName: string) => void;
queryScreenUsage?: QueryScreenUsage;
allowVariables?: boolean;
}
QuickFilter.defaultProps =
@ -71,7 +74,7 @@ export const quickFilterButtonStyles = {
minHeight: "auto",
padding: "0.375rem 0.625rem", whiteSpace: "nowrap",
marginBottom: "0.5rem"
}
};
/*******************************************************************************
** Test if a CriteriaParamType represents an actual query criteria - or, if it's
@ -89,11 +92,11 @@ const criteriaParamIsCriteria = (param: CriteriaParamType): boolean =>
*******************************************************************************/
const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteria: QFilterCriteriaWithId): boolean =>
{
if(operatorOption.value == criteria.operator)
if (operatorOption.value == criteria.operator)
{
if(operatorOption.implicitValues)
if (operatorOption.implicitValues)
{
if(JSON.stringify(operatorOption.implicitValues) == JSON.stringify(criteria.values))
if (JSON.stringify(operatorOption.implicitValues) == JSON.stringify(criteria.values))
{
return (true);
}
@ -107,7 +110,7 @@ const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteri
}
return (false);
}
};
/*******************************************************************************
@ -117,29 +120,29 @@ const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteri
*******************************************************************************/
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption =>
{
if(criteria)
if (criteria)
{
const filteredOptions = operatorOptions.filter(o => doesOperatorOptionEqualCriteria(o, criteria));
if(filteredOptions.length > 0)
if (filteredOptions.length > 0)
{
return (filteredOptions[0]);
}
}
const filteredOptions = operatorOptions.filter(o => o.value == defaultOperator);
if(filteredOptions.length > 0)
if (filteredOptions.length > 0)
{
return (filteredOptions[0]);
}
return (null);
}
};
/*******************************************************************************
** Component to render a QuickFilter - that is - a button, with a Menu under it,
** with Operator and Value controls.
*******************************************************************************/
export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField}: QuickFilterProps): JSX.Element
export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField, queryScreenUsage, allowVariables}: QuickFilterProps): JSX.Element
{
const operatorOptions = fieldMetaData ? getOperatorOptions(tableMetaData, fullFieldName) : [];
const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName);
@ -190,7 +193,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (criteriaParamIsCriteria(criteriaParam) && JSON.stringify(criteriaParam) !== JSON.stringify(criteria))
{
if(isOpen)
if (isOpen)
{
////////////////////////////////////////////////////////////////////////////////
// this was firing too-often for case where: there was a criteria originally //
@ -217,12 +220,12 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
*******************************************************************************/
const criteriaNeedsReset = (): boolean =>
{
if(criteria != null && criteriaParam == null)
if (criteria != null && criteriaParam == null)
{
const defaultOperatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0];
if(criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue()))
if (criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue()))
{
if(isOpen)
if (isOpen)
{
//////////////////////////////////////////////////////////////////////////////////
// this was firing too-often for case where: there was no criteria originally, //
@ -237,7 +240,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
}
return (false);
}
};
/*******************************************************************************
** Construct a new criteria object - resetting the values tied to the operator
@ -251,8 +254,8 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
setOperatorSelectedValue(operatorOption);
setOperatorInputValue(operatorOption?.label);
setCriteria(criteria);
return(criteria);
}
return (criteria);
};
/*******************************************************************************
** event handler to open the menu in response to the button being clicked.
@ -266,7 +269,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
{
const element = document.getElementById("value-" + criteria.id);
element?.focus();
})
});
};
/*******************************************************************************
@ -304,15 +307,15 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
// we've seen cases where switching operators can sometimes put a null in as the first value... //
// that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. //
//////////////////////////////////////////////////////////////////////////////////////////////////
if(criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
if (criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
{
criteria.values = [];
}
if(newValue.valueMode && !newValue.implicitValues)
if (newValue.valueMode && !newValue.implicitValues)
{
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
if (requiredValueCount != null && criteria.values.length > requiredValueCount)
{
criteria.values.splice(requiredValueCount);
}
@ -345,6 +348,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
// @ts-ignore
const value = newValue !== undefined ? newValue : event ? event.target.value : null;
console.log("IN HERE");
if (!criteria.values)
{
criteria.values = [];
@ -376,13 +380,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
*******************************************************************************/
const resetCriteria = (e: React.MouseEvent<HTMLSpanElement>) =>
{
if(criteriaIsValid)
if (criteriaIsValid)
{
e.stopPropagation();
const newCriteria = makeNewCriteria();
updateCriteria(newCriteria, false, true);
}
}
};
/*******************************************************************************
** event handler for clicking the (x) icon that turns off this quick filter field.
@ -390,17 +394,17 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
*******************************************************************************/
const handleTurningOffQuickFilterField = () =>
{
closeMenu()
if(handleRemoveQuickFilterField)
closeMenu();
if (handleRemoveQuickFilterField)
{
handleRemoveQuickFilterField(criteria?.fieldName);
}
}
};
////////////////////////////////////////////////////////////////////////////////////
// if no field was input (e.g., record-query is still loading), return null early //
////////////////////////////////////////////////////////////////////////////////////
if(!fieldMetaData)
if (!fieldMetaData)
{
return (null);
}
@ -410,10 +414,10 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
// from the last selected one, then set the state vars that control that autocomplete //
//////////////////////////////////////////////////////////////////////////////////////////
const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator);
if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue))
if (JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue))
{
setOperatorSelectedValue(maybeNewOperatorSelectedValue)
setOperatorInputValue(maybeNewOperatorSelectedValue?.label)
setOperatorSelectedValue(maybeNewOperatorSelectedValue);
setOperatorInputValue(maybeNewOperatorSelectedValue?.label);
}
/////////////////////////////////////////////////////////////////////////////////////
@ -431,7 +435,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const tooltipEnterDelay = 500;
let buttonAdditionalStyles: any = {};
let buttonContent = <span>{tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label}</span>
let buttonContent = <span>{tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label}</span>;
let buttonClassName = "filterNotActive";
if (criteriaIsValid)
{
@ -446,9 +450,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
// don't show the Equals or In operators //
///////////////////////////////////////////
let operatorString = (<>{operatorSelectedValue.label}&nbsp;</>);
if(operatorSelectedValue.value == QCriteriaOperator.EQUALS || operatorSelectedValue.value == QCriteriaOperator.IN)
if (operatorSelectedValue.value == QCriteriaOperator.EQUALS || operatorSelectedValue.value == QCriteriaOperator.IN)
{
operatorString = (<></>)
operatorString = (<></>);
}
buttonContent = (<><span style={{fontWeight: 700}}>{buttonContent}:</span>&nbsp;<span style={{fontWeight: 400}}>{operatorString}{valuesString}</span></>);
@ -491,7 +495,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const xClicked = (e: React.MouseEvent<HTMLSpanElement>) =>
{
e.stopPropagation();
if(criteriaIsValid)
if (criteriaIsValid)
{
resetCriteria(e);
}
@ -499,12 +503,12 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
{
handleTurningOffQuickFilterField();
}
}
};
//////////////////////////////
// return the button & menu //
//////////////////////////////
const widthAndMaxWidth = fieldMetaData?.type == QFieldType.DATE_TIME ? 275 : 250
const widthAndMaxWidth = (fieldMetaData?.type == QFieldType.DATE_TIME) ? 315 : 250;
return (
<>
{button}
@ -541,10 +545,12 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
</Box>
<Box width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="quickFilter filterValuesColumn">
<FilterCriteriaRowValues
queryScreenUsage={queryScreenUsage}
operatorOption={operatorSelectedValue}
criteria={criteria}
field={fieldMetaData}
table={tableForField}
allowVariables={allowVariables}
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
initiallyOpenMultiValuePvs={true} // todo - maybe not?
/>

View File

@ -40,16 +40,17 @@ import Snackbar from "@mui/material/Snackbar";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import FormData from "form-data";
import React, {useEffect, useReducer, useRef, useState} from "react";
import AceEditor from "react-ace";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import ScriptDocsForm from "qqq/components/scripts/ScriptDocsForm";
import ScriptTestForm from "qqq/components/scripts/ScriptTestForm";
import Client from "qqq/utils/qqq/Client";
import "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/theme-github";
import React, {useEffect, useReducer, useRef, useState} from "react";
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/ext-language_tools";
export interface ScriptEditorProps
@ -69,15 +70,15 @@ const qController = Client.getInstance();
function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFileSchemaList: QRecord[]): { [name: string]: string }
{
const rs: {[name: string]: string} = {};
const rs: { [name: string]: string } = {};
if(!scriptTypeFileSchemaList)
if (!scriptTypeFileSchemaList)
{
console.log("Missing scriptTypeFileSchemaList");
}
else
{
let files = scriptRevisionRecord?.associatedRecords?.get("files")
let files = scriptRevisionRecord?.associatedRecords?.get("files");
for (let i = 0; i < scriptTypeFileSchemaList.length; i++)
{
@ -88,7 +89,7 @@ function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFi
for (let j = 0; j < files?.length; j++)
{
let file = files[j];
if(file.values.get("fileName") == name)
if (file.values.get("fileName") == name)
{
contents = file.values.get("contents");
}
@ -103,9 +104,9 @@ function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFi
function buildFileTypeMap(scriptTypeFileSchemaList: QRecord[]): { [name: string]: string }
{
const rs: {[name: string]: string} = {};
const rs: { [name: string]: string } = {};
if(!scriptTypeFileSchemaList)
if (!scriptTypeFileSchemaList)
{
console.log("Missing scriptTypeFileSchemaList");
}
@ -125,21 +126,21 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
const [closing, setClosing] = useState(false);
const [apiName, setApiName] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiName") : null)
const [apiNameLabel, setApiNameLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiName") : null)
const [apiVersion, setApiVersion] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiVersion") : null)
const [apiVersionLabel, setApiVersionLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiVersion") : null)
const [apiName, setApiName] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiName") : null);
const [apiNameLabel, setApiNameLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiName") : null);
const [apiVersion, setApiVersion] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiVersion") : null);
const [apiVersionLabel, setApiVersionLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiVersion") : null);
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"))
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"));
const [availableFileNames, setAvailableFileNames] = useState(fileNamesFromSchema);
const [openEditorFileNames, setOpenEditorFileNames] = useState([fileNamesFromSchema[0]])
const [fileContents, setFileContents] = useState(buildInitialFileContentsMap(scriptRevisionRecord, scriptTypeFileSchemaList))
const [fileTypes, setFileTypes] = useState(buildFileTypeMap(scriptTypeFileSchemaList))
const [openEditorFileNames, setOpenEditorFileNames] = useState([fileNamesFromSchema[0]]);
const [fileContents, setFileContents] = useState(buildInitialFileContentsMap(scriptRevisionRecord, scriptTypeFileSchemaList));
const [fileTypes, setFileTypes] = useState(buildFileTypeMap(scriptTypeFileSchemaList));
console.log(`file types: ${JSON.stringify(fileTypes)}`);
const [commitMessage, setCommitMessage] = useState("")
const [commitMessage, setCommitMessage] = useState("");
const [openTool, setOpenTool] = useState(null);
const [errorAlert, setErrorAlert] = useState("")
const [errorAlert, setErrorAlert] = useState("");
const [promptForCommitMessageOpen, setPromptForCommitMessageOpen] = useState(false);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const ref = useRef();
@ -241,19 +242,19 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
// need this to make Ace recognize new height.
setTimeout(() =>
{
window.dispatchEvent(new Event("resize"))
window.dispatchEvent(new Event("resize"));
}, 100);
};
const saveClicked = (overrideCommitMessage?: string) =>
{
if(!apiName || !apiVersion)
if (!apiName || !apiVersion)
{
setErrorAlert("You must select a value for both API Name and API Version.")
setErrorAlert("You must select a value for both API Name and API Version.");
return;
}
if(!commitMessage && !overrideCommitMessage)
if (!commitMessage && !overrideCommitMessage)
{
setPromptForCommitMessageOpen(true);
return;
@ -267,18 +268,18 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
formData.append("scriptId", scriptId);
formData.append("commitMessage", overrideCommitMessage ?? commitMessage);
if(apiName)
if (apiName)
{
formData.append("apiName", apiName);
}
if(apiVersion)
if (apiVersion)
{
formData.append("apiVersion", apiVersion);
}
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"))
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"));
formData.append("fileNames", fileNamesFromSchema.join(","));
for (let fileName in fileContents)
@ -299,58 +300,58 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError
setErrorAlert(jobError.userFacingError ?? jobError.error)
const jobError = processResult as QJobError;
setErrorAlert(jobError.userFacingError ?? jobError.error);
setClosing(false);
return;
}
closeCallback(null, "saved", "Saved New Script Version");
}
catch(e)
catch (e)
{
// @ts-ignore
setErrorAlert(e.message ?? "Unexpected error saving script")
setErrorAlert(e.message ?? "Unexpected error saving script");
setClosing(false);
}
})();
}
};
const cancelClicked = () =>
{
setClosing(true);
closeCallback(null, "cancelled");
}
};
const updateCode = (value: string, event: any, index: number) =>
{
fileContents[openEditorFileNames[index]] = value;
forceUpdate();
}
};
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
{
setCommitMessage(event.target.value);
}
};
const closePromptForCommitMessage = (wasSaveClicked: boolean, message?: string) =>
{
setPromptForCommitMessageOpen(false);
if(wasSaveClicked)
if (wasSaveClicked)
{
setCommitMessage(message)
setCommitMessage(message);
saveClicked(message);
}
else
{
setClosing(false);
}
}
};
const changeApiName = (apiNamePossibleValue?: QPossibleValue) =>
{
if(apiNamePossibleValue)
if (apiNamePossibleValue)
{
setApiName(apiNamePossibleValue.id);
}
@ -358,11 +359,11 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
setApiName(null);
}
}
};
const changeApiVersion = (apiVersionPossibleValue?: QPossibleValue) =>
{
if(apiVersionPossibleValue)
if (apiVersionPossibleValue)
{
setApiVersion(apiVersionPossibleValue.id);
}
@ -370,33 +371,33 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
setApiVersion(null);
}
}
};
const handleSelectingFile = (event: SelectChangeEvent, index: number) =>
{
openEditorFileNames[index] = event.target.value
openEditorFileNames[index] = event.target.value;
setOpenEditorFileNames(openEditorFileNames);
forceUpdate();
}
};
const splitEditorClicked = () =>
{
openEditorFileNames.push(availableFileNames[0])
openEditorFileNames.push(availableFileNames[0]);
setOpenEditorFileNames(openEditorFileNames);
forceUpdate();
}
};
const closeEditorClicked = (index: number) =>
{
openEditorFileNames.splice(index, 1)
openEditorFileNames.splice(index, 1);
setOpenEditorFileNames(openEditorFileNames);
forceUpdate();
}
};
const computeEditorWidth = (): string =>
{
return (100 / openEditorFileNames.length) + "%"
}
return (100 / openEditorFileNames.length) + "%";
};
return (
<Box className="scriptEditor" sx={{position: "absolute", overflowY: "auto", height: "100%", width: "100%"}} p={6}>
@ -408,7 +409,7 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
return;
}
setErrorAlert("")
setErrorAlert("");
}} anchorOrigin={{vertical: "top", horizontal: "center"}}>
<Alert color="error" onClose={() => setErrorAlert("")}>
{errorAlert}
@ -440,10 +441,10 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
<Box sx={{height: openTool ? "45%" : "100%"}}>
<Grid container alignItems="flex-end">
<Box maxWidth={"50%"} minWidth={300}>
<DynamicSelect fieldName={"apiName"} initialValue={apiName} initialDisplayValue={apiNameLabel} fieldLabel={"API Name *"} tableName={"scriptRevision"} inForm={false} onChange={changeApiName} />
<DynamicSelect fieldPossibleValueProps={{tableName: "scriptRevision", fieldName: "apiName", initialDisplayValue: apiNameLabel}} initialValue={apiName} fieldLabel={"API Name *"} inForm={false} onChange={changeApiName} useCase="form" />
</Box>
<Box maxWidth={"50%"} minWidth={300} pl={2}>
<DynamicSelect fieldName={"apiVersion"} initialValue={apiVersion} initialDisplayValue={apiVersionLabel} fieldLabel={"API Version *"} tableName={"scriptRevision"} inForm={false} onChange={changeApiVersion} />
<DynamicSelect fieldPossibleValueProps={{tableName: "scriptRevision", fieldName: "apiVersion", initialDisplayValue: apiVersionLabel}} initialValue={apiVersion} fieldLabel={"API Version *"} inForm={false} onChange={changeApiVersion} useCase="form" />
</Box>
</Grid>
<Box display="flex" sx={{height: "100%"}}>
@ -464,19 +465,19 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
<Box>
{
openEditorFileNames.length > 1 &&
<Tooltip title="Close this editor split" enterDelay={500}>
<IconButton size="small" onClick={() => closeEditorClicked(index)}>
<Icon>close</Icon>
</IconButton>
</Tooltip>
<Tooltip title="Close this editor split" enterDelay={500}>
<IconButton size="small" onClick={() => closeEditorClicked(index)}>
<Icon>close</Icon>
</IconButton>
</Tooltip>
}
{
index == openEditorFileNames.length - 1 &&
<Tooltip title="Open a new editor split" enterDelay={500}>
<IconButton size="small" onClick={splitEditorClicked}>
<Icon>vertical_split</Icon>
</IconButton>
</Tooltip>
<Tooltip title="Open a new editor split" enterDelay={500}>
<IconButton size="small" onClick={splitEditorClicked}>
<Icon>vertical_split</Icon>
</IconButton>
</Tooltip>
}
</Box>
</Box>
@ -526,29 +527,29 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
</Grid>
</Box>
<CommitMessagePrompt isOpen={promptForCommitMessageOpen} closeHandler={closePromptForCommitMessage}/>
<CommitMessagePrompt isOpen={promptForCommitMessageOpen} closeHandler={closePromptForCommitMessage} />
</Card>
</Box>
);
}
function CommitMessagePrompt(props: {isOpen: boolean, closeHandler: (wasSaveClicked: boolean, message?: string) => void})
function CommitMessagePrompt(props: { isOpen: boolean, closeHandler: (wasSaveClicked: boolean, message?: string) => void })
{
const [commitMessage, setCommitMessage] = useState("No commit message given")
const [commitMessage, setCommitMessage] = useState("No commit message given");
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
{
setCommitMessage(event.target.value);
}
};
const keyPressHandler = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
if(e.key === "Enter")
if (e.key === "Enter")
{
props.closeHandler(true, commitMessage);
}
}
};
return (
<Dialog
@ -579,10 +580,10 @@ function CommitMessagePrompt(props: {isOpen: boolean, closeHandler: (wasSaveClic
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={() => props.closeHandler(false)} disabled={false} />
<QSaveButton label="Save" onClickHandler={() => props.closeHandler(true, commitMessage)} disabled={false}/>
<QSaveButton label="Save" onClickHandler={() => props.closeHandler(true, commitMessage)} disabled={false} />
</DialogActions>
</Dialog>
)
);
}
export default ScriptEditor;

View File

@ -24,9 +24,8 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert} from "@mui/material";
import {Alert, Box} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon";
@ -37,6 +36,7 @@ import Typography from "@mui/material/Typography";
import FormData from "form-data";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton} from "qqq/components/buttons/DefaultButtons";
import DynamicSelect, {getAutocompleteOutlinedStyle} from "qqq/components/forms/DynamicSelect";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useReducer, useState} from "react";
@ -75,6 +75,17 @@ const defaultScope = scopeOptions[0];
const qController = Client.getInstance();
interface ShareableTableMetaData
{
sharedRecordTableName: string;
assetIdFieldName: string;
scopeFieldName: string;
audienceTypesPossibleValueSourceName: string;
audiencePossibleValueSourceName: string;
thisTableOwnerIdFieldName: string;
audienceTypes: {[name: string]: any}; // values here are: ShareableAudienceType
}
/*******************************************************************************
** component containing a Modal dialog for sharing records
*******************************************************************************/
@ -83,6 +94,7 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
const [statusString, setStatusString] = useState("Loading...");
const [alert, setAlert] = useState(null as string);
const [selectedAudienceOption, setSelectedAudienceOption] = useState(null as {id: string, label: string});
const [selectedAudienceType, setSelectedAudienceType] = useState(null);
const [selectedAudienceId, setSelectedAudienceId] = useState(null);
const [selectedScopeId, setSelectedScopeId] = useState(defaultScope.id);
@ -92,8 +104,14 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
const [needToLoadCurrentShares, setNeedToLoadCurrentShares] = useState(true);
const [everLoadedCurrentShares, setEverLoadedCurrentShares] = useState(false);
const shareableTableMetaData = tableMetaData.shareableTableMetaData as ShareableTableMetaData;
const [, forceUpdate] = useReducer((x) => x + 1, 0);
if(!shareableTableMetaData)
{
console.error(`Did not find a shareableTableMetaData on table ${tableMetaData.name}`);
}
/////////////////////////////////////////////////////////
// trigger initial load, and post any changes, re-load //
@ -124,7 +142,7 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
/*******************************************************************************
**
*******************************************************************************/
function handleAudienceChange(event: React.SyntheticEvent, value: any | any[], reason: string)
function handleAudienceChange(value: any | any[], reason: string)
{
if(value)
{
@ -260,6 +278,7 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
const result = processResult as QJobComplete;
setStatusString(null);
setAlert(null);
setSelectedAudienceOption(null);
setNeedToLoadCurrentShares(true);
setSubmitting(false)
}
@ -297,16 +316,6 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
}
// todo - need this to be real
const audienceOptions = [
{id: "user:1", label: "Darin Kelkhoff"},
{id: "user:2", label: "Tom Chutterloin"},
{id: "user:3", label: "Tylers Ample"},
{id: "user:4", label: "Mames Mames"},
{id: "group:2", label: "Cold Track Engineering"}
];
/*******************************************************************************
**
*******************************************************************************/
@ -329,10 +338,11 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
*******************************************************************************/
function renderScopeDropdown(id: string, defaultValue: Scope, onChange: (event: React.SyntheticEvent, value: any | any[], reason: string) => void)
{
const isDisabled = (id == "new-share-scope" && submitting);
return (
<Autocomplete
id={id}
disabled={id == "new-share-scope" && submitting}
disabled={isDisabled}
renderInput={(params) => (<TextField {...params} label="Scope" variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
options={scopeOptions}
// @ts-ignore
@ -345,7 +355,7 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
autoHighlight={true}
disableClearable
fullWidth
sx={autocompleteSX}
sx={getAutocompleteOutlinedStyle(isDisabled)}
/>
);
}
@ -359,17 +369,18 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
<Card sx={{my: 5, mx: "auto", p: 3}}>
{/* header */}
<Box display="flex" flexDirection="row" justifyContent="space-between" alignItems="flex-start">
<Box display="flex" flexDirection="row" justifyContent="space-between" alignItems="flex-start" maxWidth="590px">
<Typography variant="h4" pb={1} fontWeight="600">
Share {tableMetaData.label}: {record?.recordLabel ?? record?.values?.get(tableMetaData.primaryKeyField) ?? "Unknown"}
<Box color={colors.gray.main} pb={"0.5rem"} fontSize={"0.875rem"} fontWeight="400" maxWidth="590px">
<Box color={colors.gray.main} pb={"0.5rem"} fontSize={"0.875rem"} fontWeight="400">
{/* todo move to helpContent (what do we attach the meta-data too??) */}
Select a user or a group to share this record with.
You can choose if they should only be able to Read the record, or also make Edits to it.
{/*You can choose if they should only be able to Read the record, or also make Edits to it.*/}
</Box>
<Box fontSize={14} maxWidth="590px" pb={1} fontWeight="300">
<Box fontSize={14} pb={1} fontWeight="300">
{alert && <Alert color="error" onClose={() => setAlert(null)}>{alert}</Alert>}
{statusString}&nbsp;
{statusString}
{!alert && !statusString && (<>&nbsp;</>)}
</Box>
</Typography>
</Box>
@ -378,26 +389,22 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
<Box pb={3} display="flex" flexDirection="column">
{/* row for adding a new share */}
<Box display="flex" flexDirection="row" alignItems="center">
<Box width="350px" pr={2}>
<Autocomplete
id="new-share-audience"
disabled={submitting}
renderInput={(params) => (<TextField {...params} label="User or Group" variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
options={audienceOptions}
<Box width="550px" pr={2} mb={-1.5}>
<DynamicSelect
fieldPossibleValueProps={{possibleValueSourceName: shareableTableMetaData.audiencePossibleValueSourceName, initialDisplayValue: selectedAudienceOption?.label}}
fieldLabel="User or Group" // todo should come from shareableTableMetaData
initialValue={selectedAudienceOption?.id}
inForm={false}
onChange={handleAudienceChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
// @ts-ignore Property label does not exist on string | {thing with label}
getOptionLabel={(option) => option.label}
autoSelect={true}
autoHighlight={true}
disableClearable
fullWidth
sx={autocompleteSX}
useCase="form"
/>
</Box>
<Box width="180px" pr={2}>
{renderScopeDropdown("new-share-scope", defaultScope, handleScopeChange)}
</Box>
{/*
when turning scope back on, change width of audience box to 350px
<Box width="180px" pr={2}>
{renderScopeDropdown("new-share-scope", defaultScope, handleScopeChange)}
</Box>
*/}
<Box>
<Tooltip title={selectedAudienceId == null ? "Select a user or group to share with." : null}>
<span>
@ -418,20 +425,25 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
}
</h5>
</Box>
<Box sx={{border: `1px solid ${colors.grayLines.main}`, borderRadius: "1rem", overflow: "auto"}} height="180px" pt="0.5rem">
{
currentShares.map((share) => (
<Box key={share.shareId} display="flex" justifyContent="space-between" alignItems="center" p="0.25rem" fontSize="1rem">
<Box display="flex" alignItems="center">
<Box width="310px" pl="1rem">{share.audienceLabel}</Box>
<Box width="160px">{renderScopeDropdown(`scope-${share.shareId}`, getScopeOption(share.scopeId), (event: React.SyntheticEvent, value: any | any[], reason: string) => editingExistingShareScope(share.shareId, value))}</Box>
<Box sx={{border: `1px solid ${colors.grayLines.main}`, borderRadius: "1rem", overflow: "hidden"}}>
<Box sx={{overflow: "auto"}} height="210px" pt="0.75rem">
{
currentShares.map((share) => (
<Box key={share.shareId} display="flex" justifyContent="space-between" alignItems="center" p="0.25rem" pb="0.75rem" fontSize="1rem">
<Box display="flex" alignItems="center">
<Box width="490px" pl="1rem">{share.audienceLabel}</Box>
{/*
when turning scope back on, change width of audience box to 310px
<Box width="160px">{renderScopeDropdown(`scope-${share.shareId}`, getScopeOption(share.scopeId), (event: React.SyntheticEvent, value: any | any[], reason: string) => editingExistingShareScope(share.shareId, value))}</Box>
*/}
</Box>
<Box pr="1rem">
<Button sx={{...iconButtonSX, ...redIconButton}} onClick={() => removeShare(share.shareId)}><Icon>clear</Icon></Button>
</Box>
</Box>
<Box pr="1rem">
<Button sx={{...iconButtonSX, ...redIconButton}} onClick={() => removeShare(share.shareId)}><Icon>clear</Icon></Button>
</Box>
</Box>
))
}
))
}
</Box>
</Box>
</Box>
@ -448,12 +460,6 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
}
const autocompleteSX =
{
"& .MuiAutocomplete-input": {padding: "0.125rem 0.5rem !important"},
"& .MuiOutlinedInput-root": {borderRadius: "0.75rem !important"}
};
const iconButtonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,

View File

@ -22,16 +22,25 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box, Skeleton} from "@mui/material";
import React from "react";
import Card from "@mui/material/Card";
import Modal from "@mui/material/Modal";
import parse from "html-react-parser";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
import React, {useEffect, useState} from "react";
interface CompositeData
export interface CompositeData
{
blockId: string;
blocks: BlockData[];
styleOverrides?: any;
layout?: string
layout?: string;
overlayHtml?: string;
overlayStyleOverrides?: any;
modalMode: string;
styles?: any;
}
@ -39,13 +48,15 @@ interface CompositeWidgetProps
{
widgetMetaData: QWidgetMetaData;
data: CompositeData;
actionCallback?: (blockData: BlockData, eventValues?: { [name: string]: any }) => boolean;
values?: { [key: string]: any };
}
/*******************************************************************************
** Widget which is a list of Blocks.
*******************************************************************************/
export default function CompositeWidget({widgetMetaData, data}: CompositeWidgetProps): JSX.Element
export default function CompositeWidget({widgetMetaData, data, actionCallback, values}: CompositeWidgetProps): JSX.Element
{
if (!data || !data.blocks)
{
@ -57,20 +68,41 @@ export default function CompositeWidget({widgetMetaData, data}: CompositeWidgetP
////////////////////////////////////////////////////////////////////////////////////
let layout = data?.layout;
let boxStyle: any = {};
if (layout == "FLEX_ROW_WRAPPED")
if (layout == "FLEX_COLUMN")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "column";
boxStyle.flexWrap = "wrap";
boxStyle.gap = "0.5rem";
}
else if (layout == "FLEX_ROW_WRAPPED")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.flexWrap = "wrap";
boxStyle.gap = "0.5rem";
}
else if (layout == "FLEX_ROW")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.gap = "0.5rem";
}
else if (layout == "FLEX_ROW_SPACE_BETWEEN")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.justifyContent = "space-between"
boxStyle.justifyContent = "space-between";
boxStyle.gap = "0.25rem";
}
else if (layout == "FLEX_ROW_CENTER")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.justifyContent = "center";
boxStyle.gap = "0.25rem";
boxStyle.flexWrap = "wrap";
}
else if (layout == "TABLE_SUB_ROW_DETAILS")
{
boxStyle.display = "flex";
@ -90,20 +122,96 @@ export default function CompositeWidget({widgetMetaData, data}: CompositeWidgetP
boxStyle.borderRadius = "0.5rem";
boxStyle.background = "#FFFFFF";
}
if (data?.styleOverrides)
{
boxStyle = {...boxStyle, ...data.styleOverrides};
}
return (<Box sx={boxStyle} className="compositeWidget">
if (data.styles?.backgroundColor)
{
boxStyle.backgroundColor = ProcessWidgetBlockUtils.processColorFromStyleMap(data.styles.backgroundColor);
}
if (data.styles?.padding)
{
boxStyle.paddingTop = data.styles?.padding.top + "px"
boxStyle.paddingBottom = data.styles?.padding.bottom + "px"
boxStyle.paddingLeft = data.styles?.padding.left + "px"
boxStyle.paddingRight = data.styles?.padding.right + "px"
}
let overlayStyle: any = {};
if (data?.overlayStyleOverrides)
{
overlayStyle = {...overlayStyle, ...data.overlayStyleOverrides};
}
const content = (
<>
{
data?.overlayHtml &&
<Box sx={overlayStyle} className="blockWidgetOverlay">{parse(data.overlayHtml)}</Box>
}
<Box sx={boxStyle} className="compositeWidget">
{
data.blocks.map((block: BlockData, index) => (
<React.Fragment key={index}>
<WidgetBlock widgetMetaData={widgetMetaData} block={block} actionCallback={actionCallback} values={values} />
</React.Fragment>
))
}
</Box>
</>
);
if (data.modalMode)
{
const [isModalOpen, setIsModalOpen] = useState(values && (values[data.blockId] == true));
/***************************************************************************
**
***************************************************************************/
const controlCallback = (newValue: boolean) =>
{
data.blocks.map((block: BlockData, index) => (
<React.Fragment key={index}>
<WidgetBlock widgetMetaData={widgetMetaData} block={block} />
</React.Fragment>
))
}
</Box>);
setIsModalOpen(newValue);
};
/***************************************************************************
**
***************************************************************************/
const modalOnClose = (event: object, reason: string) =>
{
values[data.blockId] = false;
setIsModalOpen(false);
actionCallback({blockTypeName: "BUTTON", values: {}}, {controlCode: `hideModal:${data.blockId}`});
};
//////////////////////////////////////////////////////////////////////////////////////////
// register the control-callback function - so when buttons are clicked, we can be told //
//////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (actionCallback)
{
actionCallback(null, {
registerControlCallbackName: data.blockId,
registerControlCallbackFunction: controlCallback
});
}
}, []);
return (<Modal open={isModalOpen} onClose={modalOnClose}>
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
<Card sx={{my: 5, mx: "auto", p: "1rem", maxWidth: "1024px"}}>
{content}
</Card>
</Box>
</Modal>);
}
else
{
return content;
}
}

View File

@ -18,15 +18,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert, Skeleton} from "@mui/material";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import Modal from "@mui/material/Modal";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import parse from "html-react-parser";
import QContext from "QContext";
import EntityForm from "qqq/components/forms/EntityForm";
import MDTypography from "qqq/components/legacy/MDTypography";
import TabPanel from "qqq/components/misc/TabPanel";
import BarChart from "qqq/components/widgets/charts/barchart/BarChart";
@ -38,18 +41,19 @@ import StackedBarChart from "qqq/components/widgets/charts/StackedBarChart";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import DataBagViewer from "qqq/components/widgets/misc/DataBagViewer";
import DividerWidget from "qqq/components/widgets/misc/Divider";
import DynamicFormWidget from "qqq/components/widgets/misc/DynamicFormWidget";
import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget";
import FilterAndColumnsSetupWidget from "qqq/components/widgets/misc/FilterAndColumnsSetupWidget";
import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget";
import QuickSightChart from "qqq/components/widgets/misc/QuickSightChart";
import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget";
import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget";
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer";
import StepperCard from "qqq/components/widgets/misc/StepperCard";
import USMapWidget from "qqq/components/widgets/misc/USMapWidget";
import ParentWidget from "qqq/components/widgets/ParentWidget";
import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard";
import StatisticsCard from "qqq/components/widgets/statistics/StatisticsCard";
import Widget, {HeaderIcon, LabelComponent, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT} from "qqq/components/widgets/Widget";
import Widget, {HeaderIcon, LabelComponent, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT, WidgetData} from "qqq/components/widgets/Widget";
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
import ProcessRun from "qqq/pages/processes/ProcessRun";
import Client from "qqq/utils/qqq/Client";
@ -70,6 +74,9 @@ interface Props
childUrlParams?: string;
parentWidgetMetaData?: QWidgetMetaData;
wrapWidgetsInTabPanels: boolean;
actionCallback?: (data: any, eventValues?: { [name: string]: any }) => boolean;
initialWidgetDataList: any[];
values?: { [key: string]: any };
}
DashboardWidgets.defaultProps = {
@ -81,11 +88,14 @@ DashboardWidgets.defaultProps = {
childUrlParams: "",
parentWidgetMetaData: null,
wrapWidgetsInTabPanels: false,
actionCallback: null,
initialWidgetDataList: null,
values: {}
};
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels, actionCallback, initialWidgetDataList, values}: Props): JSX.Element
{
const [widgetData, setWidgetData] = useState([] as any[]);
const [widgetData, setWidgetData] = useState(initialWidgetDataList == null ? [] as any[] : initialWidgetDataList);
const [widgetCounter, setWidgetCounter] = useState(0);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -93,6 +103,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
const [haveLoadedParams, setHaveLoadedParams] = useState(false);
const {accentColor} = useContext(QContext);
/////////////////////////
// modal form controls //
/////////////////////////
const [showEditChildForm, setShowEditChildForm] = useState(null as any);
let initialSelectedTab = 0;
let selectedTabKey: string = null;
if (parentWidgetMetaData && wrapWidgetsInTabPanels)
@ -113,7 +128,15 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
useEffect(() =>
{
if (initialWidgetDataList && initialWidgetDataList.length > 0)
{
// todo actually, should this check each element of the array, down in the loop? yeah, when we need to, do it that way.
console.log("We already have initial widget data, so not fetching from backend.");
return;
}
setWidgetData([]);
for (let i = 0; i < widgetMetaDataList.length; i++)
{
const widgetMetaData = widgetMetaDataList[i];
@ -150,7 +173,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
const reloadWidget = async (index: number, data: string) =>
{
(async () =>
await (async () =>
{
const urlParams = getQueryParams(widgetMetaDataList[index], data);
setCurrentUrlParams(urlParams);
@ -257,11 +280,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
** helper function, to convert values from a QRecord values map to a regular old
** js object
*******************************************************************************/
function convertQRecordValuesFromMapToObject(record: QRecord): {[name: string]: any}
function convertQRecordValuesFromMapToObject(record: QRecord): { [name: string]: any }
{
const rs: {[name: string]: any} = {};
const rs: { [name: string]: any } = {};
if(record.values)
if (record && record.values)
{
record.values.forEach((value, key) => rs[key] = value);
}
@ -269,6 +292,150 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
const closeEditChildForm = (event: object, reason: string) =>
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
return;
}
setShowEditChildForm(null);
};
/*******************************************************************************
**
*******************************************************************************/
function deleteChildRecord(name: string, widgetIndex: number, rowIndex: number)
{
updateChildRecordList(name, "delete", rowIndex);
actionCallback(widgetData[widgetIndex]);
};
/*******************************************************************************
**
*******************************************************************************/
function openEditChildRecord(name: string, widgetData: any, rowIndex: number)
{
let defaultValues = widgetData.queryOutput.records[rowIndex].values;
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if (!disabledFields)
{
disabledFields = widgetData.defaultValuesForNewChildRecords;
}
doOpenEditChildForm(name, widgetData.childTableMetaData, rowIndex, defaultValues, disabledFields);
}
/*******************************************************************************
**
*******************************************************************************/
function openAddChildRecord(name: string, widgetData: any)
{
let defaultValues = widgetData.defaultValuesForNewChildRecords;
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if (!disabledFields)
{
disabledFields = widgetData.defaultValuesForNewChildRecords;
}
doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields);
}
/*******************************************************************************
**
*******************************************************************************/
function doOpenEditChildForm(widgetName: string, table: QTableMetaData, rowIndex: number, defaultValues: any, disabledFields: any)
{
const showEditChildForm: any = {};
showEditChildForm.widgetName = widgetName;
showEditChildForm.table = table;
showEditChildForm.rowIndex = rowIndex;
showEditChildForm.defaultValues = defaultValues;
showEditChildForm.disabledFields = disabledFields;
setShowEditChildForm(showEditChildForm);
}
/*******************************************************************************
**
*******************************************************************************/
function submitEditChildForm(values: any)
{
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
let widgetIndex = determineChildRecordListIndex(showEditChildForm.widgetName);
actionCallback(widgetData[widgetIndex]);
}
/*******************************************************************************
**
*******************************************************************************/
function determineChildRecordListIndex(widgetName: string): number
{
let widgetIndex = -1;
for (var i = 0; i < widgetMetaDataList.length; i++)
{
const widgetMetaData = widgetMetaDataList[i];
if (widgetMetaData.name == widgetName)
{
widgetIndex = i;
break;
}
}
return (widgetIndex);
}
/*******************************************************************************
**
*******************************************************************************/
function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any)
{
////////////////////////////////////////////////
// find the correct child record widget index //
////////////////////////////////////////////////
let widgetIndex = determineChildRecordListIndex(widgetName);
if (!widgetData[widgetIndex].queryOutput.records)
{
widgetData[widgetIndex].queryOutput.records = [];
}
const newChildListWidgetData: ChildRecordListData = widgetData[widgetIndex];
if (!newChildListWidgetData.queryOutput.records)
{
newChildListWidgetData.queryOutput.records = [];
}
switch (action)
{
case "insert":
newChildListWidgetData.queryOutput.records.push({values: values});
break;
case "edit":
newChildListWidgetData.queryOutput.records[rowIndex] = {values: values};
break;
case "delete":
newChildListWidgetData.queryOutput.records.splice(rowIndex, 1);
break;
}
newChildListWidgetData.totalRows = newChildListWidgetData.queryOutput.records.length;
widgetData[widgetIndex] = newChildListWidgetData;
setWidgetData(widgetData);
setShowEditChildForm(null);
}
const renderWidget = (widgetMetaData: QWidgetMetaData, i: number): JSX.Element =>
{
const labelAdditionalComponentsRight: LabelComponent[] = [];
@ -292,7 +459,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
}
return (
<Box key={`${widgetMetaData.name}-${i}`} sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px", width: "100%", height: "100%"}}>
<Box key={`${widgetMetaData.name}-${i}`} sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px", width: "100%", height: "100%", flexDirection: widgetMetaData.type == "multiTable" ? "column" : "row"}}>
{
haveLoadedParams && widgetMetaData.type === "parentWidget" && (
<ParentWidget
@ -308,7 +475,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
)
}
{
widgetMetaData.type === "alert" && widgetData[i]?.html && (
widgetMetaData.type === "alert" && widgetData[i]?.html && !widgetData[i]?.hideWidget && (
<Widget
omitPadding={true}
widgetMetaData={widgetMetaData}
@ -318,7 +485,16 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<Alert severity={widgetData[i]?.alertType?.toLowerCase()}>{parse(widgetData[i]?.html)}</Alert>
<Alert severity={widgetData[i]?.alertType?.toLowerCase()}>
{parse(widgetData[i]?.html)}
{widgetData[i]?.bulletList && (
<div style={{fontSize: "14px"}}>
{widgetData[i].bulletList.map((bullet: string, index: number) =>
<li key={`widget-${i}-${index}`}>{parse(bullet)}</li>
)}
</div>
)}
</Alert>
</Widget>
)
}
@ -342,6 +518,20 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
/>
)
}
{
widgetMetaData.type === "multiTable" && (
widgetData[i]?.tableDataList?.map((tableData: WidgetData, index: number) =>
<Box pb={3} key={`${widgetMetaData.type}-${index}`}>
<TableWidget
widgetMetaData={widgetMetaData}
widgetData={tableData}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
/>
</Box>
)
)
}
{
widgetMetaData.type === "stackedBarChart" && (
<Widget
@ -486,9 +676,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
}
{
widgetMetaData.type === "divider" && (
<Box>
<DividerWidget />
</Box>
<DividerWidget />
)
}
{
@ -522,6 +710,12 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetMetaData.type === "childRecordList" && (
widgetData && widgetData[i] &&
<RecordGridWidget
disableRowClick={widgetData[i]?.disableRowClick}
allowRecordEdit={widgetData[i]?.allowRecordEdit}
allowRecordDelete={widgetData[i]?.allowRecordDelete}
deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, i, rowIndex)}
editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData[i], rowIndex)}
addNewRecordCallback={widgetData[i]?.isInProcess ? () => openAddChildRecord(widgetMetaData.name, widgetData[i]) : null}
widgetMetaData={widgetMetaData}
data={widgetData[i]}
/>
@ -548,7 +742,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<CompositeWidget widgetMetaData={widgetMetaData} data={widgetData[i]} />
<CompositeWidget widgetMetaData={widgetMetaData} data={widgetData[i]} actionCallback={actionCallback} values={values} />
</Widget>
)
}
@ -583,17 +777,25 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
)
}
{
widgetMetaData.type === "reportSetup" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&
<ReportSetupWidget isEditable={false} widgetMetaData={widgetMetaData} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
{}} />
widgetMetaData.type === "filterAndColumnsSetup" && (
widgetData && widgetData[i] &&
<FilterAndColumnsSetupWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
{
}} />
)
}
{
widgetMetaData.type === "pivotTableSetup" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&
<PivotTableSetupWidget isEditable={false} widgetMetaData={widgetMetaData} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
{}} />
{
}} />
)
}
{
widgetMetaData.type === "dynamicForm" && (
widgetData && widgetData[i] &&
<DynamicFormWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} record={record} recordValues={convertQRecordValuesFromMapToObject(record)} />
)
}
</Box>
@ -615,8 +817,28 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
if (!omitWrappingGridContainer)
{
// @ts-ignore
renderedWidget = (<Grid id={widgetMetaData.name} item xxl={widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12} xs={12} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
const gridProps: { [key: string]: any } = {};
for (let size of ["xs", "sm", "md", "lg", "xl", "xxl"])
{
const key = `gridCols:sizeClass:${size}`;
if (widgetMetaData?.defaultValues?.has(key))
{
gridProps[size] = widgetMetaData?.defaultValues.get(key);
}
}
if (!gridProps["xxl"])
{
gridProps["xxl"] = widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12;
}
if (!gridProps["xs"])
{
gridProps["xs"] = 12;
}
renderedWidget = (<Grid id={widgetMetaData.name} item {...gridProps} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
{renderedWidget}
</Grid>);
}
@ -667,6 +889,22 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
</Grid>
)
}
{
showEditChildForm &&
<Modal open={showEditChildForm as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
<div className="modalEditForm">
<EntityForm
isModal={true}
closeModalHandler={closeEditChildForm}
table={showEditChildForm.table}
defaultValues={showEditChildForm.defaultValues}
disabledFields={showEditChildForm.disabledFields}
onSubmitCallback={submitEditChildForm}
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
/>
</div>
</Modal>
}
</>
) : null
);

View File

@ -21,8 +21,7 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import {Box, InputLabel} from "@mui/material";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon";
@ -196,8 +195,6 @@ export function HeaderLinkButtonComponent({label, onClickCallback, disabled, dis
}
/*******************************************************************************
**
*******************************************************************************/
@ -220,7 +217,7 @@ export function HeaderToggleComponent({label, getValue, onClickCallback, disable
const onClick = () =>
{
onClickCallback();
}
};
return (
<Box alignItems="baseline" mr="-0.75rem">
@ -236,7 +233,6 @@ export function HeaderToggleComponent({label, getValue, onClickCallback, disable
}
/*******************************************************************************
**
*******************************************************************************/
@ -698,7 +694,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
);
let sublabelElement = (
<Box height="20px">
<Box key="sublabel" height="20px">
<Typography sx={{position: "relative", top: "-18px"}} variant="caption">
{props.widgetData?.sublabel}
</Typography>
@ -785,7 +781,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
{localLabelAdditionalElementsLeft}
</Box>
<Box display="flex">
<Box key="sublabelContainer" display="flex">
{
hasPermission && props.widgetData?.sublabel && (sublabelElement)
}

View File

@ -22,6 +22,9 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Alert, Skeleton} from "@mui/material";
import ButtonBlock from "qqq/components/widgets/blocks/ButtonBlock";
import AudioBlock from "qqq/components/widgets/blocks/AudioBlock";
import InputFieldBlock from "qqq/components/widgets/blocks/InputFieldBlock";
import React from "react";
import BigNumberBlock from "qqq/components/widgets/blocks/BigNumberBlock";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
@ -32,19 +35,22 @@ import TableSubRowDetailRowBlock from "qqq/components/widgets/blocks/TableSubRow
import TextBlock from "qqq/components/widgets/blocks/TextBlock";
import UpOrDownNumberBlock from "qqq/components/widgets/blocks/UpOrDownNumberBlock";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import ImageBlock from "./blocks/ImageBlock";
interface WidgetBlockProps
{
widgetMetaData: QWidgetMetaData;
block: BlockData;
actionCallback?: (blockData: BlockData, eventValues?: {[name: string]: any}) => boolean;
values?: { [key: string]: any };
}
/*******************************************************************************
** Component to render a single Block in the widget framework!
*******************************************************************************/
export default function WidgetBlock({widgetMetaData, block}: WidgetBlockProps): JSX.Element
export default function WidgetBlock({widgetMetaData, block, actionCallback, values}: WidgetBlockProps): JSX.Element
{
if(!block)
{
@ -64,7 +70,7 @@ export default function WidgetBlock({widgetMetaData, block}: WidgetBlockProps):
if(block.blockTypeName == "COMPOSITE")
{
// @ts-ignore - special case for composite type block...
return (<CompositeWidget widgetMetaData={widgetMetaData} data={block} />);
return (<CompositeWidget widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} values={values} />);
}
switch(block.blockTypeName)
@ -83,6 +89,14 @@ export default function WidgetBlock({widgetMetaData, block}: WidgetBlockProps):
return (<DividerBlock widgetMetaData={widgetMetaData} data={block} />);
case "BIG_NUMBER":
return (<BigNumberBlock widgetMetaData={widgetMetaData} data={block} />);
case "INPUT_FIELD":
return (<InputFieldBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
case "BUTTON":
return (<ButtonBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
case "AUDIO":
return (<AudioBlock widgetMetaData={widgetMetaData} data={block} />);
case "IMAGE":
return (<ImageBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
default:
return (<Alert sx={{m: "0.5rem"}} color="warning">Unsupported block type: {block.blockTypeName}</Alert>)
}

View File

@ -21,14 +21,16 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import React from "react";
import colors from "qqq/assets/theme/base/colors";
import {WidgetData} from "qqq/components/widgets/Widget";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React from "react";
import {Link} from "react-router-dom";
/*******************************************************************************
** Utility class used by Widgets
@ -51,6 +53,17 @@ export class WidgetUtils
};
/*******************************************************************************
**
*******************************************************************************/
public static generateLabelLink = (linkText: string, linkURL: string): JSX.Element =>
{
return (<Box key={1} fontSize="1rem" pl={1} display="inline" position="relative">
(<Link to={linkURL}>{linkText}</Link>)
</Box>);
};
/*******************************************************************************
**
*******************************************************************************/
@ -97,4 +110,4 @@ export class WidgetUtils
return (fileName);
};
}
}

View File

@ -0,0 +1,40 @@
/*
* 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 Box from "@mui/material/Box";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import DumpJsonBox from "qqq/utils/DumpJsonBox";
import React from "react";
/*******************************************************************************
** Block that renders ... an audio tag
**
** <audio src=${path} ${autoPlay} ${showControls} />
*******************************************************************************/
export default function AudioBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<audio src={data.values?.path} autoPlay={data.values?.autoPlay} controls={data.values?.showControls} />
</BlockElementWrapper>
);
}

View File

@ -21,18 +21,19 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Tooltip} from "@mui/material";
import React, {ReactElement, useContext} from "react";
import {Link} from "react-router-dom";
import {Box, Tooltip} from "@mui/material";
import QContext from "QContext";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import {BlockData, BlockLink, BlockTooltip} from "qqq/components/widgets/blocks/BlockModels";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import React, {ReactElement, useContext} from "react";
import {Link} from "react-router-dom";
interface BlockElementWrapperProps
{
data: BlockData;
metaData: QWidgetMetaData;
slot: string
slot: string;
linkProps?: any;
children: ReactElement;
}
@ -47,16 +48,16 @@ export default function BlockElementWrapper({data, metaData, slot, linkProps, ch
let link: BlockLink;
let tooltip: BlockTooltip;
if(slot)
if (slot)
{
link = data.linkMap && data.linkMap[slot.toUpperCase()];
if(!link)
if (!link)
{
link = data.link;
}
tooltip = data.tooltipMap && data.tooltipMap[slot.toUpperCase()];
if(!tooltip)
if (!tooltip)
{
tooltip = data.tooltip;
}
@ -67,9 +68,9 @@ export default function BlockElementWrapper({data, metaData, slot, linkProps, ch
tooltip = data.tooltip;
}
if(!tooltip)
if (!tooltip)
{
const helpRoles = ["ALL_SCREENS"]
const helpRoles = ["ALL_SCREENS"];
///////////////////////////////////////////////////////////////////////////////////////////////
// the full keys in the helpContent table will look like: //
@ -80,26 +81,39 @@ export default function BlockElementWrapper({data, metaData, slot, linkProps, ch
const key = data.blockId ? `${data.blockId},${slot}` : slot;
const showHelp = helpHelpActive || hasHelpContent(metaData?.helpContent?.get(key), helpRoles);
if(showHelp)
if (showHelp)
{
const formattedHelpContent = <HelpContent helpContents={metaData?.helpContent?.get(key)} roles={helpRoles} helpContentKey={`widget:${metaData?.name};slot:${key}`} />;
tooltip = {title: formattedHelpContent, placement: "bottom"}
tooltip = {title: formattedHelpContent, placement: "bottom"};
}
}
let rs = children;
if(link)
if (link && link.href)
{
rs = <Link to={link.href} target={link.target} style={{color: "#546E7A"}} {...linkProps}>{rs}</Link>
rs = <Link to={link.href} target={link.target} style={{color: "#546E7A"}} {...linkProps}>{rs}</Link>;
}
if(tooltip)
if (tooltip)
{
let placement = tooltip.placement ? tooltip.placement.toLowerCase() : "bottom"
let placement = tooltip.placement ? tooltip.placement.toLowerCase() : "bottom";
// @ts-ignore - placement possible values
rs = <Tooltip title={tooltip.title} placement={placement}>{rs}</Tooltip>
if (tooltip.blockData)
{
// @ts-ignore - special case for composite type block...
rs = <Tooltip title={
<Box sx={{width: "200px"}}>
<CompositeWidget widgetMetaData={metaData} data={tooltip?.blockData} />
</Box>
}>{rs}</Tooltip>;
}
else
{
// @ts-ignore - placement possible values
rs = <Tooltip title={tooltip.title} placement={placement}>{rs}</Tooltip>;
}
}
return (rs);

View File

@ -20,6 +20,7 @@
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {CompositeData} from "qqq/components/widgets/CompositeWidget";
export interface BlockData
@ -29,16 +30,19 @@ export interface BlockData
tooltip?: BlockTooltip;
link?: BlockLink;
tooltipMap?: {[slot: string]: BlockTooltip};
linkMap?: {[slot: string]: BlockLink};
tooltipMap?: { [slot: string]: BlockTooltip };
linkMap?: { [slot: string]: BlockLink };
values: any;
styles?: any;
conditional?: string;
}
export interface BlockTooltip
{
blockData?: CompositeData;
title: string | JSX.Element;
placement: string;
}
@ -55,5 +59,6 @@ export interface StandardBlockComponentProps
{
widgetMetaData: QWidgetMetaData;
data: BlockData;
actionCallback?: (blockData: BlockData, eventValues?: {[name: string]: any}) => boolean;
}

View File

@ -0,0 +1,86 @@
/*
* 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 Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import {standardWidth} from "qqq/components/buttons/DefaultButtons";
import MDButton from "qqq/components/legacy/MDButton";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import React from "react";
/*******************************************************************************
** Block that renders ... a button...
**
*******************************************************************************/
export default function ButtonBlock({widgetMetaData, data, actionCallback}: StandardBlockComponentProps): JSX.Element
{
const startIcon = data.values.startIcon?.name ? <Icon>{data.values.startIcon.name}</Icon> : null;
const endIcon = data.values.endIcon?.name ? <Icon>{data.values.endIcon.name}</Icon> : null;
function onClick()
{
if (actionCallback)
{
actionCallback(data, data.values);
}
else
{
console.log("ButtonBlock onClick with no actionCallback present, so, noop");
}
}
let buttonVariant: "gradient" | "outlined" | "text" = "gradient";
if (data.styles?.format == "outlined")
{
buttonVariant = "outlined";
}
else if (data.styles?.format == "text")
{
buttonVariant = "text";
}
else if (data.styles?.format == "filled")
{
buttonVariant = "gradient";
}
// todo - button colors... but to do RGB's, might need to move away from MDButton?
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<Box mx={1} my={1} minWidth={standardWidth}>
<MDButton
type="button"
variant={buttonVariant}
color="dark"
size="small"
fullWidth
startIcon={startIcon}
endIcon={endIcon}
onClick={onClick}
>
{data.values.label ?? "Button"}
</MDButton>
</Box>
</BlockElementWrapper>
);
}

View File

@ -0,0 +1,59 @@
/*
* 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 Box from "@mui/material/Box";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import DumpJsonBox from "qqq/utils/DumpJsonBox";
import React from "react";
/*******************************************************************************
** Block that renders ... an image tag
**
** <audio src=${path} ${autoPlay} ${showControls} />
*******************************************************************************/
export default function AudioBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
let imageStyle: any = {};
if(data.styles?.width)
{
imageStyle.width = data.styles?.width;
}
if(data.styles?.height)
{
imageStyle.height = data.styles?.height;
}
if(data.styles?.bordered)
{
imageStyle.border = "1px solid #C0C0C0";
imageStyle.borderRadius = "0.5rem";
}
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<img src={data.values?.path} alt={data.values?.alt} style={imageStyle} />
</BlockElementWrapper>
);
}

View File

@ -0,0 +1,139 @@
/*
* 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import Box from "@mui/material/Box";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import React, {SyntheticEvent, useState} from "react";
/*******************************************************************************
** Block that renders ... a text input
**
*******************************************************************************/
export default function InputFieldBlock({widgetMetaData, data, actionCallback}: StandardBlockComponentProps): JSX.Element
{
const [blurCount, setBlurCount] = useState(0)
const fieldMetaData = new QFieldMetaData(data.values.fieldMetaData);
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
let autoFocus = data.values.autoFocus as boolean
let value = data.values.value;
if(value == null || value == undefined)
{
value = "";
}
////////////////////////////////////////////////////////////////////////////////
// for an autoFocus field... //
// we're finding that if we blur it when clicking an action button, that //
// an un-desirable "now it's been touched, so show an error" happens. //
// so let us remove the default blur handler, for the first (auto) focus/blur //
// cycle, and we seem to have a better time. //
////////////////////////////////////////////////////////////////////////////////
let dynamicFormFieldRest: {onBlur?: any, sx?: any} = {}
if(autoFocus && blurCount == 0)
{
dynamicFormFieldRest.onBlur = (event: React.SyntheticEvent) =>
{
event.stopPropagation();
event.preventDefault();
setBlurCount(blurCount + 1);
}
}
/***************************************************************************
**
***************************************************************************/
function eventHandler(event: KeyboardEvent)
{
if(data.values.submitOnEnter && event.key == "Enter")
{
// @ts-ignore target.value...
const inputValue = event.target.value?.trim()
// todo - make this behavior opt-in for inputBlocks?
if(inputValue && `${inputValue}`.startsWith("->"))
{
const actionCode = inputValue.substring(2);
if(actionCallback)
{
actionCallback(data, {actionCode: actionCode, _fieldToClearIfError: fieldMetaData.name});
///////////////////////////////////////////////////////
// return, so we don't submit the actionCode as text //
///////////////////////////////////////////////////////
return;
}
}
if(fieldMetaData.isRequired && inputValue == "")
{
console.log("input field is required, but missing value, so not submitting");
return;
}
if(actionCallback)
{
console.log("InputFieldBlock calling actionCallback for submitOnEnter");
let values: {[name: string]: any} = {};
values[fieldMetaData.name] = inputValue;
actionCallback(data, values);
}
else
{
console.log("InputFieldBlock was set as submitOnEnter, but no actionCallback was present, so, noop");
}
}
}
const labelElement = <Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
<label htmlFor={fieldMetaData.name}>{fieldMetaData.label}</label>
</Box>
return (
<Box mt="0.5rem">
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<>
{labelElement}
<QDynamicFormField
name={fieldMetaData.name}
displayFormat={null}
label=""
placeholder={data.values?.placeholder}
backgroundColor="#FFFFFF"
formFieldObject={dynamicField}
type={fieldMetaData.type}
value={value}
autoFocus={autoFocus}
onKeyUp={eventHandler}
{...dynamicFormFieldRest} />
</>
</BlockElementWrapper>
</Box>
);
}

View File

@ -41,7 +41,7 @@ export default function NumberIconBadgeBlock({widgetMetaData, data}: StandardBlo
{
data.values.iconName &&
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="icon">
<Icon style={{color: data.styles.color, fontSize: "1rem", position: "relative", top: "3px"}}>{data.values.iconName}</Icon>
<Icon style={{color: data.styles.color, fontSize: "1rem", marginLeft: "2px", position: "relative", top: "4px"}}>{data.values.iconName}</Icon>
</BlockElementWrapper>
}
</div>);

View File

@ -19,8 +19,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
import React from "react";
/*******************************************************************************
** Block that renders ... just some text.
@ -29,9 +33,132 @@ import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockMo
*******************************************************************************/
export default function TextBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
let color = "rgba(0, 0, 0, 0.87)";
if (data.styles?.color)
{
color = ProcessWidgetBlockUtils.processColorFromStyleMap(data.styles.color);
}
let boxStyle = {};
if (data.styles?.format == "alert")
{
boxStyle =
{
border: `1px solid ${color}`,
background: `${color}40`,
padding: "0.5rem",
borderRadius: "0.5rem",
};
}
else if (data.styles?.format == "banner")
{
boxStyle =
{
background: `${color}40`,
padding: "0.5rem",
};
}
let fontSize = "1rem";
if (data.styles?.size)
{
switch (data.styles.size.toLowerCase())
{
case "largest":
fontSize = "3rem";
break;
case "headline":
fontSize = "2rem";
break;
case "title":
fontSize = "1.5rem";
break;
case "body":
fontSize = "1rem";
break;
case "smallest":
fontSize = "0.75rem";
break;
default:
{
if (data.styles.size.match(/^\d+$/))
{
fontSize = `${data.styles.size}px`;
}
else
{
fontSize = "1rem";
}
}
}
}
let fontWeight = "400";
if (data.styles?.weight)
{
switch (data.styles.weight.toLowerCase())
{
case "thin":
case "100":
fontWeight = "100";
break;
case "extralight":
case "200":
fontWeight = "200";
break;
case "light":
case "300":
fontWeight = "300";
break;
case "normal":
case "400":
fontWeight = "400";
break;
case "medium":
case "500":
fontWeight = "500";
break;
case "semibold":
case "600":
fontWeight = "600";
break;
case "bold":
case "700":
fontWeight = "700";
break;
case "extrabold":
case "800":
fontWeight = "800";
break;
case "black":
case "900":
fontWeight = "900";
break;
}
}
const text = data.values.interpolatedText ?? data.values.text;
const lines = text.split("\n");
const startIcon = data.values.startIcon?.name ? <Icon>{data.values.startIcon.name}</Icon> : null;
const endIcon = data.values.endIcon?.name ? <Icon>{data.values.endIcon.name}</Icon> : null;
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<span style={{fontSize: "1.000rem"}}>{data.values.text}</span>
<Box display="inline-block" lineHeight="1.2" sx={boxStyle}>
<span style={{fontSize: fontSize, color: color, fontWeight: fontWeight}}>
{lines.map((line: string, index: number) =>
(
<div key={index}>
<>
{index == 0 && startIcon ? {startIcon} : null}
{line}
{index == lines.length - 1 && endIcon ? {endIcon} : null}
</>
</div>
))
}</span>
</Box>
</BlockElementWrapper>
);
}

View File

@ -58,7 +58,7 @@ export default function UpOrDownNumberBlock({widgetMetaData, data}: StandardBloc
return (
<>
<div style={{display: "flex", flexDirection: data.styles.isStacked ? "column" : "row", alignItems: data.styles.isStacked ? "flex-end" : "baseline"}}>
<div style={{display: "flex", flexDirection: data.styles.isStacked ? "column" : "row", alignItems: data.styles.isStacked ? "flex-end" : "baseline", marginLeft: "auto"}}>
<div style={{display: "flex", alignItems: "baseline", fontWeight: 700, fontSize: ".875rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="number">

View File

@ -50,6 +50,7 @@ import DeveloperModeUtils from "qqq/utils/DeveloperModeUtils";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-java";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-json";

View File

@ -19,13 +19,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
function DividerWidget(): JSX.Element
{
return (
<Divider sx={{padding: "1px", background: "red"}}/>
<Box pl={3} pt={3} pb={3} width="100%">
<Divider sx={{width: "100%", height: "1px", background: "grey"}} />
</Box>
);
}

View File

@ -0,0 +1,265 @@
/*
* 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import {FormikContextType, useFormikContext} from "formik";
import QDynamicForm from "qqq/components/forms/DynamicForm";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import Widget from "qqq/components/widgets/Widget";
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useState} from "react";
/*******************************************************************************
** component props
*******************************************************************************/
interface DynamicFormWidgetProps
{
isEditable: boolean;
widgetMetaData: QWidgetMetaData;
widgetData: any;
record: QRecord;
recordValues: { [name: string]: any };
onSaveCallback?: (values: { [name: string]: any }) => void;
}
/*******************************************************************************
** default values for props
*******************************************************************************/
DynamicFormWidget.defaultProps = {
onSaveCallback: null
};
/*******************************************************************************
** Component to display a dynamic form - e.g., on a record edit or view screen,
** or even within a process.
*******************************************************************************/
export default function DynamicFormWidget({isEditable, widgetMetaData, widgetData, record, recordValues, onSaveCallback}: DynamicFormWidgetProps): JSX.Element
{
const [fields, setFields] = useState([] as QFieldMetaData[]);
const [effectiveIsEditable, setEffectiveIsEditable] = useState(isEditable);
if(widgetMetaData.defaultValues.has("isEditable"))
{
const defaultIsEditableValue = widgetMetaData.defaultValues.get("isEditable")
if(defaultIsEditableValue != effectiveIsEditable)
{
setEffectiveIsEditable(defaultIsEditableValue);
}
}
const [dynamicFormFields, setDynamicFormFields] = useState(null as any);
const [formValidations, setFormValidations] = useState(null as any);
const [lastKnowFormValues, setLastKnowFormValues] = useState({} as {[name: string]: any});
//////////////////////////////////////////////////////////////////////////////////////////
// on initial load, and any time widgetData changes (e.g., if widget gets re-rendered), //
// figure out what our form fields are //
//////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
setDynamicFormFields({})
setFormValidations({})
if(widgetData && widgetData.fieldList)
{
const newFields: QFieldMetaData[] = [];
for (let i = 0; i < widgetData.fieldList.length; i++)
{
newFields.push(new QFieldMetaData(widgetData.fieldList[i]));
}
setFields(newFields);
if(newFields.length > 0)
{
const recordOfFieldValues = widgetData.recordOfFieldValues ? new QRecord(widgetData.recordOfFieldValues) : null;
const {dynamicFormFields: newDynamicFormFields, formValidations: newFormValidations} = DynamicFormUtils.getFormData(newFields);
const defaultDisplayValues = new Map<string,string>(); // todo - seems not right?
DynamicFormUtils.addPossibleValueProps(newDynamicFormFields, newFields, recordValues.tableName, null, recordOfFieldValues ? recordOfFieldValues.displayValues : defaultDisplayValues);
setDynamicFormFields(newDynamicFormFields)
setFormValidations(newFormValidations)
}
setLastKnowFormValues({});
}
else
{
setFields([])
}
}, [widgetData]);
/*******************************************************************************
**
*******************************************************************************/
function checkForFormValueChanges(formikProps: FormikContextType<any>)
{
if(!fields || !fields.length)
{
return;
}
let anyChanged = false;
for (let i = 0; i < fields.length; i++)
{
const name = fields[i].name;
if(formikProps.values[name] != lastKnowFormValues[name])
{
anyChanged = true;
lastKnowFormValues[name] = formikProps.values[name];
}
}
if(anyChanged)
{
const mergedDynamicFormValuesIntoFieldName = widgetData.mergedDynamicFormValuesIntoFieldName;
if(mergedDynamicFormValuesIntoFieldName && onSaveCallback)
{
const onSaveCallbackParam: {[name: string]: any} = {};
onSaveCallbackParam[mergedDynamicFormValuesIntoFieldName] = JSON.stringify(lastKnowFormValues);
onSaveCallback(onSaveCallbackParam);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function getInitialValue(fieldName: string)
{
for (let i = 0; i < fields?.length; i++)
{
if(fields[i].name == fieldName && fields[i].defaultValue)
{
return (fields[i].defaultValue)
}
}
return (null);
}
/*******************************************************************************
**
*******************************************************************************/
function renderEditForm()
{
const formikProps = useFormikContext();
if(!fields || !fields.length)
{
return (
<Box>
<Box fontSize="1rem">{widgetData && widgetData.noFieldsMessage}</Box>
</Box>
);
}
const formData: any = {};
formData.values = formikProps.values;
formData.touched = formikProps.touched;
formData.errors = formikProps.errors;
formData.formFields = {};
// todo - merge the formValidations object with formik's - maybe in the useEffect where we build it
// setValidations(Yup.object().shape(formValidations));
// formikProps.validationSchema.
for (let key of Object.keys(dynamicFormFields))
{
const dynamicFormField = dynamicFormFields[key];
formData.formFields[dynamicFormField.name] = dynamicFormField;
const initialValue = getInitialValue(dynamicFormField.name);
if(initialValue != null)
{
console.log(`@dk trying to set an initial value [${dynamicFormField.name}] to [${initialValue}]`);
// @ts-ignore some any
formikProps.initialValues[dynamicFormField.name] = initialValue;
}
}
if(formData.values)
{
checkForFormValueChanges(formikProps);
}
return (
<Box>
<QDynamicForm formData={formData} record={record} />
</Box>
);
}
/*******************************************************************************
**
*******************************************************************************/
function renderViewForm()
{
const fieldNames: string[] = [];
const fieldMap: {[name: string]: QFieldMetaData} = {};
const fakeRecord = new QRecord(widgetData.recordOfFieldValues ?? {});
const mergedDynamicFormValuesIntoFieldName = widgetData.mergedDynamicFormValuesIntoFieldName;
for (let i = 0; i < fields?.length; i++)
{
const fieldName = fields[i].name;
fieldNames.push(fieldName);
fieldMap[fieldName] = fields[i];
if(mergedDynamicFormValuesIntoFieldName && recordValues[mergedDynamicFormValuesIntoFieldName])
{
fakeRecord.values.set(fieldName, recordValues[mergedDynamicFormValuesIntoFieldName][fieldName]);
}
}
const section = renderSectionOfFields(`dynamicFormWidget:${widgetMetaData.name}`, fieldNames, null, false, fakeRecord, fieldMap);
return (<Box>
{section}
</Box>);
}
////////////
// render //
////////////
return (<Widget widgetMetaData={widgetMetaData}>
{
<React.Fragment>
{effectiveIsEditable ? renderEditForm() : renderViewForm()}
</React.Fragment>
}
</Widget>);
}

View File

@ -22,6 +22,8 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Alert, Collapse} from "@mui/material";
import Box from "@mui/material/Box";
@ -42,15 +44,16 @@ import Client from "qqq/utils/qqq/Client";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {useContext, useEffect, useRef, useState} from "react";
interface ReportSetupWidgetProps
interface FilterAndColumnsSetupWidgetProps
{
isEditable: boolean;
widgetMetaData: QWidgetMetaData;
recordValues: {[name: string]: any};
onSaveCallback?: (values: {[name: string]: any}) => void;
widgetData: any;
recordValues: { [name: string]: any };
onSaveCallback?: (values: { [name: string]: any }) => void;
}
ReportSetupWidget.defaultProps = {
FilterAndColumnsSetupWidget.defaultProps = {
onSaveCallback: null
};
@ -80,9 +83,11 @@ const qController = Client.getInstance();
/*******************************************************************************
** Component for editing the main setup of a report - that is: filter & columns
*******************************************************************************/
export default function ReportSetupWidget({isEditable, widgetMetaData, recordValues, onSaveCallback}: ReportSetupWidgetProps): JSX.Element
export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback}: FilterAndColumnsSetupWidgetProps): JSX.Element
{
const [modalOpen, setModalOpen] = useState(false);
const [hideColumns, setHideColumns] = useState(widgetData?.hideColumns);
const [hidePreview, setHidePreview] = useState(widgetData?.hidePreview);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [alertContent, setAlertContent] = useState(null as string);
@ -101,16 +106,43 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
/////////////////////////////
// load values from record //
/////////////////////////////
let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter;
let columns: QQueryColumns = null;
let usingDefaultEmptyFilter = false;
if(!queryFilter)
let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter;
const defaultFilterFields = widgetData?.filterDefaultFieldNames;
if (!queryFilter)
{
queryFilter = new QQueryFilter();
usingDefaultEmptyFilter = true;
if (defaultFilterFields?.length == 0)
{
usingDefaultEmptyFilter = true;
}
}
else
{
queryFilter = Object.assign(new QQueryFilter(), queryFilter);
}
let columns: QQueryColumns = null;
if(recordValues["columnsJson"])
//////////////////////////////////////////////////////////////////////////////////////////////////////
// if there are default fields from which a query should be seeded, add/update the filter with them //
//////////////////////////////////////////////////////////////////////////////////////////////////////
if (defaultFilterFields?.length > 0)
{
defaultFilterFields.forEach((fieldName: string) =>
{
////////////////////////////////////////////////////////////////////////////////////////////
// if a value for the default field exists, remove the criteria for it in our query first //
////////////////////////////////////////////////////////////////////////////////////////////
queryFilter.criteria = queryFilter.criteria?.filter(c => c.fieldName != fieldName);
if (recordValues[fieldName])
{
queryFilter.addCriteria(new QFilterCriteria(fieldName, QCriteriaOperator.EQUALS, [recordValues[fieldName]]));
}
});
}
if (recordValues["columnsJson"])
{
columns = QQueryColumns.buildFromJSON(recordValues["columnsJson"]);
}
@ -120,19 +152,28 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
//////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"]))
////////////////////////////////////////////////////////////////////////////////////////
// if a default table name specified, use it, otherwise use it from the record values //
////////////////////////////////////////////////////////////////////////////////////////
let tableName = widgetData?.tableName;
if (!tableName && recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"]))
{
tableName = recordValues["tableName"];
}
if (tableName)
{
(async () =>
{
const tableMetaData = await qController.loadTableMetaData(recordValues["tableName"])
const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
const queryFilterForFrontend = Object.assign({}, queryFilter);
await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilterForFrontend)
setFrontendQueryFilter(queryFilterForFrontend)
await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilterForFrontend);
setFrontendQueryFilter(queryFilterForFrontend);
})();
}
}, [recordValues]);
}, [JSON.stringify(recordValues)]);
/*******************************************************************************
@ -140,8 +181,27 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
*******************************************************************************/
function openEditor()
{
if(recordValues["tableName"])
let missingRequiredFields = [] as string[];
widgetData?.filterDefaultFieldNames?.forEach((fieldName: string) =>
{
if (!recordValues[fieldName])
{
missingRequiredFields.push(tableMetaData.fields.get(fieldName).label);
}
});
////////////////////////////////////////////////////////////////////
// display an alert and return if any required fields are missing //
////////////////////////////////////////////////////////////////////
if (missingRequiredFields.length > 0)
{
setAlertContent("The following fields must first be selected to edit the filter: '" + missingRequiredFields.join(", ") + "'");
return;
}
if (recordValues["tableName"])
{
setAlertContent(null);
setModalOpen(true);
}
}
@ -152,7 +212,7 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
*******************************************************************************/
function saveClicked()
{
if(!onSaveCallback)
if (!onSaveCallback)
{
console.log("onSaveCallback was not defined");
return;
@ -181,7 +241,7 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
*******************************************************************************/
function closeEditor(event?: {}, reason?: "backdropClick" | "escapeKeyDown")
{
if(reason == "backdropClick" || reason == "escapeKeyDown")
if (reason == "backdropClick" || reason == "escapeKeyDown")
{
return;
}
@ -195,9 +255,9 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
*******************************************************************************/
function renderColumn(column: Column): JSX.Element
{
const [field, table] = FilterUtils.getField(tableMetaData, column.name)
const [field, table] = FilterUtils.getField(tableMetaData, column.name);
if(!column || !column.isVisible || column.name == "__check__" || !field)
if (!column || !column.isVisible || column.name == "__check__" || !field)
{
return (<React.Fragment />);
}
@ -213,11 +273,11 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
/*******************************************************************************
**
*******************************************************************************/
function mayShowQueryPreview(): boolean
function mayShowQuery(): boolean
{
if(tableMetaData)
if (tableMetaData)
{
if(frontendQueryFilter?.criteria?.length > 0 || frontendQueryFilter?.subFilters?.length > 0)
if (frontendQueryFilter?.criteria?.length > 0 || frontendQueryFilter?.subFilters?.length > 0)
{
return (true);
}
@ -229,13 +289,13 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
/*******************************************************************************
**
*******************************************************************************/
function mayShowColumnsPreview(): boolean
function mayShowColumns(): boolean
{
if(tableMetaData)
if (tableMetaData)
{
for(let i = 0; i<columns?.columns?.length; i++)
for (let i = 0; i < columns?.columns?.length; i++)
{
if(columns.columns[i].isVisible && columns.columns[i].name != "__check__")
if (columns.columns[i].isVisible && columns.columns[i].name != "__check__")
{
return (true);
}
@ -269,10 +329,17 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
// add link to widget header for opening modal //
/////////////////////////////////////////////////
const selectTableFirstTooltipTitle = tableMetaData ? null : "You must select a table before you can set up your report filters and columns";
const labelAdditionalElementsRight: JSX.Element[] = []
if(isEditable)
const labelAdditionalElementsRight: JSX.Element[] = [];
if (isEditable)
{
labelAdditionalElementsRight.push(<HeaderLinkButtonComponent label="Edit Filters and Columns" onClickCallback={openEditor} disabled={tableMetaData == null} disabledTooltip={selectTableFirstTooltipTitle} />)
if (!hideColumns)
{
labelAdditionalElementsRight.push(<HeaderLinkButtonComponent key="filterAndColumnsHeader" label="Edit Filters and Columns" onClickCallback={openEditor} disabled={tableMetaData == null} disabledTooltip={selectTableFirstTooltipTitle} />);
}
else
{
labelAdditionalElementsRight.push(<HeaderLinkButtonComponent key="filterAndColumnsHeader" label="Edit Filters" onClickCallback={openEditor} disabled={tableMetaData == null} disabledTooltip={selectTableFirstTooltipTitle} />);
}
}
@ -290,14 +357,14 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
<Box pt="0.5rem">
<Box display="flex" justifyContent="space-between" alignItems="center">
<h5>Query Filter</h5>
<Box fontSize="0.75rem" fontWeight="700">{mayShowQueryPreview() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>
<Box fontSize="0.75rem" fontWeight="700">{mayShowQuery() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>
</Box>
{
mayShowQueryPreview() &&
mayShowQuery() &&
<AdvancedQueryPreview tableMetaData={tableMetaData} queryFilter={frontendQueryFilter} isEditable={false} isQueryTooComplex={frontendQueryFilter.subFilters?.length > 0} removeCriteriaByIndexCallback={null} />
}
{
!mayShowQueryPreview() &&
!mayShowQuery() &&
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.5rem"} p={"0.5rem"} pb={"0.125rem"} borderRadius="0.75rem" border={`1px solid ${colors.grayLines.main}`}>
{
isEditable &&
@ -306,34 +373,51 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
</Tooltip>
}
{
!isEditable && <Box color={colors.gray.main}>Your report has no filters.</Box>
!isEditable && <Box color={colors.gray.main}>No filters are configured.</Box>
}
</Box>
}
</Box>
<Box pt="1rem">
<h5>Columns</h5>
<Box display="flex" flexWrap="wrap" fontSize="1rem">
{
mayShowColumnsPreview() &&
columns.columns.map((column, i) => <React.Fragment key={i}>{renderColumn(column)}</React.Fragment>)
}
{
!mayShowColumnsPreview() &&
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.375rem"} p={"0.5rem"} pb={"0.125rem"}>
{
isEditable &&
<Tooltip title={selectTableFirstTooltipTitle}>
<span><Button disabled={!recordValues["tableName"]} sx={unborderedButtonSX} onClick={openEditor}>+ Add Columns</Button></span>
</Tooltip>
}
{
!isEditable && <Box color={colors.gray.main}>Your report has no columns.</Box>
}
</Box>
}
{!hideColumns && (
<Box pt="1rem">
<h5>Columns</h5>
<Box display="flex" flexWrap="wrap" fontSize="1rem">
{
mayShowColumns() && columns &&
columns.columns.map((column, i) => <React.Fragment key={`column-${i}`}>{renderColumn(column)}</React.Fragment>)
}
{
!mayShowColumns() &&
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.375rem"} p={"0.5rem"} pb={"0.125rem"}>
{
isEditable &&
<Tooltip title={selectTableFirstTooltipTitle}>
<span><Button disabled={!recordValues["tableName"]} sx={unborderedButtonSX} onClick={openEditor}>+ Add Columns</Button></span>
</Tooltip>
}
{
!isEditable && <Box color={colors.gray.main}>No columns are selected.</Box>
}
</Box>
}
</Box>
</Box>
</Box>
)}
{!hidePreview && !isEditable && frontendQueryFilter && tableMetaData && (
<Box pt="1rem">
<h5>Preview</h5>
<RecordQuery
allowVariables={widgetData?.allowVariables}
ref={recordQueryRef}
table={tableMetaData}
isPreview={true}
usage="reportSetup"
isModal={true}
initialQueryFilter={frontendQueryFilter}
initialColumns={columns}
/>
</Box>
)}
{
modalOpen &&
<Modal open={modalOpen} onClose={(event, reason) => closeEditor(event, reason)}>
@ -349,6 +433,7 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
}
{
tableMetaData && <RecordQuery
allowVariables={widgetData?.allowVariables}
ref={recordQueryRef}
table={tableMetaData}
usage="reportSetup"

View File

@ -39,9 +39,9 @@ import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import {buttonSX, unborderedButtonSX} from "qqq/components/widgets/misc/FilterAndColumnsSetupWidget";
import {PivotTableGroupByElement} from "qqq/components/widgets/misc/PivotTableGroupByElement";
import {PivotTableValueElement} from "qqq/components/widgets/misc/PivotTableValueElement";
import {buttonSX, unborderedButtonSX} from "qqq/components/widgets/misc/ReportSetupWidget";
import Widget, {HeaderToggleComponent} from "qqq/components/widgets/Widget";
import {PivotObjectKey, PivotTableDefinition, PivotTableFunction, pivotTableFunctionLabels, PivotTableGroupBy, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels";
import QQueryColumns from "qqq/models/query/QQueryColumns";
@ -280,7 +280,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
}
modalPivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy());
validateForm()
validateForm();
forceUpdate();
}
@ -292,7 +292,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
{
updateUsedGroupByFieldNames(modalPivotTableDefinition);
updateUsedValueFieldNames(modalPivotTableDefinition);
validateForm()
validateForm();
forceUpdate();
}
@ -308,7 +308,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
}
modalPivotTableDefinition.values.push(new PivotTableValue());
validateForm()
validateForm();
forceUpdate();
}
@ -319,7 +319,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
function removeValue(index: number)
{
modalPivotTableDefinition.values.splice(index, 1);
validateForm()
validateForm();
forceUpdate();
}
@ -503,7 +503,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
const labelAdditionalElementsRight: JSX.Element[] = [];
if (isEditable)
{
labelAdditionalElementsRight.push(<HeaderToggleComponent disabled={editPopupDisabled} disabledTooltip={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle} label="Use Pivot Table?" getValue={() => enabled} onClickCallback={toggleEnabled} />);
labelAdditionalElementsRight.push(<HeaderToggleComponent key="pivotTableHeader" disabled={editPopupDisabled} disabledTooltip={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle} label="Use Pivot Table?" getValue={() => enabled} onClickCallback={toggleEnabled} />);
}
@ -659,7 +659,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
// if this isn't a call from the on-submit handler, and we haven't previously attempted a submit, then return w/o setting any alerts //
// this is like a version of considering "touched"... //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(!submitting && !attemptedSubmit)
if (!submitting && !attemptedSubmit)
{
return;
}
@ -703,7 +703,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
// now they've fixed 'em - so go back to a 'clean' state - so if they add more //
// boxes, they won't immediately show errors, until a re-submit //
////////////////////////////////////////////////////////////////////////////////////
if(attemptedSubmit)
if (attemptedSubmit)
{
setAttemptedSubmit(false);
}

View File

@ -39,15 +39,15 @@ import {Link, useNavigate} from "react-router-dom";
export interface ChildRecordListData extends WidgetData
{
title: string;
queryOutput: {records: {values: any}[]}
childTableMetaData: QTableMetaData;
tablePath: string;
viewAllLink: string;
totalRows: number;
canAddChildRecord: boolean;
defaultValuesForNewChildRecords: {[fieldName: string]: any};
disabledFieldsForNewChildRecords: {[fieldName: string]: any};
title?: string;
queryOutput?: { records: { values: any }[] };
childTableMetaData?: QTableMetaData;
tablePath?: string;
viewAllLink?: string;
totalRows?: number;
canAddChildRecord?: boolean;
defaultValuesForNewChildRecords?: { [fieldName: string]: any };
disabledFieldsForNewChildRecords?: { [fieldName: string]: any };
}
interface Props
@ -75,9 +75,9 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
{
const instance = useRef({timer: null});
const [rows, setRows] = useState([]);
const [records, setRecords] = useState([] as QRecord[])
const [records, setRecords] = useState([] as QRecord[]);
const [columns, setColumns] = useState([]);
const [allColumns, setAllColumns] = useState([])
const [allColumns, setAllColumns] = useState([]);
const [csv, setCsv] = useState(null as string);
const [fileName, setFileName] = useState(null as string);
const [gridMouseDownX, setGridMouseDownX] = useState(0);
@ -110,20 +110,20 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// capture all-columns to use for the export (before we might splice some away from the on-screen display) //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
const allColumns = [... columns];
const allColumns = [...columns];
setAllColumns(JSON.parse(JSON.stringify(columns)));
////////////////////////////////////////////////////////////////
// do not not show the foreign-key column of the parent table //
////////////////////////////////////////////////////////////////
if(data.defaultValuesForNewChildRecords)
if (data.defaultValuesForNewChildRecords)
{
for (let i = 0; i < columns.length; i++)
{
if(data.defaultValuesForNewChildRecords[columns[i].field])
if (data.defaultValuesForNewChildRecords[columns[i].field])
{
columns.splice(i, 1);
i--
i--;
}
}
}
@ -131,7 +131,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
////////////////////////////////////
// add actions cell, if available //
////////////////////////////////////
if(allowRecordEdit || allowRecordDelete)
if (allowRecordEdit || allowRecordDelete)
{
columns.unshift({
field: "_actions",
@ -145,19 +145,19 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
return <Box>
{allowRecordEdit && <IconButton onClick={() => editRecordCallback(params.row.__rowIndex)}><Icon>edit</Icon></IconButton>}
{allowRecordDelete && <IconButton onClick={() => deleteRecordCallback(params.row.__rowIndex)}><Icon>delete</Icon></IconButton>}
</Box>
</Box>;
})
})
});
}
setRows(rows);
setRecords(records)
setRecords(records);
setColumns(columns);
let csv = "";
for (let i = 0; i < allColumns.length; i++)
{
csv += `${i > 0 ? "," : ""}"${ValueUtils.cleanForCsv(allColumns[i].headerName)}"`
csv += `${i > 0 ? "," : ""}"${ValueUtils.cleanForCsv(allColumns[i].headerName)}"`;
}
csv += "\n";
@ -165,8 +165,8 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
{
for (let j = 0; j < allColumns.length; j++)
{
const value = records[i].displayValues.get(allColumns[j].field) ?? records[i].values.get(allColumns[j].field)
csv += `${j > 0 ? "," : ""}"${ValueUtils.cleanForCsv(value)}"`
const value = records[i].displayValues.get(allColumns[j].field) ?? records[i].values.get(allColumns[j].field);
csv += `${j > 0 ? "," : ""}"${ValueUtils.cleanForCsv(value)}"`;
}
csv += "\n";
}
@ -176,19 +176,19 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
setCsv(csv);
setFileName(fileName);
}
}, [data]);
}, [JSON.stringify(data?.queryOutput)]);
///////////////////
// view all link //
///////////////////
const labelAdditionalElementsLeft: JSX.Element[] = [];
if(data && data.viewAllLink)
if (data && data.viewAllLink)
{
labelAdditionalElementsLeft.push(
<Typography variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative">
<Typography key={"viewAllLink"} variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative">
<Link to={data.viewAllLink}>View All</Link>
</Typography>
)
);
}
///////////////////
@ -200,10 +200,10 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
{
isExportDisabled = false;
if(data.totalRows && data.queryOutput.records.length < data.totalRows)
if (data.totalRows && data.queryOutput.records.length < data.totalRows)
{
tooltipTitle = "Export these " + data.queryOutput.records.length + " records."
if(data.viewAllLink)
tooltipTitle = "Export these " + data.queryOutput.records.length + " records.";
if (data.viewAllLink)
{
tooltipTitle += "\nClick View All to export all records.";
}
@ -212,21 +212,21 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
const onExportClick = () =>
{
if(csv)
if (csv)
{
HtmlUtils.download(fileName, csv);
}
else
{
alert("There is no data available to export.")
alert("There is no data available to export.");
}
}
};
if(widgetMetaData?.showExportButton)
if (widgetMetaData?.showExportButton)
{
labelAdditionalElementsLeft.push(
<Typography key={1} variant="body2" px={0} display="inline" position="relative">
<Tooltip title={tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={isExportDisabled}><Icon sx={{color: "#757575", fontSize: 1.25}}>save_alt</Icon></Button></Tooltip>
<Typography key={"exportButton"} variant="body2" px={0} display="inline" position="relative">
<Tooltip title={tooltipTitle}><span><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={isExportDisabled}><Icon sx={{color: "#757575", fontSize: 1.25}}>save_alt</Icon></Button></span></Tooltip>
</Typography>
);
}
@ -234,15 +234,15 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
////////////////////
// add new button //
////////////////////
const labelAdditionalComponentsRight: LabelComponent[] = []
if(data && data.canAddChildRecord)
const labelAdditionalComponentsRight: LabelComponent[] = [];
if (data && data.canAddChildRecord)
{
let disabledFields = data.disabledFieldsForNewChildRecords;
if(!disabledFields)
if (!disabledFields)
{
disabledFields = data.defaultValuesForNewChildRecords;
}
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback))
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback));
}
@ -251,16 +251,16 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
/////////////////////////////////////////////////////////////////
const handleRowClick = (params: GridRowParams, event: MuiEvent<React.MouseEvent>, details: GridCallbackDetails) =>
{
if(disableRowClick)
if (disableRowClick)
{
return;
}
(async () =>
{
const qInstance = await qController.loadMetaData()
let tablePath = qInstance.getTablePathByName(data.childTableMetaData.name)
if(tablePath)
const qInstance = await qController.loadMetaData();
let tablePath = qInstance.getTablePathByName(data.childTableMetaData.name);
if (tablePath)
{
tablePath = `${tablePath}/${params.row[data.childTableMetaData.primaryKeyField]}`;
DataGridUtils.handleRowClick(tablePath, event, gridMouseDownX, gridMouseDownY, navigate, instance);
@ -276,7 +276,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
*******************************************************************************/
function CustomToolbar()
{
const handleMouseDown: GridEventListener<"cellMouseDown"> = ( params, event, details ) =>
const handleMouseDown: GridEventListener<"cellMouseDown"> = (params, event, details) =>
{
setGridMouseDownX(event.clientX);
setGridMouseDownY(event.clientY);
@ -295,6 +295,12 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
return (<GridToolbarContainer />);
}
let containerPadding = -3;
if (data?.isInProcess)
{
containerPadding = 0;
}
return (
<Widget
@ -304,49 +310,51 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelBoxAdditionalSx={{position: "relative", top: "-0.375rem"}}
>
<Box mx={-2} mb={-3}>
<DataGridPro
autoHeight
sx={{
borderBottom: "none",
borderLeft: "none",
borderRight: "none"
}}
rows={rows}
disableSelectionOnClick
columns={columns}
rowBuffer={10}
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
onRowClick={handleRowClick}
getRowId={(row) => row.__rowIndex}
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
components={{
Toolbar: CustomToolbar
}}
// pinnedColumns={pinnedColumns}
// onPinnedColumnsChange={handlePinnedColumnsChange}
// pagination
// paginationMode="server"
// rowsPerPageOptions={[20]}
// sortingMode="server"
// filterMode="server"
// page={pageNumber}
// checkboxSelection
rowCount={data && data.totalRows}
// onPageSizeChange={handleRowsPerPageChange}
// onStateChange={handleStateChange}
// density={density}
// loading={loading}
// filterModel={filterModel}
// onFilterModelChange={handleFilterChange}
// columnVisibilityModel={columnVisibilityModel}
// onColumnVisibilityModelChange={handleColumnVisibilityChange}
// onColumnOrderChange={handleColumnOrderChange}
// onSelectionModelChange={selectionChanged}
// onSortModelChange={handleSortChange}
// sortingOrder={[ "asc", "desc" ]}
// sortModel={columnSortModel}
/>
<Box mx={containerPadding} mb={containerPadding}>
<Box>
<DataGridPro
autoHeight
sx={{
borderBottom: "none",
borderLeft: "none",
borderRight: "none"
}}
rows={rows}
disableSelectionOnClick
columns={columns}
rowBuffer={10}
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
onRowClick={handleRowClick}
getRowId={(row) => row.__rowIndex}
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
components={{
Toolbar: CustomToolbar
}}
// pinnedColumns={pinnedColumns}
// onPinnedColumnsChange={handlePinnedColumnsChange}
// pagination
// paginationMode="server"
// rowsPerPageOptions={[20]}
// sortingMode="server"
// filterMode="server"
// page={pageNumber}
// checkboxSelection
rowCount={data && data.totalRows}
// onPageSizeChange={handleRowsPerPageChange}
// onStateChange={handleStateChange}
// density={density}
// loading={loading}
// filterModel={filterModel}
// onFilterModelChange={handleFilterChange}
// columnVisibilityModel={columnVisibilityModel}
// onColumnVisibilityModelChange={handleColumnVisibilityChange}
// onColumnOrderChange={handleColumnOrderChange}
// onSelectionModelChange={selectionChanged}
// onSortModelChange={handleSortChange}
// sortingOrder={[ "asc", "desc" ]}
// sortModel={columnSortModel}
/>
</Box>
</Box>
</Widget>
);

View File

@ -19,19 +19,14 @@
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {tooltipClasses, TooltipProps} from "@mui/material";
import {Box, tooltipClasses, TooltipProps} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import {styled} from "@mui/material/styles";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableContainer from "@mui/material/TableContainer";
import TableRow from "@mui/material/TableRow";
import Tooltip from "@mui/material/Tooltip";
import parse from "html-react-parser";
import React, {useEffect, useMemo, useState} from "react";
import {useAsyncDebounce, useExpanded, useGlobalFilter, usePagination, useSortBy, useTable} from "react-table";
import colors from "qqq/assets/theme/base/colors";
import MDInput from "qqq/components/legacy/MDInput";
import MDPagination from "qqq/components/legacy/MDPagination";
@ -43,6 +38,8 @@ import DefaultCell from "qqq/components/widgets/tables/cells/DefaultCell";
import ImageCell from "qqq/components/widgets/tables/cells/ImageCell";
import {TableDataInput} from "qqq/components/widgets/tables/TableCard";
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
import React, {useEffect, useMemo, useState} from "react";
import {useAsyncDebounce, useExpanded, useGlobalFilter, usePagination, useSortBy, useTable} from "react-table";
interface Props
{
@ -106,17 +103,17 @@ function DataTable({
entries = entriesPerPageOptions ? entriesPerPageOptions : ["10", "25", "50", "100"];
let widths = [];
for(let i = 0; i<table.columns.length; i++)
for (let i = 0; i < table.columns.length; i++)
{
const column = table.columns[i];
if(column.type !== "hidden")
if (column.type !== "hidden")
{
widths.push(table.columns[i].width ?? "1fr");
}
}
let showExpandColumn = false;
if(table.rows)
if (table.rows)
{
for (let i = 0; i < table.rows.length; i++)
{
@ -129,7 +126,7 @@ function DataTable({
}
const columnsToMemo = [...table.columns];
if(showExpandColumn)
if (showExpandColumn)
{
widths.push("60px");
columnsToMemo.push(
@ -166,18 +163,18 @@ function DataTable({
})}
>
{/* float this icon to keep it "out of the flow" - in other words, to keep it from making the row taller than it otherwise would be... */}
<Icon fontSize="medium" sx={{float: "left"}}>{row.isExpanded ? "expand_less" : "chevron_right"}</Icon>
<Icon fontSize="medium" sx={{float: "left"}}>{row.isExpanded ? "expand_less" : "chevron_left"}</Icon>
</span>
) : null,
},
);
}
if(table.columnHeaderTooltips)
if (table.columnHeaderTooltips)
{
for (let column of columnsToMemo)
{
if(table.columnHeaderTooltips[column.accessor])
if (table.columnHeaderTooltips[column.accessor])
{
column.tooltip = table.columnHeaderTooltips[column.accessor];
}
@ -297,7 +294,7 @@ function DataTable({
}
let visibleFooterRows = 1;
if(expanded && expanded[`${table.rows.length-1}`])
if (expanded && expanded[`${table.rows.length - 1}`])
{
//////////////////////////////////////////////////
// todo - should count how many are expanded... //
@ -308,156 +305,152 @@ function DataTable({
function getTable(includeHead: boolean, rows: any, isFooter: boolean)
{
let boxStyle = {};
if(fixedStickyLastRow)
if (fixedStickyLastRow)
{
boxStyle = isFooter
? {borderTop: `0.0625rem solid ${colors.grayLines.main};`, backgroundColor: "#EEEEEE"}
: {flexGrow: 1, overflowY: "scroll", scrollbarGutter: "stable", marginBottom: "-1px"};
: {height: fixedHeight ? `${fixedHeight}px` : "auto", flexGrow: 1, overflowY: "scroll", scrollbarGutter: "stable", marginBottom: "-1px"};
}
let innerBoxStyle = {};
if(fixedStickyLastRow && isFooter)
if (fixedStickyLastRow && isFooter)
{
innerBoxStyle = {overflowY: "auto", scrollbarGutter: "stable"};
}
///////////////////////////////////////////////////////////////////////////////////
// note - at one point, we had the table's sx including: whiteSpace: "nowrap"... //
///////////////////////////////////////////////////////////////////////////////////
return <Box sx={boxStyle}><Box sx={innerBoxStyle}>
<Table {...getTableProps()}>
<Table {...getTableProps()} component="div" sx={{display: "grid", gridTemplateRows: "auto", gridTemplateColumns: gridTemplateColumns}}>
{
includeHead && (
<Box component="thead" sx={{position: "sticky", top: 0, background: "white", zIndex: 10}}>
{headerGroups.map((headerGroup: any, i: number) => (
<TableRow key={i} {...headerGroup.getHeaderGroupProps()} sx={{display: "grid", gridTemplateColumns: gridTemplateColumns}}>
{headerGroup.headers.map((column: any) => (
column.type !== "hidden" && (
<DataTableHeadCell
key={i++}
{...column.getHeaderProps(isSorted && column.getSortByToggleProps())}
align={column.align ? column.align : "left"}
sorted={setSortedValue(column)}
tooltip={column.tooltip}
>
{column.render("header")}
</DataTableHeadCell>
)
))}
</TableRow>
))}
</Box>
headerGroups.map((headerGroup: any, i: number) => (
headerGroup.headers.map((column: any) => (
column.type !== "hidden" && (
<DataTableHeadCell
sx={{position: "sticky", top: 0, background: "white", zIndex: 10, alignItems: "flex-end"}}
key={i++}
{...column.getHeaderProps(isSorted && column.getSortByToggleProps())}
align={column.align ? column.align : "left"}
sorted={setSortedValue(column)}
tooltip={column.tooltip}
>
{column.render("header")}
</DataTableHeadCell>
)
))
))
)
}
<TableBody {...getTableBodyProps()}>
{rows.map((row: any, key: any) =>
{rows.map((row: any, key: any) =>
{
prepareRow(row);
let overrideNoEndBorder = false;
//////////////////////////////////////////////////////////////////////////////////
// don't do an end-border on nested rows - unless they're the last one in a set //
//////////////////////////////////////////////////////////////////////////////////
if (row.depth > 0)
{
prepareRow(row);
let overrideNoEndBorder = false;
//////////////////////////////////////////////////////////////////////////////////
// don't do an end-border on nested rows - unless they're the last one in a set //
//////////////////////////////////////////////////////////////////////////////////
if(row.depth > 0)
overrideNoEndBorder = true;
if (key + 1 < rows.length && rows[key + 1].depth == 0)
{
overrideNoEndBorder = true;
if(key + 1 < rows.length && rows[key + 1].depth == 0)
{
overrideNoEndBorder = false;
}
overrideNoEndBorder = false;
}
}
///////////////////////////////////////
// don't do end-border on the footer //
///////////////////////////////////////
if(isFooter)
{
overrideNoEndBorder = true;
}
///////////////////////////////////////
// don't do end-border on the footer //
///////////////////////////////////////
if (isFooter)
{
overrideNoEndBorder = true;
}
let background = "initial";
if(isFooter)
{
background = "#EEEEEE";
}
else if(row.depth > 0 || row.isExpanded)
{
background = "#FAFAFA";
}
let background = "initial";
if (isFooter)
{
background = "#EEEEEE";
}
else if (row.depth > 0 || row.isExpanded)
{
background = "#FAFAFA";
}
return (
<TableRow sx={{verticalAlign: "top", display: "grid", gridTemplateColumns: gridTemplateColumns, background: background}} key={key} {...row.getRowProps()}>
{row.cells.map((cell: any) => (
cell.column.type !== "hidden" && (
<DataTableBodyCell
key={key}
noBorder={noEndBorder || overrideNoEndBorder || row.isExpanded}
depth={row.depth}
align={cell.column.align ? cell.column.align : "left"}
{...cell.getCellProps()}
>
{
cell.column.type === "default" && (
cell.value && "number" === typeof cell.value ? (
<DefaultCell isFooter={isFooter}>{cell.value.toLocaleString()}</DefaultCell>
) : (<DefaultCell isFooter={isFooter}>{cell.render("Cell")}</DefaultCell>)
)
}
{
cell.column.type === "htmlAndTooltip" && (
<DefaultCell isFooter={isFooter}>
<NoMaxWidthTooltip title={parse(row.values["tooltip"])}>
<Box>
{parse(cell.value)}
</Box>
</NoMaxWidthTooltip>
</DefaultCell>
)
}
{
cell.column.type === "html" && (
<DefaultCell isFooter={isFooter}>{parse(cell.value ?? "")}</DefaultCell>
)
}
{
cell.column.type === "composite" && (
<DefaultCell isFooter={isFooter}>
<CompositeWidget widgetMetaData={widgetMetaData} data={cell.value} />
</DefaultCell>
)
}
{
cell.column.type === "block" && (
<DefaultCell isFooter={isFooter}>
<WidgetBlock widgetMetaData={widgetMetaData} block={cell.value} />
</DefaultCell>
)
}
{
cell.column.type === "image" && row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} total={row.values["imageTotal"].toLocaleString()} totalType={row.values["imageTotalType"]} />
)
}
{
cell.column.type === "image" && !row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
)
}
{
(cell.column.id === "__expander") && cell.render("cell")
}
</DataTableBodyCell>
)
))}
</TableRow>
);
})}
</TableBody>
return (
row.cells.map((cell: any) => (
cell.column.type !== "hidden" && (
<DataTableBodyCell
key={key}
sx={{verticalAlign: "top", background: background}}
noBorder={noEndBorder || overrideNoEndBorder || row.isExpanded}
depth={row.depth}
align={cell.column.align ? cell.column.align : "left"}
{...cell.getCellProps()}
>
{
cell.column.type === "default" && (
cell.value && "number" === typeof cell.value ? (
<DefaultCell isFooter={isFooter}>{cell.value.toLocaleString()}</DefaultCell>
) : (<DefaultCell isFooter={isFooter}>{cell.render("Cell")}</DefaultCell>)
)
}
{
cell.column.type === "htmlAndTooltip" && (
<DefaultCell isFooter={isFooter}>
<NoMaxWidthTooltip title={parse(row.values["tooltip"])}>
<Box>
{parse(cell.value)}
</Box>
</NoMaxWidthTooltip>
</DefaultCell>
)
}
{
cell.column.type === "html" && (
<DefaultCell isFooter={isFooter}>{parse(cell.value ?? "")}</DefaultCell>
)
}
{
cell.column.type === "composite" && (
<DefaultCell isFooter={isFooter}>
<CompositeWidget widgetMetaData={widgetMetaData} data={cell.value} />
</DefaultCell>
)
}
{
cell.column.type === "block" && (
<DefaultCell isFooter={isFooter}>
<WidgetBlock widgetMetaData={widgetMetaData} block={cell.value} />
</DefaultCell>
)
}
{
cell.column.type === "image" && row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} total={row.values["imageTotal"].toLocaleString()} totalType={row.values["imageTotalType"]} />
)
}
{
cell.column.type === "image" && !row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
)
}
{
(cell.column.id === "__expander") && cell.render("cell")
}
</DataTableBodyCell>
)
))
);
})}
</Table>
</Box></Box>
</Box></Box>;
}
return (
<TableContainer sx={{boxShadow: "none", height: fixedHeight ? `${fixedHeight}px` : "auto"}}>
<TableContainer sx={{boxShadow: "none", height: (fixedHeight && !fixedStickyLastRow) ? `${fixedHeight}px` : "auto"}}>
{entriesPerPage && ((hidePaginationDropdown !== undefined && !hidePaginationDropdown) || canSearch) ? (
<Box display="flex" justifyContent="space-between" alignItems="center" p={3}>
{entriesPerPage && (hidePaginationDropdown === undefined || !hidePaginationDropdown) && (

View File

@ -0,0 +1,96 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Modal from "@mui/material/Modal";
import EntityForm from "qqq/components/forms/EntityForm";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useReducer, useState} from "react";
////////////////////////////////
// structure of expected data //
////////////////////////////////
export interface ModalEditFormData
{
tableName: string;
defaultValues?: { [key: string]: string };
disabledFields?: { [key: string]: boolean } | string[];
overrideHeading?: string;
onSubmitCallback?: (values: any) => void;
initialShowModalValue?: boolean;
}
const qController = Client.getInstance();
function ModalEditForm({tableName, defaultValues, disabledFields, overrideHeading, onSubmitCallback, initialShowModalValue}: ModalEditFormData,): JSX.Element
{
const [showModal, setShowModal] = useState(initialShowModalValue);
const [table, setTable] = useState(null as QTableMetaData);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() =>
{
if (!tableName)
{
return;
}
(async () =>
{
const tableMetaData = await qController.loadTableMetaData(tableName);
setTable(tableMetaData);
forceUpdate();
})();
}, [tableName]);
/*******************************************************************************
**
*******************************************************************************/
const closeEditChildForm = (event: object, reason: string) =>
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
return;
}
setShowModal(null);
};
return (
table && showModal &&
<Modal open={showModal as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
<div className="modalEditForm">
<EntityForm
isModal={true}
closeModalHandler={closeEditChildForm}
table={table}
defaultValues={defaultValues}
disabledFields={disabledFields}
onSubmitCallback={onSubmitCallback}
overrideHeading={overrideHeading}
/>
</div>
</Modal>
);
}
export default ModalEditForm;

View File

@ -28,13 +28,13 @@ import TableBody from "@mui/material/TableBody";
import TableContainer from "@mui/material/TableContainer";
import TableRow from "@mui/material/TableRow";
import parse from "html-react-parser";
import React, {useEffect, useState} from "react";
import MDTypography from "qqq/components/legacy/MDTypography";
import DataTableBodyCell from "qqq/components/widgets/tables/cells/DataTableBodyCell";
import DataTableHeadCell from "qqq/components/widgets/tables/cells/DataTableHeadCell";
import DefaultCell from "qqq/components/widgets/tables/cells/DefaultCell";
import DataTable from "qqq/components/widgets/tables/DataTable";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useState} from "react";
//////////////////////////////////////
@ -43,7 +43,7 @@ import Client from "qqq/utils/qqq/Client";
export interface TableDataInput
{
columns: { [key: string]: any }[];
columnHeaderTooltips?: { [columnName: string]: string | JSX.Element }
columnHeaderTooltips?: { [columnName: string]: string | JSX.Element };
rows: { [key: string]: any }[];
}
@ -63,6 +63,7 @@ interface Props
}
const qController = Client.getInstance();
function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown, fixedStickyLastRow, fixedHeight, widgetMetaData}: Props): JSX.Element
{
const [qInstance, setQInstance] = useState(null as QInstance);
@ -92,41 +93,25 @@ function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown,
/>
: noRowsFoundHTML ?
<Box p={3} pt={0} pb={3} sx={{textAlign: "center"}}>
<MDTypography
variant="subtitle2"
color="secondary"
fontWeight="regular"
>
{
noRowsFoundHTML ? (
parse(noRowsFoundHTML)
) : "No rows found"
}
<MDTypography variant="subtitle2" color="secondary" fontWeight="regular">
{noRowsFoundHTML ? (parse(noRowsFoundHTML)) : "No rows found"}
</MDTypography>
</Box>
:
<TableContainer sx={{boxShadow: "none"}}>
<Table>
<Box component="thead">
<TableRow key="header">
{Array(8).fill(0).map((_, i) =>
<DataTableHeadCell key={`head-${i}`} sorted={false} width="auto" align="center">
<Skeleton width="100%" />
</DataTableHeadCell>
)}
</TableRow>
</Box>
<TableBody>
{Array(5).fill(0).map((_, i) =>
<TableRow sx={{verticalAlign: "top"}} key={`row-${i}`}>
{Array(8).fill(0).map((_, j) =>
<DataTableBodyCell key={`cell-${i}-${j}`} align="center">
<DefaultCell isFooter={false}><Skeleton /></DefaultCell>
</DataTableBodyCell>
)}
</TableRow>
)}
</TableBody>
<Table component="div" sx={{display: "grid", gridTemplateRows: "auto", gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr"}}>
{Array(8).fill(0).map((_, i) =>
<DataTableHeadCell key={`head-${i}`} sorted={false} width="auto" align="center">
<Skeleton width="100%" />
</DataTableHeadCell>
)}
{Array(5).fill(0).map((_, i) =>
Array(8).fill(0).map((_, j) =>
<DataTableBodyCell key={`cell-${i}-${j}`} align="center">
<DefaultCell isFooter={false}><Skeleton /></DefaultCell>
</DataTableBodyCell>
)
)}
</Table>
</TableContainer>
}

View File

@ -23,7 +23,6 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
// @ts-ignore
import {htmlToText} from "html-to-text";
import React, {useContext, useEffect, useState} from "react";
import QContext from "QContext";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import TableCard from "qqq/components/widgets/tables/TableCard";
@ -31,6 +30,7 @@ import Widget, {WidgetData} from "qqq/components/widgets/Widget";
import {WidgetUtils} from "qqq/components/widgets/WidgetUtils";
import HtmlUtils from "qqq/utils/HtmlUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext, useEffect, useState} from "react";
interface Props
{
@ -40,8 +40,7 @@ interface Props
isChild?: boolean;
}
TableWidget.defaultProps = {
};
TableWidget.defaultProps = {};
function TableWidget(props: Props): JSX.Element
{
@ -86,7 +85,7 @@ function TableWidget(props: Props): JSX.Element
const cell = rows[i][columns[j].accessor];
let text = cell;
if(columns[j].type != "default")
if (columns[j].type != "default")
{
text = htmlToText(cell,
{
@ -105,7 +104,7 @@ function TableWidget(props: Props): JSX.Element
setCsv(csv);
const fileName = WidgetUtils.makeExportFileName(props.widgetData, props.widgetMetaData);
setFileName(fileName)
setFileName(fileName);
console.log(`useEffect, setting fileName ${fileName}`);
}
@ -114,24 +113,28 @@ function TableWidget(props: Props): JSX.Element
const onExportClick = () =>
{
if(props.widgetData?.csvData)
if (props.widgetData?.csvData)
{
const csv = WidgetUtils.widgetCsvDataToString(props.widgetData);
const fileName = WidgetUtils.makeExportFileName(props.widgetData, props.widgetMetaData);
HtmlUtils.download(fileName, csv);
}
else if(csv)
else if (csv)
{
HtmlUtils.download(fileName, csv);
}
else
{
alert("There is no data available to export.")
alert("There is no data available to export.");
}
}
};
const labelAdditionalElementsLeft: JSX.Element[] = [];
if(props.widgetMetaData?.showExportButton)
if (props.widgetData?.linkText && props.widgetData?.linkURL)
{
labelAdditionalElementsLeft.push(WidgetUtils.generateLabelLink(props.widgetData?.linkText, props.widgetData?.linkURL));
}
if (props.widgetMetaData?.showExportButton)
{
labelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick));
}
@ -139,14 +142,14 @@ function TableWidget(props: Props): JSX.Element
//////////////////////////////////////////////////////
// look for column-header tooltips from helpContent //
//////////////////////////////////////////////////////
const columnHeaderTooltips: {[columnName: string]: JSX.Element} = {}
const columnHeaderTooltips: { [columnName: string]: JSX.Element } = {};
for (let column of props.widgetData?.columns ?? [])
{
const helpRoles = ["ALL_SCREENS"]
const helpRoles = ["ALL_SCREENS"];
const slotName = `columnHeader=${column.accessor}`;
const showHelp = helpHelpActive || hasHelpContent(props.widgetMetaData?.helpContent?.get(slotName), helpRoles);
if(showHelp)
if (showHelp)
{
const formattedHelpContent = <HelpContent helpContents={props.widgetMetaData?.helpContent?.get(slotName)} roles={helpRoles} helpContentKey={`widget:${props.widgetMetaData?.name};slot:${slotName}`} />;
columnHeaderTooltips[column.accessor] = formattedHelpContent;

View File

@ -19,7 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import {Box} from "@mui/material";
import {Theme} from "@mui/material/styles";
import colors from "qqq/assets/theme/base/colors";
import {ReactNode} from "react";
@ -30,13 +30,14 @@ interface Props
children: ReactNode;
noBorder?: boolean;
align?: "left" | "right" | "center";
sx?: any;
}
function DataTableBodyCell({noBorder, align, children}: Props): JSX.Element
function DataTableBodyCell({noBorder, align, sx, children}: Props): JSX.Element
{
return (
<Box
component="td"
component="div"
textAlign={align}
py={1.5}
px={1.5}
@ -54,7 +55,7 @@ function DataTableBodyCell({noBorder, align, children}: Props): JSX.Element
},
"&:last-child": {
paddingRight: "1rem"
}
}, ...sx
})}
>
<Box
@ -72,6 +73,7 @@ function DataTableBodyCell({noBorder, align, children}: Props): JSX.Element
DataTableBodyCell.defaultProps = {
noBorder: false,
align: "left",
sx: {}
};
export default DataTableBodyCell;

View File

@ -44,18 +44,14 @@ function DataTableHeadCell({width, children, sorted, align, tooltip, ...rest}: P
return (
<Box
component="th"
component="div"
width={width}
py={1.5}
px={1.5}
sx={({palette: {light}, borders: {borderWidth}}: Theme) => ({
borderBottom: `${borderWidth[1]} solid ${colors.grayLines.main}`,
"&:nth-of-type(1)": {
paddingLeft: "1rem"
},
"&:last-child": {
paddingRight: "1rem"
},
position: "sticky", top: 0, background: "white",
zIndex: 1 // so if body rows scroll behind it, they don't show through
})}
>
<Box

View File

@ -0,0 +1,38 @@
/*
* 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 {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
/*******************************************************************************
** Properties attached to a (formik?) form field, to denote how it behaves as
** as related to a possible value source.
*******************************************************************************/
export interface FieldPossibleValueProps
{
isPossibleValue?: boolean;
possibleValues?: QPossibleValue[];
initialDisplayValue: string | null;
fieldName?: string;
tableName?: string;
processName?: string;
possibleValueSourceName?: string;
}

View File

@ -29,6 +29,7 @@ export interface FieldRule
sourceField: string;
action: FieldRuleAction;
targetField: string;
targetWidget: string;
}
@ -46,5 +47,6 @@ export enum FieldRuleTrigger
*******************************************************************************/
export enum FieldRuleAction
{
CLEAR_TARGET_FIELD = "CLEAR_TARGET_FIELD"
CLEAR_TARGET_FIELD = "CLEAR_TARGET_FIELD",
RELOAD_WIDGET = "RELOAD_WIDGET"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,268 @@
/*
* 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 {QComponentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QComponentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFrontendStepMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendStepMetaData";
import {CompositeData} from "qqq/components/widgets/CompositeWidget";
/*******************************************************************************
** Utility functions used by ProcessRun for working with ad-hoc, block &
** composite type widgets.
**
*******************************************************************************/
export default class ProcessWidgetBlockUtils
{
/*******************************************************************************
**
*******************************************************************************/
public static isActionCodeValid(actionCode: string, step: QFrontendStepMetaData, processValues: any): boolean
{
///////////////////////////////////////////////////////////
// private recursive function to walk the composite tree //
///////////////////////////////////////////////////////////
function recursiveIsActionCodeValidForCompositeData(compositeWidgetData: CompositeData): boolean
{
for (let i = 0; i < compositeWidgetData.blocks.length; i++)
{
const block = compositeWidgetData.blocks[i];
////////////////////////////////////////////////////////////////
// skip the block if it has a 'conditional', which isn't true //
////////////////////////////////////////////////////////////////
const conditionalFieldName = block.conditional;
if (conditionalFieldName)
{
const value = processValues[conditionalFieldName];
if (!value)
{
continue;
}
}
if (block.blockTypeName == "COMPOSITE")
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// recursive call for composites, but only return if a true is found (in case a subsequent block has a true) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
const isValidForThisBlock = recursiveIsActionCodeValidForCompositeData(block as unknown as CompositeData);
if (isValidForThisBlock)
{
return (true);
}
// else, continue...
}
else if (block.blockTypeName == "BUTTON")
{
//////////////////////////////////////////
// look at actionCodes on button blocks //
//////////////////////////////////////////
if (block.values?.actionCode == actionCode)
{
return (true);
}
}
}
/////////////////////////////////////////
// if code wasn't found, it is invalid //
/////////////////////////////////////////
return false;
}
/////////////////////////////////////////////////////
// iterate over all components in the current step //
/////////////////////////////////////////////////////
for (let i = 0; i < step.components.length; i++)
{
const component = step.components[i];
if (component.type == "WIDGET" && component.values?.isAdHocWidget)
{
///////////////////////////////////////////////////////////////////////////////////////////////
// for ad-hoc widget components, check if this actionCode exists on any action-button blocks //
///////////////////////////////////////////////////////////////////////////////////////////////
const isValidForThisWidget = recursiveIsActionCodeValidForCompositeData(component.values);
if (isValidForThisWidget)
{
return (true);
}
}
}
////////////////////////////////////
// upon fallthrough, it's a false //
////////////////////////////////////
return false;
}
/***************************************************************************
** perform evaluations on a compositeWidget's data, given current process
** values, to do dynamic stuff, like:
** - removing fields with un-true conditions
***************************************************************************/
public static dynamicEvaluationOfCompositeWidgetData(compositeWidgetData: CompositeData, processValues: any)
{
for (let i = 0; i < compositeWidgetData.blocks.length; i++)
{
const block = compositeWidgetData.blocks[i];
////////////////////////////////////////////////////////////////////
// if the block has a conditional, evaluate, and remove if untrue //
////////////////////////////////////////////////////////////////////
const conditionalFieldName = block.conditional;
if (conditionalFieldName)
{
const value = processValues[conditionalFieldName];
if (!value)
{
console.debug(`Splicing away block based on [${conditionalFieldName}]...`);
compositeWidgetData.blocks.splice(i, 1);
i--;
continue;
}
}
if (block.blockTypeName == "COMPOSITE")
{
/////////////////////////////////////////
// make recursive calls for composites //
/////////////////////////////////////////
ProcessWidgetBlockUtils.dynamicEvaluationOfCompositeWidgetData(block as unknown as CompositeData, processValues);
}
else if (block.blockTypeName == "INPUT_FIELD")
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for input fields, put the process's value for the field-name into the block's values object as '.value' //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
const fieldName = block.values?.fieldMetaData?.name;
if (processValues.hasOwnProperty(fieldName))
{
block.values.value = processValues[fieldName];
}
}
else if (block.blockTypeName == "TEXT")
{
//////////////////////////////////////////////////////////////////////////////////////
// for text-blocks - interpolate ${fieldName} expressions into their process-values //
//////////////////////////////////////////////////////////////////////////////////////
let text = block.values?.text;
if (text)
{
for (let key of Object.keys(processValues))
{
text = text.replaceAll("${" + key + "}", processValues[key]);
}
block.values.interpolatedText = text;
}
}
}
}
/***************************************************************************
**
***************************************************************************/
public static addFieldsForCompositeWidget(step: QFrontendStepMetaData, processValues: any, addFieldCallback: (fieldMetaData: QFieldMetaData) => void)
{
///////////////////////////////////////////////////////////
// private recursive function to walk the composite tree //
///////////////////////////////////////////////////////////
function recursiveHelper(widgetData: CompositeData)
{
try
{
for (let block of widgetData.blocks)
{
if (block.blockTypeName == "COMPOSITE")
{
recursiveHelper(block as unknown as CompositeData);
}
else if (block.blockTypeName == "INPUT_FIELD")
{
const fieldMetaData = new QFieldMetaData(block.values?.fieldMetaData);
addFieldCallback(fieldMetaData);
}
}
}
catch (e)
{
console.log("Error adding fields for compositeWidget: " + e);
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// foreach component, if it's an adhoc widget or a widget w/ its data in the processValues, then, call recursive helper on it //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
for (let component of step.components)
{
if (component.type == QComponentType.WIDGET && component.values?.isAdHocWidget)
{
recursiveHelper(component.values as unknown as CompositeData);
}
else if (component.type == QComponentType.WIDGET && processValues[component.values?.widgetName])
{
recursiveHelper(processValues[component.values?.widgetName] as unknown as CompositeData);
}
}
}
/***************************************************************************
**
***************************************************************************/
public static processColorFromStyleMap(colorFromStyleMap?: string): string
{
if (colorFromStyleMap)
{
switch (colorFromStyleMap.toUpperCase())
{
case "SUCCESS":
return("#2BA83F");
case "WARNING":
return("#FBA132");
case "ERROR":
return("#FB4141");
case "INFO":
return("#458CFF");
case "MUTED":
return("#7b809a");
default:
{
if (colorFromStyleMap.match(/^[0-9A-F]{6}$/))
{
return(`#${colorFromStyleMap}`);
}
else if (colorFromStyleMap.match(/^[0-9A-F]{8}$/))
{
return(`#${colorFromStyleMap}`);
}
else
{
return(colorFromStyleMap);
}
}
}
}
}
}

View File

@ -121,7 +121,7 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
}
const valueCounts = [] as QRecord[];
for(let i = 0; i < result.values.valueCounts.length; i++)
for(let i = 0; i < result.values.valueCounts?.length; i++)
{
let valueRecord = new QRecord(result.values.valueCounts[i]);

View File

@ -779,13 +779,12 @@ function InputPossibleValueSourceSingle(tableName: string, field: QFieldMetaData
}}
>
<DynamicSelect
tableName={tableName}
fieldName={field.name}
fieldPossibleValueProps={{tableName: tableName, fieldName: field.name, initialDisplayValue: selectedPossibleValue?.label}}
fieldLabel="Value"
initialValue={selectedPossibleValue?.id}
initialDisplayValue={selectedPossibleValue?.label}
inForm={false}
onChange={handleChange}
useCase="filter"
// InputProps={applying ? {endAdornment: <Icon>sync</Icon>} : {}}
/>
</Box>
@ -847,13 +846,12 @@ function InputPossibleValueSourceMultiple(tableName: string, field: QFieldMetaDa
}}
>
<DynamicSelect
tableName={tableName}
fieldName={field.name}
fieldPossibleValueProps={{tableName: tableName, fieldName: field.name, initialDisplayValue: null}}
isMultiple={true}
fieldLabel="Value"
initialValues={selectedPossibleValues}
inForm={false}
onChange={handleChange}
useCase="filter"
/>
</Box>
);

View File

@ -33,8 +33,7 @@ import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QC
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 {Alert, Collapse, Menu, Typography} from "@mui/material";
import Box from "@mui/material/Box";
import {Alert, Box, Collapse, Menu, Typography} from "@mui/material";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Divider from "@mui/material/Divider";
@ -92,8 +91,10 @@ interface Props
launchProcess?: QProcessMetaData;
usage?: QueryScreenUsage;
isModal?: boolean;
isPreview?: boolean;
initialQueryFilter?: QQueryFilter;
initialColumns?: QQueryColumns;
allowVariables?: boolean;
}
///////////////////////////////////////////////////////
@ -109,7 +110,7 @@ const qController = Client.getInstance();
*******************************************************************************/
const getLoadingScreen = (isModal: boolean) =>
{
if(isModal)
if (isModal)
{
return (<Box>&nbsp;</Box>);
}
@ -125,7 +126,7 @@ const getLoadingScreen = (isModal: boolean) =>
**
** Yuge component. The best. Lots of very smart people are saying so.
*******************************************************************************/
const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, initialColumns}: Props, ref) =>
const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariables, initialQueryFilter, initialColumns}: Props, ref) =>
{
const tableName = table.name;
const [searchParams] = useSearchParams();
@ -151,7 +152,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
*******************************************************************************/
function localStorageSet(key: string, value: string)
{
if(mayWriteLocalStorage)
if (mayWriteLocalStorage)
{
localStorage.setItem(key, value);
}
@ -163,7 +164,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
*******************************************************************************/
function localStorageRemove(key: string)
{
if(mayWriteLocalStorage)
if (mayWriteLocalStorage)
{
localStorage.removeItem(key);
}
@ -176,7 +177,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
{
return view;
}
}
};
});
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -256,7 +257,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
defaultView.mode = defaultMode;
}
if(firstRender)
if (firstRender)
{
/////////////////////////////////////////////////////////////////////////
// allow a caller to send in an initial filter & set of columns. //
@ -408,7 +409,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
//////////////////////////////////////////////////////////////////
// we use our own header - so clear out the context page header //
//////////////////////////////////////////////////////////////////
if(!isModal)
if (!isModal)
{
setPageHeader(null);
}
@ -486,7 +487,6 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
};
/*******************************************************************************
**
*******************************************************************************/
@ -631,7 +631,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
const type = (e.target as any).type;
const validType = (type !== "text" && type !== "textarea" && type !== "input" && type !== "search");
if (validType && !dotMenuOpen && !keyboardHelpOpen && !activeModalProcess)
if (validType && !isModal && !dotMenuOpen && !keyboardHelpOpen && !activeModalProcess)
{
if (!e.metaKey && !e.ctrlKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission)
{
@ -669,7 +669,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
{
document.removeEventListener("keydown", down);
};
}, [dotMenuOpen, keyboardHelpOpen, metaData, activeModalProcess]);
}, [isModal, dotMenuOpen, keyboardHelpOpen, metaData, activeModalProcess]);
/*******************************************************************************
@ -711,7 +711,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
{
if (localStorage.getItem(currentSavedViewLocalStorageKey))
{
if(usage == "queryScreen")
if (usage == "queryScreen")
{
currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey));
navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`);
@ -750,13 +750,13 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
const viewForLocalStorage: RecordQueryView = JSON.parse(viewAsJSON);
if (viewForLocalStorage?.queryFilter?.criteria?.length > 0)
{
FilterUtils.stripAwayIncompleteCriteria(viewForLocalStorage.queryFilter)
FilterUtils.stripAwayIncompleteCriteria(viewForLocalStorage.queryFilter);
}
localStorageSet(viewLocalStorageKey, JSON.stringify(viewForLocalStorage));
}
catch(e)
catch (e)
{
console.log("Error storing view in local storage: " + e)
console.log("Error storing view in local storage: " + e);
}
};
@ -868,7 +868,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
for (let i = 0; i < queryFilter?.orderBys?.length; i++)
{
const fieldName = queryFilter.orderBys[i].fieldName;
if (fieldName.indexOf(".") > -1)
if (fieldName != null && fieldName.indexOf(".") > -1)
{
const joinTableName = fieldName.replaceAll(/\..*/g, "");
if (!vjtToUse.has(joinTableName))
@ -884,6 +884,18 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
};
/*******************************************************************************
** Opens a new query screen in a new window with the current filter
*******************************************************************************/
const openFilterInNewWindow = () =>
{
let filterForBackend = JSON.parse(JSON.stringify(view.queryFilter));
filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, filterForBackend);
const url = `${metaData?.getTablePathByName(tableName)}?filter=${encodeURIComponent(JSON.stringify(filterForBackend))}`;
window.open(url);
};
/*******************************************************************************
** This is the method that actually executes a query to update the data in the table.
*******************************************************************************/
@ -901,6 +913,26 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
return;
}
/////////////////////////////////////////////////////////////////////////////////////////////////
// if any values in the query are of type "FilterVariableExpression", display an error showing //
// that a backend query cannot be made because of missing values for that expression //
/////////////////////////////////////////////////////////////////////////////////////////////////
setWarningAlert(null);
for (var i = 0; i < queryFilter?.criteria?.length; i++)
{
for (var j = 0; j < queryFilter?.criteria[i]?.values?.length; j++)
{
const value = queryFilter.criteria[i].values[j];
if (value?.type == "FilterVariableExpression")
{
setWarningAlert("Cannot perform query because of a missing value for a variable.");
setLoading(false);
setRows([]);
return;
}
}
}
recordAnalytics({category: "tableEvents", action: "query", label: tableMetaData.label});
console.log(`In updateTable for ${reason} ${JSON.stringify(queryFilter)}`);
@ -939,7 +971,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
console.log(`Issuing query: ${thisQueryId}`);
if (tableMetaData.capabilities.has(Capability.TABLE_COUNT))
{
if(clearOutCount)
if (clearOutCount)
{
setTotalRecords(null);
setDistinctRecords(null);
@ -1437,7 +1469,6 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
return (selectedIds.length);
};
/*******************************************************************************
** get a query-string to put on the url to indicate what records are going into
** a process.
@ -2213,12 +2244,25 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
return (
<GridToolbarContainer>
<div>
<Button id="refresh-button" onClick={() => updateTable("refresh button")} startIcon={<Icon>refresh</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button>
</div>
<div style={{position: "relative"}}>
{/* @ts-ignore */}
<GridToolbarDensitySelector nonce={undefined} />
<Tooltip title="Refresh Query">
<Button id="refresh-button" onClick={() => updateTable("refresh button")} startIcon={<Icon>refresh</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button>
</Tooltip>
</div>
{
!isPreview && (
<div style={{position: "relative"}}>
{/* @ts-ignore */}
<GridToolbarDensitySelector nonce={undefined} />
</div>
)
}
{
isPreview && (
<Tooltip title="Open In New Window">
<Button id="open-filter-in-new-window-button" onClick={() => openFilterInNewWindow()} startIcon={<Icon>launch</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button>
</Tooltip>
)
}
{
usage == "queryScreen" &&
@ -2527,7 +2571,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
{
const currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey));
console.log(`returning to previously active saved view ${currentSavedViewId}`);
if(usage == "queryScreen")
if (usage == "queryScreen")
{
navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`);
}
@ -2770,7 +2814,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
spaceAboveGrid += 60;
}
if(isModal)
if (isModal)
{
spaceAboveGrid += 130;
}
@ -2853,7 +2897,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
}
{
metaData && tableMetaData &&
!isPreview && metaData && tableMetaData &&
<BasicAndAdvancedQueryControls
ref={basicAndAdvancedQueryControlsRef}
metaData={metaData}
@ -2866,6 +2910,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
gridApiRef={gridApiRef}
mode={mode}
queryScreenUsage={usage}
allowVariables={allowVariables}
setMode={doSetMode}
savedViewsComponent={savedViewsComponent}
columnMenuComponent={buildColumnMenu()}
@ -2890,9 +2935,11 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
filterPanel:
{
tableMetaData: tableMetaData,
queryScreenUsage: usage,
metaData: metaData,
queryFilter: queryFilter,
updateFilter: doSetQueryFilter,
allowVariables: allowVariables
}
}}
localeText={{
@ -2976,15 +3023,15 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
</React.Fragment>
);
if(isModal)
if (isModal)
{
return body;
}
return (
<BaseLayout>{body}</BaseLayout>
)
})
);
});
RecordQuery.defaultProps = {

View File

@ -34,6 +34,7 @@ import BaseLayout from "qqq/layouts/BaseLayout";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-java";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-json";

View File

@ -21,6 +21,7 @@
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
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";
@ -73,22 +74,94 @@ const qController = Client.getInstance();
interface Props
{
table?: QTableMetaData;
record?: QRecord;
launchProcess?: QProcessMetaData;
}
RecordView.defaultProps =
{
table: null,
record: null,
launchProcess: null,
};
const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant";
/*******************************************************************************
**
*******************************************************************************/
export function renderSectionOfFields(key: string, fieldNames: string[], tableMetaData: QTableMetaData, helpHelpActive: boolean, record: QRecord, fieldMap?: { [name: string]: QFieldMetaData })
{
return <Box key={key} display="flex" flexDirection="column" py={1} pr={2}>
{
fieldNames.map((fieldName: string) =>
{
let [field, tableForField] = tableMetaData ? TableUtils.getFieldAndTable(tableMetaData, fieldName) : fieldMap ? [fieldMap[fieldName], null] : [null, null];
if (field != null)
{
let label = field.label;
const helpRoles = ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles);
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={label} helpContentKey={`table:${tableMetaData?.name};field:${fieldName}`} />;
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default"}}>{label}:</Typography>;
return (
<Box key={fieldName} flexDirection="row" pr={2}>
<>
{
showHelp && formattedHelpContent ? <Tooltip title={formattedHelpContent}>{labelElement}</Tooltip> : labelElement
}
<div style={{display: "inline-block", width: 0}}>&nbsp;</div>
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)">
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
</Typography>
</>
</Box>
);
}
})
}
</Box>;
}
/***************************************************************************
**
***************************************************************************/
export function getVisibleJoinTables(tableMetaData: QTableMetaData): Set<string>
{
const visibleJoinTables = new Set<string>();
for (let i = 0; i < tableMetaData?.sections.length; i++)
{
const section = tableMetaData?.sections[i];
if (section.isHidden || !section.fieldNames || !section.fieldNames.length)
{
continue;
}
section.fieldNames.forEach((fieldName) =>
{
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
if (tableForField && tableForField.name != tableMetaData.name)
{
visibleJoinTables.add(tableForField.name);
}
});
}
return (visibleJoinTables);
}
/*******************************************************************************
** Record View Screen component.
*******************************************************************************/
function RecordView({table, launchProcess}: Props): JSX.Element
function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.Element
{
const {id} = useParams();
@ -105,7 +178,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
const [adornmentFieldsMap, setAdornmentFieldsMap] = useState(new Map<string, boolean>);
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
const [metaData, setMetaData] = useState(null as QInstance);
const [record, setRecord] = useState(null as QRecord);
const [record, setRecord] = useState(overrideRecord ?? null as QRecord);
const [tableSections, setTableSections] = useState([] as QTableSection[]);
const [t1Section, setT1Section] = useState(null as QTableSection);
const [t1SectionName, setT1SectionName] = useState(null as string);
@ -130,7 +203,9 @@ function RecordView({table, launchProcess}: Props): JSX.Element
const openActionsMenu = (event: any) => setActionsMenu(event.currentTarget);
const closeActionsMenu = () => setActionsMenu(null);
const {accentColor, setPageHeader, tableMetaData, setTableMetaData, tableProcesses, setTableProcesses, dotMenuOpen, keyboardHelpOpen, helpHelpActive, recordAnalytics} = useContext(QContext);
const {accentColor, setPageHeader, tableMetaData, setTableMetaData, tableProcesses, setTableProcesses, dotMenuOpen, keyboardHelpOpen, helpHelpActive, recordAnalytics, userId: currentUserId} = useContext(QContext);
const CREATE_CHILD_KEY = "createChild";
if (localStorage.getItem(tableVariantLocalStorageKey))
{
@ -234,12 +309,19 @@ function RecordView({table, launchProcess}: Props): JSX.Element
///////////////////////////////////////////////////////////////////////////////////////////////
// the path for a process looks like: .../table/id/process //
// the path for creating a child record looks like: .../table/id/createChild/:childTableName //
// the path for creating a child record in a process looks like: //
// .../table/id/processName#/createChild=... //
///////////////////////////////////////////////////////////////////////////////////////////////
let hasChildRecordKey = pathParts.some(p => p.includes(CREATE_CHILD_KEY));
if (!hasChildRecordKey)
{
hasChildRecordKey = hashParts.some(h => h.includes(CREATE_CHILD_KEY));
}
//////////////////////////////////////////////////////////////
// if our tableName is in the -3 index, try to open process //
//////////////////////////////////////////////////////////////
if (pathParts[pathParts.length - 3] === tableName)
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if our tableName is in the -3 index, and there is no token for updating child records, try to open process //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (!hasChildRecordKey && pathParts[pathParts.length - 3] === tableName)
{
const processName = pathParts[pathParts.length - 1];
const processList = allTableProcesses.filter(p => p.name.endsWith(processName));
@ -276,7 +358,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
// if our table is in the -4 index, and there's `createChild` in the -2 index, try to open a createChild form //
// e.g., person/42/createChild/address (to create an address under person 42) //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (pathParts[pathParts.length - 4] === tableName && pathParts[pathParts.length - 2] == "createChild")
if (pathParts[pathParts.length - 4] === tableName && pathParts[pathParts.length - 2] == CREATE_CHILD_KEY)
{
(async () =>
{
@ -295,7 +377,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
for (let i = 0; i < hashParts.length; i++)
{
const parts = hashParts[i].split("=");
if (parts.length > 1 && parts[0] == "createChild")
if (parts.length > 1 && parts[0] == CREATE_CHILD_KEY)
{
(async () =>
{
@ -339,31 +421,6 @@ function RecordView({table, launchProcess}: Props): JSX.Element
reload();
}, [location.pathname, location.hash]);
const getVisibleJoinTables = (tableMetaData: QTableMetaData): Set<string> =>
{
const visibleJoinTables = new Set<string>();
for (let i = 0; i < tableMetaData?.sections.length; i++)
{
const section = tableMetaData?.sections[i];
if (section.isHidden || !section.fieldNames || !section.fieldNames.length)
{
continue;
}
section.fieldNames.forEach((fieldName) =>
{
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
if (tableForField && tableForField.name != tableMetaData.name)
{
visibleJoinTables.add(tableForField.name);
}
});
}
return (visibleJoinTables);
};
/*******************************************************************************
** get an element (or empty) to use as help content for a section
@ -439,7 +496,18 @@ function RecordView({table, launchProcess}: Props): JSX.Element
let record: QRecord;
try
{
record = await qController.get(tableName, id, tableVariant, null, queryJoins);
////////////////////////////////////////////////////////////////////////////
// if the component took in a record object, then we don't need to GET it //
////////////////////////////////////////////////////////////////////////////
if (overrideRecord)
{
record = overrideRecord;
}
else
{
record = await qController.get(tableName, id, tableVariant, null, queryJoins);
}
setRecord(record);
recordAnalytics({category: "tableEvents", action: "view", label: tableMetaData?.label + " / " + record?.recordLabel});
}
@ -476,7 +544,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
setPageHeader(record.recordLabel);
if (!launchingProcess)
if (!launchingProcess && !activeModalProcess)
{
try
{
@ -528,40 +596,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
// for a section with field names, render the field values. //
// for the T1 section, the "wrapper" will come out below - but for other sections, produce a wrapper too. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
const fields = (
<Box key={section.name} display="flex" flexDirection="column" py={1} pr={2}>
{
section.fieldNames.map((fieldName: string) =>
{
let [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
if (field != null)
{
let label = field.label;
const helpRoles = ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles);
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={label} helpContentKey={`table:${tableName};field:${fieldName}`} />;
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default"}}>{label}:</Typography>;
return (
<Box key={fieldName} flexDirection="row" pr={2}>
<>
{
showHelp && formattedHelpContent ? <Tooltip title={formattedHelpContent}>{labelElement}</Tooltip> : labelElement
}
<div style={{display: "inline-block", width: 0}}>&nbsp;</div>
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)">
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
</Typography>
</>
</Box>
);
}
})
}
</Box>
);
const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record);
if (section.tier === "T1")
{
@ -796,13 +831,37 @@ function RecordView({table, launchProcess}: Props): JSX.Element
*******************************************************************************/
const renderShareButton = () =>
{
if (tableMetaData && (tableMetaData.name == "savedReport" || tableMetaData.name == "savedView")) // todo - not just based on name
if (tableMetaData && tableMetaData.shareableTableMetaData)
{
const shareDisabled = false; // todo - only share if you're the owner? or do that in the modal?
return (<Box width={standardWidth} mr={3}>
<MDButton id="shareButton" type="button" color="info" size="small" onClick={() => openShareModal()} fullWidth startIcon={<Icon>share</Icon>} disabled={shareDisabled}>
Share
</MDButton>
let shareDisabled = true;
let disabledTooltipText = "";
if (tableMetaData.shareableTableMetaData.thisTableOwnerIdFieldName && record)
{
const ownerId = record.values.get(tableMetaData.shareableTableMetaData.thisTableOwnerIdFieldName);
if (ownerId != currentUserId)
{
disabledTooltipText = `Only the owner of a ${tableMetaData.label} may share it.`;
shareDisabled = true;
}
else
{
disabledTooltipText = "";
shareDisabled = false;
}
}
else
{
shareDisabled = false;
}
return (<Box width={standardWidth} mr={2}>
<Tooltip title={disabledTooltipText}>
<span>
<MDButton id="shareButton" type="button" color="info" size="small" onClick={() => openShareModal()} fullWidth startIcon={<Icon>group_add</Icon>} disabled={shareDisabled}>
Share
</MDButton>
</span>
</Tooltip>
</Box>);
}
@ -1031,7 +1090,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
{
showEditChildForm &&
<Modal open={showEditChildForm as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
<Modal open={showEditChildForm !== null} onClose={(event, reason) => closeEditChildForm(event, reason)}>
<div className="modalEditForm">
<EntityForm
isModal={true}

View File

@ -0,0 +1,163 @@
/*
* 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 {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 {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {QueryJoin} from "@kingsrook/qqq-frontend-core/lib/model/query/QueryJoin";
import {Alert, Box} from "@mui/material";
import Grid from "@mui/material/Grid";
import BaseLayout from "qqq/layouts/BaseLayout";
import RecordView, {getVisibleJoinTables} from "qqq/pages/records/view/RecordView";
import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {useEffect, useState} from "react";
import {useSearchParams} from "react-router-dom";
interface RecordViewByUniqueKeyProps
{
table: QTableMetaData;
}
RecordViewByUniqueKey.defaultProps = {};
const qController = Client.getInstance();
/***************************************************************************
** Wrapper around RecordView, that reads a unique key from the query string,
** looks for a record matching that key, and shows that record.
***************************************************************************/
export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProps): JSX.Element
{
const tableName = table.name;
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [doneLoading, setDoneLoading] = useState(false);
const [record, setRecord] = useState(null as QRecord);
const [errorMessage, setErrorMessage] = useState(null as string);
const [queryParams] = useSearchParams();
if (!asyncLoadInited)
{
setAsyncLoadInited(true);
(async () =>
{
const tableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
const criteria: QFilterCriteria[] = [];
for (let [name, value] of queryParams.entries())
{
criteria.push(new QFilterCriteria(name, QCriteriaOperator.EQUALS, [value]));
if(!tableMetaData.fields.has(name))
{
setErrorMessage(`Query-string parameter [${name}] is not a defined field on the ${tableMetaData.label} table.`);
setDoneLoading(true);
return;
}
}
let queryJoins: QueryJoin[] = null;
const visibleJoinTables = getVisibleJoinTables(tableMetaData);
if (visibleJoinTables.size > 0)
{
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
}
const filter = new QQueryFilter(criteria, null, null, "AND", 0, 2);
qController.query(tableName, filter, queryJoins)
.then((queryResult) =>
{
setDoneLoading(true);
if (queryResult.length == 1)
{
setRecord(queryResult[0]);
}
else if (queryResult.length == 0)
{
setErrorMessage(`No ${tableMetaData.label} record was found matching the given values.`);
}
else if (queryResult.length > 1)
{
setErrorMessage(`More than one ${tableMetaData.label} record was found matching the given values.`);
}
})
.catch((error) =>
{
setDoneLoading(true);
console.log(error);
if (error && error.message)
{
setErrorMessage(error.message);
}
else if (error && error.response && error.response.data && error.response.data.error)
{
setErrorMessage(error.response.data.error);
}
else
{
setErrorMessage("Unexpected error running query");
}
});
})();
}
useEffect(() =>
{
if (asyncLoadInited)
{
setAsyncLoadInited(false);
setDoneLoading(false);
setRecord(null);
}
}, [queryParams]);
if (!doneLoading)
{
return (<div>Loading...</div>);
}
else if (record)
{
return (<RecordView table={table} record={record} />);
}
else if (errorMessage)
{
return (<BaseLayout>
<Box className="recordView">
<Grid container>
<Grid item xs={12}>
<Box mb={3}>
{
<Alert color="error" sx={{mb: 3}}>{errorMessage}</Alert>
}
</Box>
</Grid>
</Grid>
</Box>
</BaseLayout>);
}
}

View File

@ -421,6 +421,14 @@ input[type="search"]::-webkit-search-results-decoration
font-size: 2rem !important;
}
.dashboard-table-actions-icon
{
font-size: 1.5rem !important;
position: relative;
top: -5px;
margin-right: 8px;
}
.dashboard-schedule-icon
{
font-size: 1.1rem !important;
@ -653,6 +661,11 @@ input[type="search"]::-webkit-search-results-decoration
min-height: unset !important;
}
.MuiDataGrid-columnHeaders
{
scrollbar-gutter: stable;
}
/* new style for toggle buttons */
.MuiToggleButtonGroup-root
{
@ -688,8 +701,106 @@ input[type="search"]::-webkit-search-results-decoration
font-weight: 500;
}
.recordView .widget
{
padding: 24px;
}
.entityForm .widget
{
padding: 24px;
}
.MuiPickersDay-root.Mui-selected, .MuiPickersDay-root.MuiPickersDay-dayWithMargin:hover
{
color: white;
background-color: #0062FF !important;
}
/* several styles below here for user-defined alert inside helpContent */
.helpContentAlert
{
padding: 6px 16px;
font-size: 1rem;
font-weight: 300;
line-height: 1.6;
display: flex;
}
.helpContentAlert .MuiAlert-icon
{
display: flex;
margin-right: 12px;
padding: 7px 0;
font-size: 22px;
opacity: 0.9;
}
.helpContentAlert .MuiAlert-icon .material-icons-round
{
display: inline-block;
width: 1em;
height: 1em;
}
.helpContentAlert .MuiAlert-message
{
padding: 8px 0;
}
.helpContentAlert.success
{
background-color: rgb(240, 248, 241);
color: rgb(44, 76, 46);
}
.helpContentAlert.success .MuiAlert-icon .material-icons-round
{
color: #4CAF50;
}
.helpContentAlert.warning
{
background-color: rgb(254, 245, 234);
color: rgb(100, 65, 20);
}
.helpContentAlert.warning .MuiAlert-icon .material-icons-round
{
color: #fb8c00;
}
.helpContentAlert.error
{
background-color: rgb(254, 239, 238);
color: rgb(98, 41, 37);
}
.helpContentAlert.error .MuiAlert-icon .material-icons-round
{
color: #F44335;
}
/* the alert widget, was built with minimal (no?) margins, for embedding in
a parent widget; but for using it on a process, give it some breathing room */
.processRun .widget .MuiAlert-root
{
margin: 2rem 1rem;
}
/* default styles for a block widget overlay */
.blockWidgetOverlay
{
font-weight: 400;
position: relative;
top: 15px;
height: 0;
display: flex;
font-size: 14px;
flex-direction: column;
align-items: center;
}
.blockWidgetOverlay a
{
color: #0062FF !important;
}

View File

@ -113,7 +113,7 @@ export default class DataGridUtils
{
console.log(`row-click mouse-up happened ${diff} x or y pixels away from the mouse-down - so not considering it a click.`);
}
}
};
/*******************************************************************************
**
@ -133,13 +133,13 @@ export default class DataGridUtils
row[field.name] = ValueUtils.getDisplayValue(field, record, "query");
});
if(tableMetaData.exposedJoins)
if (tableMetaData.exposedJoins)
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const join = tableMetaData.exposedJoins[i];
if(join?.joinTable?.fields?.values())
if (join?.joinTable?.fields?.values())
{
const fields = [...join.joinTable.fields.values()];
fields.forEach((field) =>
@ -151,15 +151,15 @@ export default class DataGridUtils
}
}
if(!row["id"])
if (!row["id"])
{
row["id"] = record.values.get(tableMetaData.primaryKeyField) ?? row[tableMetaData.primaryKeyField];
if(row["id"] === null || row["id"] === undefined)
if (row["id"] === null || row["id"] === undefined)
{
/////////////////////////////////////////////////////////////////////////////////////////
// DataGrid gets very upset about a null or undefined here, so, try to make it happier //
/////////////////////////////////////////////////////////////////////////////////////////
if(!allowEmptyId)
if (!allowEmptyId)
{
row["id"] = "--";
}
@ -170,7 +170,7 @@ export default class DataGridUtils
});
return (rows);
}
};
/*******************************************************************************
**
@ -180,24 +180,24 @@ export default class DataGridUtils
const columns = [] as GridColDef[];
this.addColumnsForTable(tableMetaData, linkBase, columns, columnSort, null, null);
if(metaData)
if (metaData)
{
if(tableMetaData.exposedJoins)
if (tableMetaData.exposedJoins)
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const join = tableMetaData.exposedJoins[i];
let joinTableName = join.joinTable.name;
if(metaData.tables.has(joinTableName) && metaData.tables.get(joinTableName).readPermission)
if (metaData.tables.has(joinTableName) && metaData.tables.get(joinTableName).readPermission)
{
let joinLinkBase = null;
joinLinkBase = metaData.getTablePath(join.joinTable);
if(joinLinkBase)
if (joinLinkBase)
{
joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/";
}
if(join?.joinTable?.fields?.values())
if (join?.joinTable?.fields?.values())
{
this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, joinTableName + ".", join.label + ": ");
}
@ -220,7 +220,7 @@ export default class DataGridUtils
////////////////////////////////////////////////////////////////////////
// this sorted by sections - e.g., manual sorting by the meta-data... //
////////////////////////////////////////////////////////////////////////
if(columnSort === "bySection")
if (columnSort === "bySection")
{
for (let i = 0; i < tableMetaData.sections.length; i++)
{
@ -241,19 +241,23 @@ export default class DataGridUtils
///////////////////////////
// sort by labels... mmm //
///////////////////////////
sortedKeys.push(...tableMetaData.fields.keys())
sortedKeys.push(...tableMetaData.fields.keys());
sortedKeys.sort((a: string, b: string): number =>
{
return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label))
})
return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label));
});
}
sortedKeys.forEach((key) =>
{
const field = tableMetaData.fields.get(key);
if(field.isHeavy)
if (!field)
{
if(field.type == QFieldType.BLOB)
return;
}
if (field.isHeavy)
{
if (field.type == QFieldType.BLOB)
{
////////////////////////////////////////////////////////
// assume we DO want heavy blobs - as download links. //
@ -270,7 +274,7 @@ export default class DataGridUtils
const column = this.makeColumnFromField(field, tableMetaData, namePrefix, labelPrefix);
if(key === tableMetaData.primaryKeyField && linkBase && namePrefix == null)
if (key === tableMetaData.primaryKeyField && linkBase && namePrefix == null)
{
columns.splice(0, 0, column);
}
@ -346,9 +350,9 @@ export default class DataGridUtils
(cellValues.value)
);
const helpRoles = ["QUERY_SCREEN", "READ_SCREENS", "ALL_SCREENS"]
const helpRoles = ["QUERY_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
const showHelp = hasHelpContent(field.helpContents, helpRoles); // todo - maybe - take helpHelpActive from context all the way down to here?
if(showHelp)
if (showHelp)
{
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={headerName} helpContentKey={`table:${tableMetaData.name};field:${fieldName}`} />;
column.renderHeader = (params: GridColumnHeaderParams) => (
@ -361,7 +365,7 @@ export default class DataGridUtils
}
return (column);
}
};
/*******************************************************************************
@ -390,7 +394,7 @@ export default class DataGridUtils
}
}
if(field.possibleValueSourceName)
if (field.possibleValueSourceName)
{
return (200);
}
@ -415,6 +419,6 @@ export default class DataGridUtils
}
return (200);
}
};
}

View File

@ -0,0 +1,50 @@
/*
* 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 Box from "@mui/material/Box";
import React from "react";
interface DumpJsonBoxProps
{
data: any;
title?: string;
}
/***************************************************************************
** Utillity for debugging an object as JSON
***************************************************************************/
export default function DumpJsonBox({data, title}: DumpJsonBoxProps): JSX.Element
{
return (
<Box border="1px solid gray" my="1rem" borderRadius="0.5rem">
{
title &&
<Box borderBottom="1px solid gray" mb="0.5rem" px="0.25rem" borderRadius="0.5rem 0.5rem 0 0" fontSize="1rem" fontWeight="600kkk" sx={{backgroundColor: "#D0D0D0"}}>
{title}
</Box>
}
<Box maxHeight="200px" p="0.25rem" overflow="auto" sx={{whiteSpace: "pre-wrap", fontFamily: "monospace", fontSize: "0.75rem", lineHeight: "1.2"}}>
{JSON.stringify(data, null, 3)}
</Box>
</Box>
);
}

View File

@ -19,7 +19,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Client from "qqq/utils/qqq/Client";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
/*******************************************************************************
** Utility functions for basic html/webpage/browser things.
@ -68,10 +69,16 @@ export default class HtmlUtils
** it was originally built like this when we had to submit full access token to backend...
**
*******************************************************************************/
static downloadUrlViaIFrame = (url: string, filename: string) =>
static downloadUrlViaIFrame = (field: QFieldMetaData, url: string, filename: string) =>
{
if(url.startsWith("data:"))
if (url.startsWith("data:") || url.startsWith("http"))
{
if (url.startsWith("http"))
{
const separator = url.includes("?") ? "&" : "?";
url += encodeURIComponent(`${separator}response-content-disposition=attachment; ${filename}`);
}
const link = document.createElement("a");
link.download = filename;
link.href = url;
@ -93,8 +100,14 @@ export default class HtmlUtils
// todo - onload event handler to let us know when done?
document.body.appendChild(iframe);
var method = "get";
if (QFieldType.BLOB == field.type)
{
method = "post";
}
const form = document.createElement("form");
form.setAttribute("method", "post");
form.setAttribute("method", method);
form.setAttribute("action", url);
form.setAttribute("target", "downloadIframe");
iframe.appendChild(form);
@ -117,7 +130,7 @@ export default class HtmlUtils
*******************************************************************************/
static openInNewWindow = (url: string, filename: string) =>
{
if(url.startsWith("data:"))
if (url.startsWith("data:"))
{
/////////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////////
@ -153,4 +166,4 @@ export default class HtmlUtils
};
}
}

View File

@ -23,6 +23,7 @@ import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QControl
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
import {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression";
import {NowWithOffsetExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
@ -108,6 +109,8 @@ class FilterUtils
let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
let values = criteria.values;
let hasFilterVariable = false;
if (field.possibleValueSourceName)
{
//////////////////////////////////////////////////////////////////////////////////
@ -121,7 +124,17 @@ class FilterUtils
//////////////////////////////////////////////////////////////////////////////////
if (values && values.length > 0 && values[0] !== null && values[0] !== undefined && values[0] !== "")
{
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values);
////////////////////////////////////////////////////////////////////////
// do not do this lookup if the field is a filter variable expression //
////////////////////////////////////////////////////////////////////////
if (values[0].type && values[0].type == "FilterVariableExpression")
{
hasFilterVariable = true;
}
else
{
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values, undefined, "filter");
}
}
////////////////////////////////////////////
@ -232,6 +245,10 @@ class FilterUtils
{
return (new ThisOrLastPeriodExpression(value));
}
else if (value.type == "FilterVariableExpression")
{
return (new FilterVariableExpression(value));
}
}
return (null);
@ -365,7 +382,12 @@ class FilterUtils
for (let i = 0; i < maxLoops; i++)
{
const value = criteria.values[i];
if (value.type == "NowWithOffset")
if (value.type == "FilterVariableExpression")
{
const expression = new FilterVariableExpression(value);
labels.push(expression.toString());
}
else if (value.type == "NowWithOffset")
{
const expression = new NowWithOffsetExpression(value);
labels.push(expression.toString());
@ -657,7 +679,7 @@ class FilterUtils
filterForBackend.subFilters = subFilters;
if(pageNumber !== undefined && rowsPerPage !== undefined)
if (pageNumber !== undefined && rowsPerPage !== undefined)
{
filterForBackend.skip = pageNumber * rowsPerPage;
filterForBackend.limit = rowsPerPage;

View File

@ -133,6 +133,11 @@ class TableUtils
*******************************************************************************/
public static getFieldAndTable(tableMetaData: QTableMetaData, fieldName: string): [QFieldMetaData, QTableMetaData]
{
if(!fieldName)
{
return [null, null];
}
if (fieldName.indexOf(".") > -1)
{
const nameParts = fieldName.split(".", 2);

View File

@ -28,18 +28,17 @@ import "datejs"; // https://github.com/datejs/Datejs
import {Chip, ClickAwayListener, Icon} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import {makeStyles} from "@mui/styles";
import parse from "html-react-parser";
import React, {Fragment, useReducer, useState} from "react";
import AceEditor from "react-ace";
import {Link} from "react-router-dom";
import HtmlUtils from "qqq/utils/HtmlUtils";
import Client from "qqq/utils/qqq/Client";
import "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-sql";
import React, {Fragment, useReducer, useState} from "react";
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/mode-velocity";
import {Link} from "react-router-dom";
/*******************************************************************************
** Utility class for working with QQQ Values
@ -198,7 +197,7 @@ class ValueUtils
);
}
if (field.type == QFieldType.BLOB)
if (field.type == QFieldType.BLOB || field.hasAdornment(AdornmentType.FILE_DOWNLOAD))
{
return (<BlobComponent field={field} url={rawValue} filename={displayValue} usage={usage} />);
}
@ -219,7 +218,7 @@ class ValueUtils
if (field.type === QFieldType.DATE_TIME)
{
if(displayValue && displayValue != rawValue)
if (displayValue && displayValue != rawValue)
{
//////////////////////////////////////////////////////////////////////////////
// if the date-time actually has a displayValue set, and it isn't just the //
@ -268,7 +267,15 @@ class ValueUtils
{
if (!(date instanceof Date))
{
////////////////////////////////////////////////////////////////////////////////////
// so, a new Date here will interpret the string as being at midnight UTC, but //
// the data object will be in the user/browser timezone. //
// so "2024-08-22", for a user in US/Central, will be "2024-08-21T19:00:00-0500". //
// correct for that by adding the date's timezone offset (converted from minutes //
// to millis) back to it //
////////////////////////////////////////////////////////////////////////////////////
date = new Date(date);
date.setTime(date.getTime() + date.getTimezoneOffset() * 60 * 1000);
}
// @ts-ignore
return (`${date.toString("yyyy-MM-dd")}`);
@ -466,7 +473,7 @@ class ValueUtils
*******************************************************************************/
public static cleanForCsv(param: any): string
{
if(param === undefined || param === null)
if (param === undefined || param === null)
{
return ("");
}
@ -491,7 +498,7 @@ class ValueUtils
////////////////////////////////////////////////////////////////////////////////////////////////
// little private component here, for rendering an AceEditor with some buttons/controls/state //
////////////////////////////////////////////////////////////////////////////////////////////////
function CodeViewer({name, mode, code}: {name: string; mode: string; code: string;}): JSX.Element
function CodeViewer({name, mode, code}: { name: string; mode: string; code: string; }): JSX.Element
{
const [activeCode, setActiveCode] = useState(code);
const [isFormatted, setIsFormatted] = useState(false);
@ -588,7 +595,7 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// little private component here, for rendering "secret-ish" values, that you can click to reveal or copy //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
function RevealComponent({fieldName, value, usage}: {fieldName: string, value: string, usage: string;}): JSX.Element
function RevealComponent({fieldName, value, usage}: { fieldName: string, value: string, usage: string; }): JSX.Element
{
const [adornmentFieldsMap, setAdornmentFieldsMap] = useState(new Map<string, boolean>);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -645,7 +652,7 @@ function RevealComponent({fieldName, value, usage}: {fieldName: string, value: s
</Tooltip>
</ClickAwayListener>
</Box>
):(
) : (
<Box display="inline"><Icon onClick={(e) => handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_off</Icon>{displayValue}</Box>
)
)
@ -672,7 +679,7 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
const download = (event: React.MouseEvent<HTMLSpanElement>) =>
{
event.stopPropagation();
HtmlUtils.downloadUrlViaIFrame(url, filename);
HtmlUtils.downloadUrlViaIFrame(field, url, filename);
};
const open = (event: React.MouseEvent<HTMLSpanElement>) =>
@ -681,7 +688,7 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
HtmlUtils.openInNewWindow(url, filename);
};
if(!filename || !url)
if (!filename || !url)
{
return (<React.Fragment />);
}
@ -696,10 +703,22 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
usage == "view" && filename
}
<Tooltip placement={tooltipPlacement} title="Open file">
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => open(e)}>open_in_new</Icon>
{
field.type == QFieldType.BLOB ? (
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => open(e)}>open_in_new</Icon>
) : (
<a style={{color: "inherit"}} rel="noopener noreferrer" href={url} target="_blank"><Icon className={"blobIcon"} fontSize="small">open_in_new</Icon></a>
)
}
</Tooltip>
<Tooltip placement={tooltipPlacement} title="Download file">
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => download(e)}>save_alt</Icon>
{
field.type == QFieldType.BLOB ? (
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => download(e)}>save_alt</Icon>
) : (
<a style={{color: "inherit"}} href={url} download="test.pdf"><Icon className={"blobIcon"} fontSize="small">save_alt</Icon></a>
)
}
</Tooltip>
{
usage == "query" && filename
@ -709,5 +728,4 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
}
export default ValueUtils;

View File

@ -47,6 +47,8 @@ module.exports = function (app)
app.use("/download/*", getRequestHandler());
app.use("/metaData/*", getRequestHandler());
app.use("/data/*", getRequestHandler());
app.use("/possibleValues/*", getRequestHandler());
app.use("/possibleValues", getRequestHandler());
app.use("/widget/*", getRequestHandler());
app.use("/serverInfo", getRequestHandler());
app.use("/manageSession", getRequestHandler());
@ -55,4 +57,5 @@ module.exports = function (app)
app.use("/images", getRequestHandler());
app.use("/api*", getRequestHandler());
app.use("/*api", getRequestHandler());
app.use("/qqq/*", getRequestHandler());
};

View File

@ -29,6 +29,9 @@ import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.frontend.materialdashboard.junit.BaseTest;
import com.kingsrook.qqq.frontend.materialdashboard.junit.TestUtils;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleAction;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleTrigger;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ -76,6 +79,37 @@ class MaterialDashboardTableMetaDataTest extends BaseTest
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withDefaultQuickFilterFieldNames(List.of("firstName", "lastName", "firstName"))),
"duplicated field name: firstName");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testValidateFieldRules()
{
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withFieldRule(new FieldRule())),
"without an action",
"without a trigger",
"without a sourceField");
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withFieldRule(new FieldRule()
.withTrigger(FieldRuleTrigger.ON_CHANGE)
.withAction(FieldRuleAction.CLEAR_TARGET_FIELD)
.withSourceField("notAField")
.withTargetField("alsoNotAField")
)),
"unrecognized sourceField: notAField",
"unrecognized targetField: alsoNotAField");
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withFieldRule(new FieldRule()
.withTrigger(FieldRuleTrigger.ON_CHANGE)
.withAction(FieldRuleAction.RELOAD_WIDGET)
.withSourceField("id")
.withTargetWidget("notAWidget")
)),
"unrecognized targetWidget: notAWidget");
}

View File

@ -33,6 +33,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.extension.ExtendWith;
import org.openqa.selenium.Dimension;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
@ -43,6 +44,7 @@ import static org.junit.jupiter.api.Assertions.fail;
/*******************************************************************************
** Base class for Selenium tests
*******************************************************************************/
@ExtendWith(SeleniumTestWatcher.class)
public class QBaseSeleniumTest
{
protected static ChromeOptions chromeOptions;
@ -93,6 +95,8 @@ public class QBaseSeleniumTest
driver.manage().window().setSize(new Dimension(1700, 1300));
qSeleniumLib = new QSeleniumLib(driver);
SeleniumTestWatcher.setCurrentSeleniumLib(qSeleniumLib);
if(useInternalJavalin())
{
qSeleniumJavalin = new QSeleniumJavalin();
@ -197,10 +201,10 @@ public class QBaseSeleniumTest
qSeleniumLib.takeScreenshotToFile(getClass().getSimpleName() + "/" + testInfo.getDisplayName());
}
if(driver != null)
{
driver.quit();
}
////////////////////////////////////////////////////////////////////////////////////////
// note - at one time we did a driver.quit here - but we're moving that into //
// SeleniumTestWatcher, so it can dump logs if it wants to (it runs after the @After) //
////////////////////////////////////////////////////////////////////////////////////////
if(qSeleniumJavalin != null)
{

View File

@ -42,6 +42,8 @@ import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.Actions;
import org.openqa.selenium.logging.LogEntries;
import org.openqa.selenium.logging.LogEntry;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import static org.assertj.core.api.Assertions.assertThat;
@ -333,7 +335,7 @@ public class QSeleniumLib
return;
}
if(elements.stream().noneMatch(e -> e.getText().toLowerCase().contains(textContains)))
if(elements.stream().noneMatch(e -> e.getText().toLowerCase().contains(textContains.toLowerCase())))
{
LOG.debug("Found non-existence of element(s) matching selector [" + cssSelector + "] containing text [" + textContains + "]");
return;
@ -343,7 +345,7 @@ public class QSeleniumLib
}
while(start + (1000 * WAIT_SECONDS) > System.currentTimeMillis());
fail("Failed for non-existence of element matching selector [" + cssSelector + "] after [" + WAIT_SECONDS + "] seconds.");
fail("Failed for non-existence of element matching selector [" + cssSelector + "] containing text [" + textContains + "] after [" + WAIT_SECONDS + "] seconds.");
}
@ -735,4 +737,22 @@ public class QSeleniumLib
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
public void dumpConsole()
{
Set<String> availableLogTypes = driver.manage().logs().getAvailableLogTypes();
for(String logType : availableLogTypes)
{
LogEntries logEntries = driver.manage().logs().get(logType);
for(LogEntry logEntry : logEntries)
{
System.out.println(logEntry.toJson());
}
}
}
}

View File

@ -261,9 +261,7 @@ public class QueryScreenLib
if(StringUtils.hasContent(value))
{
qSeleniumLib.waitForSelector(".filterValuesColumn INPUT").click();
// todo - no, not in a listbox/LI here...
qSeleniumLib.waitForSelectorContaining(".MuiAutocomplete-listbox LI", value).click();
System.out.println(value);
qSeleniumLib.waitForSelector(".filterValuesColumn INPUT").sendKeys(value);
}
qSeleniumLib.clickBackdrop();
@ -271,6 +269,21 @@ public class QueryScreenLib
/*******************************************************************************
**
*******************************************************************************/
public void setBasicBooleanFilter(String fieldLabel, String operatorLabel)
{
qSeleniumLib.waitForSelectorContaining("BUTTON", fieldLabel).click();
qSeleniumLib.waitForMillis(250);
qSeleniumLib.waitForSelector("#criteriaOperator").click();
qSeleniumLib.waitForSelectorContaining("LI", operatorLabel).click();
qSeleniumLib.clickBackdrop();
}
/*******************************************************************************
**
*******************************************************************************/

View File

@ -0,0 +1,126 @@
/*
* 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/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib;
import java.util.Optional;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestWatcher;
/*******************************************************************************
**
*******************************************************************************/
public class SeleniumTestWatcher implements TestWatcher
{
private static QSeleniumLib qSeleniumLib;
/*******************************************************************************
**
*******************************************************************************/
public static void setCurrentSeleniumLib(QSeleniumLib qSeleniumLib)
{
SeleniumTestWatcher.qSeleniumLib = qSeleniumLib;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void testFailed(ExtensionContext context, Throwable cause)
{
if(qSeleniumLib != null)
{
System.out.println("Dumping browser console after failed test: " + context.getDisplayName());
System.out.println("----------------------------------------------------------------------------");
try
{
qSeleniumLib.dumpConsole();
}
catch(Exception e)
{
System.out.println("Error dumping console:");
e.printStackTrace();
}
System.out.println("----------------------------------------------------------------------------");
}
tryToQuitSelenium();
}
/*******************************************************************************
**
*******************************************************************************/
private void tryToQuitSelenium()
{
if(qSeleniumLib != null)
{
try
{
qSeleniumLib.driver.quit();
}
catch(Exception e)
{
System.err.println("Error quiting selenium driver: " + e.getMessage());
}
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void testSuccessful(ExtensionContext context)
{
tryToQuitSelenium();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void testAborted(ExtensionContext context, Throwable cause)
{
tryToQuitSelenium();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void testDisabled(ExtensionContext context, Optional<String> reason)
{
tryToQuitSelenium();
}
}

View File

@ -56,8 +56,8 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest
"label": "Sample Table Widget",
"footerHTML": "<span class='material-icons-round notranslate MuiIcon-root MuiIcon-fontSizeInherit' aria-hidden='true'><span class='dashboard-schedule-icon'>schedule</span></span>Updated at 2023-10-17 09:11:38 AM CDT",
"columns": [
{ "type": "html", "header": "Id", "accessor": "id", "width": "1%" },
{ "type": "html", "header": "Name", "accessor": "name", "width": "99%" }
{ "type": "html", "header": "Id", "accessor": "id", "width": "30px" },
{ "type": "html", "header": "Name", "accessor": "name", "width": "1fr" }
],
"rows": [
{ "id": "1", "name": "<a href='/setup/person/1'>Homer S.</a>" },
@ -83,7 +83,7 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest
// assert that the table widget rendered its header and some contents //
////////////////////////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget h6", "Sample Table Widget");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget table a", "Homer S.");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget a", "Homer S.");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget div", "Updated at 2023-10-17 09:11:38 AM CDT");
/////////////////////////////

View File

@ -0,0 +1,172 @@
/*
* 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/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
import java.util.List;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.WebElement;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/*******************************************************************************
** Test for Saved Report screen (table has some special behaviors)
*******************************************************************************/
public class SavedReportTest extends QBaseSeleniumTest
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
{
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin
.withRouteToFile("/metaData/table/savedReport", "metaData/table/savedReport.json")
.withRouteToFile("/widget/reportSetupWidget", "widget/reportSetupWidget.json")
.withRouteToFile("/widget/pivotTableSetupWidget", "widget/pivotTableSetupWidget.json")
.withRouteToFile("/data/savedReport/possibleValues/tableName", "data/savedReport/possibleValues/tableName.json")
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/person/query", "data/person/index.json")
;
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCreate()
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/userCustomizations/savedReport/create", "Creating New Saved Report");
//////////////////////////////////////////////////////////////
// make sure things are disabled before a table is selected //
//////////////////////////////////////////////////////////////
WebElement webElement = qSeleniumLib.waitForSelectorContaining("button", "Edit Filters and Columns");
assertEquals("true", webElement.getAttribute("disabled"));
qSeleniumLib.waitForSelector("#label").click();
qSeleniumLib.waitForSelector("#label").sendKeys("My Report");
qSeleniumLib.waitForSelector("#tableName").click();
qSeleniumLib.waitForSelector("#tableName").sendKeys("Person" + Keys.DOWN + Keys.ENTER);
//////////////////////////////////
// make sure things enabled now //
//////////////////////////////////
webElement = qSeleniumLib.waitForSelectorContaining("button", "Edit Filters and Columns");
assertNull(webElement.getAttribute("disabled"));
////////////////////////////////////////////////////
// open query-screen popup, wait for query to run //
////////////////////////////////////////////////////
qSeleniumJavalin.beginCapture();
qSeleniumLib.waitForSelectorContaining("button", "Edit Filters and Columns").click();
qSeleniumJavalin.waitForCapturedPath("/data/person/count");
qSeleniumJavalin.waitForCapturedPath("/data/person/query");
qSeleniumJavalin.endCapture();
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
queryScreenLib.setBasicFilter("First Name", "contains", "Darin");
////////////////////////
// close query screen //
////////////////////////
qSeleniumLib.waitForSelectorContaining("button", "OK").click();
//////////////////////////////////////////////////////
// make sure query things appear on edit screen now //
//////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "First Name");
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "contains");
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "Darin");
List<WebElement> columns = qSeleniumLib.waitForSelectorContaining("h5", "Columns")
.findElement(QSeleniumLib.PARENT)
.findElements(By.cssSelector("DIV"));
assertThat(columns)
.hasSizeGreaterThanOrEqualTo(5) // at least this many
.anyMatch(we -> we.getText().equals("Home City")); // a few fields are found
///////////////////
// turn on pivot //
///////////////////
qSeleniumLib.waitForSelectorContaining("label", "Use Pivot Table").click();
qSeleniumLib.waitForSelectorContaining("button", "Edit Pivot Table").click();
qSeleniumLib.waitForSelectorContaining("h3", "Edit Pivot Table");
///////////////
// add a row //
///////////////
qSeleniumLib.waitForSelectorContaining(".MuiModal-root button", "Add new row").click();
WebElement row0Input = qSeleniumLib.waitForSelector("#rows-0");
row0Input.click();
row0Input.sendKeys("Last Name" + Keys.ENTER);
//////////////////
// add a column //
//////////////////
qSeleniumLib.waitForSelectorContaining(".MuiModal-root button", "Add new column").click();
WebElement column0Input = qSeleniumLib.waitForSelector("#columns-0");
column0Input.click();
column0Input.sendKeys("Home City" + Keys.ENTER);
/////////////////
// add a value //
/////////////////
qSeleniumLib.waitForSelectorContaining(".MuiModal-root button", "Add new value").click();
WebElement value0Input = qSeleniumLib.waitForSelector("#values-field-0");
value0Input.click();
value0Input.sendKeys("Id" + Keys.ENTER);
/////////////////////////////////////////
// try to submit - but expect an error //
/////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("button", "OK").click();
qSeleniumLib.waitForSelectorContaining(".MuiAlert-standard", "Missing value in 1 field.").click();
///////////////////////////
// now select a function //
///////////////////////////
WebElement function0Input = qSeleniumLib.waitForSelector("#values-function-0");
function0Input.click();
function0Input.sendKeys("Count" + Keys.ENTER);
qSeleniumLib.waitForSelectorContaining("button", "OK").click();
qSeleniumLib.waitForSelectorContainingToNotExist("h3", "Edit Pivot Table");
// qSeleniumLib.waitForever();
}
}

View File

@ -153,16 +153,16 @@ public class QueryScreenTest extends QBaseSeleniumTest
queryScreenLib.addBasicFilter("Is Employed");
testBasicCriteria(queryScreenLib, "Is Employed", "equals yes", null, "(?s).*Is Employed:.*yes.*", """
testBasicBooleanCriteria(queryScreenLib, "Is Employed", "equals yes", "(?s).*Is Employed:.*yes.*", """
{"fieldName":"isEmployed","operator":"EQUALS","values":[true]}""");
testBasicCriteria(queryScreenLib, "Is Employed", "equals no", null, "(?s).*Is Employed:.*no.*", """
testBasicBooleanCriteria(queryScreenLib, "Is Employed", "equals no", "(?s).*Is Employed:.*no.*", """
{"fieldName":"isEmployed","operator":"EQUALS","values":[false]}""");
testBasicCriteria(queryScreenLib, "Is Employed", "is empty", null, "(?s).*Is Employed:.*is empty.*", """
testBasicBooleanCriteria(queryScreenLib, "Is Employed", "is empty", "(?s).*Is Employed:.*is empty.*", """
{"fieldName":"isEmployed","operator":"IS_BLANK","values":[]}""");
testBasicCriteria(queryScreenLib, "Is Employed", "is not empty", null, "(?s).*Is Employed:.*is not empty.*", """
testBasicBooleanCriteria(queryScreenLib, "Is Employed", "is not empty", "(?s).*Is Employed:.*is not empty.*", """
{"fieldName":"isEmployed","operator":"IS_NOT_BLANK","values":[]}""");
}
@ -203,10 +203,10 @@ public class QueryScreenTest extends QBaseSeleniumTest
/*******************************************************************************
**
*******************************************************************************/
private void testBasicCriteria(QueryScreenLib queryScreenLib, String fieldLabel, String operatorLabel, String value, String expectButtonStringRegex, String expectFilterJsonContains)
private void testBasicBooleanCriteria(QueryScreenLib queryScreenLib, String fieldLabel, String operatorLabel, String expectButtonStringRegex, String expectFilterJsonContains)
{
qSeleniumJavalin.beginCapture();
queryScreenLib.setBasicFilter(fieldLabel, operatorLabel, value);
queryScreenLib.setBasicBooleanFilter(fieldLabel, operatorLabel);
queryScreenLib.waitForBasicFilterButtonMatchingRegex(expectButtonStringRegex);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectFilterJsonContains);
qSeleniumJavalin.endCapture();

View File

@ -0,0 +1,16 @@
{
"options": [
{
"id": "person",
"label": "Person"
},
{
"id": "city",
"label": "City"
},
{
"id": "savedReport",
"label": "Saved Report"
}
]
}

View File

@ -189,6 +189,26 @@
"insertPermission": true,
"editPermission": true,
"deletePermission": true
},
"savedReport": {
"name": "savedReport",
"label": "Saved Report",
"isHidden": false,
"iconName": "article",
"capabilities": [
"TABLE_COUNT",
"TABLE_GET",
"TABLE_QUERY",
"QUERY_STATS",
"TABLE_UPDATE",
"TABLE_INSERT",
"TABLE_DELETE"
],
"readPermission": true,
"insertPermission": true,
"editPermission": true,
"deletePermission": true,
"usesVariants": false
}
},
"processes": {
@ -420,6 +440,40 @@
}
]
},
"userCustomizations": {
"name": "userCustomizations",
"label": "User Customizations",
"iconName": "article",
"widgets": [],
"children": [
{
"type": "TABLE",
"name": "savedReport",
"label": "Saved Report",
"iconName": "article"
}
],
"childMap": {
"savedReport": {
"type": "TABLE",
"name": "savedReport",
"label": "Saved Report",
"iconName": "article"
}
},
"sections": [
{
"name": "userCustomizations",
"label": "User Customizations",
"icon": {
"name": "badge"
},
"tables": [
"savedReport"
]
}
]
},
"miscellaneous": {
"name": "miscellaneous",
"label": "Miscellaneous",
@ -730,6 +784,20 @@
}
],
"iconName": "data_object"
},
{
"type": "APP",
"name": "userCustomizations",
"label": "User Customizations",
"children": [
{
"type": "TABLE",
"name": "savedReport",
"label": "Saved Report",
"iconName": "article"
}
],
"iconName": "data_object"
}
],
"branding": {
@ -750,6 +818,28 @@
"isCard": true,
"storeDropdownSelections": false,
"hasPermission": true
},
"reportSetupWidget": {
"name": "reportSetupWidget",
"label": "Filters and Columns",
"type": "filterAndColumnsSetup",
"isCard": true,
"storeDropdownSelections": false,
"showReloadButton": true,
"showExportButton": false,
"defaultValues": {},
"hasPermission": true
},
"pivotTableSetupWidget": {
"name": "pivotTableSetupWidget",
"label": "Pivot Table",
"type": "pivotTableSetup",
"isCard": true,
"storeDropdownSelections": false,
"showReloadButton": true,
"showExportButton": false,
"defaultValues": {},
"hasPermission": true
}
},
"environmentValues": {

View File

@ -0,0 +1,218 @@
{
"table": {
"name": "savedReport",
"label": "Saved Report",
"isHidden": false,
"primaryKeyField": "id",
"iconName": "article",
"fields": {
"queryFilterJson": {
"name": "queryFilterJson",
"label": "Query Filter",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"columnsJson": {
"name": "columnsJson",
"label": "Columns",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"inputFieldsJson": {
"name": "inputFieldsJson",
"label": "Input Fields",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"pivotTableJson": {
"name": "pivotTableJson",
"label": "Pivot Table",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"modifyDate": {
"name": "modifyDate",
"label": "Modify Date",
"type": "DATE_TIME",
"isRequired": false,
"isEditable": false,
"isHeavy": false,
"displayFormat": "%s"
},
"label": {
"name": "label",
"label": "Report Name",
"type": "STRING",
"isRequired": true,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"id": {
"name": "id",
"label": "Id",
"type": "INTEGER",
"isRequired": false,
"isEditable": false,
"isHeavy": false,
"displayFormat": "%s"
},
"userId": {
"name": "userId",
"label": "User Id",
"type": "STRING",
"isRequired": false,
"isEditable": true,
"isHeavy": false,
"displayFormat": "%s"
},
"tableName": {
"name": "tableName",
"label": "Table",
"type": "STRING",
"isRequired": true,
"isEditable": true,
"isHeavy": false,
"possibleValueSourceName": "tables",
"displayFormat": "%s"
},
"createDate": {
"name": "createDate",
"label": "Create Date",
"type": "DATE_TIME",
"isRequired": false,
"isEditable": false,
"isHeavy": false,
"displayFormat": "%s"
}
},
"sections": [
{
"name": "identity",
"label": "Identity",
"tier": "T1",
"fieldNames": [
"id",
"label",
"tableName"
],
"icon": {
"name": "badge"
},
"isHidden": false
},
{
"name": "filtersAndColumns",
"label": "Filters and Columns",
"tier": "T2",
"widgetName": "reportSetupWidget",
"icon": {
"name": "table_chart"
},
"isHidden": false
},
{
"name": "pivotTable",
"label": "Pivot Table",
"tier": "T2",
"widgetName": "pivotTableSetupWidget",
"icon": {
"name": "pivot_table_chart"
},
"isHidden": false
},
{
"name": "data",
"label": "Data",
"tier": "T2",
"fieldNames": [
"queryFilterJson",
"columnsJson",
"pivotTableJson"
],
"icon": {
"name": "text_snippet"
},
"isHidden": true
},
{
"name": "hidden",
"label": "Hidden",
"tier": "T2",
"fieldNames": [
"inputFieldsJson",
"userId"
],
"icon": {
"name": "text_snippet"
},
"isHidden": true
},
{
"name": "dates",
"label": "Dates",
"tier": "T3",
"fieldNames": [
"createDate",
"modifyDate"
],
"icon": {
"name": "calendar_month"
},
"isHidden": false
}
],
"exposedJoins": [],
"supplementalTableMetaData": {
"materialDashboard": {
"fieldRules": [
{
"trigger": "ON_CHANGE",
"sourceField": "tableName",
"action": "CLEAR_TARGET_FIELD",
"targetField": "queryFilterJson"
},
{
"trigger": "ON_CHANGE",
"sourceField": "tableName",
"action": "CLEAR_TARGET_FIELD",
"targetField": "columnsJson"
},
{
"trigger": "ON_CHANGE",
"sourceField": "tableName",
"action": "CLEAR_TARGET_FIELD",
"targetField": "pivotTableJson"
}
],
"type": "materialDashboard"
}
},
"capabilities": [
"TABLE_COUNT",
"TABLE_GET",
"TABLE_QUERY",
"QUERY_STATS",
"TABLE_UPDATE",
"TABLE_INSERT",
"TABLE_DELETE"
],
"readPermission": true,
"insertPermission": true,
"editPermission": true,
"deletePermission": true,
"usesVariants": false
}
}

View File

@ -0,0 +1 @@
{"type":"pivotTableSetup"}

View File

@ -0,0 +1 @@
{"type":"reportSetup"}