Compare commits

...

339 Commits

Author SHA1 Message Date
185775ca4d Merge branch 'release/0.25.0' 2025-05-20 07:52:28 -05:00
ce91f68088 Update versions for release 2025-05-20 07:06:35 -05:00
81da1a4627 Merged feature/oauth2-authentication-module into dev 2025-05-19 20:33:35 -05:00
b279a04b43 quick bug fix for goto fields 2025-04-16 16:45:46 -05:00
1f2e57d688 Merged feature/better-goto-behavior into dev 2025-04-09 11:14:14 -05:00
52bb7ba411 Merged feature/disable-show-default-vs-display-value into dev 2025-04-09 11:14:02 -05:00
34c6f650b5 updated to handle (ignore) fields with empty strings when using goto dialog 2025-04-07 16:50:01 -05:00
d792c23035 Cleanup from code review 2025-04-05 19:58:35 -05:00
e3d30633f1 Refactor authentication handling to pass authentication metadata into App.
eliminates warnings from oauth2 hook by conditionally using its useAuth hook.
2025-04-05 19:37:02 -05:00
a6ee682671 Merged feature/dk-misc-20250318 into dev 2025-04-03 14:28:34 -05:00
c62252075f Merged feature/banners into dev 2025-04-03 14:26:13 -05:00
debc6f3ebf turn off replacing of displayValue with defaultValue 2025-04-02 12:10:39 -05:00
679375ba63 update processClicked to set alert if min/max input records isn't satisfied 2025-03-18 11:44:32 -05:00
fb10dad803 Add support for query-param defaultProcessValues (as a json object) 2025-03-18 11:40:22 -05:00
c9a618c7f6 Fix full-width file upload adornment for lg-size (regressed with field-level grid columns addition) 2025-03-10 12:12:37 -05:00
f654208769 Update kingsrook/qqq-frontend-core to 1.0.118 (add more params to manageSession call) 2025-03-07 20:33:09 -06:00
3dacab8d60 Add support for oauth2 authentication module.
In so doing, extract auth0- and anonymous- -authenticationModule implementations from index.tsx and App.tsx, moving each to it own useXyz hook.
2025-03-07 20:10:06 -06:00
13ce684d23 Initial checkin of Banners under QBrandingMetaData
- includes migration from (now deprecated) MetaDataFilterInterface to MetaDataActionCustomizerInterface (stored on the QInstance and used by MetaDataAction)
- includes migration from (now deprecated) environmentBannerText and environmentBannerColor in QBrandingMetaData to now be implemented as a banner
2025-03-07 14:58:51 -06:00
b67eea7d87 Merge branch 'release/0.24.0' 2025-03-06 11:20:23 -06:00
8ae3b95105 Merge tag 'version-0.24.0' into dev
Tag release
2025-03-06 11:20:23 -06:00
5a309e5628 Update for next development version 2025-03-06 11:04:18 -06:00
67e1e78817 Update versions for release 2025-03-06 11:04:17 -06:00
214b6b8af4 Merged feature/sftp-and-headless-bulk-load into dev 2025-03-05 19:45:22 -06:00
8ec0ce5455 Cleanup from code review 2025-03-05 19:30:52 -06:00
07cb6fd323 Fix show blob download urls when not using dynamic url 2025-02-25 10:55:49 -06:00
3bb8451671 add support for toRecordFromTableDynamic in LINK adornment, and downloadUrlDynamic in FILE_DOWNLOAD adornment 2025-02-24 11:10:36 -06:00
6076c4ddfd CE-2261: updated to respect field column widths on view and edit forms 2025-02-19 17:05:10 -06:00
44a8810260 Remove textTransform="capitalize" from pageHeader h3 2025-02-14 20:37:12 -06:00
c69a4b8203 Make variants work for blob/download fields 2025-02-14 20:36:49 -06:00
7db4f34ddd add LONG to types that get numeric operators 2025-02-14 20:34:59 -06:00
71dc3f3f65 Merge pull request #79 from Kingsrook/feature/support-CE-2257-ice-logic
Add support for defaultValuesForNewChildRecordsFromParentFields for C…
2025-02-14 20:33:47 -06:00
ce22db2f89 Merge pull request #78 from Kingsrook/feature/CE-2258-manual-add-carton [skip ci]
CE-2258: updated dashboard widgets with a forcereload when child reco…
2025-02-14 20:32:15 -06:00
aacb239164 Add support for defaultValuesForNewChildRecordsFromParentFields for ChildRecordList; Load display values for possible-value fields when adding them to childRecord list and when opening child-edit form (adding passing of all other-values to the possible-value lookup, for filtered scenarios that need them); 2025-02-03 09:10:39 -06:00
219458ec63 CE-2258: updated dashboard widgets with a forcereload when child record is removed 2025-01-28 15:30:42 -06:00
59fdc72455 Merged feature/filter-json-field-improvements into dev 2025-01-22 20:01:10 -06:00
5c3ddb7dec Take label (e.g., of the field) as parameter 2025-01-21 12:18:21 -06:00
d65c1fb5d8 Padding & margin adjustments for script viewer 2025-01-21 12:12:03 -06:00
19a63d6956 Read filterFieldName and columnsFieldName from widgetData 2025-01-14 10:56:07 -06:00
40f5b55307 CE-1955 add error if no fields mapped 2025-01-07 11:47:52 -06:00
7320b19fbb CE-1955 Add warning about duplicate column headers, and un-selection of dupes if switching from no-header-row mode to header-row mode 2025-01-07 10:12:45 -06:00
3f8a3e7e4d CE-1955 Fix (new) switchLayout method to ... actually save the new layout 2025-01-06 16:52:19 -06:00
3ef2d64327 CE-1955 Bulk load bugs & usability improvements 2024-12-27 14:58:40 -06:00
d793c23861 CE-1955 Add guard around a call to onChangeCallback 2024-12-26 19:14:21 -06:00
d0201d96e1 CE-1955 Fix select box handling of 'x' and typing... 2024-12-26 19:13:48 -06:00
7b66ece466 Try to avoid an error a user is getting where no operatorSeletedValue is being selected when page is loading 2024-12-10 09:14:32 -06:00
02c163899a CE-1955 Handle associated fields; better messaging w/ undefined values 2024-12-04 16:11:49 -06:00
8fafe16a95 CE-1955 handle currentSavedBulkLoadProfile being set, when going back to this screen 2024-12-04 16:11:08 -06:00
722c8d3bcf CE-1955 Update to fetch label for possible-values being used as a default value 2024-12-04 16:10:33 -06:00
85acb612c9 CE-1955 Add add ? to record.associatedRecords?.get to avoid crash if no associations 2024-12-03 20:47:50 -06:00
74c634414a CE-1955 Add helpContent to hasHeaderRow and layout fields 2024-12-03 20:47:27 -06:00
f8368b030c CE-1955 make PreviewRecordUsingTableLayout a private component - try to make it re-render the associated child grids when switching records 2024-12-03 15:55:18 -06:00
dda4ea4f4b CE-1955 Delete an unused effect 2024-12-03 15:53:23 -06:00
0c3a6ac278 Merged dev into feature/bulk-upload-v2 2024-12-03 10:02:16 -06:00
85a8bd2d0a CE-1955 small bulk-load cleanups 2024-12-03 09:16:14 -06:00
4b64c46c57 CE-1955 Add value-mapping details to diff 2024-12-03 09:15:53 -06:00
6db003026b CE-1955 Remove filterOperators from the column objects we produce, since we're not using dataGridPro's filtering any more 2024-12-03 09:15:07 -06:00
65b347b794 CE-1955 Break file-input into its own component, w/ support for FILE_UPLOAD adornment type, to specify drag&drop 2024-12-03 09:11:21 -06:00
1626648dda CE-1955 Update qfc to 1.0.113; add react-dropzone 2024-12-03 09:10:46 -06:00
f503c008ec Make record sidebar stop growing at some point (400px, when screen is 1400) 2024-11-27 15:27:48 -06:00
8a16010977 CE-1955 - Add better support for bulk-load (by doing layout more like view screen), for when formatPreviewRecordUsingTableLayout processValues is present; including associations! 2024-11-27 15:25:50 -06:00
ab530121ca CE-1955 - Avoid a few null pointers if missing compareField 2024-11-27 15:24:40 -06:00
9b5d9f1290 CE-1955 - Add styleOverrides argument to renderSectionOfFields; add css classes recordSidebar and recordWithSidebar 2024-11-27 15:23:47 -06:00
ee9cd5a5f6 CE-1955 - add support for child-record lists on process validation preview, via:
- add properties: gridOnly and gridDensity;
- allow the input query records and tableMetaData to come in as pre-typed TS objects, rather than POJSO's, that need to go through constructors.
2024-11-27 15:22:21 -06:00
45be12c728 CE-1955 - Add support for bulletsOfText on a processSummaryLine 2024-11-27 15:20:46 -06:00
169bd4ee7e CE-1955 - Add support for 'back' in processes. add a 'loadingRecords' state var, to help validation screen not flicker 'none found' 2024-11-27 15:20:31 -06:00
85056b121b CE-1955 - Update qfc to 1.0.112 (add backStep to QJobComplete) 2024-11-27 15:14:46 -06:00
b90b5217ca CE-1955 - Add QAlternateButton 2024-11-27 15:11:49 -06:00
911ba1da21 CE-1955 - Remove unused method 2024-11-27 15:11:27 -06:00
bfa9b1d182 CE-1955 - Trying a sleep (wait) around point of failure... 2024-11-25 11:32:52 -06:00
cce73fcb0b CE-1955 - Update qfc to 1.0.111 2024-11-25 10:48:20 -06:00
f2b41532d4 CE-1955 - Initial checkin of qfmd support for bulk-load 2024-11-25 10:48:00 -06:00
3fc4e37c12 CE-1955 - Break ProcessViewForm out into its own reusable component 2024-11-25 10:35:35 -06:00
451af347f7 CE-1955 - Add support for pre-submit callbacks, defined in components - specifically, ones used by bulk-load. Add awareness of the bulkLoad components, specifically with a block for value-mapping form, to integrate with formik 2024-11-25 10:16:22 -06:00
1630fbacda CE-1955 - Break DynamicFormFieldLabel out into a component that others can use 2024-11-25 10:12:28 -06:00
2220e6f86e CE-1955 - Remove mb from cancel button (incorrectly added in last sprint's work) 2024-11-25 10:12:04 -06:00
a66ffa753d CE-1955 - Add optional name prop 2024-11-25 10:11:41 -06:00
b07d65aaca CE-1955 - Add onChangeCallback to form fields; add ability to get a DynamicSelect out of DynamicFormField; 2024-11-25 10:11:27 -06:00
501b8b34c9 Merge branch 'release/0.23.0' 2024-11-22 16:18:57 -06:00
78fc2c50d0 Merge tag 'version-0.23.0' into dev
Tag release
2024-11-22 16:18:57 -06:00
8a6eef9907 Update for next development version 2024-11-22 15:59:23 -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
7b364a1e09 CE-882 Update qqq-frontend-core to 1.0.97 2024-04-28 20:34:53 -05:00
6ef4dd8fbe CE-882 Support working outside of table or process 2024-04-28 20:34:30 -05:00
17893a0cfd CE-882 Put userId (email...) in context 2024-04-28 20:34:07 -05:00
33056963a4 CE-882 Initial checkin 2024-04-24 08:33:40 -05:00
ef8eecd6cb CE-882 Add button share modal dialog with button to open it;
also (unrelated) disable delete dialog's buttons while delete is running
2024-04-24 08:32:05 -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
387aad8087 CE-1115 do not setPageHeader to null on modals 2024-04-18 09:59:28 -05:00
47cf625c7c CE-1123 - Fix to only record analytics when menu is clicked (not when it's rendered...) 2024-04-18 09:52:14 -05:00
8459263762 Merge pull request #52 from Kingsrook/feature/CE-881-create-basic-saved-reports
Feature/ce 881 create basic saved reports
2024-04-18 09:46:23 -05:00
8fe8fd41eb fix for backend type 2024-04-18 09:42:10 -05:00
7bda54b4a6 Fix merge conflict 2024-04-18 09:19:54 -05:00
362c528514 Merged main into feature/CE-881-create-basic-saved-reports 2024-04-18 09:12:27 -05:00
727990ed4b Merge pull request #54 from Kingsrook/feature/CE-1107-add-day-by-day-views-for-the
Feature/ce 1107 add day by day views for the
2024-04-18 00:19:41 -05:00
9b0d135dc1 Merge branch 'main' into feature/CE-1107-add-day-by-day-views-for-the 2024-04-17 20:54:19 -05:00
a84b0a0243 CE-1115 Pass a filter-for-backend version of the filter to the create-report screen 2024-04-17 10:52:12 -05:00
5f13e244d6 Merge pull request #55 from Kingsrook/feature/CE-1123-expose-access-lo-data-in
Feature/ce 1123 expose access lo data in
2024-04-17 09:34:48 -05:00
6faca42b3b CE-1115 Don't allow the same field to be used as both a row or column and a value. our generated xlsx pivot's don't support that (and i'm not sure it's a valid use-case anyway) 2024-04-17 08:54:22 -05:00
cb3162f084 CE-1115 Add sort indication to report setup; make recordQuery, when being used for report setup, not write local storage; 2024-04-16 20:39:33 -05:00
da57226fe5 CE-1123 - Update google analytics to work with events as well as page views; add calls to it to most actual pages. 2024-04-16 16:33:27 -05:00
73c907a3e1 CE-1115 Fix so that record query popup actually shows the filter & columns that are on the report edit screen... fix filters saved w/ record to be prep'ed for backend; refactor that prepForBackend method out of RecordQuery, into FilterUtils; update recordQuery to be a better manager of counts (showing when counting after the initial load, plus not always re-counting (e.g., when paginating) 2024-04-16 11:36:46 -05:00
dac0a24ec7 Update toalways deploy on integration branches, same as main 2024-04-16 07:57:17 -05:00
bcade32ed1 CE-1107: style updates to icon 2024-04-15 21:37:46 -05:00
e3cbf9414b CE-1123 Initial checkin 2024-04-15 14:51:03 -05:00
6282723ff6 CE-1123 Update qfc to 1.0.96; add react-ga4 2024-04-15 12:54:52 -05:00
68d3119c6a CE-1123 store values in localStorage from backend from manageSession call; add googleAnalytics utils & function to recordAnalytics 2024-04-15 12:54:52 -05:00
731eab7136 CE-1123 update exception status to be number (for qfc change) 2024-04-15 12:52:59 -05:00
4339f74c07 Do not trigger commands if controlKey is down (to help windows users) 2024-04-15 10:41:45 -05:00
8071c54ccd CE-1107: updates to date picker styles 2024-04-15 09:01:31 -05:00
48e3eeabd4 CE-1115 fix availableFieldNames addition 2024-04-15 08:47:25 -05:00
04932030df Increase @kingsrook/qqq-frontend-core to 1.0.94 2024-04-14 20:11:23 -05:00
eafd8d98cd CE-1115 pre-QA commit on saved report UI, including:
- redo pivots so editing is in a modal
- add form validations
- field rules for clearing one field when another changes
2024-04-14 20:10:29 -05:00
334871988b CE-1107: added alert widget, fixed axios problems 2024-04-12 14:47:37 -05:00
2c0725852e CE-1115 change exception status to numbers per qfc, axios update 2024-04-11 10:59:37 -05:00
53c3e4d078 CE-1115 change exception status to numbers per qfc, axios update 2024-04-11 10:49:14 -05:00
5e0e4c37bb CE-1115 Update qfc (rebuilt to include latest dependabot updates) 2024-04-11 10:25:12 -05:00
cb7fa641eb CE-1115 checkpoint on report & pivotTable setup widgets:
- refactor into sub-components
- working drag & drop
- more help content
- disable things rather than alert if no table
2024-04-11 10:11:43 -05:00
cdec98afd8 CE-1115 - Updated qfc (widget help content multi-role); add react dnd 2024-04-11 10:09:54 -05:00
7e2a46b362 CE-1115 - take optional array of availableFieldNames (e.g., to only show a sub-set) 2024-04-11 10:07:59 -05:00
803725b8f1 CE-1115 - initial prototype of field-rules - e.g., clear one field when another changes 2024-04-11 10:07:41 -05:00
6b8049d4ce Remove dupe line from half-commit of a WIP change 2024-04-09 16:11:46 -05:00
d5381e23bf CE-1115 - add HeaderLinkButton and HeaderToggleComponent, and start doing right-additional things as components (despite backward naming!) 2024-04-09 16:06:25 -05:00
87ffd821f8 CE-1115 - Update to be used as a modal, and to take a usage prop, e.g., to differentiate between being used as query screen vs. used on report-setup screen 2024-04-09 16:04:50 -05:00
703868a725 CE-1115 - Initial working version 2024-04-09 16:04:35 -05:00
dee4b91a96 CE-1115 - Pass record into DashboardWidgets 2024-04-09 16:03:37 -05:00
f47924787a CE-1115 - Update to be used as a modal, and to take a usage prop, e.g., to differentiate between being used as query screen vs. used on report-setup screen 2024-04-09 16:03:15 -05:00
37b854baf0 CE-1115 - export interface Column 2024-04-09 16:00:24 -05:00
fb2e392dcb CE-1115 - Initial working version 2024-04-09 16:00:09 -05:00
034264eaa1 CE-1115 - Add options to control appearance; make hiddenFields ignore the selected field; 2024-04-09 15:59:23 -05:00
3558a91e7b CE-1115 - support having the ReportSetupWidget and PivotTableSetupWidget actually edit the form values used on the page 2024-04-09 15:58:19 -05:00
e5e49a6db8 CE-1115 - Add ReportSetupWidget and PivotTableSetupWidget 2024-04-09 15:54:12 -05:00
4c9c9ab80e CE-1115 - Break this component out into its own ... component. 2024-04-09 15:51:42 -05:00
ddb055bc81 Merge pull request #53 from Kingsrook/feature/CE-1072-update-how-we-display-imported
CE-1072 return displayValue for DATE_TIME fields (if they're differen…
2024-04-05 11:30:07 -05:00
66ddf4cb57 CE-1072 return displayValue for DATE_TIME fields (if they're different from the raw value) 2024-04-04 20:06:00 -05:00
30991cb34e CE-881 - Fix parsing hash (e.g., for defaultValues) in case it has a # in it 2024-04-02 15:46:23 -05:00
2fd6272ea3 CE-881 - Update download component to understand storageTableName & storageReference, as alternative to serverFilePath 2024-04-01 16:05:16 -05:00
b63d74f785 Merged main into feature/CE-881-create-basic-saved-reports 2024-03-29 18:36:51 -05:00
7e15f4601d Merged feature/quartz-scheduler into main 2024-03-29 18:35:41 -05:00
cdb61695d1 Merge pull request #50 from Kingsrook/feature/CE-1120-bug-order-statuses-not
CE-1120: updated to handle errors on join tables (specifically was ha…
2024-03-28 15:20:10 -05:00
5e3991d9ae CE-1120: updated to handle errors on join tables (specifically was happening with deposco customer orders) 2024-03-28 15:09:56 -05:00
ff946df461 CE-881 - Add views menu option to punch into create-saved-view screen w/ pre-populated form 2024-03-27 20:16:06 -05:00
f1826c81a9 Merge pull request #48 from Kingsrook/bugfix/null-field-names-in-criteria
Strip away null field names in criteria (e.g., from incomplete advanc…
2024-03-27 20:14:01 -05:00
230aaeef8c Strip away null field names in criteria (e.g., from incomplete advanced filters) when storing in local storage, in saved views, and any time we load a view. 2024-03-21 16:41:09 -05:00
c08696b3a1 Remove todo no longer needed 2024-03-20 10:34:37 -05:00
84e627467f CE-936 - Update to receive warnings within the response QRecord and display them (this fixes inserts that warn) 2024-03-19 11:13:58 -05:00
6c524c4eca CE-936 - Fix editing child records; fix warning icon on view screen 2024-03-19 10:41:03 -05:00
edab918763 CE-969: fixed flex wrapping on advanced queries, recursive calls to 'clean values' and 'prep subquery for backend' 2024-03-18 19:39:19 -05:00
5c442b2024 qqq-frontend-core 1.0.89 2024-03-18 15:06:17 -05:00
b8c36bccd2 Add abiltiy to edit child records as an association on insert/edit screens. 2024-03-18 15:06:17 -05:00
67feb95c60 Add abiltiy to edit child records as an association on insert/edit screens. 2024-03-18 12:48:16 -05:00
e34811354f CE-969: reverted change when no criteria on query 2024-03-15 18:10:25 -05:00
ef85f5cd40 CE-969: cleanups from review feedback 2024-03-15 17:27:30 -05:00
c36dfb5683 CE-969: added basic support for 'too complex' subfilters 2024-03-12 17:40:56 -05:00
626ada3507 Update to qqq-backend-core 0.20.0-20240308.165846-65 2024-03-08 12:42:31 -06:00
6cf1c2a0e4 Merge pull request #45 from Kingsrook/feature/CE-989-bug-performance-issue
CE-989 add option to (not) includeTableCountsOn(app)HomeScreen
2024-03-08 10:56:11 -06:00
39a7aadd3f CE-989 add option to (not) includeTableCountsOn(app)HomeScreen 2024-03-07 20:30:14 -06:00
167af989d5 Add tooltips from metaData/helpContent to widget blocks. 2024-03-05 14:36:54 -06:00
ad7ea994a8 CE-889 - try to fix NPE's on localeCompares 2024-03-04 15:09:22 -06:00
e925310173 Merge pull request #44 from Kingsrook/feature/CE-878-make-the-operations-dashboard
CE-878: updated to allow sublabel to be displayed under label
2024-03-04 14:47:52 -06:00
809f01e43e CE-878: updated to allow sublabel to be displayed under label 2024-02-29 14:47:00 -06:00
8ebc2415fe Merged feature/CE-878-make-the-operations-dashboard into main 2024-02-29 09:39:08 -06:00
5f3957e897 CE-878 - Add tooltips from helpContent to widget labels & table-widget column headers 2024-02-28 14:59:27 -06:00
88a4c17bbc CE-798 follow-up - in cleanupValuesInFilerFromQueryString, don't try to translate [null] to a list of possible values (which fetches all of them)... 2024-02-27 13:57:04 -06:00
2900cd8593 CE-798 follow-up - Prevent tab in date/date-time filter value input boxes from closing a quick-filter menu (via an onKeyDown handler) 2024-02-27 13:35:37 -06:00
8ab0f5f549 CE-798 follow-up - increase width of date-time boxes (was too small to see all the fields!) 2024-02-27 11:56:54 -06:00
8cffbbcac4 Update to cimg/openjdk:17.0.9 2024-02-22 20:30:51 -06:00
37eb280d79 Revert to previous check for disabling export-menu items - that is - totalRecords === 0, instead of !totalRecords. makes tables w/o count allowed to do exports again. 2024-02-22 12:16:27 -06:00
e6b9b34206 Merge branch 'main' into feature/CE-878-make-the-operations-dashboard 2024-02-22 10:55:26 -06:00
dee7bc693e Merge branch 'feature/CE-876-develop-missing-widget-types' into feature/CE-878-make-the-operations-dashboard 2024-02-22 10:52:37 -06:00
948aee70fd Disable 'c' hotkey for columns (broke with buiding custom columsn button instead of using data grid's) 2024-02-21 19:43:20 -06:00
f0c1af18d0 Fix last commit (shouldn't add layout to ChartSubheaderWithData) 2024-02-21 19:42:00 -06:00
fa65d6c0ad Add cursor:pointer to PieChart and StackedBarChart 2024-02-21 19:18:34 -06:00
630f0d2dc1 CE-876 add negative marginBottom to heading 2024-02-21 19:17:11 -06:00
d795bd8427 Merge branch 'feature/CE-876-develop-missing-widget-types' into feature/CE-878-make-the-operations-dashboard 2024-02-21 11:32:14 -06:00
80fc16f0ae Update qqq-backend-core to feature-CE-876-develop-missing-widget-types-20240221.002945-1 2024-02-20 18:37:58 -06:00
bdc39e6d16 Remove widgetMetaData.layout reference (that's been removed from frontend-core) 2024-02-20 18:33:44 -06:00
20da53075c Update circleci/browser-tools@1.4.7 2024-02-20 18:25:51 -06:00
3fd9f8d243 CE-876 New dashboard widgets (more or less):
- New Composite & Block widget constructs.
- Option for a parent widget's label to be the app home page's label
- Updates to table-widget handling of fixed footer (to expand and stay fixed)
- Option for widgets to have CSV Data that can be exported differently from just the data "in" the widget.
-- This included changing the default value for showExportButton from true to false
2024-02-20 16:39:33 -06:00
d6c9bf79b1 Update circleci/browser-tools@1.4.7 and try to re-activate verify 2024-02-19 14:49:55 -06:00
677b93a09f Turn off broken selenium/int tests 2024-02-19 14:49:02 -06:00
314bf0fd67 Fix to clear out limit when using a select-all filter for launching processes 2024-02-19 13:47:04 -06:00
76642f13e9 HOTFIX - Change defaultRowsPerPage from 10 to 50 2024-02-16 11:23:31 -06:00
0eaf171523 Merge pull request #43 from Kingsrook/feature/CE-798-quick-filters-fixes
Feature/ce 798 quick filters fixes
2024-02-16 10:56:15 -06:00
148 changed files with 41501 additions and 10675 deletions

View File

@ -2,12 +2,12 @@ version: 2.1
orbs:
node: circleci/node@5.1.0
browser-tools: circleci/browser-tools@1.4.6
browser-tools: circleci/browser-tools@1.4.7
executors:
java17:
docker:
- image: 'cimg/openjdk:17.0'
- image: 'cimg/openjdk:17.0.9'
commands:
install_java17:
@ -115,7 +115,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters:
branches:
ignore: /main/
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/
only: /(main|dev|integration.*)/
tags:
only: /(version|snapshot)-.*/

View File

@ -28,8 +28,7 @@
},
"plugins": [
"react",
"@typescript-eslint",
"import"
"@typescript-eslint"
],
"rules": {
"brace-style": [
@ -43,41 +42,6 @@
"SwitchCase": 1
}
],
"import/extensions": [
"error",
"ignorePackages",
{
"ts": "never",
"tsx": "never",
"js": "never"
}
],
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": true
}
],
"import/order": [
"error",
{
"groups": [
"builtin", // Built-in imports (come from NodeJS native) go first
"external", // <- External imports
"internal", // <- Absolute imports
["sibling", "parent"], // <- Relative imports, the sibling and parent types they can be mingled together
"index", // <- index imports
"unknown"
],
"newlines-between": "never",
"alphabetize": {
/* sort in ascending order. Options: ["ignore", "asc", "desc"] */
"order": "asc",
/* ignore case. Options: [true, false] */
"caseInsensitive": true
}
}
],
"jsx-one-expression-per-line": "off",
"max-len": "off",
"no-console": "off",
@ -114,15 +78,6 @@
"quotes": [
"error",
"double"
],
"sort-imports": [
"error",
{
"ignoreCase": false,
"ignoreDeclarationSort": true,
"ignoreMemberSort": true,
"allowSeparatedGroups": false
}
]
},
"settings": {

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
.yalc*
yalc.lock
.env
/certs
# dependencies
/node_modules

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
},
},
});

29177
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,13 +6,14 @@
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.85",
"@kingsrook/qqq-frontend-core": "1.0.119",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",
"@mui/system": "5.11.1",
"@mui/x-data-grid": "5.17.23",
"@mui/x-data-grid-pro": "5.17.23",
"@mui/x-date-pickers": "7.1.1",
"@mui/x-license-pro": "5.12.3",
"@react-jvectormap/core": "1.0.1",
"@react-jvectormap/unitedstates": "1.0.1",
@ -26,6 +27,7 @@
"chroma-js": "2.4.2",
"cmdk": "0.2.0",
"datejs": "1.0.0-rc3",
"dayjs": "1.11.10",
"downshift": "3.2.10",
"faker": "5.5.3",
"form-data": "4.0.0",
@ -34,12 +36,18 @@
"html-to-text": "^9.0.5",
"http-proxy-middleware": "2.0.6",
"jwt-decode": "3.1.2",
"oidc-client-ts": "2.4.1",
"react-oidc-context": "2.3.1",
"rapidoc": "9.3.4",
"react": "18.0.0",
"react-ace": "10.1.0",
"react-chartjs-2": "3.0.4",
"react-cookie": "4.1.1",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.0.0",
"react-dropzone": "14.3.5",
"react-ga4": "2.1.0",
"react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0",
"react-markdown": "9.0.1",
@ -54,7 +62,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.25.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>feature-CE-798-quick-filters-20240123.205854-1</version>
<version>0.25.0-integration-sprint-62-20250307-205536</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

@ -19,7 +19,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {useAuth0} from "@auth0/auth0-react";
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
import {QAppNodeType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppNodeType";
import {QAppTreeNode} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppTreeNode";
@ -29,20 +28,20 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import CssBaseline from "@mui/material/CssBaseline";
import Icon from "@mui/material/Icon";
import {ThemeProvider} from "@mui/material/styles";
import {LicenseInfo} from "@mui/x-license-pro";
import jwt_decode from "jwt-decode";
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
import {useCookies} from "react-cookie";
import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom";
import {Md5} from "ts-md5/dist/md5";
import CommandMenu from "CommandMenu";
import QContext from "QContext";
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule";
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
import Sidenav from "qqq/components/horseshoe/sidenav/SideNav";
import theme from "qqq/components/legacy/Theme";
import {setMiniSidenav, setOpenConfigurator, useMaterialUIController} from "qqq/context";
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
import {setMiniSidenav, useMaterialUIController} from "qqq/context";
import AppHome from "qqq/pages/apps/Home";
import NoApps from "qqq/pages/apps/NoApps";
import ProcessRun from "qqq/pages/processes/ProcessRun";
@ -53,17 +52,27 @@ 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";
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
import {useCookies} from "react-cookie";
import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom";
import {Md5} from "ts-md5/dist/md5";
const qController = Client.getInstance();
export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
export default function App()
interface Props
{
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const {user, getAccessTokenSilently, logout} = useAuth0();
authenticationMetaData: QAuthenticationMetaData;
}
export default function App({authenticationMetaData}: Props)
{
const [, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [loadingToken, setLoadingToken] = useState(false);
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
const [profileRoutes, setProfileRoutes] = useState({});
@ -72,68 +81,20 @@ export default function App()
const [needLicenseKey, setNeedLicenseKey] = useState(true);
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
const [defaultRoute, setDefaultRoute] = useState("/no-apps");
const [earlyReturnForAuth, setEarlyReturnForAuth] = useState(null as JSX.Element);
const {setupSession: auth0SetupSession, logout: auth0Logout} = useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
const {setupSession: oauth2SetupSession, logout: oauth2Logout} = useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext: authenticationMetaData.type === "OAUTH2"});
const {setupSession: anonymousSetupSession, logout: anonymousLogout} = useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
/////////////////////////////////////////////////////////
// tell the client how to do a logout if it sees a 401 //
/////////////////////////////////////////////////////////
Client.setUnauthorizedCallback(() =>
{
logout();
})
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!cookies[SESSION_UUID_COOKIE_NAME])
{
console.log("No session uuid cookie - so we should store a new one.");
return (true);
}
if (!oldToken)
{
console.log("No accessToken in localStorage - so we should store a new one.");
return (true);
}
try
{
const oldJSON: any = jwt_decode(oldToken);
const newJSON: any = jwt_decode(newToken);
////////////////////////////////////////////////////////////////////////////////////
// if the old (local storage) token is expired, then we need to store the new one //
////////////////////////////////////////////////////////////////////////////////////
const oldExp = oldJSON["exp"];
if(oldExp * 1000 < (new Date().getTime()))
{
console.log("Access token in local storage was expired - so we should store a new one.");
return (true);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// remove the exp & iat values from what we compare - as they are always different from auth0 //
// note, this is only deleting them from what we compare, not from what we'd store. //
////////////////////////////////////////////////////////////////////////////////////////////////
delete newJSON["exp"]
delete newJSON["iat"]
delete oldJSON["exp"]
delete oldJSON["iat"]
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if(different)
{
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
}
return (different);
}
catch(e)
{
console.log("Caught in shouldStoreNewToken: " + e)
}
return (true);
};
Client.setUnauthorizedCallback(() => doLogout());
/////////////////////////////////////////////////
// deal with making sure user is authenticated //
/////////////////////////////////////////////////
useEffect(() =>
{
if (loadingToken)
@ -144,64 +105,17 @@ export default function App()
(async () =>
{
const authenticationMetaData: QAuthenticationMetaData = await qController.getAuthenticationMetaData();
if (authenticationMetaData.type === "AUTH_0")
{
/////////////////////////////////////////
// use auth0 if auth type is ... auth0 //
/////////////////////////////////////////
try
{
console.log("Loading token from auth0...");
const accessToken = await getAccessTokenSilently();
const lsAccessToken = localStorage.getItem("accessToken");
if (shouldStoreNewToken(accessToken, lsAccessToken))
{
console.log("Sending accessToken to backend, requesting a sessionUUID...");
const newSessionUuid = await qController.manageSession(accessToken, null);
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// the request to the backend should send a header to set the cookie, so we don't need to do it ourselves. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"});
localStorage.setItem("accessToken", accessToken);
console.log("Got new sessionUUID from backend, and stored new accessToken");
}
else
{
console.log("Using existing sessionUUID cookie");
}
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(user);
console.log("Token load complete.");
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken")
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
logout();
return;
}
await auth0SetupSession();
}
else if (authenticationMetaData.type === "OAUTH2")
{
await oauth2SetupSession();
}
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
{
/////////////////////////////////////////////
// use a random token if anonymous or mock //
/////////////////////////////////////////////
console.log("Generating random token...");
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
return;
await anonymousSetupSession();
}
else
{
@ -217,13 +131,36 @@ export default function App()
(async () =>
{
const metaData: QInstance = await qController.loadMetaData();
LicenseInfo.setLicenseKey(metaData.environmentValues.get("MATERIAL_UI_LICENSE_KEY") || process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY);
LicenseInfo.setLicenseKey(metaData.environmentValues?.get("MATERIAL_UI_LICENSE_KEY") || process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY);
setNeedLicenseKey(false);
})();
}
/***************************************************************************
** call appropriate logout function based on authentication meta data type
***************************************************************************/
function doLogout()
{
if (authenticationMetaData?.type === "AUTH_0")
{
auth0Logout();
}
else if (authenticationMetaData?.type === "OAUTH2")
{
oauth2Logout();
}
else if (authenticationMetaData?.type === "FULLY_ANONYMOUS" || authenticationMetaData?.type === "MOCK")
{
anonymousLogout();
}
else
{
console.log(`No logout callback for authentication type [${authenticationMetaData?.type}].`);
}
}
const [controller, dispatch] = useMaterialUIController();
const {miniSidenav, direction, layout, openConfigurator, sidenavColor} = controller;
const {miniSidenav, direction, sidenavColor} = controller;
const [onMouseEnter, setOnMouseEnter] = useState(false);
const {pathname} = useLocation();
const [queryParams] = useSearchParams();
@ -390,6 +327,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`,
@ -509,11 +453,10 @@ export default function App()
}
}
let profileRoutes = {};
const gravatarBase = "https://www.gravatar.com/avatar/";
const hash = Md5.hashStr(loggedInUser?.email || "user");
const profilePicture = `${gravatarBase}${hash}`;
profileRoutes = {
const profileRoutes = {
type: "collapse",
name: loggedInUser?.name ?? "Anonymous",
key: "username",
@ -550,7 +493,7 @@ export default function App()
});
}
const pathToLabelMap: {[path: string]: string} = {}
const pathToLabelMap: { [path: string]: string } = {};
for (let i = 0; i < appRoutesList.length; i++)
{
const route = appRoutesList[i];
@ -575,17 +518,14 @@ export default function App()
console.error(e);
if (e instanceof QException)
{
if ((e as QException).status === "401")
if ((e as QException).status === 401)
{
console.log("Exception is a QException with status = 401. Clearing some of localStorage & cookies");
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken")
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
//////////////////////////////////////////////////////
// todo - this is auth0 logout... make more generic //
//////////////////////////////////////////////////////
logout();
doLogout();
return;
}
}
@ -593,7 +533,9 @@ export default function App()
})();
}, [needToLoadRoutes, isFullyAuthenticated]);
// Open sidenav when mouse enter on mini sidenav
///////////////////////////////////////////////////
// Open sidenav when mouse enter on mini sidenav //
///////////////////////////////////////////////////
const handleOnMouseEnter = () =>
{
if (miniSidenav && !onMouseEnter)
@ -603,7 +545,9 @@ export default function App()
}
};
// Close sidenav when mouse leave mini sidenav
/////////////////////////////////////////////////
// Close sidenav when mouse leave mini sidenav //
/////////////////////////////////////////////////
const handleOnMouseLeave = () =>
{
if (onMouseEnter)
@ -613,16 +557,14 @@ export default function App()
}
};
// Change the openConfigurator state
const handleConfiguratorOpen = () => setOpenConfigurator(dispatch, !openConfigurator);
// Setting the dir attribute for the body element
useEffect(() =>
{
document.body.setAttribute("dir", direction);
}, [direction]);
// Setting page scroll to 0 when changing the route
//////////////////////////////////////////////////////
// Setting page scroll to 0 when changing the route //
//////////////////////////////////////////////////////
useEffect(() =>
{
document.documentElement.scrollTop = 0;
@ -656,12 +598,56 @@ export default function App()
const [pageHeader, setPageHeader] = useState("" as string | JSX.Element);
const [accentColor, setAccentColor] = useState("#0062FF");
const [accentColorLight, setAccentColorLight] = useState("#C0D6F7")
const [accentColorLight, setAccentColorLight] = useState("#C0D6F7");
const [tableMetaData, setTableMetaData] = useState(null);
const [tableProcesses, setTableProcesses] = useState(null);
const [dotMenuOpen, setDotMenuOpen] = useState(false);
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
const [userId, setUserId] = useState(loggedInUser?.email);
useEffect(() =>
{
setUserId(loggedInUser?.email);
}, [loggedInUser]);
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
/*******************************************************************************
**
*******************************************************************************/
function recordAnalytics(model: AnalyticsModel)
{
googleAnalyticsUtils.recordAnalytics(model);
}
///////////////////////////////////////////////////////////////////
// if any of the auth/session setup code determined that we need //
// to render something and return early - then do so here. //
///////////////////////////////////////////////////////////////////
if (earlyReturnForAuth)
{
return (earlyReturnForAuth);
}
/***************************************************************************
**
***************************************************************************/
function banner(): JSX.Element | null
{
const banner = getBanner(metaData?.branding, "QFMD_TOP_OF_SITE");
if (!banner)
{
return (null);
}
return (<Box className={getBannerClassName(banner)} sx={{display: "flex", justifyContent: "center", padding: "0.5rem", position: "sticky", top: "0", zIndex: 1, ...getBannerStyles(banner)}}>
{makeBannerContent(banner)}
</Box>);
}
return (
@ -675,6 +661,7 @@ export default function App()
dotMenuOpen: dotMenuOpen,
keyboardHelpOpen: keyboardHelpOpen,
helpHelpActive: helpHelpActive,
userId: userId,
setPageHeader: (header: string | JSX.Element) => setPageHeader(header),
setAccentColor: (accentColor: string) => setAccentColor(accentColor),
setAccentColorLight: (accentColorLight: string) => setAccentColorLight(accentColorLight),
@ -682,12 +669,14 @@ export default function App()
setTableProcesses: (tableProcesses: QProcessMetaData[]) => setTableProcesses(tableProcesses),
setDotMenuOpen: (dotMenuOpent: boolean) => setDotMenuOpen(dotMenuOpent),
setKeyboardHelpOpen: (keyboardHelpOpen: boolean) => setKeyboardHelpOpen(keyboardHelpOpen),
recordAnalytics: recordAnalytics,
pathToLabelMap: pathToLabelMap,
branding: branding
}}>
<ThemeProvider theme={theme}>
<CssBaseline />
<CommandMenu metaData={metaData} />
{banner()}
<Sidenav
color={sidenavColor}
icon={branding.icon}
@ -697,6 +686,7 @@ export default function App()
routes={sideNavRoutes}
onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave}
logout={doLogout}
/>
<Routes>
<Route path="*" element={<Navigate to={defaultRoute} />} />
@ -707,4 +697,4 @@ export default function App()
</QContext.Provider>
)
);
}
}

View File

@ -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,69 +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) =>
{
return (metaData.tables.get(a).label.localeCompare(metaData.tables.get(b).label));
})
const labelA = metaData.tables.get(a).label ?? "";
const labelB = metaData.tables.get(b).label ?? "";
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) =>
{
return (metaData.tables.get(a).label.localeCompare(metaData.tables.get(b).label));
})
return(
const labelA = metaData.tables.get(a).label ?? "";
const labelB = metaData.tables.get(b).label ?? "";
return comparator(labelA, labelB);
});
return (
<Command.Group heading="Tables">
{
tableNames.map((tableName: string, index: number) =>
@ -239,6 +293,7 @@ const CommandMenu = ({metaData}: Props) =>
);
}
/*******************************************************************************
**
*******************************************************************************/
@ -248,14 +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) =>
{
return (getFullAppLabel(metaData.appTree, a, 1, "").localeCompare(getFullAppLabel(metaData.appTree, b, 1, "")));
})
const labelA = getFullAppLabel(metaData.appTree, a, 1, "") ?? "";
const labelB = getFullAppLabel(metaData.appTree, b, 1, "") ?? "";
return comparator(labelA, labelB);
});
return(
return (
<Command.Group heading="Apps">
{
appNames.map((appName: string, index: number) =>
@ -270,31 +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) =>
{
return (metaData.apps.get(a).label.localeCompare(metaData.apps.get(b).label));
})
const labelA = metaData.apps.get(a).label ?? "";
const labelB = metaData.apps.get(b).label ?? "";
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>
)
)
@ -303,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>
@ -354,8 +489,7 @@ const CommandMenu = ({metaData}: Props) =>
<Grid container columnSpacing={5} rowSpacing={1}>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>n</span>Create a New Record</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>r</span>Refresh the Query</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>c</span>Open the Columns Panel</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>f</span>Open the Filter Panel</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>f</span>Open the Filter Builder (Advanced mode only)</Grid>
</Grid>
<Typography variant="h6" pt={3}>Record View</Typography>
@ -374,6 +508,6 @@ const CommandMenu = ({metaData}: Props) =>
</Dialog>
}
</React.Fragment>
)
}
);
};
export default CommandMenu;

View File

@ -22,6 +22,7 @@
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import {createContext} from "react";
interface QContext
@ -47,12 +48,18 @@ interface QContext
tableProcesses?: QProcessMetaData[];
setTableProcesses?: (tableProcesses: QProcessMetaData[]) => void;
///////////////////////////////////////////
// function to record an analytics event //
///////////////////////////////////////////
recordAnalytics?: (model: AnalyticsModel) => void;
///////////////////////////////////
// constants - no setters needed //
///////////////////////////////////
pathToLabelMap?: {[path: string]: string};
branding?: QBrandingMetaData;
helpHelpActive?: boolean;
userId?: string;
}
const defaultState = {

View File

@ -19,116 +19,97 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Auth0Provider} from "@auth0/auth0-react";
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import React from "react";
import {createRoot} from "react-dom/client";
import {BrowserRouter, useNavigate, useSearchParams} from "react-router-dom";
import App from "App";
import "qqq/styles/qqq-override-styles.css";
import "qqq/styles/globals.scss";
import "qqq/styles/raycast.scss";
import HandleAuthorizationError from "HandleAuthorizationError";
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule";
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
import {MaterialUIControllerProvider} from "qqq/context";
import Client from "qqq/utils/qqq/Client";
import React from "react";
import {createRoot} from "react-dom/client";
import {BrowserRouter} from "react-router-dom";
const qController = Client.getInstance();
if(document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1)
if (document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1)
{
qController.clearAuthenticationMetaDataLocalStorage()
qController.clearAuthenticationMetaDataLocalStorage();
}
const authenticationMetaDataPromise: Promise<QAuthenticationMetaData> = qController.getAuthenticationMetaData()
const authenticationMetaDataPromise: Promise<QAuthenticationMetaData> = qController.getAuthenticationMetaData();
authenticationMetaDataPromise.then((authenticationMetaData) =>
{
// @ts-ignore
function Auth0ProviderWithRedirectCallback({children, ...props})
{
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// @ts-ignore
const onRedirectCallback = (appState) =>
{
navigate((appState && appState.returnTo) || window.location.pathname);
};
if (searchParams.get("error"))
{
return (
// @ts-ignore
<Auth0Provider {...props}>
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
</Auth0Provider>
);
}
else
{
return (
// @ts-ignore
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
{children}
</Auth0Provider>
);
}
/***************************************************************************
**
***************************************************************************/
function Auth0RouterBody()
{
const {renderAppWrapper} = useAuth0AuthenticationModule({});
return (renderAppWrapper(authenticationMetaData));
}
/***************************************************************************
**
***************************************************************************/
function OAuth2RouterBody()
{
const {renderAppWrapper} = useOAuth2AuthenticationModule({inOAuthContext: false});
return (renderAppWrapper(authenticationMetaData, (
<MaterialUIControllerProvider>
<App authenticationMetaData={authenticationMetaData} />
</MaterialUIControllerProvider>
)));
}
/***************************************************************************
**
***************************************************************************/
function AnonymousRouterBody()
{
const {renderAppWrapper} = useAnonymousAuthenticationModule({});
return (renderAppWrapper(authenticationMetaData, (
<MaterialUIControllerProvider>
<App authenticationMetaData={authenticationMetaData} />
</MaterialUIControllerProvider>
)));
}
const container = document.getElementById("root");
const root = createRoot(container);
if (authenticationMetaData.type === "AUTH_0")
{
// @ts-ignore
let domain: string = authenticationMetaData.data.baseUrl;
// @ts-ignore
const clientId = authenticationMetaData.data.clientId;
// @ts-ignore
const audience = authenticationMetaData.data.audience;
if(!domain || !clientId)
{
root.render(
<div>Error: AUTH0 authenticationMetaData is missing domain [{domain}] and/or clientId [{clientId}].</div>
);
return;
}
if(domain.endsWith("/"))
{
/////////////////////////////////////////////////////////////////////////////////////
// auth0 lib fails if we have a trailing slash. be a bit more graceful than that. //
/////////////////////////////////////////////////////////////////////////////////////
domain = domain.replace(/\/$/, "");
}
root.render(
<BrowserRouter>
<Auth0ProviderWithRedirectCallback
domain={domain}
clientId={clientId}
audience={audience}
redirectUri={`${window.location.origin}/`}
>
<MaterialUIControllerProvider>
<ProtectedRoute component={App} />
</MaterialUIControllerProvider>
</Auth0ProviderWithRedirectCallback>
</BrowserRouter>
);
root.render(<BrowserRouter>
<Auth0RouterBody />
</BrowserRouter>);
}
else if (authenticationMetaData.type === "OAUTH2")
{
root.render(<BrowserRouter>
<OAuth2RouterBody />
</BrowserRouter>);
}
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
{
root.render(<BrowserRouter>
<AnonymousRouterBody />
</BrowserRouter>);
}
else
{
root.render(
<BrowserRouter>
<MaterialUIControllerProvider>
<App />
</MaterialUIControllerProvider>
</BrowserRouter>
);
root.render(<div>
Error: Unknown authenticationMetaData type: [{authenticationMetaData.type}].
</div>);
}
})
});

View File

@ -0,0 +1,153 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QSupplementalAppMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** app-level meta-data for this module (handled as QSupplementalTableMetaData)
*******************************************************************************/
public class MaterialDashboardAppMetaData extends QSupplementalAppMetaData
{
public static final String TYPE_NAME = "materialDashboard";
private Boolean showAppLabelOnHomeScreen = true;
private Boolean includeTableCountsOnHomeScreen = true;
/*******************************************************************************
**
*******************************************************************************/
public static MaterialDashboardAppMetaData of(QAppMetaData app)
{
return ((MaterialDashboardAppMetaData) CollectionUtils.nonNullMap(app.getSupplementalMetaData()).get(TYPE_NAME));
}
/*******************************************************************************
** either get the supplemental meta dat attached to an app - or create a new one
** and attach it to the app, and return that.
*******************************************************************************/
public static MaterialDashboardAppMetaData ofOrWithNew(QAppMetaData app)
{
MaterialDashboardAppMetaData materialDashboardAppMetaData = of(app);
if(materialDashboardAppMetaData == null)
{
materialDashboardAppMetaData = new MaterialDashboardAppMetaData();
app.withSupplementalMetaData(materialDashboardAppMetaData);
}
return (materialDashboardAppMetaData);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean includeInFullFrontendMetaData()
{
return (true);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getType()
{
return TYPE_NAME;
}
/*******************************************************************************
** Getter for showAppLabelOnHomeScreen
*******************************************************************************/
public Boolean getShowAppLabelOnHomeScreen()
{
return (this.showAppLabelOnHomeScreen);
}
/*******************************************************************************
** Setter for showAppLabelOnHomeScreen
*******************************************************************************/
public void setShowAppLabelOnHomeScreen(Boolean showAppLabelOnHomeScreen)
{
this.showAppLabelOnHomeScreen = showAppLabelOnHomeScreen;
}
/*******************************************************************************
** Fluent setter for showAppLabelOnHomeScreen
*******************************************************************************/
public MaterialDashboardAppMetaData withShowAppLabelOnHomeScreen(Boolean showAppLabelOnHomeScreen)
{
this.showAppLabelOnHomeScreen = showAppLabelOnHomeScreen;
return (this);
}
/*******************************************************************************
** Getter for includeTableCountsOnHomeScreen
*******************************************************************************/
public Boolean getIncludeTableCountsOnHomeScreen()
{
return (this.includeTableCountsOnHomeScreen);
}
/*******************************************************************************
** Setter for includeTableCountsOnHomeScreen
*******************************************************************************/
public void setIncludeTableCountsOnHomeScreen(Boolean includeTableCountsOnHomeScreen)
{
this.includeTableCountsOnHomeScreen = includeTableCountsOnHomeScreen;
}
/*******************************************************************************
** Fluent setter for includeTableCountsOnHomeScreen
*******************************************************************************/
public MaterialDashboardAppMetaData withIncludeTableCountsOnHomeScreen(Boolean includeTableCountsOnHomeScreen)
{
this.includeTableCountsOnHomeScreen = includeTableCountsOnHomeScreen;
return (this);
}
}

View File

@ -1,6 +1,6 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
@ -19,20 +19,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {useAuth0} from "@auth0/auth0-react";
import {Button} from "@mui/material";
import React from "react";
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
function AuthenticationButton()
import com.kingsrook.qqq.backend.core.model.metadata.branding.BannerSlot;
/*******************************************************************************
**
*******************************************************************************/
public enum MaterialDashboardBannerSlots implements BannerSlot
{
const {loginWithRedirect, logout, isAuthenticated} = useAuth0();
if (isAuthenticated)
{
return <Button onClick={() => logout({returnTo: window.location.origin})}>Log Out</Button>;
}
return <Button onClick={() => loginWithRedirect()}>Log In</Button>;
QFMD_TOP_OF_SITE,
QFMD_TOP_OF_BODY,
QFMD_SIDE_NAV_UNDER_LOGO
}
export default AuthenticationButton;

View File

@ -28,4 +28,5 @@ package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
public interface MaterialDashboardIconRoleNames
{
String TOP_RIGHT_INSIDE_CARD = "topRightInsideCard";
String TOP_LEFT_INSIDE_CARD = "topLeftInsideCard";
}

View File

@ -22,6 +22,7 @@
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@ -30,6 +31,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule;
/*******************************************************************************
@ -37,8 +40,11 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
*******************************************************************************/
public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
{
public static final String TYPE = "materialDashboard";
private List<List<String>> gotoFieldNames;
private List<String> defaultQuickFilterFieldNames;
private List<String> defaultQuickFilterFieldNames;
private List<FieldRule> fieldRules;
/*******************************************************************************
@ -58,10 +64,25 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
@Override
public String getType()
{
return ("materialDashboard");
return (TYPE);
}
/*******************************************************************************
**
*******************************************************************************/
public static MaterialDashboardTableMetaData ofOrWithNew(QTableMetaData table)
{
MaterialDashboardTableMetaData supplementalMetaData = (MaterialDashboardTableMetaData) table.getSupplementalMetaData(TYPE);
if(supplementalMetaData == null)
{
supplementalMetaData = new MaterialDashboardTableMetaData();
table.withSupplementalMetaData(supplementalMetaData);
}
return (supplementalMetaData);
}
/*******************************************************************************
** Getter for gotoFieldNames
@ -110,6 +131,41 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
validateListOfFieldNames(tableMetaData, gotoFieldNameSubList, qInstanceValidator, prefix + "gotoFieldNames: ");
}
validateListOfFieldNames(tableMetaData, defaultQuickFilterFieldNames, qInstanceValidator, prefix + "defaultQuickFilterFieldNames: ");
for(FieldRule fieldRule : CollectionUtils.nonNullList(fieldRules))
{
validateFieldRule(qInstance, tableMetaData, qInstanceValidator, fieldRule, prefix);
}
}
/*******************************************************************************
**
*******************************************************************************/
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.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");
}
}
}
@ -124,7 +180,7 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
{
if(qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldName), prefix + " unrecognized field name: " + fieldName))
{
qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + " has a duplicated field name: " + fieldName);
qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + "has a duplicated field name: " + fieldName);
usedNames.add(fieldName);
}
}
@ -161,4 +217,51 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
return (this);
}
/*******************************************************************************
** Getter for fieldRules
*******************************************************************************/
public List<FieldRule> getFieldRules()
{
return (this.fieldRules);
}
/*******************************************************************************
** Setter for fieldRules
*******************************************************************************/
public void setFieldRules(List<FieldRule> fieldRules)
{
this.fieldRules = fieldRules;
}
/*******************************************************************************
** Fluent setter for fieldRules
*******************************************************************************/
public MaterialDashboardTableMetaData withFieldRules(List<FieldRule> fieldRules)
{
this.fieldRules = fieldRules;
return (this);
}
/*******************************************************************************
** Fluent setter for fieldRules
*******************************************************************************/
public MaterialDashboardTableMetaData withFieldRule(FieldRule fieldRule)
{
if(this.fieldRules == null)
{
this.fieldRules = new ArrayList<>();
}
this.fieldRules.add(fieldRule);
return (this);
}
}

View File

@ -0,0 +1,198 @@
/*
* 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.model.metadata.fieldrules;
import java.io.Serializable;
/*******************************************************************************
** definition of rules for how UI fields should behave.
**
** e.g., one field being changed causing different things to be needed in another
** field.
*******************************************************************************/
public class FieldRule implements Serializable
{
private FieldRuleTrigger trigger;
private String sourceField;
private FieldRuleAction action;
private String targetField;
private String targetWidget;
/*******************************************************************************
** Getter for trigger
*******************************************************************************/
public FieldRuleTrigger getTrigger()
{
return (this.trigger);
}
/*******************************************************************************
** Setter for trigger
*******************************************************************************/
public void setTrigger(FieldRuleTrigger trigger)
{
this.trigger = trigger;
}
/*******************************************************************************
** Fluent setter for trigger
*******************************************************************************/
public FieldRule withTrigger(FieldRuleTrigger trigger)
{
this.trigger = trigger;
return (this);
}
/*******************************************************************************
** Getter for sourceField
*******************************************************************************/
public String getSourceField()
{
return (this.sourceField);
}
/*******************************************************************************
** Setter for sourceField
*******************************************************************************/
public void setSourceField(String sourceField)
{
this.sourceField = sourceField;
}
/*******************************************************************************
** Fluent setter for sourceField
*******************************************************************************/
public FieldRule withSourceField(String sourceField)
{
this.sourceField = sourceField;
return (this);
}
/*******************************************************************************
** Getter for action
*******************************************************************************/
public FieldRuleAction getAction()
{
return (this.action);
}
/*******************************************************************************
** Setter for action
*******************************************************************************/
public void setAction(FieldRuleAction action)
{
this.action = action;
}
/*******************************************************************************
** Fluent setter for action
*******************************************************************************/
public FieldRule withAction(FieldRuleAction action)
{
this.action = action;
return (this);
}
/*******************************************************************************
** Getter for targetField
*******************************************************************************/
public String getTargetField()
{
return (this.targetField);
}
/*******************************************************************************
** Setter for targetField
*******************************************************************************/
public void setTargetField(String targetField)
{
this.targetField = targetField;
}
/*******************************************************************************
** Fluent setter for targetField
*******************************************************************************/
public FieldRule withTargetField(String targetField)
{
this.targetField = targetField;
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

@ -0,0 +1,32 @@
/*
* 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.model.metadata.fieldrules;
/*******************************************************************************
** possible actions associated with field rules
*******************************************************************************/
public enum FieldRuleAction
{
CLEAR_TARGET_FIELD,
RELOAD_WIDGET
}

View File

@ -0,0 +1,31 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules;
/*******************************************************************************
** possible triggers associated with field rules
*******************************************************************************/
public enum FieldRuleTrigger
{
ON_CHANGE
}

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/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.savedreports;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.MaterialDashboardTableMetaData;
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;
/*******************************************************************************
** Add frontend material dashboard enhacements to saved report table
*******************************************************************************/
public class SavedReportTableFrontendMaterialDashboardEnricher
{
/*******************************************************************************
**
*******************************************************************************/
public static void enrich(QTableMetaData tableMetaData)
{
MaterialDashboardTableMetaData materialDashboardTableMetaData = MaterialDashboardTableMetaData.ofOrWithNew(tableMetaData);
/////////////////////////////////////////////////////////////////////////
// make changes to the tableName field clear the value in these fields //
/////////////////////////////////////////////////////////////////////////
for(String targetField : List.of("queryFilterJson", "columnsJson", "pivotTableJson"))
{
materialDashboardTableMetaData.withFieldRule(new FieldRule()
.withSourceField("tableName")
.withTrigger(FieldRuleTrigger.ON_CHANGE)
.withAction(FieldRuleAction.CLEAR_TARGET_FIELD)
.withTargetField(targetField));
}
}
}

View File

@ -0,0 +1,82 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {Md5} from "ts-md5/dist/md5";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
}
/***************************************************************************
** hook for working with the anonymous authentication module
***************************************************************************/
export default function useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}: Props)
{
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
console.log("Generating random token...");
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
};
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
{
return children;
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -0,0 +1,252 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Auth0Provider, useAuth0} from "@auth0/auth0-react";
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import App, {SESSION_UUID_COOKIE_NAME} from "App";
import HandleAuthorizationError from "HandleAuthorizationError";
import jwt_decode from "jwt-decode";
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
import {MaterialUIControllerProvider} from "qqq/context";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {useNavigate, useSearchParams} from "react-router-dom";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
}
/***************************************************************************
** hook for working with the Auth0 authentication module
***************************************************************************/
export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser}: Props)
{
const {user: auth0User, getAccessTokenSilently: auth0GetAccessTokenSilently, logout: useAuth0Logout} = useAuth0();
const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
/***************************************************************************
**
***************************************************************************/
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!cookies[SESSION_UUID_COOKIE_NAME])
{
console.log("No session uuid cookie - so we should store a new one.");
return (true);
}
if (!oldToken)
{
console.log("No accessToken in localStorage - so we should store a new one.");
return (true);
}
try
{
const oldJSON: any = jwt_decode(oldToken);
const newJSON: any = jwt_decode(newToken);
////////////////////////////////////////////////////////////////////////////////////
// if the old (local storage) token is expired, then we need to store the new one //
////////////////////////////////////////////////////////////////////////////////////
const oldExp = oldJSON["exp"];
if (oldExp * 1000 < (new Date().getTime()))
{
console.log("Access token in local storage was expired - so we should store a new one.");
return (true);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// remove the exp & iat values from what we compare - as they are always different from auth0 //
// note, this is only deleting them from what we compare, not from what we'd store. //
////////////////////////////////////////////////////////////////////////////////////////////////
delete newJSON["exp"];
delete newJSON["iat"];
delete oldJSON["exp"];
delete oldJSON["iat"];
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if (different)
{
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
}
return (different);
}
catch (e)
{
console.log("Caught in shouldStoreNewToken: " + e);
}
return (true);
};
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
try
{
console.log("Loading token from auth0...");
const accessToken = await auth0GetAccessTokenSilently();
const lsAccessToken = localStorage.getItem("accessToken");
if (shouldStoreNewToken(accessToken, lsAccessToken))
{
console.log("Sending accessToken to backend, requesting a sessionUUID...");
const {uuid: values} = await qController.manageSession(accessToken, null);
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("sessionValues", JSON.stringify(values));
console.log("Got new sessionUUID from backend, and stored new accessToken");
}
else
{
console.log("Using existing sessionUUID cookie");
}
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(auth0User);
console.log("Token load complete.");
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
useAuth0Logout();
return;
}
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
useAuth0Logout({returnTo: window.location.origin});
};
/***************************************************************************
**
***************************************************************************/
// @ts-ignore
function Auth0ProviderWithRedirectCallback({children, ...props})
{
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// @ts-ignore
const onRedirectCallback = (appState) =>
{
navigate((appState && appState.returnTo) || window.location.pathname);
};
if (searchParams.get("error"))
{
return (
// @ts-ignore
<Auth0Provider {...props}>
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
</Auth0Provider>
);
}
else
{
return (
// @ts-ignore
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
{children}
</Auth0Provider>
);
}
}
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData): JSX.Element =>
{
// @ts-ignore
let domain: string = authenticationMetaData.data.baseUrl;
// @ts-ignore
const clientId = authenticationMetaData.data.clientId;
// @ts-ignore
const audience = authenticationMetaData.data.audience;
if (!domain || !clientId)
{
return (
<div>Error: AUTH0 authenticationMetaData is missing baseUrl [{domain}] and/or clientId [{clientId}].</div>
);
}
if (domain.endsWith("/"))
{
/////////////////////////////////////////////////////////////////////////////////////
// auth0 lib fails if we have a trailing slash. be a bit more graceful than that. //
/////////////////////////////////////////////////////////////////////////////////////
domain = domain.replace(/\/$/, "");
}
/***************************************************************************
** simple Functional Component to wrap the <App> and pass the authentication-
** MetaData prop in, so a simple Component can be passed into ProtectedRoute
***************************************************************************/
function WrappedApp()
{
return <App authenticationMetaData={authenticationMetaData} />
}
return (
<Auth0ProviderWithRedirectCallback
domain={domain}
clientId={clientId}
audience={audience}
redirectUri={`${window.location.origin}/`}>
<MaterialUIControllerProvider>
<ProtectedRoute component={WrappedApp} />
</MaterialUIControllerProvider>
</Auth0ProviderWithRedirectCallback>
);
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -0,0 +1,188 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {AuthContextProps, AuthProvider, useAuth} from "react-oidc-context";
import {useNavigate, useSearchParams} from "react-router-dom";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
inOAuthContext: boolean;
}
/***************************************************************************
** hook for working with the OAuth2 authentication module
***************************************************************************/
export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext}: Props)
{
///////////////////////////////////////////////////////////////////////////////////////
// the useAuth hook should only be called if we're inside the <AuthProvider> element //
// so on the page that uses this hook to call renderAppWrapper, we aren't in that //
// element/context, thus, don't call that hook. //
///////////////////////////////////////////////////////////////////////////////////////
const authOidc: AuthContextProps | null = inOAuthContext ? useAuth() : null;
const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
try
{
const preSigninRedirectPathnameKey = "oauth2.preSigninRedirect.pathname";
if (window.location.pathname == "/token")
{
///////////////////////////////////////////////////////////////////////////
// if we're at a path of /token, get code & state params, look up values //
// from that state in local storage, and make a post to the backend to //
// with these values - which will itself talk to the identity provider //
// to get an access token, and ultimately a session. //
///////////////////////////////////////////////////////////////////////////
const code = searchParams.get("code");
const state = searchParams.get("state");
const oidcString = localStorage.getItem(`oidc.${state}`);
if (oidcString)
{
const oidcObject = JSON.parse(oidcString) as { [name: string]: any };
console.log(oidcObject);
const manageSessionRequestBody = {code: code, codeVerifier: oidcObject.code_verifier, redirectUri: oidcObject.redirect_uri};
const {uuid: newSessionUuid, values} = await qController.manageSession(null, null, manageSessionRequestBody);
console.log(`we have new session UUID: ${newSessionUuid}`);
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(values?.user);
console.log("Token load complete.");
const preSigninRedirectPathname = localStorage.getItem(preSigninRedirectPathnameKey);
localStorage.removeItem(preSigninRedirectPathname);
navigate(preSigninRedirectPathname ?? "/", {replace: true});
}
else
{
////////////////////////////////////////////
// if unrecognized state, render an error //
////////////////////////////////////////////
setEarlyReturnForAuth(<div>Login error: Unrecognized state. Refresh to try again.</div>);
}
}
else
{
//////////////////////////////////////////////////////////////////////////
// if we have a sessionUUID cookie, try to validate it with the backend //
//////////////////////////////////////////////////////////////////////////
const sessionUuid = cookies[SESSION_UUID_COOKIE_NAME];
if (sessionUuid)
{
console.log(`we have session UUID: ${sessionUuid} - validating it...`);
const {values} = await qController.manageSession(null, sessionUuid, null);
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(values?.user);
console.log("Token load complete.");
}
else
{
/////////////////////////////////////////////////////////////////////////////////////////////////
// else no cookie, and not a token url, we need to redirect to the provider's login page //
// capture the path the user was trying to access in local storage, to redirect back to later. //
/////////////////////////////////////////////////////////////////////////////////////////////////
console.log("Loading token from OAuth2 provider...");
console.log(authOidc);
localStorage.setItem(preSigninRedirectPathnameKey, window.location.pathname);
setEarlyReturnForAuth(<div>Signing in...</div>);
authOidc?.signinRedirect();
}
}
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
logout();
return;
}
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
authOidc?.signoutRedirect();
};
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
{
const authority: string = authenticationMetaData.data.baseUrl;
const clientId = authenticationMetaData.data.clientId;
if (!authority || !clientId)
{
return (
<div>Error: OAuth2 authenticationMetaData is missing baseUrl [{authority}] and/or clientId [{clientId}].</div>
);
}
const oidcConfig =
{
authority: authority,
client_id: clientId,
redirect_uri: `${window.location.origin}/token`,
response_type: "code",
scope: "openid profile email offline_access",
};
return (<AuthProvider {...oidcConfig}>
{children}
</AuthProvider>
);
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -34,10 +34,10 @@ import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import React, {JSXElementConstructor, useContext, useEffect, useState} from "react";
import QContext from "QContext";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext, useEffect, useState} from "react";
interface Props
{
@ -58,19 +58,19 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const [limit, setLimit] = useState(1000);
const [statusString, setStatusString] = useState("Loading audits...");
const [auditsByDate, setAuditsByDate] = useState([] as QRecord[][]);
const [auditDetailMap, setAuditDetailMap] = useState(null as Map<number, JSX.Element[]>)
const [fieldChangeMap, setFieldChangeMap] = useState(null as Map<number, JSX.Element>)
const [auditDetailMap, setAuditDetailMap] = useState(null as Map<number, JSX.Element[]>);
const [fieldChangeMap, setFieldChangeMap] = useState(null as Map<number, JSX.Element>);
const [sortDirection, setSortDirection] = useState(localStorage.getItem("audit.sortDirection") === "true");
const {accentColor} = useContext(QContext);
function wrapValue(value: any): JSX.Element
{
return <span style={{fontWeight: "500", color: " rgb(123, 128, 154)"}}>{value}</span>
return <span style={{fontWeight: "500", color: " rgb(123, 128, 154)"}}>{value}</span>;
}
function wasValue(value: any): JSX.Element
{
return <span style={{fontWeight: "100", color: " rgb(123, 128, 154)"}}>{value}</span>
return <span style={{fontWeight: "100", color: " rgb(123, 128, 154)"}}>{value}</span>;
}
function getAuditDetailFieldChangeRow(qRecord: QRecord): JSX.Element | null
@ -79,10 +79,14 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const fieldName = qRecord.values.get("auditDetail.fieldName");
const oldValue = qRecord.values.get("auditDetail.oldValue");
const newValue = qRecord.values.get("auditDetail.newValue");
if(fieldName && (oldValue !== null || newValue !== null))
if (fieldName && (oldValue !== null || newValue !== null))
{
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName
return (<tr><td>{fieldLabel}</td><td>{oldValue}</td><td>{newValue}</td></tr>)
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName;
return (<tr>
<td>{fieldLabel}</td>
<td>{oldValue}</td>
<td>{newValue}</td>
</tr>);
}
return (null);
}
@ -93,22 +97,22 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const fieldName = qRecord.values.get("auditDetail.fieldName");
const oldValue = qRecord.values.get("auditDetail.oldValue");
const newValue = qRecord.values.get("auditDetail.newValue");
if(fieldName && (oldValue !== null || newValue !== null))
if (fieldName && (oldValue !== null || newValue !== null))
{
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName;
if(oldValue !== undefined && newValue !== undefined)
if (oldValue !== undefined && newValue !== undefined)
{
return (<>{fieldLabel}: Changed from {(oldValue)} to <b>{(newValue)}</b></>);
}
else if(newValue !== undefined)
else if (newValue !== undefined)
{
return (<>{fieldLabel}: Set to <b>{(newValue)}</b></>);
}
else if(oldValue !== undefined)
else if (oldValue !== undefined)
{
return (<>{fieldLabel}: Removed value {(oldValue)}</>);
}
else if(message)
else if (message)
{
return (<>{message}</>);
}
@ -177,7 +181,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
}
*/
}
else if(message)
else if (message)
{
return (<>{message}</>);
}
@ -198,22 +202,22 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
new QFilterOrderBy("timestamp", sortDirection),
new QFilterOrderBy("id", sortDirection),
new QFilterOrderBy("auditDetail.id", true)
], "AND", 0, limit);
], null, "AND", 0, limit);
///////////////////////////////
// fetch audits in try-catch //
///////////////////////////////
let audits = [] as QRecord[]
let audits = [] as QRecord[];
try
{
audits = await qController.query("audit", filter, [new QueryJoin("auditDetail", true, "LEFT")]);
setAudits(audits);
}
catch(e)
catch (e)
{
if (e instanceof QException)
{
if ((e as QException).status === "403")
if ((e as QException).status === 403)
{
setStatusString("You do not have permission to view audits");
return;
@ -233,33 +237,33 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
//////////////////////////////////////////////////////////////////////////////////////////////////////////
// group the audits by auditId (e.g., this is a list that joined audit & auditDetail, so un-flatten it) //
//////////////////////////////////////////////////////////////////////////////////////////////////////////
const unflattenedAudits: QRecord[] = []
const unflattenedAudits: QRecord[] = [];
const detailMap: Map<number, JSX.Element[]> = new Map();
const fieldChangeRowsMap: Map<number, JSX.Element[]> = new Map();
for (let i = 0; i < audits.length; i++)
{
let id = audits[i].values.get("id");
if(i == 0 || unflattenedAudits[unflattenedAudits.length-1].values.get("id") != id)
if (i == 0 || unflattenedAudits[unflattenedAudits.length - 1].values.get("id") != id)
{
unflattenedAudits.push(audits[i]);
}
let auditDetail = getAuditDetailElement(audits[i]);
if(auditDetail)
if (auditDetail)
{
if(!detailMap.has(id))
if (!detailMap.has(id))
{
detailMap.set(id, []);
}
detailMap.get(id).push(auditDetail)
detailMap.get(id).push(auditDetail);
}
// table version, probably not to commit
let fieldChangeRow = getAuditDetailFieldChangeRow(audits[i]);
if(auditDetail)
if (auditDetail)
{
if(!fieldChangeRowsMap.has(id))
if (!fieldChangeRowsMap.has(id))
{
fieldChangeRowsMap.set(id, []);
}
@ -273,7 +277,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
for (let i = 0; i < unflattenedAudits.length; i++)
{
let id = unflattenedAudits[i].values.get("id");
if(fieldChangeRowsMap.has(id) && fieldChangeRowsMap.get(id).length > 0)
if (fieldChangeRowsMap.has(id) && fieldChangeRowsMap.get(id).length > 0)
{
const fieldChangeTable = (
<table style={{fontSize: "0.875rem"}} className="auditDetailTable" cellSpacing="0">
@ -288,11 +292,11 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
{fieldChangeRowsMap.get(id).map((row, key) => <React.Fragment key={key}>{row}</React.Fragment>)}
</tbody>
</table>
)
);
fieldChangeMap.set(id, fieldChangeTable);
}
}
setFieldChangeMap(fieldChangeMap)
setFieldChangeMap(fieldChangeMap);
//////////////////////////////
// group the audits by date //
@ -350,7 +354,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const changeSortDirection = () =>
{
setAudits([]);
const newSortDirection = !sortDirection
const newSortDirection = !sortDirection;
setSortDirection(newSortDirection);
localStorage.setItem("audit.sortDirection", String(newSortDirection));
};

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} 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>
@ -175,3 +180,24 @@ QSubmitButton.defaultProps = {
label: "Submit",
iconName: "check",
};
interface QAlternateButtonProps
{
label: string,
iconName?: string,
disabled: boolean,
onClick?: () => void
}
export function QAlternateButton({label, iconName, disabled, onClick}: QAlternateButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<MDButton type="button" variant="gradient" color="secondary" size="small" fullWidth startIcon={iconName && <Icon>{iconName}</Icon>} onClick={onClick} disabled={disabled}>
{label}
</MDButton>
</Box>
);
}
QAlternateButton.defaultProps = {};

View File

@ -80,11 +80,12 @@ interface Props
label: string;
value: boolean;
isDisabled: boolean;
onChangeCallback?: (newValue: any) => void;
}
function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Element
function BooleanFieldSwitch({name, label, value, isDisabled, onChangeCallback}: Props) : JSX.Element
{
const {setFieldValue} = useFormikContext();
@ -93,6 +94,10 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
if(!isDisabled)
{
setFieldValue(name, newValue);
if(onChangeCallback)
{
onChangeCallback(newValue);
}
event.stopPropagation();
}
}
@ -100,6 +105,10 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
const toggleSwitch = () =>
{
setFieldValue(name, !value);
if(onChangeCallback)
{
onChangeCallback(!value);
}
}
const classNullSwitch = (value === null || value == undefined || `${value}` == "") ? "nullSwitch" : "";

View File

@ -19,21 +19,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {colors, Icon, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
import Tooltip from "@mui/material/Tooltip";
import {useFormikContext} from "formik";
import React, {useState} from "react";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import FileInputField from "qqq/components/forms/FileInputField";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React from "react";
interface Props
{
@ -50,28 +45,6 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
{
const {formFields, values, errors, touched} = formData;
const formikProps = useFormikContext();
const [fileName, setFileName] = useState(null as string);
const fileChanged = (event: React.FormEvent<HTMLInputElement>, field: any) =>
{
setFileName(null);
if (event.currentTarget.files && event.currentTarget.files[0])
{
setFileName(event.currentTarget.files[0].name);
}
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
};
const removeFile = (fieldName: string) =>
{
setFileName(null);
formikProps.setFieldValue(fieldName, null);
record?.values.delete(fieldName)
record?.displayValues.delete(fieldName)
};
const bulkEditSwitchChanged = (name: string, value: boolean) =>
{
bulkEditSwitchChangeHandler(name, value);
@ -82,14 +55,9 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
<Box>
<Box lineHeight={0}>
<MDTypography variant="h5">{formLabel}</MDTypography>
{/* TODO - help text
<MDTypography variant="button" color="text">
Mandatory information
</MDTypography>
*/}
</Box>
<Box mt={1.625}>
<Grid container spacing={3}>
<Grid container lg={12} display="flex" spacing={3}>
{formFields
&& Object.keys(formFields).length > 0
&& Object.keys(formFields).map((fieldName: any) =>
@ -105,93 +73,74 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
values[fieldName] = "";
}
let formattedHelpContent = <HelpContent helpContents={field.fieldMetaData.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
if(formattedHelpContent)
let formattedHelpContent = <HelpContent helpContents={field?.fieldMetaData?.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
if (formattedHelpContent)
{
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>;
}
const labelElement = <Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
<label htmlFor={field.name}>{field.label}</label>
</Box>
const labelElement = <DynamicFormFieldLabel name={field.name} label={field.label} />;
let itemLG = (field?.fieldMetaData?.gridColumns && field?.fieldMetaData?.gridColumns > 0) ? field.fieldMetaData.gridColumns : 6;
let itemXS = 12;
let itemSM = 6;
/////////////
// files!! //
/////////////
if (field.type === "file")
{
const pseudoField = new QFieldMetaData({name: fieldName, type: QFieldType.BLOB});
return (
<Grid item xs={12} sm={6} key={fieldName}>
<Box mb={1.5}>
{labelElement}
{
record && record.values.get(fieldName) && <Box fontSize="0.875rem" pb={1}>
Current File:
<Box display="inline-flex" pl={1}>
{ValueUtils.getDisplayValue(pseudoField, record, "view")}
<Tooltip placement="bottom" title="Remove current file">
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(fieldName)}>delete</Icon>
</Tooltip>
</Box>
</Box>
}
<Box display="flex" alignItems="center">
<Button variant="outlined" component="label">
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
<input
id={fieldName}
name={fieldName}
type="file"
hidden
onChange={(event: React.FormEvent<HTMLInputElement>) => fileChanged(event, field)}
/>
</Button>
<Box ml={1} fontSize={"1rem"}>
{fileName}
</Box>
</Box>
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
const width = fileUploadAdornment?.values?.get("width") ?? "half";
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{errors[fieldName] && <span>You must select a file to proceed</span>}
</MDTypography>
</Box>
</Box>
if (width == "full")
{
itemSM = 12;
itemLG = 12;
}
return (
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} flexDirection="column" key={fieldName}>
{labelElement}
<FileInputField field={field} record={record} errorMessage={errors[fieldName]} />
</Grid>
);
}
// possible values!!
if (field.possibleValueProps)
///////////////////////
// possible values!! //
///////////////////////
else if (field.possibleValueProps)
{
const otherValuesMap = field.possibleValueProps.otherValues ?? new Map<string, any>();
Object.keys(values).forEach((key) =>
{
otherValuesMap.set(key, values[key]);
})
});
return (
<Grid item xs={12} sm={6} key={fieldName}>
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} 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>
);
}
// todo? inputProps={{ autoComplete: "" }}
// todo? placeholder={password.placeholder}
///////////////////////
// everything else!! //
///////////////////////
return (
<Grid item xs={12} sm={6} key={fieldName}>
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName}>
{labelElement}
<QDynamicFormField
id={field.name}
@ -226,4 +175,19 @@ QDynamicForm.defaultProps = {
},
};
interface DynamicFormFieldLabelProps
{
name: string;
label: string;
}
export function DynamicFormFieldLabel({name, label}: DynamicFormFieldLabelProps): JSX.Element
{
return (<Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
<label htmlFor={name}>{label}</label>
</Box>);
}
export default QDynamicForm;

View File

@ -19,16 +19,19 @@
* 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 {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
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 DynamicSelect from "qqq/components/forms/DynamicSelect";
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 +42,10 @@ interface Props
value: any;
type: string;
isEditable?: boolean;
placeholder?: string;
backgroundColor?: string;
onChangeCallback?: (newValue: any) => void;
[key: string]: any;
@ -48,7 +55,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, onChangeCallback, ...rest
}: Props): JSX.Element
{
const [switchChecked, setSwitchChecked] = useState(false);
@ -64,18 +71,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,13 +104,95 @@ 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 || onChangeCallback)
{
onChange.onChange = (e: any) =>
{
if(isToUpperCase || isToLowerCase)
{
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);
if(onChangeCallback)
{
onChangeCallback(newValue);
}
});
const input = document.getElementById(name) as HTMLInputElement;
if (input)
{
input.setSelectionRange(beforeStart, beforeEnd);
}
}
else if(onChangeCallback)
{
onChangeCallback(e.currentTarget.value);
}
};
}
/***************************************************************************
**
***************************************************************************/
function dynamicSelectOnChange(newValue?: QPossibleValue)
{
if(onChangeCallback)
{
onChangeCallback(newValue == null ? null : newValue.id)
}
}
let field;
let getsBulkEditHtmlLabel = true;
if (type === "checkbox")
if(formFieldObject.possibleValueProps)
{
field = (<DynamicSelect
name={name}
fieldPossibleValueProps={formFieldObject.possibleValueProps}
isEditable={!isDisabled}
fieldLabel={label}
initialValue={value}
bulkEditMode={bulkEditMode}
bulkEditSwitchChangeHandler={bulkEditSwitchChangeHandler}
onChange={dynamicSelectOnChange}
// otherValues={otherValuesMap}
useCase="form"
/>)
}
else if (type === "checkbox")
{
getsBulkEditHtmlLabel = false;
field = (<>
<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} />
<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} onChangeCallback={onChangeCallback} />
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={name} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
@ -102,7 +203,7 @@ function QDynamicFormField({
else if (type === "ace")
{
let mode = "text";
if(formFieldObject && formFieldObject.languageMode)
if (formFieldObject && formFieldObject.languageMode)
{
mode = formFieldObject.languageMode;
}
@ -119,6 +220,10 @@ function QDynamicFormField({
onChange={(value: string, event: any) =>
{
setFieldValue(name, value, false);
if(onChangeCallback)
{
onChangeCallback(value);
}
}}
setOptions={{useWorker: false}}
width="100%"
@ -133,7 +238,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 +278,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,29 +22,41 @@
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";
type DisabledFields = { [fieldName: string]: boolean } | string[];
/*******************************************************************************
** Meta-data to represent a single field in a table.
**
*******************************************************************************/
class DynamicFormUtils
{
public static getFormData(qqqFormFields: QFieldMetaData[])
/*******************************************************************************
**
*******************************************************************************/
public static getFormData(qqqFormFields: QFieldMetaData[], disabledFields?: DisabledFields)
{
const dynamicFormFields: any = {};
const formValidations: any = {};
qqqFormFields.forEach((field) =>
{
dynamicFormFields[field.name] = this.getDynamicField(field);
formValidations[field.name] = this.getValidationForField(field);
dynamicFormFields[field.name] = this.getDynamicField(field, disabledFields);
formValidations[field.name] = this.getValidationForField(field, disabledFields);
});
return {dynamicFormFields, formValidations};
}
public static getDynamicField(field: QFieldMetaData)
/*******************************************************************************
**
*******************************************************************************/
public static getDynamicField(field: QFieldMetaData, disabledFields?: DisabledFields)
{
let fieldType: string;
switch (field.type.toString())
@ -85,15 +97,21 @@ class DynamicFormUtils
}
}
////////////////////////////////////////////////////////////
// this feels right, but... might be cases where it isn't //
////////////////////////////////////////////////////////////
const effectiveIsEditable = field.isEditable && !this.isFieldDynamicallyDisabled(field.name, disabledFields);
const effectivelyIsRequired = field.isRequired && effectiveIsEditable;
let label = field.label ? field.label : field.name;
label += field.isRequired ? " *" : "";
label += effectivelyIsRequired ? " *" : "";
return ({
fieldMetaData: field,
name: field.name,
label: label,
isRequired: field.isRequired,
isEditable: field.isEditable,
isRequired: effectivelyIsRequired,
isEditable: effectiveIsEditable,
type: fieldType,
displayFormat: field.displayFormat,
// todo invalidMsg: "Zipcode is not valid (e.g. 70000).",
@ -101,64 +119,143 @@ class DynamicFormUtils
});
}
public static getValidationForField(field: QFieldMetaData)
/*******************************************************************************
**
*******************************************************************************/
public static getValidationForField(field: QFieldMetaData, disabledFields?: DisabledFields)
{
if (field.isRequired)
const effectiveIsEditable = field.isEditable && !this.isFieldDynamicallyDisabled(field.name, disabledFields);
const effectivelyIsRequired = field.isRequired && effectiveIsEditable;
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);
}
/*******************************************************************************
**
*******************************************************************************/
public static addPossibleValueProps(dynamicFormFields: any, qFields: QFieldMetaData[], tableName: string, processName: string, displayValues: Map<string, string>)
{
for (let i = 0; i < qFields.length; i++)
{
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;
}
}
}
/*******************************************************************************
** private helper - check the disabled fields object (array or map), and return
** true iff fieldName is in it.
*******************************************************************************/
private static isFieldDynamicallyDisabled(fieldName: string, disabledFields?: DisabledFields): boolean
{
if (!disabledFields)
{
return (false);
}
if (Array.isArray(disabledFields))
{
return (disabledFields.indexOf(fieldName) > -1);
}
else
{
return (disabledFields[fieldName]);
}
}
/***************************************************************************
* 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

@ -28,21 +28,20 @@ import Box from "@mui/material/Box";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import {ErrorMessage, useFormikContext} from "formik";
import React, {useEffect, useState} from "react";
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;
fieldPossibleValueProps: FieldPossibleValueProps;
overrideId?: string;
name?: string;
fieldLabel: string;
inForm: boolean;
initialValue?: any;
initialDisplayValue?: string;
initialValues?: QPossibleValue[];
onChange?: any;
isEditable?: boolean;
@ -52,14 +51,12 @@ interface Props
otherValues?: Map<string, any>;
variant: "standard" | "outlined";
initiallyOpen: boolean;
useCase: "form" | "filter";
}
DynamicSelect.defaultProps = {
tableName: null,
processName: null,
inForm: true,
initialValue: null,
initialDisplayValue: null,
initialValues: undefined,
onChange: null,
isEditable: true,
@ -73,16 +70,76 @@ DynamicSelect.defaultProps = {
},
};
const {inputBorderColor} = colors;
export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
{
return ({
"& .MuiOutlinedInput-root": {
borderRadius: "0.75rem",
},
"& .MuiInputBase-root": {
padding: "0.5rem",
background: isDisabled ? "#f0f2f5!important" : "initial",
},
"& .MuiOutlinedInput-root .MuiAutocomplete-input": {
padding: "0",
fontSize: "1rem"
},
"& .Mui-disabled .MuiOutlinedInput-notchedOutline": {
borderColor: inputBorderColor
}
});
};
const qController = Client.getInstance();
function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen}: Props)
function DynamicSelect({fieldPossibleValueProps, overrideId, name, 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 {inputBorderColor} = colors;
const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues))));
useEffect(() =>
{
if (tableName && processName)
{
console.log("DynamicSelect - you may not provide both a tableName and a processName");
}
if (tableName && !fieldName)
{
console.log("DynamicSelect - if you provide a tableName, you must also provide a fieldName");
}
if (processName && !fieldName)
{
console.log("DynamicSelect - if you provide a processName, you must also provide a fieldName");
}
if (!fieldName && !possibleValueSourceName)
{
console.log("DynamicSelect - you must provide either a fieldName (and a tableName or processName) or a possibleValueSourceName");
}
if (fieldName && !possibleValueSourceName)
{
if (!tableName || !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 (tableName || processName)
{
console.log("DynamicSelect - if you provide a possibleValueSourceName, you should not also provide a tableName or processName");
}
}
}, [tableName, processName, fieldName, possibleValueSourceName]);
////////////////////////////////////////////////////////////////////////////////////////////////
// default value - needs to be an array (from initialValues (array) prop) for multiple mode - //
@ -110,9 +167,38 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
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);
@ -133,9 +219,9 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
(async () =>
{
// console.log(`doing a search with ${searchTerm}`);
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, fieldName, 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);
@ -146,7 +232,7 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
// console.log(`${results}`);
if (active)
{
setOptions([ ...results ]);
setOptions([...results]);
}
})();
@ -154,50 +240,67 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
{
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, 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);
}
@ -206,12 +309,16 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
onChange(value ? new QPossibleValue(value) : null);
}
}
else if(setFieldValueRef)
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; }[] =>
{
/////////////////////////////////////////////////////////////////////////////////
@ -219,8 +326,12 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
// 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}) =>
{
@ -228,23 +339,24 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
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 = (
<>
@ -266,8 +378,12 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
{content}
</li>
);
}
};
/***************************************************************************
**
***************************************************************************/
const bulkEditSwitchChanged = () =>
{
const newSwitchValue = !switchChecked;
@ -282,28 +398,14 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
let autocompleteSX = {};
if (variant == "outlined")
{
autocompleteSX = {
"& .MuiOutlinedInput-root": {
borderRadius: "0.75rem",
},
"& .MuiInputBase-root": {
padding: "0.5rem",
background: isDisabled ? "#f0f2f5!important" : "initial",
},
"& .MuiOutlinedInput-root .MuiAutocomplete-input": {
padding: "0",
fontSize: "1rem"
},
"& .Mui-disabled .MuiOutlinedInput-notchedOutline": {
borderColor: inputBorderColor
}
}
autocompleteSX = getAutocompleteOutlinedStyle(isDisabled);
}
const autocomplete = (
<Box>
<Autocomplete
id={overrideId ?? fieldName}
id={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"}
name={name}
sx={autocompleteSX}
open={open}
fullWidth
@ -311,7 +413,7 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
{
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("");
@ -324,19 +426,19 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
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}
@ -383,7 +485,7 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
inForm &&
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={fieldName} 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>
}
@ -400,7 +502,8 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
id={`bulkEditSwitch-${fieldName}`}
checked={switchChecked}
onClick={bulkEditSwitchChanged}
sx={{top: "-4px",
sx={{
top: "-4px",
"& .MuiSwitch-track": {
height: 20,
borderRadius: 10,
@ -419,7 +522,7 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
else
{
return (
<Box mb={1.5}>
<Box>
{autocomplete}
</Box>
);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,156 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AdornmentType} from "@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 {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Button, colors, Icon} from "@mui/material";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import {useFormikContext} from "formik";
import MDTypography from "qqq/components/legacy/MDTypography";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useCallback, useState} from "react";
import {useDropzone} from "react-dropzone";
interface FileInputFieldProps
{
field: any,
record?: QRecord,
errorMessage?: any
}
export default function FileInputField({field, record, errorMessage}: FileInputFieldProps): JSX.Element
{
const [fileName, setFileName] = useState(null as string);
const formikProps = useFormikContext();
const fileChanged = (event: React.FormEvent<HTMLInputElement>, field: any) =>
{
setFileName(null);
if (event.currentTarget.files && event.currentTarget.files[0])
{
setFileName(event.currentTarget.files[0].name);
}
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
};
const onDrop = useCallback((acceptedFiles: any) =>
{
setFileName(null);
if (acceptedFiles.length && acceptedFiles[0])
{
setFileName(acceptedFiles[0].name);
}
formikProps.setFieldValue(field.name, acceptedFiles[0]);
}, []);
const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop});
const removeFile = (fieldName: string) =>
{
setFileName(null);
formikProps.setFieldValue(fieldName, null);
record?.values.delete(fieldName);
record?.displayValues.delete(fieldName);
};
const pseudoField = new QFieldMetaData({name: field.name, type: QFieldType.BLOB});
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
const format = fileUploadAdornment?.values?.get("format") ?? "button";
return (
<Box mb={1.5}>
{
record && record.values.get(field.name) && <Box fontSize="0.875rem" pb={1}>
Current File:
<Box display="inline-flex" pl={1}>
{ValueUtils.getDisplayValue(pseudoField, record, "view")}
<Tooltip placement="bottom" title="Remove current file">
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(field.name)}>delete</Icon>
</Tooltip>
</Box>
</Box>
}
{
format == "button" &&
<Box display="flex" alignItems="center">
<Button variant="outlined" component="label">
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
<input
id={field.name}
name={field.name}
type="file"
hidden
onChange={(event: React.FormEvent<HTMLInputElement>) => fileChanged(event, field)}
/>
</Button>
<Box ml={1} fontSize={"1rem"}>
{fileName}
</Box>
</Box>
}
{
format == "dragAndDrop" &&
<>
<Box {...getRootProps()} sx={
{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "300px",
borderRadius: "2rem",
backgroundColor: isDragActive ? colors.lightBlue[50] : "transparent",
border: `2px ${isDragActive ? "solid" : "dashed"} ${colors.lightBlue[500]}`
}}>
<input {...getInputProps()} />
<Box display="flex" alignItems="center" flexDirection="column">
<Icon sx={{fontSize: "4rem !important", color: colors.lightBlue[500]}}>upload_file</Icon>
<Box>Drag and drop a file</Box>
<Box fontSize="1rem" m="0.5rem">or</Box>
<Box border={`2px solid ${colors.lightBlue[500]}`} mt="0.25rem" padding="0.25rem 1rem" borderRadius="0.5rem" sx={{cursor: "pointer"}} fontSize="1rem">
Browse files
</Box>
</Box>
</Box>
<Box fontSize={"1rem"} mt="0.25rem">
{fileName}&nbsp;
</Box>
</>
}
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{errorMessage && <span>{errorMessage}</span>}
</MDTypography>
</Box>
</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,17 +255,22 @@ 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>
{
pageHeader &&
<Box display="flex" justifyContent="space-between">
<MDTypography pb="0.5rem" textTransform="capitalize" variant="h3" color={light ? "white" : "dark"} noWrap>
<MDTypography pb="0.5rem" variant="h3" color={light ? "white" : "dark"} noWrap>
{pageHeader}
</MDTypography>
</Box>

View File

@ -20,21 +20,22 @@
*/
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
import {Button} from "@mui/material";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import Link from "@mui/material/Link";
import List from "@mui/material/List";
import {ReactNode, useEffect, useReducer, useState} from "react";
import {NavLink, useLocation} from "react-router-dom";
import AuthenticationButton from "qqq/components/buttons/AuthenticationButton";
import SideNavCollapse from "qqq/components/horseshoe/sidenav/SideNavCollapse";
import SideNavItem from "qqq/components/horseshoe/sidenav/SideNavItem";
import SideNavList from "qqq/components/horseshoe/sidenav/SideNavList";
import SidenavRoot from "qqq/components/horseshoe/sidenav/SideNavRoot";
import sidenavLogoLabel from "qqq/components/horseshoe/sidenav/styles/SideNav";
import MDTypography from "qqq/components/legacy/MDTypography";
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
import {setMiniSidenav, setTransparentSidenav, setWhiteSidenav, useMaterialUIController,} from "qqq/context";
import {ReactNode, useEffect, useReducer, useState} from "react";
import {NavLink, useLocation} from "react-router-dom";
interface Props
@ -44,6 +45,7 @@ interface Props
logo?: string;
appName?: string;
branding?: QBrandingMetaData;
logout: () => void;
routes: {
[key: string]:
| ReactNode
@ -66,7 +68,7 @@ interface Props
[key: string]: any;
}
function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props): JSX.Element
function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}: Props): JSX.Element
{
const [openCollapse, setOpenCollapse] = useState<boolean | string>(false);
const [openNestedCollapse, setOpenNestedCollapse] = useState<boolean | string>(false);
@ -257,7 +259,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
active={key === collapseName}
open={openCollapse === key}
noCollapse={noCollapse}
onClick={() => (! noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null) }
onClick={() => (!noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null)}
>
{collapse ? renderCollapse(collapse) : null}
</SideNavCollapse>
@ -300,6 +302,30 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
}
);
/***************************************************************************
**
***************************************************************************/
function EnvironmentBanner({branding}: { branding: QBrandingMetaData }): JSX.Element | null
{
// deprecated!
if (branding && branding.environmentBannerText)
{
return <Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
{branding.environmentBannerText}
</Box>;
}
const banner = getBanner(branding, "QFMD_SIDE_NAV_UNDER_LOGO");
if (banner)
{
return <Box className={getBannerClassName(banner)} mt={2} borderRadius={2} sx={getBannerStyles(banner)}>
{makeBannerContent(banner)}
</Box>;
}
return (null);
}
return (
<SidenavRoot
{...rest}
@ -330,12 +356,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
</Box>
}
</Box>
{
branding && branding.environmentBannerText &&
<Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
{branding.environmentBannerText}
</Box>
}
<EnvironmentBanner branding={branding} />
</Box>
<Divider
light={
@ -350,7 +371,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
(darkMode && !transparentSidenav && whiteSidenav)
}
/>
<AuthenticationButton />
<Button onClick={logout}>Log Out</Button>
</SidenavRoot>
);
}

View File

@ -97,6 +97,7 @@ export default styled(Drawer)(({theme, ownerState}: { theme?: Theme | any; owner
margin: "0",
borderRadius: "0",
height: "100%",
top: "unset",
...(miniSidenav ? drawerCloseStyles() : drawerOpenStyles()),
},

View File

@ -0,0 +1,97 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Banner} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Banner";
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
import parse from "html-react-parser";
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// One may render a banner using the functions in this file as: //
// //
// const banner = getBanner(branding, "QFMD_SIDE_NAV_UNDER_LOGO"); //
// return (<Box className={getBannerClassName(banner)} sx={{padding: "1rem", ...getBannerStyles(banner)}}> //
// {makeBannerContent(banner)} //
// </Box>); //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
/***************************************************************************
**
***************************************************************************/
export function getBanner(branding: QBrandingMetaData, slot: string): Banner | null
{
if (branding?.banners?.has(slot))
{
return (branding.banners.get(slot));
}
return (null);
}
/***************************************************************************
**
***************************************************************************/
export function getBannerStyles(banner: Banner)
{
let bgColor = "";
let color = "";
if (banner)
{
if (banner.backgroundColor)
{
bgColor = banner.backgroundColor;
}
if (banner.textColor)
{
bgColor = banner.textColor;
}
}
const rest = banner?.additionalStyles ?? {};
return ({
backgroundColor: bgColor,
color: color,
...rest
});
}
/***************************************************************************
**
***************************************************************************/
export function getBannerClassName(banner: Banner)
{
return `banner ${banner?.severity?.toLowerCase()}`;
}
/***************************************************************************
**
***************************************************************************/
export function makeBannerContent(banner: Banner): JSX.Element
{
return <>{banner?.messageHTML ? parse(banner?.messageHTML) : banner?.messageText}</>;
}

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

@ -23,9 +23,11 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {Box} from "@mui/material";
import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete";
import Icon from "@mui/material/Icon";
import TextField from "@mui/material/TextField";
import React, {ReactNode} from "react";
import React, {ReactNode, useState} from "react";
interface FieldAutoCompleteProps
{
@ -33,10 +35,17 @@ interface FieldAutoCompleteProps
metaData: QInstance;
tableMetaData: QTableMetaData;
handleFieldChange: (event: any, newValue: any, reason: string) => void;
defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string};
defaultValue?: { field: QFieldMetaData, table: QTableMetaData, fieldName: string };
autoFocus?: boolean;
forceOpen?: boolean;
hiddenFieldNames?: string[];
availableFieldNames?: string[];
variant?: "standard" | "filled" | "outlined";
label?: string;
textFieldSX?: any;
autocompleteSlotProps?: any;
hasError?: boolean;
noOptionsText?: string;
}
FieldAutoComplete.defaultProps =
@ -44,17 +53,29 @@ FieldAutoComplete.defaultProps =
defaultValue: null,
autoFocus: false,
forceOpen: null,
hiddenFieldNames: []
hiddenFieldNames: [],
availableFieldNames: [],
variant: "standard",
label: "Field",
textFieldSX: null,
autocompleteSlotProps: null,
hasError: false,
noOptionsText: "No options",
};
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[])
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string)
{
const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label));
for (let i = 0; i < sortedFields.length; i++)
{
const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name;
if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1)
if (hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1 && fieldName != selectedFieldName)
{
continue;
}
if (availableFieldNames?.length && availableFieldNames.indexOf(fieldName) == -1)
{
continue;
}
@ -63,10 +84,16 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a
}
}
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element
/*******************************************************************************
** Component for rendering a list of field names from a table as an auto-complete.
*******************************************************************************/
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError, noOptionsText}: FieldAutoCompleteProps): JSX.Element
{
const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null);
const fieldOptions: any[] = [];
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames);
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName);
let fieldsGroupBy = null;
if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0)
@ -77,7 +104,7 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi
if (metaData.tables.has(exposedJoin.joinTable.name))
{
fieldsGroupBy = (option: any) => `${option.table.label} fields`;
makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames);
makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames, availableFieldNames, selectedFieldName);
}
}
}
@ -130,27 +157,48 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi
// seems like, if we always add the open attribute, then if its false or null, then the autocomplete //
// doesn't open at all... so, only add the attribute at all, if forceOpen is true //
///////////////////////////////////////////////////////////////////////////////////////////////////////
const alsoOpen: {[key: string]: any} = {}
if(forceOpen)
const alsoOpen: { [key: string]: any } = {};
if (forceOpen)
{
alsoOpen["open"] = forceOpen;
}
/*******************************************************************************
**
*******************************************************************************/
function onChange(event: any, newValue: any, reason: string)
{
setSelectedFieldName(newValue ? newValue.fieldName : null);
handleFieldChange(event, newValue, reason);
}
return (
<Autocomplete
id={id}
renderInput={(params) => (<TextField {...params} autoFocus={autoFocus} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
renderInput={(params) =>
{
const inputProps = params.InputProps;
const originalEndAdornment = inputProps.endAdornment;
inputProps.endAdornment = <Box>
{hasError && <Icon color="error">error_outline</Icon>}
{originalEndAdornment}
</Box>;
return (<TextField {...params} autoFocus={autoFocus} label={label} variant={variant} sx={textFieldSX} autoComplete="off" type="search" InputProps={inputProps} />)
}}
// @ts-ignore
defaultValue={defaultValue}
options={fieldOptions}
onChange={handleFieldChange}
onChange={onChange}
isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)}
groupBy={fieldsGroupBy}
getOptionLabel={(option) => getFieldOptionLabel(option)}
renderOption={(props, option, state) => renderFieldOption(props, option, state)}
autoSelect={true}
autoHighlight={true}
slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
slotProps={autocompleteSlotProps ?? {}}
noOptionsText={noOptionsText}
{...alsoOpen}
/>

View File

@ -20,6 +20,7 @@
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
@ -35,11 +36,11 @@ 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 React, {useState} from "react";
import {useNavigate} from "react-router-dom";
import {QCancelButton} from "qqq/components/buttons/DefaultButtons";
import MDButton from "qqq/components/legacy/MDButton";
import Client from "qqq/utils/qqq/Client";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
interface Props
{
@ -61,7 +62,7 @@ const qController = Client.getInstance();
function hasGotoFieldNames(tableMetaData: QTableMetaData): boolean
{
const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
if(mdbMetaData && mdbMetaData.gotoFieldNames)
if (mdbMetaData && mdbMetaData.gotoFieldNames)
{
return (true);
}
@ -71,44 +72,56 @@ 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;
const mdbMetaData = props?.tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
if(mdbMetaData)
if (mdbMetaData)
{
if(mdbMetaData.gotoFieldNames)
if (mdbMetaData.gotoFieldNames)
{
for(let i = 0; i<mdbMetaData.gotoFieldNames.length; i++)
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 && !addedPkey)
//////////////////////////////////////////////////////////////////////////////////////////
// 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] = "");
const rs = {} as { [field: string]: string };
options.forEach((option) => option.forEach((field) => rs[field.name] = ""));
return (rs);
}
};
const [error, setError] = useState("");
const [values, setValues] = useState(makeInitialValues());
@ -118,49 +131,95 @@ function GotoRecordDialog(props: Props): JSX.Element
{
values[fieldName] = newValue;
setValues(JSON.parse(JSON.stringify(values)));
}
};
const close = () =>
{
setError("");
setValues(makeInitialValues());
props.closeHandler();
}
};
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
// @ts-ignore
const targetId: string = e.target?.id;
if(e.key == "Esc")
if (e.key == "Esc")
{
if(props.mayClose)
if (props.mayClose)
{
close();
}
}
else if(e.key == "Enter" && targetId?.startsWith("gotoInput-"))
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)
if (props.mayClose)
{
close();
}
}
};
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, "AND", null, 10);
const criteria: QFilterCriteria[] = [];
const queryStringParts: string[] = [];
options[optionIndex].forEach((field) =>
{
if (field.type == QFieldType.STRING && !values[field.name])
{
return;
}
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)
const queryResult = await qController.query(props.tableMetaData.name, filter, null, props.tableVariant);
if (queryResult.length == 0)
{
setError("Record not found.");
@ -168,28 +227,42 @@ 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);
}
}
catch(e)
catch (e)
{
// @ts-ignore
setError(`Error: ${(e && e.message) ? e.message : e}`);
setTimeout(() => setError(""), 6000);
}
}
};
if(props.tableMetaData)
if (props.tableMetaData)
{
if (fields.length == 0 && !error)
if (options.length == 0 && !error)
{
setError("This table is not configured for this feature.")
setError("This table is not configured for this feature.");
}
}
@ -200,31 +273,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 &&
@ -244,7 +324,7 @@ function GotoRecordDialog(props: Props): JSX.Element
: <Box>&nbsp;</Box>
}
</Dialog>
)
);
}
interface GotoRecordButtonProps
@ -266,7 +346,7 @@ GotoRecordButton.defaultProps = {
export function GotoRecordButton(props: GotoRecordButtonProps): JSX.Element
{
const [gotoIsOpen, setGotoIsOpen] = useState(props.autoOpen)
const [gotoIsOpen, setGotoIsOpen] = useState(props.autoOpen);
function openGoto()
{
@ -282,7 +362,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,13 +22,14 @@
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";
interface Props
{
helpContents: QHelpContent[];
helpContents: null | QHelpContent | QHelpContent[];
roles: string[];
heading?: string;
helpContentKey?: string;
@ -93,9 +94,27 @@ const getMatchingHelpContent = (helpContents: QHelpContent[], roles: string[]):
/*******************************************************************************
** test if a list of help contents would find any matches from a list of roles.
*******************************************************************************/
export const hasHelpContent = (helpContents: QHelpContent[], roles: string[]) =>
export const hasHelpContent = (helpContents: null | QHelpContent | QHelpContent[], roles: string[]) =>
{
return getMatchingHelpContent(helpContents, roles) != null;
return getMatchingHelpContent(nullOrSingletonOrArrayToArray(helpContents), roles) != null;
}
/*******************************************************************************
**
*******************************************************************************/
const nullOrSingletonOrArrayToArray = (helpContents: null | QHelpContent | QHelpContent[]): QHelpContent[] =>
{
let array: QHelpContent[] = [];
if(Array.isArray(helpContents))
{
array = helpContents;
}
else if(helpContents != null)
{
array.push(helpContents);
}
return (array);
}
@ -106,9 +125,11 @@ export const hasHelpContent = (helpContents: QHelpContent[], roles: string[]) =>
function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX.Element
{
const {helpHelpActive} = useContext(QContext);
let selectedHelpContent = getMatchingHelpContent(helpContents, roles);
const helpContentsArray = nullOrSingletonOrArrayToArray(helpContents);
let selectedHelpContent = getMatchingHelpContent(helpContentsArray, roles);
let content = null;
let errorContent = "Error rendering help content.";
if (helpHelpActive)
{
if (!selectedHelpContent)
@ -116,6 +137,7 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
selectedHelpContent = new QHelpContent({content: ""});
}
content = selectedHelpContent.content + ` [${helpContentKey ?? "?"}]`;
errorContent += ` [${helpContentKey ?? "?"}]`;
}
else if(selectedHelpContent)
{
@ -129,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

@ -0,0 +1,795 @@
/*
* 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 Button from "@mui/material/Button";
import FormControlLabel from "@mui/material/FormControlLabel";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List/List";
import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem";
import Menu from "@mui/material/Menu";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import React, {useState} from "react";
export type Option = { label: string, value: string | number, [key: string]: any }
export type Group = { label: string, value: string | number, options: Option[], subGroups?: Group[], [key: string]: any }
type StringOrNumber = string | number
interface QHierarchyAutoCompleteProps
{
idPrefix: string;
heading?: string;
placeholder?: string;
defaultGroup: Group;
showGroupHeaderEvenIfNoSubGroups: boolean;
optionValuesToHide?: StringOrNumber[];
buttonProps: any;
buttonChildren: JSX.Element | string;
menuDirection: "down" | "up";
isModeSelectOne?: boolean;
keepOpenAfterSelectOne?: boolean;
handleSelectedOption?: (option: Option, group: Group) => void;
isModeToggle?: boolean;
toggleStates?: { [optionValue: string]: boolean };
disabledStates?: { [optionValue: string]: boolean };
tooltips?: { [optionValue: string]: string };
handleToggleOption?: (option: Option, group: Group, newValue: boolean) => void;
optionEndAdornment?: JSX.Element;
handleAdornmentClick?: (option: Option, group: Group, event: React.MouseEvent<any>) => void;
forceRerender?: number
}
QHierarchyAutoComplete.defaultProps = {
menuDirection: "down",
showGroupHeaderEvenIfNoSubGroups: false,
isModeSelectOne: false,
keepOpenAfterSelectOne: false,
isModeToggle: false,
};
interface GroupWithOptions
{
group?: Group;
options: Option[];
}
/***************************************************************************
** a sort of re-implementation of Autocomplete, that can display headers
** & children, which may be collapsable (Is that only for toggle mode?)
** but which also can have adornments that trigger actions, or be in a
** single-click-do-something mode.
*
** Originally built just for fields exposed on a table query screen, but
** then factored out of that for use in bulk-load (where it wasn't based on
** exposed joins).
***************************************************************************/
export default function QHierarchyAutoComplete({idPrefix, heading, placeholder, defaultGroup, showGroupHeaderEvenIfNoSubGroups, optionValuesToHide, buttonProps, buttonChildren, isModeSelectOne, keepOpenAfterSelectOne, isModeToggle, handleSelectedOption, toggleStates, disabledStates, tooltips, handleToggleOption, optionEndAdornment, handleAdornmentClick, menuDirection, forceRerender}: QHierarchyAutoCompleteProps): JSX.Element
{
const [menuAnchorElement, setMenuAnchorElement] = useState(null);
const [searchText, setSearchText] = useState("");
const [focusedIndex, setFocusedIndex] = useState(null as number);
const [optionsByGroup, setOptionsByGroup] = useState([] as GroupWithOptions[]);
const [collapsedGroups, setCollapsedGroups] = useState({} as { [groupValue: string | number]: boolean });
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
const [timeOfLastArrow, setTimeOfLastArrow] = useState(0);
//////////////////
// check usages //
//////////////////
if(isModeSelectOne)
{
if(!handleSelectedOption)
{
throw("In QAutoComplete, if isModeSelectOne=true, then a callback for handleSelectedOption must be provided.");
}
}
if(isModeToggle)
{
if(!toggleStates)
{
throw("In QAutoComplete, if isModeToggle=true, then a model for toggleStates must be provided.");
}
if(!handleToggleOption)
{
throw("In QAutoComplete, if isModeToggle=true, then a callback for handleToggleOption must be provided.");
}
}
/////////////////////
// init some stuff //
/////////////////////
if (optionsByGroup.length == 0)
{
collapsedGroups[defaultGroup.value] = false;
if (defaultGroup.subGroups?.length > 0)
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
optionsByGroup.push({group: defaultGroup, options: getGroupOptionsAsAlphabeticalArray(defaultGroup)});
for (let i = 0; i < defaultGroup.subGroups?.length; i++)
{
const subGroup = defaultGroup.subGroups[i];
optionsByGroup.push({group: subGroup, options: getGroupOptionsAsAlphabeticalArray(subGroup)});
collapsedGroups[subGroup.value] = false;
}
}
else
{
///////////////////////////////////////////////////////////
// no exposed joins - just the table (w/o its meta-data) //
///////////////////////////////////////////////////////////
optionsByGroup.push({options: getGroupOptionsAsAlphabeticalArray(defaultGroup)});
}
setOptionsByGroup(optionsByGroup);
setCollapsedGroups(collapsedGroups);
}
/*******************************************************************************
**
*******************************************************************************/
function getGroupOptionsAsAlphabeticalArray(group: Group): Option[]
{
const options: Option[] = [];
group.options.forEach(option =>
{
let fullOptionValue = option.value;
if(group.value != defaultGroup.value)
{
fullOptionValue = `${defaultGroup.value}.${option.value}`;
}
if(optionValuesToHide && optionValuesToHide.indexOf(fullOptionValue) > -1)
{
return;
}
options.push(option)
});
options.sort((a, b) => a.label.localeCompare(b.label));
return (options);
}
const optionsByGroupToShow: GroupWithOptions[] = [];
let maxOptionIndex = 0;
optionsByGroup.forEach((groupWithOptions) =>
{
let optionsToShowForThisGroup = groupWithOptions.options.filter(doesOptionMatchSearchText);
if (optionsToShowForThisGroup.length > 0)
{
optionsByGroupToShow.push({group: groupWithOptions.group, options: optionsToShowForThisGroup});
maxOptionIndex += optionsToShowForThisGroup.length;
}
});
/*******************************************************************************
**
*******************************************************************************/
function doesOptionMatchSearchText(option: Option): boolean
{
if (searchText == "")
{
return (true);
}
const columnLabelMinusTable = option.label.replace(/.*: /, "");
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
try
{
////////////////////////////////////////////////////////////
// try to match word-boundary followed by the filter text //
// e.g., "name" would match "First Name" or "Last Name" //
////////////////////////////////////////////////////////////
const re = new RegExp("\\b" + searchText.toLowerCase());
if (columnLabelMinusTable.toLowerCase().match(re))
{
return (true);
}
}
catch (e)
{
//////////////////////////////////////////////////////////////////////////////////
// in case text is an invalid regex... well, at least do a starts-with match... //
//////////////////////////////////////////////////////////////////////////////////
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
}
const tableLabel = option.label.replace(/:.*/, "");
if (tableLabel)
{
try
{
////////////////////////////////////////////////////////////
// try to match word-boundary followed by the filter text //
// e.g., "name" would match "First Name" or "Last Name" //
////////////////////////////////////////////////////////////
const re = new RegExp("\\b" + searchText.toLowerCase());
if (tableLabel.toLowerCase().match(re))
{
return (true);
}
}
catch (e)
{
//////////////////////////////////////////////////////////////////////////////////
// in case text is an invalid regex... well, at least do a starts-with match... //
//////////////////////////////////////////////////////////////////////////////////
if (tableLabel.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
}
}
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
function openMenu(event: any)
{
setFocusedIndex(null);
setMenuAnchorElement(event.currentTarget);
setTimeout(() =>
{
document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus();
doSetFocusedIndex(0, true);
});
}
/*******************************************************************************
**
*******************************************************************************/
function closeMenu()
{
setMenuAnchorElement(null);
}
/*******************************************************************************
** Event handler for toggling an option in toggle mode
*******************************************************************************/
function handleOptionToggle(event: React.ChangeEvent<HTMLInputElement>, option: Option, group: Group)
{
event.stopPropagation();
handleToggleOption(option, group, event.target.checked);
}
/*******************************************************************************
** Event handler for toggling a group in toggle mode
*******************************************************************************/
function handleGroupToggle(event: React.ChangeEvent<HTMLInputElement>, group: Group)
{
event.stopPropagation();
const optionsList = [...group.options.values()];
for (let i = 0; i < optionsList.length; i++)
{
const option = optionsList[i];
if (doesOptionMatchSearchText(option))
{
handleToggleOption(option, group, event.target.checked);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function toggleCollapsedGroup(value: string | number)
{
collapsedGroups[value] = !collapsedGroups[value];
setCollapsedGroups(Object.assign({}, collapsedGroups));
}
/*******************************************************************************
**
*******************************************************************************/
function getShownOptionAndGroupByIndex(targetIndex: number): { option: Option, group: Group }
{
let index = -1;
for (let i = 0; i < optionsByGroupToShow.length; i++)
{
const groupWithOption = optionsByGroupToShow[i];
for (let j = 0; j < groupWithOption.options.length; j++)
{
index++;
if (index == targetIndex)
{
return {option: groupWithOption.options[j], group: groupWithOption.group};
}
}
}
return (null);
}
/*******************************************************************************
** event handler for keys presses
*******************************************************************************/
function keyDown(event: any)
{
// console.log(`Event key: ${event.key}`);
setTimeout(() => document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus());
if (isModeSelectOne && event.key == "Enter" && focusedIndex != null)
{
setTimeout(() =>
{
event.stopPropagation();
const {option, group} = getShownOptionAndGroupByIndex(focusedIndex);
if (option)
{
const fullOptionValue = group && group.value != defaultGroup.value ? `${group.value}.${option.value}` : option.value;
const isDisabled = disabledStates && disabledStates[fullOptionValue]
if(isDisabled)
{
return;
}
if(!keepOpenAfterSelectOne)
{
closeMenu();
}
handleSelectedOption(option, group ?? defaultGroup);
}
});
return;
}
const keyOffsetMap: { [key: string]: number } = {
"End": 10000,
"Home": -10000,
"ArrowDown": 1,
"ArrowUp": -1,
"PageDown": 5,
"PageUp": -5,
};
const offset = keyOffsetMap[event.key];
if (offset)
{
event.stopPropagation();
setTimeOfLastArrow(new Date().getTime());
if (isModeSelectOne)
{
let startIndex = focusedIndex;
if (offset > 0)
{
/////////////////
// a down move //
/////////////////
if (startIndex == null)
{
startIndex = -1;
}
let goalIndex = startIndex + offset;
if (goalIndex > maxOptionIndex - 1)
{
goalIndex = maxOptionIndex - 1;
}
doSetFocusedIndex(goalIndex, true);
}
else
{
////////////////
// an up move //
////////////////
let goalIndex = startIndex + offset;
if (goalIndex < 0)
{
goalIndex = 0;
}
doSetFocusedIndex(goalIndex, true);
}
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function doSetFocusedIndex(i: number, tryToScrollIntoView: boolean): void
{
if (isModeSelectOne)
{
setFocusedIndex(i);
console.log(`Setting index to ${i}`);
if (tryToScrollIntoView)
{
const element = document.getElementById(`field-list-dropdown-${idPrefix}-${i}`);
element?.scrollIntoView({block: "center"});
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function setFocusedOption(option: Option, group: Group, tryToScrollIntoView: boolean)
{
let index = -1;
for (let i = 0; i < optionsByGroupToShow.length; i++)
{
const groupWithOption = optionsByGroupToShow[i];
for (let j = 0; j < groupWithOption.options.length; j++)
{
const loopOption = groupWithOption.options[j];
index++;
const groupMatches = (group == null || group.value == groupWithOption.group.value);
if (groupMatches && option.value == loopOption.value)
{
doSetFocusedIndex(index, tryToScrollIntoView);
return;
}
}
}
}
/*******************************************************************************
** event handler for mouse-over the menu
*******************************************************************************/
function handleMouseOver(event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLDivElement> | React.MouseEvent<HTMLLIElement>, option: Option, group: Group, isDisabled: boolean)
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// so we're trying to fix the case where, if you put your mouse over an element, but then press up/down arrows, //
// where the mouse will become over a different element after the scroll, and the focus will follow the mouse instead of keyboard. //
// the last x/y isn't really useful, because the mouse generally isn't left exactly where it was when the mouse-over happened (edge of the element) //
// but the keyboard last-arrow time that we capture, that's what's actually being useful in here //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y)
{
// console.log("mouse didn't move, so, doesn't count");
return;
}
const now = new Date().getTime();
// console.log(`Compare now [${now}] to last arrow [${timeOfLastArrow}] (diff: [${now - timeOfLastArrow}])`);
if (now < timeOfLastArrow + 300)
{
// console.log("An arrow event happened less than 300 mills ago, so doesn't count.");
return;
}
// console.log("yay, mouse over...");
if(isDisabled)
{
setFocusedIndex(null);
}
else
{
setFocusedOption(option, group, false);
}
setLastMouseOverXY({x: event.clientX, y: event.clientY});
}
/*******************************************************************************
** event handler for text input changes
*******************************************************************************/
function updateSearch(event: React.ChangeEvent<HTMLInputElement>)
{
setSearchText(event?.target?.value ?? "");
doSetFocusedIndex(0, true);
}
/*******************************************************************************
**
*******************************************************************************/
function doHandleAdornmentClick(option: Option, group: Group, event: React.MouseEvent<any>)
{
console.log("In doHandleAdornmentClick");
closeMenu();
handleAdornmentClick(option, group, event);
}
/////////////////////////////////////////////////////////
// compute the group-level toggle state & count values //
/////////////////////////////////////////////////////////
const groupToggleStates: { [value: string]: boolean } = {};
const groupToggleCounts: { [value: string]: number } = {};
if (isModeToggle)
{
const {allOn, count} = getGroupToggleState(defaultGroup, true);
groupToggleStates[defaultGroup.value] = allOn;
groupToggleCounts[defaultGroup.value] = count;
for (let i = 0; i < defaultGroup.subGroups?.length; i++)
{
const subGroup = defaultGroup.subGroups[i];
const {allOn, count} = getGroupToggleState(subGroup, false);
groupToggleStates[subGroup.value] = allOn;
groupToggleCounts[subGroup.value] = count;
}
}
/*******************************************************************************
**
*******************************************************************************/
function getGroupToggleState(group: Group, isMainGroup: boolean): {allOn: boolean, count: number}
{
const optionsList = [...group.options.values()];
let allOn = true;
let count = 0;
for (let i = 0; i < optionsList.length; i++)
{
const option = optionsList[i];
const name = isMainGroup ? option.value : `${group.value}.${option.value}`;
if(!toggleStates[name])
{
allOn = false;
}
else
{
count++;
}
}
return ({allOn: allOn, count: count});
}
let index = -1;
const textFieldId = `field-list-dropdown-${idPrefix}-textField`;
let listItemPadding = isModeToggle ? "0.125rem" : "0.5rem";
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for z-indexes, we set each table header to i+1, then the fields in that table to i (so they go behind it) //
// then we increment i by 2 for the next table (so the next header goes above the previous header) //
// this fixes a thing where, if one table's name wrapped to 2 lines, then when the next table below it would //
// come up, if it was only 1 line, then the second line from the previous one would bleed through. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
let zIndex = 1;
return (
<>
<Button onClick={openMenu} {...buttonProps}>
{buttonChildren}
</Button>
<Menu
anchorEl={menuAnchorElement}
anchorOrigin={{vertical: menuDirection == "down" ? "bottom" : "top", horizontal: "left"}}
transformOrigin={{vertical: menuDirection == "down" ? "top" : "bottom", horizontal: "left"}}
open={menuAnchorElement != null}
onClose={closeMenu}
onKeyDown={keyDown} // this is added here so arrow-key-up/down events don't make the whole menu become "focused" (blue outline). it works.
keepMounted
>
<Box width={isModeToggle ? "305px" : "265px"} borderRadius={2} className={`fieldListMenuBody fieldListMenuBody-${idPrefix}`}>
{
heading &&
<Box px={1} py={0.5} fontWeight={"700"}>
{heading}
</Box>
}
<Box p={1} pt={0.5}>
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}}} />
{
searchText != "" && <IconButton sx={{position: "absolute", right: "0.5rem", top: "0.5rem"}} onClick={() =>
{
updateSearch(null);
document.getElementById(textFieldId).focus();
}}><Icon fontSize="small">close</Icon></IconButton>
}
</Box>
<Box maxHeight={"445px"} minHeight={"445px"} overflow="auto" mr={"-0.5rem"} sx={{scrollbarGutter: "stable"}}>
<List sx={{px: "0.5rem", cursor: "default"}}>
{
optionsByGroupToShow.map((groupWithOptions) =>
{
let headerContents = null;
const headerGroup = groupWithOptions.group || defaultGroup;
if (groupWithOptions.group || showGroupHeaderEvenIfNoSubGroups)
{
headerContents = (<b>{headerGroup.label}</b>);
}
if (isModeToggle)
{
headerContents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "1px"}}
checked={toggleStates[headerGroup.value]}
onChange={(event) => handleGroupToggle(event, headerGroup)}
/>}
label={<span style={{marginTop: "0.25rem", display: "inline-block"}}><b>{headerGroup.label} Fields</b>&nbsp;<span style={{fontWeight: 400}}>({groupToggleCounts[headerGroup.value]})</span></span>} />);
}
if (isModeToggle)
{
headerContents = (
<>
<IconButton
onClick={() => toggleCollapsedGroup(headerGroup.value)}
sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}}
disableRipple={true}
>
<Icon sx={{fontSize: "1.5rem !important", position: "relative", top: "2px"}}>{collapsedGroups[headerGroup.value] ? "expand_less" : "expand_more"}</Icon>
</IconButton>
{headerContents}
</>
);
}
let marginLeft = "unset";
if (isModeToggle)
{
marginLeft = "-1rem";
}
zIndex += 2;
return (
<React.Fragment key={groupWithOptions.group?.value ?? "theGroup"}>
<>
{headerContents && <ListItem sx={{position: "sticky", top: -1, zIndex: zIndex + 1, padding: listItemPadding, ml: marginLeft, display: "flex", alignItems: "flex-start", backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 90%, rgba(255,255,255,0))"}}>{headerContents}</ListItem>}
{
groupWithOptions.options.map((option) =>
{
index++;
const key = `${groupWithOptions?.group?.value}-${option.value}`;
let label: JSX.Element | string = option.label;
const fullOptionValue = groupWithOptions.group && groupWithOptions.group.value != defaultGroup.value ? `${groupWithOptions.group.value}.${option.value}` : option.value;
const isDisabled = disabledStates && disabledStates[fullOptionValue]
if (collapsedGroups[headerGroup.value])
{
return (<React.Fragment key={key} />);
}
let style = {};
if (index == focusedIndex)
{
style = {backgroundColor: "#EFEFEF"};
}
const onClick: ListItemProps = {};
if (isModeSelectOne)
{
onClick.onClick = () =>
{
if(isDisabled)
{
return;
}
if(!keepOpenAfterSelectOne)
{
closeMenu();
}
handleSelectedOption(option, groupWithOptions.group ?? defaultGroup);
};
}
if (optionEndAdornment)
{
label = <Box width="100%" display="inline-flex" justifyContent="space-between">
{label}
<Box onClick={(event) => handleAdornmentClick(option, groupWithOptions.group, event)}>
{optionEndAdornment}
</Box>
</Box>;
}
let contents = <>{label}</>;
let paddingLeft = "0.5rem";
if (isModeToggle)
{
contents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", color: "#606060", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "-3px"}}
checked={toggleStates[fullOptionValue]}
onChange={(event) => handleOptionToggle(event, option, groupWithOptions.group)}
/>}
label={label} />);
paddingLeft = "2.5rem";
}
const listItem = <ListItem
key={key}
id={`field-list-dropdown-${idPrefix}-${index}`}
sx={{color: isDisabled ? "#C0C0C0" : "#757575", p: 1, borderRadius: ".5rem", padding: listItemPadding, pl: paddingLeft, scrollMarginTop: "3rem", zIndex: zIndex, background: "#FFFFFF", ...style}}
onMouseOver={(event) =>
{
handleMouseOver(event, option, groupWithOptions.group, isDisabled)
}}
{...onClick}
>{contents}</ListItem>;
if(tooltips[fullOptionValue])
{
return <Tooltip key={key} title={tooltips[fullOptionValue]} placement="right" enterDelay={500}>{listItem}</Tooltip>
}
else
{
return listItem
}
})
}
</>
</React.Fragment>
);
})
}
{
index == -1 && <ListItem sx={{p: "0.5rem"}}><i>No options found.</i></ListItem>
}
</List>
</Box>
</Box>
</Menu>
</>
);
}

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

@ -0,0 +1,790 @@
/*
* 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
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, Button} from "@mui/material";
import Box from "@mui/material/Box";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import {TooltipProps} from "@mui/material/Tooltip/Tooltip";
import FormData from "form-data";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import {BulkLoadMapping, BulkLoadTableStructure, FileDescription} from "qqq/models/processes/BulkLoadModels";
import Client from "qqq/utils/qqq/Client";
import {SavedBulkLoadProfileUtils} from "qqq/utils/qqq/SavedBulkLoadProfileUtils";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation} from "react-router-dom";
interface Props
{
metaData: QInstance,
tableMetaData: QTableMetaData,
tableStructure: BulkLoadTableStructure,
currentSavedBulkLoadProfileRecord: QRecord,
currentMapping: BulkLoadMapping,
bulkLoadProfileOnChangeCallback?: (record: QRecord | null) => void,
allowSelectingProfile?: boolean,
fileDescription?: FileDescription,
bulkLoadProfileResetToSuggestedMappingCallback?: () => void
}
SavedBulkLoadProfiles.defaultProps = {
allowSelectingProfile: true
};
const qController = Client.getInstance();
/***************************************************************************
** menu-button, text elements, and modal(s) that let you work with saved
** bulk-load profiles.
***************************************************************************/
function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, currentSavedBulkLoadProfileRecord, bulkLoadProfileOnChangeCallback, currentMapping, allowSelectingProfile, fileDescription, bulkLoadProfileResetToSuggestedMappingCallback}: Props): JSX.Element
{
const [yourSavedBulkLoadProfiles, setYourSavedBulkLoadProfiles] = useState([] as QRecord[]);
const [bulkLoadProfilesSharedWithYou, setBulkLoadProfilesSharedWithYou] = useState([] as QRecord[]);
const [savedBulkLoadProfilesMenu, setSavedBulkLoadProfilesMenu] = useState(null);
const [savedBulkLoadProfilesHaveLoaded, setSavedBulkLoadProfilesHaveLoaded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePopupOpen, setSavePopupOpen] = useState(false);
const [isSaveAsAction, setIsSaveAsAction] = useState(false);
const [isRenameAction, setIsRenameAction] = useState(false);
const [isDeleteAction, setIsDeleteAction] = useState(false);
const [savedBulkLoadProfileNameInputValue, setSavedBulkLoadProfileNameInputValue] = useState(null as string);
const [popupAlertContent, setPopupAlertContent] = useState("");
const [savedSuccessMessage, setSavedSuccessMessage] = useState(null as string);
const [savedFailedMessage, setSavedFailedMessage] = useState(null as string);
const anchorRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
const SAVE_OPTION = "Save...";
const DUPLICATE_OPTION = "Duplicate...";
const RENAME_OPTION = "Rename...";
const DELETE_OPTION = "Delete...";
const CLEAR_OPTION = "New Profile";
const RESET_TO_SUGGESTION = "Reset to Suggested Mapping";
const {accentColor, accentColorLight, userId: currentUserId} = useContext(QContext);
const openSavedBulkLoadProfilesMenu = (event: any) => setSavedBulkLoadProfilesMenu(event.currentTarget);
const closeSavedBulkLoadProfilesMenu = () => setSavedBulkLoadProfilesMenu(null);
////////////////////////////////////////////////////////////////////////
// load records on first run (if user is allowed to select a profile) //
////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (allowSelectingProfile)
{
loadSavedBulkLoadProfiles()
.then(() =>
{
setSavedBulkLoadProfilesHaveLoaded(true);
});
}
}, []);
const baseBulkLoadMapping: BulkLoadMapping = currentSavedBulkLoadProfileRecord ? BulkLoadMapping.fromSavedProfileRecord(tableStructure, currentSavedBulkLoadProfileRecord) : new BulkLoadMapping(tableStructure);
const bulkLoadProfileDiffs: any[] = SavedBulkLoadProfileUtils.diffBulkLoadMappings(tableStructure, fileDescription, baseBulkLoadMapping, currentMapping);
let bulkLoadProfileIsModified = false;
if (bulkLoadProfileDiffs.length > 0)
{
bulkLoadProfileIsModified = true;
}
/*******************************************************************************
** make request to load all saved profiles from backend
*******************************************************************************/
async function loadSavedBulkLoadProfiles()
{
if (!tableMetaData)
{
return;
}
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
const savedBulkLoadProfiles = await makeSavedBulkLoadProfileRequest("querySavedBulkLoadProfile", formData);
const yourSavedBulkLoadProfiles: QRecord[] = [];
const bulkLoadProfilesSharedWithYou: QRecord[] = [];
for (let i = 0; i < savedBulkLoadProfiles.length; i++)
{
const record = savedBulkLoadProfiles[i];
if (record.values.get("userId") == currentUserId)
{
yourSavedBulkLoadProfiles.push(record);
}
else
{
bulkLoadProfilesSharedWithYou.push(record);
}
}
setYourSavedBulkLoadProfiles(yourSavedBulkLoadProfiles);
setBulkLoadProfilesSharedWithYou(bulkLoadProfilesSharedWithYou);
}
/*******************************************************************************
** fired when a saved record is clicked from the dropdown
*******************************************************************************/
const handleSavedBulkLoadProfileRecordOnClick = async (record: QRecord) =>
{
setSavePopupOpen(false);
closeSavedBulkLoadProfilesMenu();
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(record);
}
};
/*******************************************************************************
** fired when a save option is selected from the save... button/dropdown combo
*******************************************************************************/
const handleDropdownOptionClick = (optionName: string) =>
{
setSaveOptionsOpen(false);
setPopupAlertContent("");
closeSavedBulkLoadProfilesMenu();
setSavePopupOpen(true);
setIsSaveAsAction(false);
setIsRenameAction(false);
setIsDeleteAction(false);
switch (optionName)
{
case SAVE_OPTION:
if (currentSavedBulkLoadProfileRecord == null)
{
setSavedBulkLoadProfileNameInputValue("");
}
break;
case DUPLICATE_OPTION:
setSavedBulkLoadProfileNameInputValue("");
setIsSaveAsAction(true);
break;
case CLEAR_OPTION:
setSavePopupOpen(false);
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(null);
}
break;
case RESET_TO_SUGGESTION:
setSavePopupOpen(false);
if(bulkLoadProfileResetToSuggestedMappingCallback)
{
bulkLoadProfileResetToSuggestedMappingCallback();
}
break;
case RENAME_OPTION:
if (currentSavedBulkLoadProfileRecord != null)
{
setSavedBulkLoadProfileNameInputValue(currentSavedBulkLoadProfileRecord.values.get("label"));
}
setIsRenameAction(true);
break;
case DELETE_OPTION:
setIsDeleteAction(true);
break;
}
};
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
*******************************************************************************/
async function handleDialogButtonOnClick()
{
try
{
setPopupAlertContent("");
setIsSubmitting(true);
const formData = new FormData();
if (isDeleteAction)
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
await makeSavedBulkLoadProfileRequest("deleteSavedBulkLoadProfile", formData);
setSavePopupOpen(false);
setSaveOptionsOpen(false);
await (async () =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
}
else
{
formData.append("tableName", tableMetaData.name);
/////////////////////////////////////////////////////////////////////////////////////////
// convert the BulkLoadMapping object to a BulkLoadProfile - the thing that gets saved //
/////////////////////////////////////////////////////////////////////////////////////////
const bulkLoadProfile = currentMapping.toProfile();
const mappingJson = JSON.stringify(bulkLoadProfile.profile);
formData.append("mappingJson", mappingJson);
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
{
formData.append("label", savedBulkLoadProfileNameInputValue);
if (currentSavedBulkLoadProfileRecord != null && isRenameAction)
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
}
}
else
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
formData.append("label", currentSavedBulkLoadProfileRecord?.values.get("label"));
}
const recordList = await makeSavedBulkLoadProfileRequest("storeSavedBulkLoadProfile", formData);
await (async () =>
{
if (recordList && recordList.length > 0)
{
setSavedBulkLoadProfilesHaveLoaded(false);
setSavedSuccessMessage("Profile Saved.");
setTimeout(() => setSavedSuccessMessage(null), 2500);
if (allowSelectingProfile)
{
loadSavedBulkLoadProfiles();
handleSavedBulkLoadProfileRecordOnClick(recordList[0]);
}
else
{
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(recordList[0]);
}
}
}
})();
}
setSavePopupOpen(false);
setSaveOptionsOpen(false);
}
catch (e: any)
{
let message = JSON.stringify(e);
if (typeof e == "string")
{
message = e;
}
else if (typeof e == "object" && e.message)
{
message = e.message;
}
setPopupAlertContent(message);
console.log(`Setting error: ${message}`);
}
finally
{
setIsSubmitting(false);
}
}
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
const handleSaveDialogInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
{
setSavedBulkLoadProfileNameInputValue(event.target.value);
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
const handleSavePopupClose = () =>
{
setSavePopupOpen(false);
};
/*******************************************************************************
** make a request to the backend for various savedBulkLoadProfile processes
*******************************************************************************/
async function makeSavedBulkLoadProfileRequest(processName: string, formData: FormData): Promise<QRecord[]>
{
/////////////////////////
// fetch saved records //
/////////////////////////
let savedBulkLoadProfiles = [] as QRecord[];
try
{
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000);
const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders());
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
throw (jobError.error);
}
else
{
const result = processResult as QJobComplete;
if (result.values.savedBulkLoadProfileList)
{
for (let i = 0; i < result.values.savedBulkLoadProfileList.length; i++)
{
const qRecord = new QRecord(result.values.savedBulkLoadProfileList[i]);
savedBulkLoadProfiles.push(qRecord);
}
}
}
}
catch (e)
{
throw (e);
}
return (savedBulkLoadProfiles);
}
const hasStorePermission = metaData?.processes.has("storeSavedBulkLoadProfile");
const hasDeletePermission = metaData?.processes.has("deleteSavedBulkLoadProfile");
const hasQueryPermission = metaData?.processes.has("querySavedBulkLoadProfile");
const tooltipMaxWidth = (maxWidth: string) =>
{
return ({
slotProps: {
tooltip: {
sx: {
maxWidth: maxWidth
}
}
}
});
};
const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps;
let disabledBecauseNotOwner = false;
let notOwnerTooltipText = null;
if (currentSavedBulkLoadProfileRecord && currentSavedBulkLoadProfileRecord.values.get("userId") != currentUserId)
{
disabledBecauseNotOwner = true;
notOwnerTooltipText = "You may not save changes to this bulk load profile, because you are not its owner.";
}
const menuWidth = "300px";
const renderSavedBulkLoadProfilesMenu = tableMetaData && (
<Menu
anchorEl={savedBulkLoadProfilesMenu}
anchorOrigin={{vertical: "bottom", horizontal: "left",}}
transformOrigin={{vertical: "top", horizontal: "left",}}
open={Boolean(savedBulkLoadProfilesMenu)}
onClose={closeSavedBulkLoadProfilesMenu}
keepMounted
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: menuWidth}}}
>
{
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial"}}><b>Bulk Load Profile Actions</b></MenuItem>
}
{
!allowSelectingProfile &&
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial", whiteSpace: "wrap", display: "block"}}>
{
currentSavedBulkLoadProfileRecord ?
<span>You are using the bulk load profile:<br /><b style={{paddingLeft: "1rem"}}>{currentSavedBulkLoadProfileRecord.values.get("label")}</b><br /><br />You can manage this profile on this screen.</span>
: <span>You are not using a saved bulk load profile.<br /><br />You can save your profile on this screen.</span>
}
</MenuItem>
}
{
!allowSelectingProfile && <Divider />
}
{
hasStorePermission &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? <>Save your current mapping, 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>
{currentSavedBulkLoadProfileRecord ? "Save..." : "Save As..."}
</MenuItem>
</span>
</Tooltip>
}
{
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Change the name for this saved bulk load profile."}>
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
Rename...
</MenuItem>
</span>
</Tooltip>
}
{
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title="Save a new copy this bulk load profile, with a different name, separate from the original.">
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
Save As...
</MenuItem>
</span>
</Tooltip>
}
{
hasDeletePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Delete this saved bulk load profile."}>
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
Delete...
</MenuItem>
</span>
</Tooltip>
}
{
allowSelectingProfile &&
<Tooltip {...menuTooltipAttribs} title="Create a new blank bulk load profile for this table, removing all mappings.">
<span>
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>monitor</Icon></ListItemIcon>
New Bulk Load Profile
</MenuItem>
</span>
</Tooltip>
}
{
allowSelectingProfile &&
<Box>
{
<Divider />
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Bulk Load Profiles</b></MenuItem>
{
yourSavedBulkLoadProfiles && yourSavedBulkLoadProfiles.length > 0 ? (
yourSavedBulkLoadProfiles.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedBulkLoadProfileRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any saved bulk load profiles for this table.</i>
</MenuItem>
)
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Bulk Load Profiles Shared with you</b></MenuItem>
{
bulkLoadProfilesSharedWithYou && bulkLoadProfilesSharedWithYou.length > 0 ? (
bulkLoadProfilesSharedWithYou.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedBulkLoadProfileRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any bulk load profiles shared with you for this table.</i>
</MenuItem>
)
}
</Box>
}
</Menu>
);
let buttonText = "Saved Bulk Load Profiles";
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
if (currentSavedBulkLoadProfileRecord)
{
if (bulkLoadProfileIsModified)
{
buttonBackground = accentColorLight;
buttonBorder = buttonBackground;
buttonColor = accentColor;
}
else
{
buttonBackground = accentColor;
buttonBorder = buttonBackground;
buttonColor = "#FFFFFF";
}
}
const buttonStyles = {
border: `1px solid ${buttonBorder}`,
backgroundColor: buttonBackground,
color: buttonColor,
"&:focus:not(:hover)": {
color: buttonColor,
backgroundColor: buttonBackground,
},
"&:hover": {
color: buttonColor,
backgroundColor: buttonBackground,
}
};
/*******************************************************************************
**
*******************************************************************************/
function isSaveButtonDisabled(): boolean
{
if (isSubmitting)
{
return (true);
}
const haveInputText = (savedBulkLoadProfileNameInputValue != null && savedBulkLoadProfileNameInputValue.trim() != "");
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
{
if (!haveInputText)
{
return (true);
}
}
return (false);
}
const linkButtonStyle = {
minWidth: "unset",
textTransform: "none",
fontSize: "0.875rem",
fontWeight: "500",
padding: "0.5rem"
};
return (
hasQueryPermission && tableMetaData ? (
<>
<Box order="1" mr={"0.5rem"}>
<Button
onClick={openSavedBulkLoadProfilesMenu}
sx={{
borderRadius: "0.75rem",
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
...buttonStyles
}}
>
<Icon sx={{mr: "0.5rem"}}>save</Icon>
{buttonText}
<Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon>
</Button>
{renderSavedBulkLoadProfilesMenu}
</Box>
<Box order="3" display="flex" justifyContent="center" flexDirection="column">
<Box pl={2} pr={2} fontSize="0.875rem" sx={{display: "flex", alignItems: "center"}}>
{
savedSuccessMessage && <Box color={colors.success.main}>{savedSuccessMessage}</Box>
}
{
savedFailedMessage && <Box color={colors.error.main}>{savedFailedMessage}</Box>
}
{
!currentSavedBulkLoadProfileRecord /*&& bulkLoadProfileIsModified*/ && <>
{
<>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Mapping</b>
<ul style={{padding: "0.5rem 1rem"}}>
<li>You are not using a saved bulk load profile.</li>
{
/*bulkLoadProfileDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)*/
}
</ul>
</>}>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save Bulk Load Profile As&hellip;</Button>
</Tooltip>
{/* vertical rule */}
{allowSelectingProfile && <Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />}
</>
}
{/* for the no-profile use-case, don't give a reset-link on screens other than the first (file mapping) one - which is tied to the allowSelectingProfile attribute */}
{allowSelectingProfile && <>
<Box pl="0.5rem">Reset to:</Box>
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Empty Mapping</Button>
<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={() => handleDropdownOptionClick(RESET_TO_SUGGESTION)}>Suggested Mapping</Button>
</>}
</>
}
{
currentSavedBulkLoadProfileRecord && bulkLoadProfileIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
bulkLoadProfileDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul>
{
notOwnerTooltipText && <i>{notOwnerTooltipText}</i>
}
</>}>
<Box display="inline" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>{bulkLoadProfileDiffs.length} Unsaved Change{bulkLoadProfileDiffs.length == 1 ? "" : "s"}</Box>
</Tooltip>
{disabledBecauseNotOwner ? <>&nbsp;&nbsp;</> : <Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save&hellip;</Button>}
{/* vertical rule */}
{/* also, don't give a reset-link on screens other than the first (file mapping) one - which is tied to the allowSelectingProfile attribute */}
{/* partly because it isn't correctly resetting the values, but also because, it's a litle unclear that what, it would reset changes from other screens too?? */}
{
allowSelectingProfile && <>
<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={() => handleSavedBulkLoadProfileRecordOnClick(currentSavedBulkLoadProfileRecord)}>Reset All Changes</Button>
</>
}
</>
}
</Box>
</Box>
{
<Dialog
open={savePopupOpen}
onClose={handleSavePopupClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
onKeyPress={(e) =>
{
////////////////////////////////////////////////////
// make user actually hit delete button //
// but for other modes, let Enter submit the form //
////////////////////////////////////////////////////
if (e.key == "Enter" && !isDeleteAction)
{
handleDialogButtonOnClick();
}
}}
>
{
currentSavedBulkLoadProfileRecord ? (
isDeleteAction ? (
<DialogTitle id="alert-dialog-title">Delete Bulk Load Profile</DialogTitle>
) : (
isSaveAsAction ? (
<DialogTitle id="alert-dialog-title">Save Bulk Load Profile As</DialogTitle>
) : (
isRenameAction ? (
<DialogTitle id="alert-dialog-title">Rename Bulk Load Profile</DialogTitle>
) : (
<DialogTitle id="alert-dialog-title">Update Existing Bulk Load Profile</DialogTitle>
)
)
)
) : (
<DialogTitle id="alert-dialog-title">Save New Bulk Load Profile</DialogTitle>
)
}
<DialogContent sx={{width: "500px"}}>
{popupAlertContent ? (
<Box mb={1}>
<Alert severity="error" onClose={() => setPopupAlertContent("")}>{popupAlertContent}</Alert>
</Box>
) : ("")}
{
(!currentSavedBulkLoadProfileRecord || isSaveAsAction || isRenameAction) && !isDeleteAction ? (
<Box>
{
isSaveAsAction ? (
<Box mb={3}>Enter a name for this new saved bulk load profile.</Box>
) : (
<Box mb={3}>Enter a new name for this saved bulk load profile.</Box>
)
}
<TextField
autoFocus
name="custom-delimiter-value"
placeholder="Bulk Load Profile Name"
inputProps={{width: "100%", maxLength: 100}}
value={savedBulkLoadProfileNameInputValue}
sx={{width: "100%"}}
onChange={handleSaveDialogInputChange}
onFocus={event =>
{
event.target.select();
}}
/>
</Box>
) : (
isDeleteAction ? (
<Box>Are you sure you want to delete the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
) : (
<Box>Are you sure you want to update the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
)
)
}
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={handleSavePopupClose} disabled={false} />
{
isDeleteAction ?
<QDeleteButton onClickHandler={handleDialogButtonOnClick} disabled={isSubmitting} />
:
<QSaveButton label="Save" onClickHandler={handleDialogButtonOnClick} disabled={isSaveButtonDisabled()} />
}
</DialogActions>
</Dialog>
}
</>
) : null
);
}
export default SavedBulkLoadProfiles;

View File

@ -25,7 +25,7 @@ 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, Button, Link} from "@mui/material";
import {Alert, Button} from "@mui/material";
import Box from "@mui/material/Box";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
@ -40,14 +40,15 @@ import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import {TooltipProps} from "@mui/material/Tooltip/Tooltip";
import FormData from "form-data";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import RecordQueryView from "qqq/models/query/RecordQueryView";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
interface Props
{
@ -59,14 +60,17 @@ interface Props
view?: RecordQueryView;
viewAsJson?: string;
viewOnChangeCallback?: (selectedSavedViewId: number) => void;
loadingSavedView: boolean
loadingSavedView: boolean;
queryScreenUsage: QueryScreenUsage;
}
function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element
function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView, queryScreenUsage}: Props): JSX.Element
{
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);
@ -87,9 +91,17 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
const RENAME_OPTION = "Rename...";
const DELETE_OPTION = "Delete...";
const CLEAR_OPTION = "New View";
const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION];
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 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);
const closeSavedViewsMenu = () => setSavedViewsMenu(null);
@ -104,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;
}
@ -120,7 +132,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
*******************************************************************************/
async function loadSavedViews()
{
if (! tableMetaData)
if (!tableMetaData)
{
return;
}
@ -130,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);
}
/*******************************************************************************
@ -142,11 +170,13 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
setSaveFilterPopupOpen(false);
closeSavedViewsMenu();
viewOnChangeCallback(record.values.get("id"));
navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`);
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
*******************************************************************************/
@ -158,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("");
}
@ -173,24 +203,46 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
setIsSaveFilterAs(true);
break;
case CLEAR_OPTION:
setSaveFilterPopupOpen(false)
setSaveFilterPopupOpen(false);
viewOnChangeCallback(null);
navigate(metaData.getTablePathByName(tableMetaData.name));
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;
}
}
};
/*******************************************************************************
**
*******************************************************************************/
function createNewReport()
{
const defaultValues: { [key: string]: any } = {};
defaultValues.tableName = tableMetaData.name;
let filterForBackend = JSON.parse(JSON.stringify(view.queryFilter));
filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, filterForBackend);
defaultValues.queryFilterJson = JSON.stringify(filterForBackend);
defaultValues.columnsJson = JSON.stringify(view.queryColumns);
navigate(`${metaData.getTablePathByName("savedReport")}/create#defaultValues=${encodeURIComponent(JSON.stringify(defaultValues))}`);
}
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
@ -211,7 +263,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
setSaveFilterPopupOpen(false);
setSaveOptionsOpen(false);
await(async() =>
await (async () =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
@ -227,12 +279,18 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
/////////////////////////////////////////////////////////////////////////////////////////////////
const viewObject = JSON.parse(JSON.stringify(view));
viewObject.queryFilter = JSON.parse(JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(viewObject.queryFilter)));
////////////////////////////////////////////////////////////////////////////
// strip away incomplete filters too, just for cleaner saved view filters //
////////////////////////////////////////////////////////////////////////////
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"));
}
@ -243,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)
{
@ -260,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;
}
@ -279,7 +337,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
}
/*******************************************************************************
** hides/shows the save options
*******************************************************************************/
@ -289,7 +346,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
};
/*******************************************************************************
** closes save options menu (on clickaway)
*******************************************************************************/
@ -304,7 +360,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
};
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
@ -314,7 +369,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
@ -324,7 +378,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
};
/*******************************************************************************
** make a request to the backend for various savedView processes
*******************************************************************************/
@ -333,7 +386,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
/////////////////////////
// fetch saved filters //
/////////////////////////
let savedViews = [] as QRecord[]
let savedViews = [] as QRecord[];
try
{
//////////////////////////////////////////////////////////////////
@ -344,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++)
{
@ -361,7 +414,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
}
catch (e)
{
throw(e);
throw (e);
}
return (savedViews);
@ -370,20 +423,31 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
const hasStorePermission = metaData?.processes.has("storeSavedView");
const hasDeletePermission = metaData?.processes.has("deleteSavedView");
const hasQueryPermission = metaData?.processes.has("querySavedView");
const hasSavedReportsPermission = metaData?.tables.has("savedReport");
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}
@ -392,68 +456,109 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
open={Boolean(savedViewsMenu)}
onClose={closeSavedViewsMenu}
keepMounted
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minHeight: "200px"}}}
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: "300px"}}}
>
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>View Actions</b></MenuItem>
{
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>
isQueryScreen &&
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>View Actions</b></MenuItem>
}
{
isQueryScreen && hasStorePermission &&
<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>
}
{
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>
isQueryScreen && hasStorePermission && currentSavedView != null &&
<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>
}
{
hasStorePermission && currentSavedView != null &&
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>
}
{
hasStorePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Delete this saved view.">
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
Delete...
</MenuItem>
isQueryScreen && hasDeletePermission && currentSavedView != null &&
<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>
}
<Divider/>
{
isQueryScreen && hasSavedReportsPermission &&
<Tooltip {...menuTooltipAttribs} title="Create a new Saved Report using your current view of this table as a starting point.">
<span>
<MenuItem onClick={() => handleDropdownOptionClick(NEW_REPORT_OPTION)}>
<ListItemIcon><Icon>article</Icon></ListItemIcon>
Create Report from Current View
</MenuItem>
</span>
</Tooltip>
}
{
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>
) : (
<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>
);
@ -462,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)
{
@ -490,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);
}
@ -535,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>
@ -548,6 +653,29 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
<Box pl={2} pr={2} sx={{display: "flex", alignItems: "center"}}>
{
!currentSavedView && viewIsModified && <>
{
isQueryScreen && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul>
</>}>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save View As&hellip;</Button>
</Tooltip>
{/* 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={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset All Changes</Button>
</>
}
{
isQueryScreen && currentSavedView && viewIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
@ -555,36 +683,50 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul>
{
notOwnerTooltipText && <i>{notOwnerTooltipText}</i>
}
</>}>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save View As&hellip;</Button>
</Tooltip>
{/* 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={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset All Changes</Button>
</>
}
{
currentSavedView && viewIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul></>}>
<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>
</>
}
{
!isQueryScreen && currentSavedView &&
<Box>
<Box display="inline-block" fontSize="0.875rem" fontWeight="500" sx={{position: "relative", top: "-1px"}}>
{currentSavedView.values.get("label")}
</Box>
{
viewIsModified &&
<>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</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>
</>
}
{/* 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>
</Box>
}
</Box>
</Box>
{
@ -612,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>
)
}
@ -631,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>
)
}
@ -654,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>
)
)
@ -669,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

@ -0,0 +1,329 @@
/*
* 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 {Checkbox, FormControlLabel, Radio, Tooltip} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import RadioGroup from "@mui/material/RadioGroup";
import TextField from "@mui/material/TextField";
import {useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import {BulkLoadField, FileDescription} from "qqq/models/processes/BulkLoadModels";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useState} from "react";
interface BulkLoadMappingFieldProps
{
bulkLoadField: BulkLoadField,
isRequired: boolean,
removeFieldCallback?: () => void,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
}
const xIconButtonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.5rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
width: "30px",
minWidth: "30px",
height: "2rem",
minHeight: "2rem",
paddingLeft: 0,
paddingRight: 0,
marginRight: "0.5rem",
marginTop: "0.5rem",
color: colors.error.main,
"&:hover": {color: colors.error.main},
"&:focus": {color: colors.error.main},
"&:focus:not(:hover)": {color: colors.error.main},
};
const qController = Client.getInstance();
/***************************************************************************
** row for a single field on the bulk load mapping screen.
***************************************************************************/
export default function BulkLoadFileMappingField({bulkLoadField, isRequired, removeFieldCallback, fileDescription, forceParentUpdate}: BulkLoadMappingFieldProps): JSX.Element
{
const columnNames = fileDescription.getColumnNames();
const [valueType, setValueType] = useState(bulkLoadField.valueType);
const [selectedColumn, setSelectedColumn] = useState({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex});
const [selectedColumnInputValue, setSelectedColumnInputValue] = useState(columnNames[bulkLoadField.columnIndex]);
const [doingInitialLoadOfPossibleValue, setDoingInitialLoadOfPossibleValue] = useState(false);
const [everDidInitialLoadOfPossibleValue, setEverDidInitialLoadOfPossibleValue] = useState(false);
const [possibleValueInitialDisplayValue, setPossibleValueInitialDisplayValue] = useState(null as string);
const fieldMetaData = new QFieldMetaData(bulkLoadField.field);
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
const dynamicFieldInObject: any = {};
dynamicFieldInObject[fieldMetaData["name"]] = dynamicField;
DynamicFormUtils.addPossibleValueProps(dynamicFieldInObject, [fieldMetaData], bulkLoadField.tableStructure.tableName, null, null);
/////////////////////////////////////////////////////////////////////////////////////
// deal with dynamically loading the initial default value for a possible value... //
/////////////////////////////////////////////////////////////////////////////////////
let actuallyDoingInitialLoadOfPossibleValue = doingInitialLoadOfPossibleValue;
if(dynamicField.possibleValueProps && bulkLoadField.defaultValue && !doingInitialLoadOfPossibleValue && !everDidInitialLoadOfPossibleValue)
{
actuallyDoingInitialLoadOfPossibleValue = true;
setDoingInitialLoadOfPossibleValue(true);
setEverDidInitialLoadOfPossibleValue(true);
(async () =>
{
try
{
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, "filter");
if (possibleValues && possibleValues.length > 0)
{
setPossibleValueInitialDisplayValue(possibleValues[0].label);
}
else
{
setPossibleValueInitialDisplayValue(null);
}
}
catch(e)
{
console.log(`Error loading possible value: ${e}`)
}
actuallyDoingInitialLoadOfPossibleValue = false;
setDoingInitialLoadOfPossibleValue(false);
})();
}
if(dynamicField.possibleValueProps && possibleValueInitialDisplayValue)
{
dynamicField.possibleValueProps.initialDisplayValue = possibleValueInitialDisplayValue;
}
//////////////////////////////////////////////////////
// build array of options for the columns drop down //
// don't allow duplicates //
//////////////////////////////////////////////////////
const columnOptions: { value: number, label: string }[] = [];
const usedLabels: {[label: string]: boolean} = {};
for (let i = 0; i < columnNames.length; i++)
{
const label = columnNames[i];
if(!usedLabels[label])
{
columnOptions.push({label: label, value: i});
usedLabels[label] = true;
}
}
//////////////////////////////////////////////////////////////////////
// try to pick up changes in the hasHeaderRow toggle from way above //
//////////////////////////////////////////////////////////////////////
if(bulkLoadField.columnIndex != null && bulkLoadField.columnIndex != undefined && selectedColumn.label && columnNames[bulkLoadField.columnIndex] != selectedColumn.label)
{
setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex})
setSelectedColumnInputValue(columnNames[bulkLoadField.columnIndex]);
}
const mainFontSize = "0.875rem";
const smallerFontSize = "0.75rem";
/////////////////////////////////////////////////////////////////////////////////////////////
// some field types get their value from formik. //
// so for a pre-populated value, do an on-load useEffect, that'll set the value in formik. //
/////////////////////////////////////////////////////////////////////////////////////////////
const {setFieldValue} = useFormikContext();
useEffect(() =>
{
if (valueType == "defaultValue")
{
setFieldValue(`${bulkLoadField.field.name}.defaultValue`, bulkLoadField.defaultValue);
}
}, []);
/***************************************************************************
**
***************************************************************************/
function columnChanged(event: any, newValue: any, reason: string)
{
setSelectedColumn(newValue);
setSelectedColumnInputValue(newValue == null ? "" : newValue.label);
bulkLoadField.columnIndex = newValue == null ? null : newValue.value;
if (fileDescription.hasHeaderRow)
{
bulkLoadField.headerName = newValue == null ? null : newValue.label;
}
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function defaultValueChanged(newValue: any)
{
setFieldValue(`${bulkLoadField.field.name}.defaultValue`, newValue);
bulkLoadField.defaultValue = newValue;
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function valueTypeChanged(isColumn: boolean)
{
const newValueType = isColumn ? "column" : "defaultValue";
bulkLoadField.valueType = newValueType;
setValueType(newValueType);
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function mapValuesChanged(value: boolean)
{
bulkLoadField.doValueMapping = value;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function changeSelectedColumnInputValue(e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>)
{
setSelectedColumnInputValue(e.target.value);
}
return (<Box py="0.5rem" sx={{borderBottom: "1px solid lightgray", width: "100%", overflow: "auto"}} id={`blfmf-${bulkLoadField.field.name}`}>
<Box display="grid" gridTemplateColumns="200px 400px auto" fontSize="1rem" gap="0.5rem" sx={
{
"& .MuiFormControlLabel-label": {ml: "0 !important", fontWeight: "normal !important", fontSize: mainFontSize}
}}>
<Box display="flex" alignItems="flex-start">
{
(!isRequired) && <Tooltip placement="bottom" title="Remove this field from your mapping.">
<Button sx={xIconButtonSX} onClick={() => removeFieldCallback()}><Icon>clear</Icon></Button>
</Tooltip>
}
<Box pt="0.625rem">
{bulkLoadField.getQualifiedLabel()}
</Box>
</Box>
<RadioGroup name="valueType" value={valueType}>
<Box>
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="column" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(checked)} />} label={"File column"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
valueType == "column" && <Box width="100%">
<Autocomplete
id={bulkLoadField.field.name}
renderInput={(params) => (<TextField {...params} label={""} value={selectedColumnInputValue} onChange={e => changeSelectedColumnInputValue(e)} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
fullWidth
options={columnOptions}
multiple={false}
defaultValue={selectedColumn}
value={selectedColumn}
inputValue={selectedColumnInputValue}
onChange={columnChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.value == value.value}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
</Box>
}
</Box>
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="defaultValue" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(!checked)} />} label={"Default value"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
valueType == "defaultValue" && actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">Loading...</Box>
}
{
valueType == "defaultValue" && !actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">
<QDynamicFormField
name={`${bulkLoadField.field.name}.defaultValue`}
displayFormat={""}
label={""}
formFieldObject={dynamicField}
type={dynamicField.type}
value={bulkLoadField.defaultValue}
onChangeCallback={defaultValueChanged}
/>
</Box>
}
</Box>
</Box>
{
bulkLoadField.warning &&
<Box fontSize={smallerFontSize} color={colors.warning.main} ml="145px" className="bulkLoadFieldError">
{bulkLoadField.warning}
</Box>
}
{
bulkLoadField.error &&
<Box fontSize={smallerFontSize} color={colors.error.main} ml="145px" className="bulkLoadFieldError">
{bulkLoadField.error}
</Box>
}
</RadioGroup>
<Box ml="1rem">
{
valueType == "column" && <>
<Box>
<FormControlLabel value="mapValues" control={<Checkbox size="small" defaultChecked={bulkLoadField.doValueMapping} onChange={(event, checked) => mapValuesChanged(checked)} />} label={"Map values"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
</Box>
<Box fontSize={mainFontSize} mt="0.5rem">
Preview Values: <span style={{color: "gray"}}>{(fileDescription.getPreviewValues(selectedColumn?.value) ?? [""]).join(", ")}</span>
</Box>
</>
}
</Box>
</Box>
</Box>);
}

View File

@ -0,0 +1,308 @@
/*
* 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 colors from "qqq/assets/theme/base/colors";
import QHierarchyAutoComplete, {Group, Option} from "qqq/components/misc/QHierarchyAutoComplete";
import BulkLoadFileMappingField from "qqq/components/processes/BulkLoadFileMappingField";
import {BulkLoadField, BulkLoadMapping, FileDescription} from "qqq/models/processes/BulkLoadModels";
import React, {useEffect, useReducer, useState} from "react";
interface BulkLoadMappingFieldsProps
{
bulkLoadMapping: BulkLoadMapping,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
}
const ADD_SINGLE_FIELD_TOOLTIP = "Click to add this field to your mapping.";
const ADD_MANY_FIELD_TOOLTIP = "Click to add this field to your mapping as many times as you need.";
const ALREADY_ADDED_FIELD_TOOLTIP = "This field has already been added to your mapping.";
/***************************************************************************
** The section of the bulk load mapping screen with all the fields.
***************************************************************************/
export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate}: BulkLoadMappingFieldsProps): JSX.Element
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [forceHierarchyAutoCompleteRerender, setForceHierarchyAutoCompleteRerender] = useState(0);
////////////////////////////////////////////
// build list of fields that can be added //
////////////////////////////////////////////
const [addFieldsGroup, setAddFieldsGroup] = useState({
label: bulkLoadMapping.tablesByPath[""]?.label,
value: "mainTable",
options: [],
subGroups: []
} as Group);
// const [addFieldsToggleStates, setAddFieldsToggleStates] = useState({} as { [name: string]: boolean });
const [addFieldsDisableStates, setAddFieldsDisableStates] = useState({} as { [name: string]: boolean });
const [tooltips, setTooltips] = useState({} as { [name: string]: string });
useEffect(() =>
{
const newDisableStates: { [name: string]: boolean } = {};
const newTooltips: { [name: string]: string } = {};
/////////////////////////////////////////////////////////////////////////////////////////////
// do the unused fields array first, as we've got some use-case where i think a field from //
// suggested mappings (or profiles?) are in this list, even though they shouldn't be? //
/////////////////////////////////////////////////////////////////////////////////////////////
for (let field of bulkLoadMapping.unusedFields)
{
const qualifiedName = field.getQualifiedName();
newTooltips[qualifiedName] = field.isMany() ? ADD_MANY_FIELD_TOOLTIP : ADD_SINGLE_FIELD_TOOLTIP;
}
//////////////////////////////////////////////////
// then do all the required & additional fields //
//////////////////////////////////////////////////
for (let field of [...(bulkLoadMapping.requiredFields ?? []), ...(bulkLoadMapping.additionalFields ?? [])])
{
const qualifiedName = field.getQualifiedName();
if (bulkLoadMapping.layout == "WIDE" && field.isMany())
{
newDisableStates[qualifiedName] = false;
newTooltips[qualifiedName] = ADD_MANY_FIELD_TOOLTIP;
}
else
{
newDisableStates[qualifiedName] = true;
newTooltips[qualifiedName] = ALREADY_ADDED_FIELD_TOOLTIP;
}
}
setAddFieldsDisableStates(newDisableStates);
setTooltips(newTooltips);
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}, [bulkLoadMapping, bulkLoadMapping.layout]);
///////////////////////////////////////////////
// initialize this structure on first render //
///////////////////////////////////////////////
if (addFieldsGroup.options.length == 0)
{
for (let qualifiedFieldName in bulkLoadMapping.fieldsByTablePrefix[""])
{
const bulkLoadField = bulkLoadMapping.fieldsByTablePrefix[""][qualifiedFieldName];
const field = bulkLoadField.field;
addFieldsGroup.options.push({label: field.label, value: field.name, bulkLoadField: bulkLoadField});
}
for (let prefix in bulkLoadMapping.fieldsByTablePrefix)
{
if (prefix == "")
{
continue;
}
const associationOptions: Option[] = [];
const tableStructure = bulkLoadMapping.tablesByPath[prefix];
addFieldsGroup.subGroups.push({label: tableStructure.label, value: tableStructure.associationPath, options: associationOptions});
for (let qualifiedFieldName in bulkLoadMapping.fieldsByTablePrefix[prefix])
{
const bulkLoadField = bulkLoadMapping.fieldsByTablePrefix[prefix][qualifiedFieldName];
const field = bulkLoadField.field;
associationOptions.push({label: field.label, value: field.name, bulkLoadField: bulkLoadField});
}
}
}
/***************************************************************************
**
***************************************************************************/
function removeField(bulkLoadField: BulkLoadField)
{
addFieldsDisableStates[bulkLoadField.getQualifiedName()] = false;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
if (bulkLoadMapping.layout == "WIDE" && bulkLoadField.isMany())
{
//////////////////////////////////////////////////////////////////////////
// ok, you can add more - so don't disable and don't change the tooltip //
//////////////////////////////////////////////////////////////////////////
}
else
{
tooltips[bulkLoadField.getQualifiedName()] = ADD_SINGLE_FIELD_TOOLTIP;
}
bulkLoadMapping.removeField(bulkLoadField);
forceUpdate();
forceParentUpdate();
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}
/***************************************************************************
**
***************************************************************************/
function handleToggleField(option: Option, group: Group, newValue: boolean)
{
const fieldKey = group.value == "mainTable" ? option.value : group.value + "." + option.value;
// addFieldsToggleStates[fieldKey] = newValue;
// setAddFieldsToggleStates(Object.assign({}, addFieldsToggleStates));
addFieldsDisableStates[fieldKey] = newValue;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
const bulkLoadField = bulkLoadMapping.fields[fieldKey];
if (bulkLoadField)
{
if (newValue)
{
bulkLoadMapping.addField(bulkLoadField);
}
else
{
bulkLoadMapping.removeField(bulkLoadField);
}
forceUpdate();
forceParentUpdate();
}
}
/***************************************************************************
**
***************************************************************************/
function handleAddField(option: Option, group: Group)
{
const fieldKey = group.value == "mainTable" ? option.value : group.value + "." + option.value;
const bulkLoadField = bulkLoadMapping.fields[fieldKey];
if (bulkLoadField)
{
bulkLoadMapping.addField(bulkLoadField);
// addFieldsDisableStates[fieldKey] = true;
// setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
if (bulkLoadMapping.layout == "WIDE" && bulkLoadField.isMany())
{
//////////////////////////////////////////////////////////////////////////
// ok, you can add more - so don't disable and don't change the tooltip //
//////////////////////////////////////////////////////////////////////////
}
else
{
addFieldsDisableStates[fieldKey] = true;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
tooltips[fieldKey] = ALREADY_ADDED_FIELD_TOOLTIP;
}
forceUpdate();
forceParentUpdate();
document.getElementById("addFieldsButton")?.scrollIntoView();
}
}
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
const addFieldMenuButtonStyles = {
borderRadius: "0.75rem",
border: `1px solid ${buttonBorder}`,
color: buttonColor,
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
backgroundColor: buttonBackground,
"&:focus:not(:hover)": {
color: buttonColor,
backgroundColor: buttonBackground,
},
"&:hover": {
color: buttonColor,
backgroundColor: buttonBackground,
}
};
return (
<>
<h5>Required Fields</h5>
<Box pl={"1rem"}>
{
bulkLoadMapping.requiredFields.length == 0 &&
<i style={{fontSize: "0.875rem"}}>There are no required fields in this table.</i>
}
{bulkLoadMapping.requiredFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
fileDescription={fileDescription}
key={bulkLoadField.getKey()}
bulkLoadField={bulkLoadField}
isRequired={true}
forceParentUpdate={forceParentUpdate}
/>
))}
</Box>
<Box mt="1rem">
<h5>Additional Fields</h5>
<Box pl={"1rem"}>
{bulkLoadMapping.additionalFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
fileDescription={fileDescription}
key={bulkLoadField.getKey()}
bulkLoadField={bulkLoadField}
isRequired={false}
removeFieldCallback={() => removeField(bulkLoadField)}
forceParentUpdate={forceParentUpdate}
/>
))}
<Box display="flex" pt="1rem" pl="12.5rem">
<QHierarchyAutoComplete
idPrefix="addFieldAutocomplete"
defaultGroup={addFieldsGroup}
menuDirection="up"
buttonProps={{id: "addFieldsButton", sx: addFieldMenuButtonStyles}}
buttonChildren={<><Icon sx={{mr: "0.5rem"}}>add</Icon> Add Fields <Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon></>}
isModeSelectOne
keepOpenAfterSelectOne
handleSelectedOption={handleAddField}
forceRerender={forceHierarchyAutoCompleteRerender}
disabledStates={addFieldsDisableStates}
tooltips={tooltips}
/>
</Box>
</Box>
</Box>
</>
);
}

View File

@ -0,0 +1,566 @@
/*
* 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 {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QFrontendStepMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendStepMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Badge, Icon} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import {useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import BulkLoadFileMappingFields from "qqq/components/processes/BulkLoadFileMappingFields";
import {BulkLoadField, BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
import ProcessViewForm from "./ProcessViewForm";
interface BulkLoadMappingFormProps
{
processValues: any,
tableMetaData: QTableMetaData,
metaData: QInstance,
setActiveStepLabel: (label: string) => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
}
/***************************************************************************
** process component - screen where user does a bulk-load file mapping.
***************************************************************************/
const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaData, setActiveStepLabel, frontendStep, processMetaData}: BulkLoadMappingFormProps, ref) =>
{
const {setFieldValue} = useFormikContext();
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(currentSavedBulkLoadProfile));
const [fieldErrors, setFieldErrors] = useState({} as { [fieldName: string]: string });
const [noMappedFieldsError, setNoMappedFieldsError] = useState(null as string);
const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile);
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile));
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(bulkLoadMapping));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(bulkLoadMapping.hasHeaderRow);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/////////////////////////////////////////////////////////////////////////////////////////////////
// ok - so - ... Autocomplete, at least as we're using it for the layout field - doesn't like //
// to change its initial value. So, we want to work hard to force the Header sub-component to //
// re-render upon external changes to the layout (e.g., new profile being selected). //
// use this state-counter to make that happen (and let's please never speak of it again). //
/////////////////////////////////////////////////////////////////////////////////////////////////
const [rerenderHeader, setRerenderHeader] = useState(1);
////////////////////////////////////////////////////////
// ref-based callback for integration with ProcessRun //
////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
///////////////////////////////////////////////////////////////////////////////////////////////
// convert the BulkLoadMapping to a BulkLoadProfile - the thing that the backend understands //
///////////////////////////////////////////////////////////////////////////////////////////////
const {haveErrors: haveProfileErrors, profile} = wrappedBulkLoadMapping.get().toProfile();
const values: { [name: string]: any } = {};
////////////////////////////////////////////////////
// always re-submit the full profile //
// note mostly a copy in BulkLoadValueMappingForm //
////////////////////////////////////////////////////
values["version"] = profile.version;
values["fieldListJSON"] = JSON.stringify(profile.fieldList);
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
let haveLocalErrors = false;
const fieldErrors: { [fieldName: string]: string } = {};
if (!values["layout"])
{
haveLocalErrors = true;
fieldErrors["layout"] = "This field is required.";
}
if (values["hasHeaderRow"] == null || values["hasHeaderRow"] == undefined)
{
haveLocalErrors = true;
fieldErrors["hasHeaderRow"] = "This field is required.";
}
setFieldErrors(fieldErrors);
if(wrappedBulkLoadMapping.get().requiredFields.length == 0 && wrappedBulkLoadMapping.get().additionalFields.length == 0)
{
setNoMappedFieldsError("You must have at least 1 field.");
haveLocalErrors = true;
setTimeout(() => setNoMappedFieldsError(null), 2500);
}
else
{
setNoMappedFieldsError(null);
}
if(haveProfileErrors)
{
setTimeout(() =>
{
document.querySelector(".bulkLoadFieldError")?.scrollIntoView({behavior: "smooth", block: "center", inline: "center"});
}, 250);
}
return {maySubmit: !haveProfileErrors && !haveLocalErrors, values};
}
};
});
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setCurrentSavedBulkLoadProfile(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
let newBulkLoadMapping: BulkLoadMapping;
if (profileRecord)
{
newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(processValues.tableStructure, profileRecord);
}
else
{
newBulkLoadMapping = new BulkLoadMapping(processValues.tableStructure);
}
handleNewBulkLoadMapping(newBulkLoadMapping);
}
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileResetToSuggestedMappingCallback()
{
handleNewBulkLoadMapping(BulkLoadMapping.fromBulkLoadProfile(processValues.tableStructure, suggestedBulkLoadProfile));
}
/***************************************************************************
**
***************************************************************************/
function handleNewBulkLoadMapping(newBulkLoadMapping: BulkLoadMapping)
{
const newRequiredFields: BulkLoadField[] = [];
for (let field of newBulkLoadMapping.requiredFields)
{
newRequiredFields.push(BulkLoadField.clone(field));
}
newBulkLoadMapping.requiredFields = newRequiredFields;
setBulkLoadMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping);
setFieldValue("hasHeaderRow", newBulkLoadMapping.hasHeaderRow);
setFieldValue("layout", newBulkLoadMapping.layout);
setRerenderHeader(rerenderHeader + 1);
}
if (currentSavedBulkLoadProfile)
{
setActiveStepLabel(`File Mapping / ${currentSavedBulkLoadProfile.values.get("label")}`);
}
else
{
setActiveStepLabel("File Mapping");
}
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={currentSavedBulkLoadProfile}
currentMapping={bulkLoadMapping}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
bulkLoadProfileResetToSuggestedMappingCallback={bulkLoadProfileResetToSuggestedMappingCallback}
fileDescription={fileDescription}
/>
</Box>
<BulkLoadMappingHeader
key={rerenderHeader}
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
tableStructure={tableStructure}
fileName={processValues.fileBaseName}
fieldErrors={fieldErrors}
frontendStep={frontendStep}
processMetaData={processMetaData}
forceParentUpdate={() => forceUpdate()}
/>
<Box mt="2rem">
<BulkLoadFileMappingFields
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
forceParentUpdate={() =>
{
setRerenderHeader(rerenderHeader + 1);
forceUpdate();
}}
/>
{
noMappedFieldsError && <Box color={colors.error.main} textAlign="right">{noMappedFieldsError}</Box>
}
</Box>
</Box>);
});
export default BulkLoadFileMappingForm;
interface BulkLoadMappingHeaderProps
{
fileDescription: FileDescription,
fileName: string,
bulkLoadMapping?: BulkLoadMapping,
fieldErrors: { [fieldName: string]: string },
tableStructure: BulkLoadTableStructure,
forceParentUpdate?: () => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
}
/***************************************************************************
** private subcomponent - the header section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData}: BulkLoadMappingHeaderProps): JSX.Element
{
const viewFields = [
new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}),
new QFieldMetaData({name: "fileDetails", label: "File Details", type: "STRING"}),
];
const viewValues = {
"fileName": fileName,
"fileDetails": `${fileDescription.getColumnNames().length} column${fileDescription.getColumnNames().length == 1 ? "" : "s"}`
};
const hasHeaderRowFormField = {name: "hasHeaderRow", label: "Does the file have a header row?", type: "checkbox", isRequired: true, isEditable: true};
const layoutOptions = [
{label: "Flat", id: "FLAT"},
{label: "Tall", id: "TALL"},
{label: "Wide", id: "WIDE"},
];
if (!tableStructure.associations)
{
layoutOptions.splice(1);
}
const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null;
/***************************************************************************
**
***************************************************************************/
function hasHeaderRowChanged(newValue: any)
{
bulkLoadMapping.hasHeaderRow = newValue;
fileDescription.hasHeaderRow = newValue;
bulkLoadMapping.handleChangeToHasHeaderRow(newValue, fileDescription);
fieldErrors.hasHeaderRow = null;
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function layoutChanged(event: any, newValue: any)
{
bulkLoadMapping.switchLayout(newValue ? newValue.id : null);
fieldErrors.layout = null;
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function getFormattedHelpContent(fieldName: string): JSX.Element
{
const field = frontendStep?.formFields?.find(f => f.name == fieldName);
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"];
let formattedHelpContent = <HelpContent helpContents={field?.helpContents} roles={helpRoles} helpContentKey={`process:${processMetaData?.name};field:${fieldName}`} />;
if (formattedHelpContent)
{
const mt = field && field.type == QFieldType.BOOLEAN ? "-0.5rem" : "0.5rem";
return <Box color="#757575" fontSize="0.875rem" mt={mt}>{formattedHelpContent}</Box>;
}
return null;
}
return (
<Box>
<h5>File Details</h5>
<Box ml="1rem">
<ProcessViewForm fields={viewFields} values={viewValues} columns={2} />
<BulkLoadMappingFilePreview fileDescription={fileDescription} bulkLoadMapping={bulkLoadMapping} />
<Grid container pt="1rem">
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={hasHeaderRowFormField.name} label={`${hasHeaderRowFormField.label} *`} />
<QDynamicFormField name={hasHeaderRowFormField.name} displayFormat={""} label={""} formFieldObject={hasHeaderRowFormField} type={"checkbox"} value={bulkLoadMapping.hasHeaderRow} onChangeCallback={hasHeaderRowChanged} />
{
fieldErrors.hasHeaderRow &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.hasHeaderRow}</div>}
</MDTypography>
}
{getFormattedHelpContent("hasHeaderRow")}
</Grid>
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
<Autocomplete
id={"layout"}
renderInput={(params) => (<TextField {...params} label={""} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
options={layoutOptions}
multiple={false}
defaultValue={selectedLayout}
onChange={layoutChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.id == value.id}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
disableClearable
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
{
fieldErrors.layout &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.layout}</div>}
</MDTypography>
}
{getFormattedHelpContent("layout")}
</Grid>
</Grid>
</Box>
</Box>
);
}
interface BulkLoadMappingFilePreviewProps
{
fileDescription: FileDescription,
bulkLoadMapping?: BulkLoadMapping
}
/***************************************************************************
** private subcomponent - the file-preview section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoadMappingFilePreviewProps): JSX.Element
{
const rows: number[] = [];
for (let i = 0; i < fileDescription.bodyValuesPreview[0].length; i++)
{
rows.push(i);
}
/***************************************************************************
**
***************************************************************************/
function getValue(i: number, j: number)
{
const value = fileDescription.bodyValuesPreview[j][i];
if (value == null)
{
return "";
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this was useful at one point in time when we had an object coming back for xlsx files with many different data types //
// we'd see a .string attribute, which would have the value we'd want to show. not using it now, but keep in case //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// @ts-ignore
if (value && value.string)
{
// @ts-ignore
return (value.string);
}
return `${value}`;
}
/***************************************************************************
**
***************************************************************************/
function getHeaderColor(count: number): string
{
if (count > 0)
{
return "blue";
}
return "black";
}
/***************************************************************************
**
***************************************************************************/
function getCursor(count: number): string
{
if (count > 0)
{
return "pointer";
}
return "default";
}
/***************************************************************************
**
***************************************************************************/
function getColumnTooltip(fields: BulkLoadField[])
{
return (<Box>
This column is mapped to the field{fields.length == 1 ? "" : "s"}:
<ul style={{marginLeft: "1rem"}}>
{fields.map((field, i) => <li key={i}>{field.getQualifiedLabel()}</li>)}
</ul>
</Box>);
}
return (
<Box sx={{"& table, & td": {border: "1px solid black", borderCollapse: "collapse", padding: "0 0.25rem", fontSize: "0.875rem", whiteSpace: "nowrap"}}}>
<Box sx={{width: "100%", overflow: "auto"}}>
<table cellSpacing="0" width="100%">
<thead>
<tr style={{backgroundColor: "#d3d3d3", height: "1.75rem"}}>
<td></td>
{fileDescription.headerLetters.map((letter, index) =>
{
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
let dupeWarning = <></>
if(fileDescription.hasHeaderRow && fileDescription.duplicateHeaderIndexes[index])
{
dupeWarning = <Tooltip title="This column header is a duplicate. Only the first occurrance of it will be used." placement="top" enterDelay={500}>
<Icon color="warning" sx={{p: "0.125rem", mr: "0.25rem"}}>warning</Icon>
</Tooltip>
}
return (<td key={letter} style={{textAlign: "center", color: getHeaderColor(count), cursor: getCursor(count)}}>
<>
{
count > 0 &&
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}>
<Box>
{dupeWarning}
{letter}
<Badge badgeContent={count} variant={"standard"} color="secondary" sx={{marginTop: ".75rem"}}><Icon></Icon></Badge>
</Box>
</Tooltip>
}
{
count == 0 && <Box>{dupeWarning}{letter}</Box>
}
</>
</td>);
})}
</tr>
</thead>
<tbody>
<tr>
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>1</td>
{fileDescription.headerValues.map((value, index) =>
{
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
const tdStyle = {color: getHeaderColor(count), cursor: getCursor(count), backgroundColor: ""};
if(fileDescription.hasHeaderRow)
{
tdStyle.backgroundColor = "#ebebeb";
if(count > 0)
{
return <td key={value} style={tdStyle}>
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}><Box>{value}</Box></Tooltip>
</td>
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
}
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
}
}
)}
</tr>
{rows.map((i) => (
<tr key={i}>
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>{i + 2}</td>
{fileDescription.headerLetters.map((letter, j) => <td key={j}>{getValue(i, j)}</td>)}
</tr>
))}
</tbody>
</table>
</Box>
</Box>
);
}

View File

@ -0,0 +1,102 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import {BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useImperativeHandle, useState} from "react";
interface BulkLoadValueMappingFormProps
{
processValues: any,
tableMetaData: QTableMetaData,
metaData: QInstance
}
/***************************************************************************
** For review & result screens of bulk load - this process component shows
** the SavedBulkLoadProfiles button.
***************************************************************************/
const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}: BulkLoadValueMappingFormProps, ref) =>
{
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue))
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
const [currentMapping, setCurrentMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile))
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow);
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
const values: { [name: string]: any } = {};
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
return ({maySubmit: true, values});
}
};
});
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setSavedBulkLoadProfileRecord(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
const newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(tableStructure, profileRecord);
setCurrentMapping(newBulkLoadMapping);
}
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={savedBulkLoadProfileRecord}
currentMapping={currentMapping}
allowSelectingProfile={false}
fileDescription={fileDescription}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
/>
</Box>
</Box>);
});
export default BulkLoadProfileForm;

View File

@ -0,0 +1,233 @@
/*
* 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 {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import colors from "qqq/assets/theme/base/colors";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import {BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useEffect, useImperativeHandle, useReducer, useState} from "react";
interface BulkLoadValueMappingFormProps
{
processValues: any,
setActiveStepLabel: (label: string) => void,
tableMetaData: QTableMetaData,
metaData: QInstance,
formFields: any[]
}
/***************************************************************************
** process component used in bulk-load - on a screen that gets looped for
** each field whose values are being mapped.
***************************************************************************/
const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel, tableMetaData, metaData, formFields}: BulkLoadValueMappingFormProps, ref) =>
{
const [field, setField] = useState(processValues.valueMappingField ? new QFieldMetaData(processValues.valueMappingField) : null);
const [fieldFullName, setFieldFullName] = useState(processValues.valueMappingFullFieldName);
const [fileValues, setFileValues] = useState((processValues.fileValues ?? []) as string[]);
const [valueErrors, setValueErrors] = useState({} as { [fileValue: string]: any });
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [currentMapping, setCurrentMapping] = useState(initializeCurrentBulkLoadMapping());
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(currentMapping));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/*******************************************************************************
**
*******************************************************************************/
function initializeCurrentBulkLoadMapping(): BulkLoadMapping
{
const bulkLoadMapping = BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile);
if (!bulkLoadMapping.valueMappings[fieldFullName])
{
bulkLoadMapping.valueMappings[fieldFullName] = {};
}
return (bulkLoadMapping);
}
useEffect(() =>
{
if (processValues.valueMappingField)
{
setField(new QFieldMetaData(processValues.valueMappingField));
}
else
{
setField(null);
}
}, [processValues.valueMappingField]);
////////////////////////////////////////////////////////
// ref-based callback for integration with ProcessRun //
////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
const values: { [name: string]: any } = {};
let anyErrors = false;
const mappedValues = currentMapping.valueMappings[fieldFullName];
if (field.isRequired)
{
for (let fileValue of fileValues)
{
valueErrors[fileValue] = null;
if (mappedValues[fileValue] == null || mappedValues[fileValue] == undefined || mappedValues[fileValue] == "")
{
valueErrors[fileValue] = "A value is required for this mapping";
anyErrors = true;
}
}
}
///////////////////////////////////////////////////
// always re-submit the full profile //
// note mostly a copy in BulkLoadFileMappingForm //
///////////////////////////////////////////////////
const {haveErrors, profile} = wrappedBulkLoadMapping.get().toProfile();
values["version"] = profile.version;
values["fieldListJSON"] = JSON.stringify(profile.fieldList);
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
values["mappedValuesJSON"] = JSON.stringify(mappedValues);
return ({maySubmit: !anyErrors, values});
}
};
});
if (!field)
{
//////////////////////////////////////////////////////////////////////////////////////
// this happens like between steps - render empty rather than a flash of half-stuff //
//////////////////////////////////////////////////////////////////////////////////////
return (<Box></Box>);
}
/***************************************************************************
**
***************************************************************************/
function mappedValueChanged(fileValue: string, newValue: any)
{
valueErrors[fileValue] = null;
if(newValue == null)
{
delete currentMapping.valueMappings[fieldFullName][fileValue];
}
else
{
currentMapping.valueMappings[fieldFullName][fileValue] = newValue;
}
forceUpdate();
}
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setSavedBulkLoadProfileRecord(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
const newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(tableStructure, profileRecord);
setCurrentMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping);
}
setActiveStepLabel(`Value Mapping: ${field.label} (${processValues.valueMappingFieldIndex + 1} of ${processValues.fieldNamesToDoValueMapping?.length})`);
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={savedBulkLoadProfileRecord}
currentMapping={currentMapping}
allowSelectingProfile={false}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
fileDescription={fileDescription}
/>
</Box>
{
fileValues.map((fileValue, i) => (
<Box key={i} py="0.5rem" sx={{borderBottom: "0px solid lightgray", width: "100%", overflow: "auto"}}>
<Box display="grid" gridTemplateColumns="40% auto 60%" fontSize="1rem" gap="0.5rem">
<Box mt="0.5rem" textAlign="right">{fileValue}</Box>
<Box mt="0.625rem"><Icon>arrow_forward</Icon></Box>
<Box maxWidth="300px">
<QDynamicFormField
name={`${fieldFullName}.value.${i}`}
displayFormat={""}
label={""}
formFieldObject={formFields[i]}
type={formFields[i].type}
value={currentMapping.valueMappings[fieldFullName][fileValue]}
onChangeCallback={(newValue) => mappedValueChanged(fileValue, newValue)}
/>
{
valueErrors[fileValue] &&
<Box fontSize={"0.875rem"} mt={"-0.75rem"} color={colors.error.main}>
{valueErrors[fileValue]}
</Box>
}
</Box>
</Box>
</Box>
))
}
</Box>);
});
export default BulkLoadValueMappingForm;

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

@ -0,0 +1,71 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import Grid from "@mui/material/Grid";
import MDTypography from "qqq/components/legacy/MDTypography";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface ProcessViewFormProps
{
fields: QFieldMetaData[];
values: { [fieldName: string]: any };
columns?: number;
}
ProcessViewForm.defaultProps = {
columns: 2
};
/***************************************************************************
** a "view form" within a process step
**
***************************************************************************/
export default function ProcessViewForm({fields, values, columns}: ProcessViewFormProps): JSX.Element
{
const sm = Math.floor(12 / columns);
return <Grid container>
{fields.map((field: QFieldMetaData) => (
field.hasAdornment(AdornmentType.ERROR) ? (
values[field.name] && (
<Grid item xs={12} sm={sm} key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="regular">
{ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")}
</MDTypography>
</Grid>
)
) : (
<Grid item xs={12} sm={sm} key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="bold">
{field.label}
: &nbsp;
</MDTypography>
<MDTypography variant="button" fontWeight="regular" color="text">
{ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")}
</MDTypography>
</Grid>
)))
}
</Grid>;
}

View File

@ -24,29 +24,45 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Box, Button, FormControlLabel, ListItem, Radio, RadioGroup, Typography} from "@mui/material";
import {Button, FormControlLabel, ListItem, Radio, RadioGroup, Typography} from "@mui/material";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List";
import ListItemText from "@mui/material/ListItemText";
import React, {useState} from "react";
import MDTypography from "qqq/components/legacy/MDTypography";
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
import {ProcessSummaryLine} from "qqq/models/processes/ProcessSummaryLine";
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useEffect, useState} from "react";
interface Props
{
qInstance: QInstance;
process: QProcessMetaData;
table: QTableMetaData;
processValues: any;
step: QFrontendStepMetaData;
previewRecords: QRecord[];
formValues: any;
doFullValidationRadioChangedHandler: any
qInstance: QInstance,
process: QProcessMetaData,
table: QTableMetaData,
processValues: any,
step: QFrontendStepMetaData,
previewRecords: QRecord[],
formValues: any,
doFullValidationRadioChangedHandler: any,
loadingRecords?: boolean
}
////////////////////////////////////////////////////////////////////////////
// e.g., for bulk-load, where we want to show associations under a record //
// the processValue will have these data, to drive this screen. //
////////////////////////////////////////////////////////////////////////////
interface AssociationPreview
{
tableName: string;
widgetName: string;
associationName: string;
}
/*******************************************************************************
@ -55,21 +71,76 @@ interface Props
** results when they are available.
*******************************************************************************/
function ValidationReview({
qInstance, process, table = null, processValues, step, previewRecords = [], formValues, doFullValidationRadioChangedHandler,
qInstance, process, table = null, processValues, step, previewRecords = [], formValues, doFullValidationRadioChangedHandler, loadingRecords
}: Props): JSX.Element
{
const [previewRecordIndex, setPreviewRecordIndex] = useState(0);
const [sourceTableMetaData, setSourceTableMetaData] = useState(null as QTableMetaData);
const [previewTableMetaData, setPreviewTableMetaData] = useState(null as QTableMetaData);
const [childTableMetaData, setChildTableMetaData] = useState({} as { [name: string]: QTableMetaData });
if(processValues.sourceTable && !sourceTableMetaData)
const [associationPreviewsByWidgetName, setAssociationPreviewsByWidgetName] = useState({} as { [widgetName: string]: AssociationPreview });
if (processValues.sourceTable && !sourceTableMetaData)
{
(async () =>
{
const sourceTableMetaData = await Client.getInstance().loadTableMetaData(processValues.sourceTable)
const sourceTableMetaData = await Client.getInstance().loadTableMetaData(processValues.sourceTable);
setSourceTableMetaData(sourceTableMetaData);
})();
}
////////////////////////////////////////////////////////////////////////////////////////
// load meta-data and set up associations-data structure, if so directed from backend //
////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (processValues.formatPreviewRecordUsingTableLayout && !previewTableMetaData)
{
(async () =>
{
const previewTableMetaData = await Client.getInstance().loadTableMetaData(processValues.formatPreviewRecordUsingTableLayout);
setPreviewTableMetaData(previewTableMetaData);
})();
}
try
{
const previewRecordAssociatedTableNames: string[] = processValues.previewRecordAssociatedTableNames ?? [];
const previewRecordAssociatedWidgetNames: string[] = processValues.previewRecordAssociatedWidgetNames ?? [];
const previewRecordAssociationNames: string[] = processValues.previewRecordAssociationNames ?? [];
const associationPreviewsByWidgetName: { [widgetName: string]: AssociationPreview } = {};
for (let i = 0; i < Math.min(previewRecordAssociatedTableNames.length, previewRecordAssociatedWidgetNames.length, previewRecordAssociationNames.length); i++)
{
const associationPreview = {tableName: previewRecordAssociatedTableNames[i], widgetName: previewRecordAssociatedWidgetNames[i], associationName: previewRecordAssociationNames[i]};
associationPreviewsByWidgetName[associationPreview.widgetName] = associationPreview;
}
setAssociationPreviewsByWidgetName(associationPreviewsByWidgetName);
if (Object.keys(associationPreviewsByWidgetName))
{
(async () =>
{
for (let key in associationPreviewsByWidgetName)
{
const associationPreview = associationPreviewsByWidgetName[key];
childTableMetaData[associationPreview.tableName] = await Client.getInstance().loadTableMetaData(associationPreview.tableName);
setChildTableMetaData(Object.assign({}, childTableMetaData));
}
})();
}
}
catch (e)
{
console.log(`Error setting up association previews: ${e}`);
}
}, []);
/***************************************************************************
**
***************************************************************************/
const updatePreviewRecordIndex = (offset: number) =>
{
let newIndex = previewRecordIndex + offset;
@ -85,6 +156,10 @@ function ValidationReview({
setPreviewRecordIndex(newIndex);
};
/***************************************************************************
**
***************************************************************************/
const buildDoFullValidationRadioListItem = (value: "true" | "false", labelText: string, tooltipHTML: JSX.Element): JSX.Element =>
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -191,6 +266,7 @@ function ValidationReview({
</List>
);
const recordPreviewWidget = step.recordListFields && (
<Box border="1px solid rgb(70%, 70%, 70%)" borderRadius="10px" p={2} mt={2}>
<Box mx={2} mt={-5} p={1} sx={{width: "fit-content", borderColor: "rgb(70%, 70%, 70%)", borderWidth: "2px", borderStyle: "solid", borderRadius: ".25em", backgroundColor: "#FFFFFF"}} width="initial" color="white">
@ -200,43 +276,47 @@ function ValidationReview({
<MDTypography color="body" variant="body2" component="div" mb={2}>
<Box display="flex">
{
processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? (
<>
<i>{processValues?.previewMessage}</i>
<CustomWidthTooltip
title={(
<div>
Note that the number of preview records available may be fewer than the total number of records being processed.
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
) : (
<>
<i>No record previews are available at this time.</i>
<CustomWidthTooltip
title={(
<div>
{
processValues.validationSummary ? (
<>
It appears as though this process does not contain any valid records.
</>
) : (
<>
If you choose to Perform Validation, and there are any valid records, then you will see a preview here.
</>
)
}
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
)
loadingRecords ? <i>Loading...</i> : <>
{
processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? (
<>
<i>{processValues?.previewMessage}</i>
<CustomWidthTooltip
title={(
<div>
Note that the number of preview records available may be fewer than the total number of records being processed.
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
) : (
<>
<i>No record previews are available at this time.</i>
<CustomWidthTooltip
title={(
<div>
{
processValues.validationSummary ? (
<>
It appears as though this process does not contain any valid records.
</>
) : (
<>
If you choose to Perform Validation, and there are any valid records, then you will see a preview here.
</>
)
}
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
)
}
</>
}
</Box>
</MDTypography>
@ -244,16 +324,27 @@ function ValidationReview({
<Box sx={{maxHeight: "calc(100vh - 640px)", overflow: "auto", minHeight: "300px", marginRight: "-40px"}}>
<Box sx={{paddingRight: "40px"}}>
{
previewRecords && previewRecords[previewRecordIndex] && step.recordListFields.map((field) => (
previewRecords && !processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] && step.recordListFields.map((field) => (
<Box key={field.name} style={{marginBottom: "12px"}}>
<b>{`${field.label}:`}</b>
{" "}
&nbsp;
&nbsp;
{" "}
{ValueUtils.getDisplayValue(field, previewRecords[previewRecordIndex], "view")}
</Box>
))
}
{
previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] &&
<PreviewRecordUsingTableLayout
index={previewRecordIndex}
record={previewRecords[previewRecordIndex]}
tableMetaData={previewTableMetaData}
qInstance={qInstance}
associationPreviewsByWidgetName={associationPreviewsByWidgetName}
childTableMetaData={childTableMetaData}
/>
}
</Box>
</Box>
{
@ -273,7 +364,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">
@ -288,4 +379,84 @@ function ValidationReview({
);
}
interface PreviewRecordUsingTableLayoutProps
{
index: number
record: QRecord,
tableMetaData: QTableMetaData,
qInstance: QInstance,
associationPreviewsByWidgetName: { [widgetName: string]: AssociationPreview },
childTableMetaData: { [name: string]: QTableMetaData },
}
function PreviewRecordUsingTableLayout({record, tableMetaData, qInstance, associationPreviewsByWidgetName, childTableMetaData, index}: PreviewRecordUsingTableLayoutProps): JSX.Element
{
if (!tableMetaData)
{
return (<i>Loading...</i>);
}
const renderedSections: JSX.Element[] = [];
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData);
for (let i = 0; i < tableSections.length; i++)
{
const section = tableSections[i];
if (section.isHidden)
{
continue;
}
if (section.fieldNames)
{
renderedSections.push(<Box mb="1rem">
<Box><h4>{section.label}</h4></Box>
<Box ml="1rem">
{renderSectionOfFields(section.name, section.fieldNames, tableMetaData, false, record, undefined, {label: {fontWeight: "500"}})}
</Box>
</Box>);
}
else if (section.widgetName)
{
const widget = qInstance.widgets.get(section.widgetName);
if (widget)
{
let data: ChildRecordListData = null;
if (associationPreviewsByWidgetName[section.widgetName])
{
const associationPreview = associationPreviewsByWidgetName[section.widgetName];
const associationRecords = record.associatedRecords?.get(associationPreview.associationName) ?? [];
data = {
canAddChildRecord: false,
childTableMetaData: childTableMetaData[associationPreview.tableName],
defaultValuesForNewChildRecords: {},
disabledFieldsForNewChildRecords: {},
queryOutput: {records: associationRecords},
totalRows: associationRecords.length,
tablePath: "",
title: "",
viewAllLink: "",
};
renderedSections.push(<Box mb="1rem">
{
data && <Box>
<Box mb="0.5rem"><h4>{section.label}</h4></Box>
<Box pl="1rem">
<RecordGridWidget key={index} data={data} widgetMetaData={widget} disableRowClick gridOnly={true} gridDensity={"compact"} />
</Box>
</Box>
}
</Box>);
}
}
}
}
return <>{renderedSections}</>;
}
export default ValidationReview;

View File

@ -0,0 +1,153 @@
/*
* 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 {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import Box from "@mui/material/Box";
import colors from "qqq/assets/theme/base/colors";
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import XIcon from "qqq/components/query/XIcon";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {useState} from "react";
interface AdvancedQueryPreviewProps
{
tableMetaData: QTableMetaData;
queryFilter: QQueryFilter;
isEditable: boolean;
isQueryTooComplex: boolean;
removeCriteriaByIndexCallback: (index: number) => void;
}
/*******************************************************************************
** Box shown on query screen (and more??) to preview what a query looks like,
** as an "advanced" style/precursor-to-writing-your-own-query thing.
*******************************************************************************/
export default function AdvancedQueryPreview({tableMetaData, queryFilter, isEditable, isQueryTooComplex, removeCriteriaByIndexCallback}: AdvancedQueryPreviewProps): JSX.Element
{
const [mouseOverElement, setMouseOverElement] = useState(null as string);
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOverElement(name: string)
{
setMouseOverElement(name);
}
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOutElement()
{
setMouseOverElement(null);
}
/*******************************************************************************
** format the current query as a string for showing on-screen as a preview.
*******************************************************************************/
const queryToAdvancedString = (thisQueryFilter: QQueryFilter) =>
{
if (queryFilter == null || !queryFilter.criteria)
{
return (<span></span>);
}
let counter = 0;
return (
<React.Fragment>
{thisQueryFilter.criteria?.map((criteria, i) =>
{
const {criteriaIsValid} = validateCriteria(criteria, null);
if (criteriaIsValid)
{
counter++;
return (
<span key={i} style={{marginBottom: "0.125rem"}} onMouseOver={() => handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}>
{counter > 1 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span />}
{FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)}
{isEditable && !isQueryTooComplex && (
mouseOverElement == `queryPreview-${i}` && <span className={`advancedQueryPreviewX-${counter - 1}`}>
<XIcon position="forAdvancedQueryPreview" onClick={() => removeCriteriaByIndexCallback(i)} /></span>
)}
{counter > 1 && i == thisQueryFilter.criteria?.length - 1 && thisQueryFilter.subFilters?.length > 0 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span />}
</span>
);
}
else
{
return (<span />);
}
})}
{thisQueryFilter.subFilters?.length > 0 && (thisQueryFilter.subFilters.map((filter: QQueryFilter, j) =>
{
return (
<React.Fragment key={j}>
{j > 0 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span></span>}
<span style={{display: "flex", marginRight: "0.20rem"}}>(</span>
{queryToAdvancedString(filter)}
<span style={{display: "flex", marginRight: "0.20rem"}}>)</span>
</React.Fragment>
);
}))}
</React.Fragment>
);
};
const moreSX = isEditable ?
{
borderTop: `1px solid ${colors.grayLines.main}`,
boxShadow: "inset 0px 0px 4px 2px #EFEFED",
borderRadius: "0 0 0.75rem 0.75rem",
} :
{
borderRadius: "0.75rem",
border: `1px solid ${colors.grayLines.main}`,
}
return (
<Box whiteSpace="nowrap" display="flex" flexShrink={1} flexGrow={1} alignItems="center">
{
<Box
className="advancedQueryString"
display="inline-block"
width="100%"
sx={{fontSize: "1rem", background: "#FFFFFF"}}
minHeight={"2.5rem"}
p={"0.5rem"}
pb={"0.125rem"}
{...moreSX}
>
<Box display="flex" flexWrap="wrap" fontSize="0.875rem">
{queryToAdvancedString(queryFilter)}
</Box>
</Box>
}
</Box>
)
}

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

@ -29,7 +29,6 @@ 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 {Badge, ToggleButton, ToggleButtonGroup} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
@ -38,19 +37,23 @@ import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import Icon from "@mui/material/Icon";
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import Tooltip from "@mui/material/Tooltip";
import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro";
import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import AdvancedQueryPreview from "qqq/components/query/AdvancedQueryPreview";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import FieldListMenu from "qqq/components/query/FieldListMenu";
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter";
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, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react";
interface BasicAndAdvancedQueryControlsProps
{
@ -74,12 +77,36 @@ interface BasicAndAdvancedQueryControlsProps
/////////////////////////////////////////////////////////////////////////////////////////////
queryFilterJSON: string;
queryScreenUsage: QueryScreenUsage;
allowVariables?: boolean;
mode: string;
setMode: (mode: string) => void;
}
let debounceTimeout: string | number | NodeJS.Timeout;
/*******************************************************************************
** function to generate an element that says how a filter is sorted.
*******************************************************************************/
export function getCurrentSortIndicator(queryFilter: QQueryFilter, tableMetaData: QTableMetaData, toggleSortDirection: (event: React.MouseEvent<HTMLSpanElement, MouseEvent>) => void)
{
if (queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0)
{
const orderBy = queryFilter.orderBys[0];
const orderByFieldName = orderBy.fieldName;
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName);
const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`;
return <>Sort: {fieldLabel} <Icon onClick={toggleSortDirection} sx={{ml: "0.5rem"}}>{orderBy.isAscending ? "arrow_upward" : "arrow_downward"}</Icon></>;
}
else
{
return <>Sort...</>;
}
}
/*******************************************************************************
** Component to provide the basic & advanced query-filter controls for the
** RecordQueryOrig screen.
@ -89,14 +116,14 @@ let debounceTimeout: string | number | NodeJS.Timeout;
*******************************************************************************/
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 //
/////////////////////
const [defaultQuickFilterFieldNames, setDefaultQuickFilterFieldNames] = useState(getDefaultQuickFilterFieldNames(tableMetaData));
const [defaultQuickFilterFieldNameMap, setDefaultQuickFilterFieldNameMap] = useState(Object.fromEntries(defaultQuickFilterFieldNames.map(k => [k, true])));
const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null)
const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null);
const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0);
const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false);
const [mouseOverElement, setMouseOverElement] = useState(null as string);
@ -104,6 +131,11 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const {accentColor} = useContext(QContext);
/////////////////////////////////////////////////
// temporary, until we implement sub-filtering //
/////////////////////////////////////////////////
const [isQueryTooComplex, setIsQueryTooComplex] = useState(false);
//////////////////////////////////////////////////////////////////////////////////
// make some functions available to our parent - so it can tell us to do things //
//////////////////////////////////////////////////////////////////////////////////
@ -122,7 +154,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
{
return (mode);
}
}
};
});
@ -151,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;
}
@ -176,7 +208,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
let foundIndex = null;
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
if(queryFilter.criteria[i].fieldName == newCriteria.fieldName)
if (queryFilter.criteria[i].fieldName == newCriteria.fieldName)
{
queryFilter.criteria[i] = newCriteria;
found = true;
@ -185,9 +217,9 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
}
}
if(doClearCriteria)
if (doClearCriteria)
{
if(found)
if (found)
{
queryFilter.criteria.splice(foundIndex, 1);
setQueryFilter(queryFilter);
@ -195,9 +227,9 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
return;
}
if(!found)
if (!found)
{
if(!queryFilter.criteria)
if (!queryFilter.criteria)
{
queryFilter.criteria = [];
}
@ -205,9 +237,9 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
found = true;
}
if(found)
if (found)
{
clearTimeout(debounceTimeout)
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() =>
{
setQueryFilter(queryFilter);
@ -227,17 +259,17 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const matches: QFilterCriteriaWithId[] = [];
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
if(queryFilter.criteria[i].fieldName == fieldName)
if (queryFilter.criteria[i].fieldName == fieldName)
{
matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId);
}
}
if(matches.length == 0)
if (matches.length == 0)
{
return (null);
}
else if(matches.length == 1)
else if (matches.length == 1)
{
return (matches[0]);
}
@ -254,8 +286,8 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
*******************************************************************************/
const handleRemoveQuickFilterField = (fieldName: string): void =>
{
const index = quickFilterFieldNames.indexOf(fieldName)
if(index >= 0)
const index = quickFilterFieldNames.indexOf(fieldName);
if (index >= 0)
{
//////////////////////////////////////
// remove this field from the query //
@ -276,7 +308,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
{
setAddQuickFilterMenu(event.currentTarget);
setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1);
}
};
/*******************************************************************************
@ -285,7 +317,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const closeAddQuickFilterMenu = () =>
{
setAddQuickFilterMenu(null);
}
};
/*******************************************************************************
@ -306,7 +338,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const fieldName = newValue ? newValue.fieldName : null;
if (fieldName)
{
if(defaultQuickFilterFieldNameMap[fieldName])
if (defaultQuickFilterFieldNameMap[fieldName])
{
return;
}
@ -322,12 +354,12 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// only do this when user has added the field (e.g., not when adding it because of a selected view or filter-in-url) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected" && reason != "activatedView")
if (reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected" && reason != "activatedView")
{
setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5);
}
}
else if(reason == "columnMenu")
else if (reason == "columnMenu")
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if field was already on-screen, but user clicked an option from the columnMenu, then open the quick-filter field //
@ -346,13 +378,13 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const handleFieldListMenuSelection = (field: QFieldMetaData, table: QTableMetaData): void =>
{
let fullFieldName = field.name;
if(table && table.name != tableMetaData.name)
if (table && table.name != tableMetaData.name)
{
fullFieldName = `${table.name}.${field.name}`;
}
addQuickFilterField({fieldName: fullFieldName}, "selectedFromAddFilterMenu");
}
};
/*******************************************************************************
@ -361,7 +393,10 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
*******************************************************************************/
const openFilterBuilder = (e: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>) =>
{
gridApiRef.current.showFilterPanel();
if (!isQueryTooComplex)
{
gridApiRef.current.showFilterPanel();
}
};
@ -385,45 +420,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
{
queryFilter.criteria.splice(index, 1);
setQueryFilter(queryFilter);
}
/*******************************************************************************
** format the current query as a string for showing on-screen as a preview.
*******************************************************************************/
const queryToAdvancedString = () =>
{
if(queryFilter == null || !queryFilter.criteria)
{
return (<span></span>);
}
let counter = 0;
return (
<Box display="flex" flexWrap="wrap" fontSize="0.875rem">
{queryFilter.criteria.map((criteria, i) =>
{
const {criteriaIsValid} = validateCriteria(criteria, null);
if(criteriaIsValid)
{
counter++;
return (
<span key={i} style={{marginBottom: "0.125rem"}} onMouseOver={() => handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}>
{counter > 1 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{queryFilter.booleanOperator}&nbsp;</span> : <span/>}
{FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)}
{mouseOverElement == `queryPreview-${i}` && <span className={`advancedQueryPreviewX-${counter - 1}`}><XIcon position="forAdvancedQueryPreview" onClick={() => removeCriteriaByIndex(i)} /></span>}
</span>
);
}
else
{
return (<span />);
}
})}
</Box>
);
};
@ -434,7 +430,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
{
if (newValue)
{
if(newValue == "basic")
if (newValue == "basic")
{
////////////////////////////////////////////////////////////////////////////////
// we're always allowed to go to advanced - //
@ -443,7 +439,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
if (!canFilterWorkAsBasic)
{
console.log("Query cannot work as basic - so - not allowing toggle to basic.")
console.log("Query cannot work as basic - so - not allowing toggle to basic.");
return;
}
@ -470,11 +466,16 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
*******************************************************************************/
const ensureAllFilterCriteriaAreActiveQuickFilters = (tableMetaData: QTableMetaData, queryFilter: QQueryFilter, reason: "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | string, newMode?: string) =>
{
if(!tableMetaData || !queryFilter)
if (!tableMetaData || !queryFilter)
{
return;
}
//////////////////////////////////////////////
// set a flag if the query is 'too complex' //
//////////////////////////////////////////////
setIsQueryTooComplex(queryFilter.subFilters?.length > 0);
const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
if (!canFilterWorkAsBasic)
{
@ -485,7 +486,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
}
const modeToUse = newMode ?? mode;
if(modeToUse == "basic")
if (modeToUse == "basic")
{
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
@ -496,7 +497,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
}
}
}
}
};
/*******************************************************************************
@ -508,13 +509,22 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
const {criteriaIsValid} = validateCriteria(queryFilter.criteria[i], null);
if(criteriaIsValid)
if (criteriaIsValid)
{
count++;
}
}
/////////////////////////////////////////////////////////////
// recursively add any children filters to the total count //
/////////////////////////////////////////////////////////////
for (let i = 0; i < queryFilter.subFilters?.length; i++)
{
count += countValidCriteria(queryFilter.subFilters[i]);
}
return count;
}
};
/*******************************************************************************
@ -523,11 +533,11 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const handleSetSort = (field: QFieldMetaData, table: QTableMetaData, isAscending: boolean = true): void =>
{
const fullFieldName = table && table.name != tableMetaData.name ? `${table.name}.${field.name}` : field.name;
queryFilter.orderBys = [new QFilterOrderBy(fullFieldName, isAscending)]
queryFilter.orderBys = [new QFilterOrderBy(fullFieldName, isAscending)];
setQueryFilter(queryFilter);
forceUpdate();
}
};
/*******************************************************************************
@ -542,11 +552,11 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const isAscending = event.target.innerHTML == "arrow_upward";
const isDescending = event.target.innerHTML == "arrow_downward";
if(isAscending || isDescending)
if (isAscending || isDescending)
{
handleSetSort(field, table, isAscending);
}
}
};
/*******************************************************************************
@ -561,30 +571,22 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
setQueryFilter(queryFilter);
forceUpdate();
}
catch(e)
catch (e)
{
console.log(`Error toggling sort: ${e}`)
console.log(`Error toggling sort: ${e}`);
}
}
/////////////////////////////////
// set up the sort menu button //
/////////////////////////////////
let sortButtonContents = <>Sort...</>
if(queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0)
{
const orderBy = queryFilter.orderBys[0];
const orderByFieldName = orderBy.fieldName;
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName);
const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`;
sortButtonContents = <>Sort: {fieldLabel} <Icon onClick={toggleSortDirection} sx={{ml: "0.5rem"}}>{orderBy.isAscending ? "arrow_upward" : "arrow_downward"}</Icon></>
}
let sortButtonContents = getCurrentSortIndicator(queryFilter, tableMetaData, toggleSortDirection);
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this is being used as a version of like forcing that we get re-rendered if the query filter changes... //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [lastIndex, setLastIndex] = useState(queryFilterJSON);
if(queryFilterJSON != lastIndex)
if (queryFilterJSON != lastIndex)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "defaultFilterLoaded");
setLastIndex(queryFilterJSON);
@ -594,16 +596,22 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
// set some status flags based on current filter //
///////////////////////////////////////////////////
const hasValidFilters = queryFilter && countValidCriteria(queryFilter) > 0;
const {canFilterWorkAsBasic, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
const {canFilterWorkAsBasic, canFilterWorkAsAdvanced, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
let reasonWhyBasicIsDisabled = null;
if(reasonsWhyItCannot && reasonsWhyItCannot.length > 0)
if (canFilterWorkAsAdvanced && reasonsWhyItCannot && reasonsWhyItCannot.length > 0)
{
reasonWhyBasicIsDisabled = <>
Your current Filter cannot be managed using Basic mode because:
<ul style={{marginLeft: "1rem"}}>
{reasonsWhyItCannot.map((reason, i) => <li key={i}>{reason}</li>)}
</ul>
</>
</>;
}
if (isQueryTooComplex)
{
reasonWhyBasicIsDisabled = <>
Your current Filter is too complex to modify because it contains Sub-filters.
</>;
}
const borderGray = colors.grayLines.main;
@ -670,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} />);
})
}
@ -694,7 +704,9 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
allowVariables={props.allowVariables}
defaultOperator={defaultOperator}
queryScreenUsage={queryScreenUsage}
handleRemoveQuickFilterField={handleRemoveQuickFilterField} />);
})
}
@ -731,20 +743,20 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
<Button
className="filterBuilderButton"
onClick={(e) => openFilterBuilder(e)}
{... filterBuilderMouseEvents}
{...filterBuilderMouseEvents}
startIcon={<Icon>build</Icon>}
sx={{borderRadius: "0.75rem", padding: "0.5rem", pl: "1rem", fontSize: "0.875rem", fontWeight: 500, border: `1px solid ${accentColor}`, textTransform: "none"}}
>
Filter Builder
{
countValidCriteria(queryFilter) > 0 &&
<Box {... filterBuilderMouseEvents} sx={{backgroundColor: accentColor, marginLeft: "0.25rem", minWidth: "1rem", fontSize: "0.75rem"}} borderRadius="50%" color="#FFFFFF" position="relative" top="-2px" className="filterBuilderCountBadge">
{countValidCriteria(queryFilter) }
<Box {...filterBuilderMouseEvents} sx={{backgroundColor: accentColor, marginLeft: "0.25rem", minWidth: "1rem", fontSize: "0.75rem"}} borderRadius="50%" color="#FFFFFF" position="relative" top="-2px" className="filterBuilderCountBadge">
{countValidCriteria(queryFilter)}
</Box>
}
</Button>
{
hasValidFilters && mouseOverElement == "filterBuilderButton" && <span {... filterBuilderMouseEvents} className="filterBuilderXIcon"><XIcon shade="accent" position="default" onClick={() => setShowClearFiltersWarning(true)} /></span>
hasValidFilters && mouseOverElement == "filterBuilderButton" && <span {...filterBuilderMouseEvents} className="filterBuilderXIcon"><XIcon shade="accent" position="default" onClick={() => setShowClearFiltersWarning(true)} /></span>
}
</>
</Tooltip>
@ -763,24 +775,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
{sortMenuComponent}
</Box>
</Box>
<Box whiteSpace="nowrap" display="flex" flexShrink={1} flexGrow={1} alignItems="center">
{
<Box
className="advancedQueryString"
display="inline-block"
borderTop={`1px solid ${borderGray}`}
borderRadius="0 0 0.75rem 0.75rem"
width="100%"
sx={{fontSize: "1rem", background: "#FFFFFF"}}
minHeight={"2.375rem"}
p={"0.5rem"}
pb={"0.125rem"}
boxShadow={"inset 0px 0px 4px 2px #EFEFED"}
>
{queryToAdvancedString()}
</Box>
}
</Box>
<AdvancedQueryPreview tableMetaData={tableMetaData} queryFilter={queryFilter} isEditable={true} isQueryTooComplex={isQueryTooComplex} removeCriteriaByIndexCallback={removeCriteriaByIndex} />
</Box>
}
</Box>

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

@ -23,8 +23,9 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import MenuItem from "@mui/material/MenuItem";
import {GridColDef, GridExportMenuItemProps} from "@mui/x-data-grid-pro";
import React from "react";
import QContext from "QContext";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext} from "react";
interface QExportMenuItemProps extends GridExportMenuItemProps<{}>
{
@ -43,11 +44,15 @@ export default function ExportMenuItem(props: QExportMenuItemProps)
{
const {format, tableMetaData, totalRecords, columnsModel, columnVisibilityModel, queryFilter, hideMenu} = props;
const {recordAnalytics} = useContext(QContext);
return (
<MenuItem
disabled={!totalRecords}
disabled={totalRecords === 0}
onClick={() =>
{
recordAnalytics({category: "tableEvents", action: "export", label: tableMetaData.label});
///////////////////////////////////////////////////////////////////////////////
// build the list of visible fields. note, not doing them in-order (in case //
// the user did drag & drop), because column order model isn't right yet //

View File

@ -33,10 +33,11 @@ import MenuItem from "@mui/material/MenuItem";
import Select, {SelectChangeEvent} from "@mui/material/Select/Select";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import React, {ReactNode, SyntheticEvent, useState} from "react";
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";
export enum ValueMode
@ -72,7 +73,7 @@ export const getValueModeRequiredCount = (valueMode: ValueMode): number =>
case ValueMode.PVS_MULTI:
return (null);
}
}
};
export interface OperatorOption
{
@ -108,6 +109,7 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str
{
case QFieldType.DECIMAL:
case QFieldType.INTEGER:
case QFieldType.LONG:
operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE});
operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE});
operatorOptions.push({label: "greater than", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE});
@ -183,7 +185,7 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str
}
return (operatorOptions);
}
};
interface FilterCriteriaRowProps
@ -197,13 +199,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 +216,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 +269,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 +287,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 +306,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 +319,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 +382,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 +396,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 +427,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;
}
@ -484,7 +487,9 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
: <span />}
</Box>
<Box display="inline-block" width={250} className="fieldColumn">
<FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange} />
<FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange}
autocompleteSlotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
/>
</Box>
<Box display="inline-block" width={200} className="operatorColumn">
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={tooltipEnterDelay}>
@ -512,6 +517,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,12 +107,70 @@ 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...
*******************************************************************************/
const handleKeyDown = (e: any) =>
{
if (field.type == QFieldType.DATE || field.type == QFieldType.DATE_TIME)
{
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();
}
}
};
/***************************************************************************
* 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">
@ -103,22 +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)}
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);
@ -127,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)
@ -150,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:
@ -209,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);
@ -237,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
@ -250,6 +410,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
inForm={false}
onChange={(value: any) => valueChangeHandler(null, "all", value)}
variant="standard"
useCase="filter"
/>
</Box>;
}
@ -257,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);
}
};
/*******************************************************************************
@ -115,31 +118,48 @@ const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteri
** autocomplete), given an array of options, the query's active criteria in this
** field, and the default operator to use for this field
*******************************************************************************/
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption =>
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator, return0thOptionInsteadOfNull: boolean = false): 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]);
}
if(return0thOptionInsteadOfNull)
{
console.log("Returning 0th operator instead of null - this isn't expected, but has been seen to happen - so here's some additional debugging:");
try
{
console.log("Operator options: " + JSON.stringify(operatorOptions));
console.log("Criteria: " + JSON.stringify(criteria));
console.log("Default Operator: " + JSON.stringify(defaultOperator));
}
catch(e)
{
console.log(`Error in debug output: ${e}`);
}
return operatorOptions[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);
@ -154,7 +174,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? Object.assign({}, criteriaParam) as QFilterCriteriaWithId : null);
const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId);
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator));
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator, true));
const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label);
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
@ -190,7 +210,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 +237,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 +257,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
}
return (false);
}
};
/*******************************************************************************
** Construct a new criteria object - resetting the values tied to the operator
@ -251,8 +271,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 +286,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
{
const element = document.getElementById("value-" + criteria.id);
element?.focus();
})
});
};
/*******************************************************************************
@ -304,15 +324,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 +365,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 +397,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 +411,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 +431,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 +452,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 +467,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 +512,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const xClicked = (e: React.MouseEvent<HTMLSpanElement>) =>
{
e.stopPropagation();
if(criteriaIsValid)
if (criteriaIsValid)
{
resetCriteria(e);
}
@ -499,12 +520,12 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
{
handleTurningOffQuickFilterField();
}
}
};
//////////////////////////////
// return the button & menu //
//////////////////////////////
const widthAndMaxWidth = 250
const widthAndMaxWidth = (fieldMetaData?.type == QFieldType.DATE_TIME) ? 315 : 250;
return (
<>
{button}
@ -541,10 +562,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

@ -49,7 +49,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
<Card sx={{width: "100%", height: "100%"}}>
<Typography variant="h6" p={2} pb={1}>{heading}</Typography>
<Box className="devDocumentation" height="100%">
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "100%"}}>
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "calc(100% - 0.5rem)"}}>
<AceEditor
mode={mode}
theme="github"
@ -62,7 +62,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
width="100%"
showPrintMargin={false}
height="100%"
style={{borderBottomRightRadius: "1rem", borderBottomLeftRadius: "1rem"}}
style={{borderBottomRightRadius: "0.75rem", borderBottomLeftRadius: "0.75rem"}}
/>
</Typography>
</Box>

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

@ -0,0 +1,487 @@
/*
* 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 {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, Box} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon";
import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
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";
interface ShareModalProps
{
open: boolean;
onClose: () => void;
tableMetaData: QTableMetaData;
record: QRecord;
}
ShareModal.defaultProps = {};
interface CurrentShare
{
shareId: any;
scopeId: string;
audienceType: string;
audienceId: any;
audienceLabel: string;
}
interface Scope
{
id: string;
label: string;
}
const scopeOptions: Scope[] = [
{id: "READ_ONLY", label: "Read-Only"},
{id: "READ_WRITE", label: "Read and Edit"}
];
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
*******************************************************************************/
export default function ShareModal({open, onClose, tableMetaData, record}: ShareModalProps): JSX.Element
{
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);
const [submitting, setSubmitting] = useState(false);
const [currentShares, setCurrentShares] = useState([] as CurrentShare[])
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 //
/////////////////////////////////////////////////////////
useEffect(() =>
{
if(needToLoadCurrentShares)
{
loadShares();
}
}, [needToLoadCurrentShares]);
/*******************************************************************************
**
*******************************************************************************/
function close(event: object, reason: string)
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
return;
}
onClose();
}
/*******************************************************************************
**
*******************************************************************************/
function handleAudienceChange(value: any | any[], reason: string)
{
if(value)
{
const [audienceType, audienceId] = value.id.split(":");
setSelectedAudienceType(audienceType);
setSelectedAudienceId(audienceId);
}
else
{
setSelectedAudienceType(null);
setSelectedAudienceId(null);
}
}
/*******************************************************************************
**
*******************************************************************************/
function handleScopeChange(event: React.SyntheticEvent, value: any | any[], reason: string)
{
if(value)
{
setSelectedScopeId(value.id);
}
else
{
setSelectedScopeId(null);
}
}
/*******************************************************************************
**
*******************************************************************************/
async function editingExistingShareScope(shareId: number, value: any | any[])
{
setStatusString("Saving...");
setAlert(null);
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
formData.append("recordId", record.values.get(tableMetaData.primaryKeyField));
formData.append("shareId", shareId);
formData.append("scopeId", value.id);
const processResult = await qController.processRun("editSharedRecord", formData, null, true);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
setStatusString(null);
setAlert("Error editing shared record: " + jobError.error);
setSubmitting(false)
}
else
{
const result = processResult as QJobComplete;
setStatusString(null);
setAlert(null);
setNeedToLoadCurrentShares(true);
setSubmitting(false)
}
}
/*******************************************************************************
**
*******************************************************************************/
async function loadShares()
{
setNeedToLoadCurrentShares(false);
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
formData.append("recordId", record.values.get(tableMetaData.primaryKeyField));
const processResult = await qController.processRun("getSharedRecords", formData, null, true);
setStatusString("Loading...");
setAlert(null)
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
setStatusString(null);
setAlert("Error loading: " + jobError.error);
}
else
{
const result = processResult as QJobComplete;
const newCurrentShares: CurrentShare[] = [];
for (let i in result.values["resultList"])
{
newCurrentShares.push(result.values["resultList"][i].values);
}
setCurrentShares(newCurrentShares);
setEverLoadedCurrentShares(true);
setStatusString(null);
setAlert(null);
}
}
/*******************************************************************************
**
*******************************************************************************/
async function saveNewShare()
{
setSubmitting(true)
setStatusString("Saving...");
setAlert(null);
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
formData.append("recordId", record.values.get(tableMetaData.primaryKeyField));
formData.append("audienceType", selectedAudienceType);
formData.append("audienceId", selectedAudienceId);
formData.append("scopeId", selectedScopeId);
const processResult = await qController.processRun("insertSharedRecord", formData, null, true);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
setStatusString(null);
setAlert("Error sharing record: " + jobError.error);
setSubmitting(false)
}
else
{
const result = processResult as QJobComplete;
setStatusString(null);
setAlert(null);
setSelectedAudienceOption(null);
setNeedToLoadCurrentShares(true);
setSubmitting(false)
}
}
/*******************************************************************************
**
*******************************************************************************/
async function removeShare(shareId: number)
{
setStatusString("Deleting...");
setAlert(null);
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
formData.append("recordId", record.values.get(tableMetaData.primaryKeyField));
formData.append("shareId", shareId);
const processResult = await qController.processRun("deleteSharedRecord", formData, null, true);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
setStatusString(null);
setAlert("Error deleting share: " + jobError.error);
}
else
{
const result = processResult as QJobComplete;
setNeedToLoadCurrentShares(true);
setStatusString(null);
setAlert(null);
}
}
/*******************************************************************************
**
*******************************************************************************/
function getScopeOption(scopeId: string): Scope
{
for (let scopeOption of scopeOptions)
{
if(scopeOption.id == scopeId)
{
return (scopeOption);
}
}
return (null);
}
/*******************************************************************************
**
*******************************************************************************/
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={isDisabled}
renderInput={(params) => (<TextField {...params} label="Scope" variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
options={scopeOptions}
// @ts-ignore
defaultValue={defaultValue}
onChange={onChange}
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={getAutocompleteOutlinedStyle(isDisabled)}
/>
);
}
//////////////////////
// render the modal //
//////////////////////
return (<Modal open={open} onClose={close}>
<div className="share">
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%", display: "flex", height: "100%", flexDirection: "column", justifyContent: "center"}}>
<Card sx={{my: 5, mx: "auto", p: 3}}>
{/* header */}
<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">
{/* 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.*/}
</Box>
<Box fontSize={14} pb={1} fontWeight="300">
{alert && <Alert color="error" onClose={() => setAlert(null)}>{alert}</Alert>}
{statusString}
{!alert && !statusString && (<>&nbsp;</>)}
</Box>
</Typography>
</Box>
{/* body */}
<Box pb={3} display="flex" flexDirection="column">
{/* row for adding a new share */}
<Box display="flex" flexDirection="row" alignItems="center">
<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}
useCase="form"
/>
</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>
<Button disabled={submitting || selectedAudienceId == null} sx={iconButtonSX} onClick={() => saveNewShare()}>
<Icon color={selectedAudienceId == null ? "secondary" : "info"}>save</Icon>
</Button>
</span>
</Tooltip>
</Box>
</Box>
{/* row showing existing shares */}
<Box pt={3}>
<Box pb="0.25rem">
<h5 style={{fontWeight: "600"}}>Current Shares
{
everLoadedCurrentShares ? <>&nbsp;({currentShares.length})</> : <></>
}
</h5>
</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>
</Box>
</Box>
</Box>
{/* footer */}
<Box display="flex" flexDirection="row" justifyContent="flex-end">
<QCancelButton label="Done" iconName="check" onClickHandler={() => close(null, null)} disabled={false} />
</Box>
</Card>
</Box>
</div>
</Modal>);
}
const iconButtonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.75rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
width: "40px",
minWidth: "40px",
paddingLeft: 0,
paddingRight: 0,
color: colors.secondary.main,
"&:hover": {color: colors.secondary.main},
"&:focus": {color: colors.secondary.main},
"&:focus:not(:hover)": {color: colors.secondary.main},
};
const redIconButton =
{
color: colors.error.main,
"&:hover": {color: colors.error.main},
"&:focus": {color: colors.error.main},
"&:focus:not(:hover)": {color: colors.error.main},
};

View File

@ -0,0 +1,217 @@
/*
* 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 {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box, Skeleton} from "@mui/material";
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";
export interface CompositeData
{
blockId: string;
blocks: BlockData[];
styleOverrides?: any;
layout?: string;
overlayHtml?: string;
overlayStyleOverrides?: any;
modalMode: string;
styles?: any;
}
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, actionCallback, values}: CompositeWidgetProps): JSX.Element
{
if (!data || !data.blocks)
{
return (<Skeleton />);
}
////////////////////////////////////////////////////////////////////////////////////
// note - these layouts are defined in qqq in the CompositeWidgetData.Layout enum //
////////////////////////////////////////////////////////////////////////////////////
let layout = data?.layout;
let boxStyle: any = {};
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.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";
boxStyle.flexDirection = "column";
boxStyle.fontSize = "0.875rem";
boxStyle.fontWeight = 400;
boxStyle.borderRight = "1px solid #D0D0D0";
}
else if (layout == "BADGES_WRAPPER")
{
boxStyle.display = "flex";
boxStyle.gap = "0.25rem";
boxStyle.padding = "0 0.25rem";
boxStyle.fontSize = "0.875rem";
boxStyle.fontWeight = 400;
boxStyle.border = "1px solid gray";
boxStyle.borderRadius = "0.5rem";
boxStyle.background = "#FFFFFF";
}
if (data?.styleOverrides)
{
boxStyle = {...boxStyle, ...data.styleOverrides};
}
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) =>
{
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 {Skeleton} from "@mui/material";
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 React, {useContext, useEffect, useReducer, useState} from "react";
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";
@ -35,20 +38,26 @@ import DefaultLineChart from "qqq/components/widgets/charts/linechart/DefaultLin
import SmallLineChart from "qqq/components/widgets/charts/linechart/SmallLineChart";
import PieChart from "qqq/components/widgets/charts/piechart/PieChart";
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 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, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT, LabelComponent} 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";
import React, {useContext, useEffect, useReducer, useState} from "react";
import TableWidget from "./tables/TableWidget";
@ -59,11 +68,15 @@ interface Props
widgetMetaDataList: QWidgetMetaData[];
tableName?: string;
entityPrimaryKey?: string;
record?: QRecord;
omitWrappingGridContainer: boolean;
areChildren?: boolean;
childUrlParams?: string;
parentWidgetMetaData?: QWidgetMetaData;
wrapWidgetsInTabPanels: boolean;
actionCallback?: (data: any, eventValues?: { [name: string]: any }) => boolean;
initialWidgetDataList: any[];
values?: { [key: string]: any };
}
DashboardWidgets.defaultProps = {
@ -75,11 +88,14 @@ DashboardWidgets.defaultProps = {
childUrlParams: "",
parentWidgetMetaData: null,
wrapWidgetsInTabPanels: false,
actionCallback: null,
initialWidgetDataList: null,
values: {}
};
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, 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);
@ -87,11 +103,16 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
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)
if (parentWidgetMetaData && wrapWidgetsInTabPanels)
{
selectedTabKey = `qqq.widgets.selectedTabs.${parentWidgetMetaData.name}`
selectedTabKey = `qqq.widgets.selectedTabs.${parentWidgetMetaData.name}`;
if (localStorage.getItem(selectedTabKey))
{
initialSelectedTab = Number(localStorage.getItem(selectedTabKey));
@ -107,7 +128,15 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
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];
@ -144,7 +173,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const reloadWidget = async (index: number, data: string) =>
{
(async () =>
await (async () =>
{
const urlParams = getQueryParams(widgetMetaDataList[index], data);
setCurrentUrlParams(urlParams);
@ -189,7 +218,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const metaDataToUse = (thisWidgetHasDropdowns) ? widgetMetaData : parentWidgetMetaData;
for (let i = 0; i < metaDataToUse.dropdowns.length; i++)
{
const dropdownName = metaDataToUse.dropdowns[i].possibleValueSourceName;
const dropdownName = metaDataToUse.dropdowns[i].possibleValueSourceName ?? metaDataToUse.dropdowns[i].name;
const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${metaDataToUse.name}.${dropdownName}`;
const json = JSON.parse(localStorage.getItem(localStorageKey));
if (json)
@ -246,6 +275,168 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const widgetCount = widgetMetaDataList ? widgetMetaDataList.length : 0;
/*******************************************************************************
** helper function, to convert values from a QRecord values map to a regular old
** js object
*******************************************************************************/
function convertQRecordValuesFromMapToObject(record: QRecord): { [name: string]: any }
{
const rs: { [name: string]: any } = {};
if (record && record.values)
{
record.values.forEach((value, key) => rs[key] = value);
}
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);
forceUpdate();
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, tableName: string)
{
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[] = [];
@ -254,12 +445,22 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const topRightInsideCardIcon = widgetMetaData.icons.get("topRightInsideCard");
if (topRightInsideCardIcon)
{
labelAdditionalComponentsRight.push(new HeaderIcon(topRightInsideCardIcon.name, topRightInsideCardIcon.path, topRightInsideCardIcon.color));
labelAdditionalComponentsRight.push(new HeaderIcon(topRightInsideCardIcon.name, topRightInsideCardIcon.path, topRightInsideCardIcon.color, "topRightInsideCard"));
}
}
const labelAdditionalComponentsLeft: LabelComponent[] = [];
if (widgetMetaData && widgetMetaData.icons)
{
const topLeftInsideCardIcon = widgetMetaData.icons.get("topLeftInsideCard");
if (topLeftInsideCardIcon)
{
labelAdditionalComponentsLeft.push(new HeaderIcon(topLeftInsideCardIcon.name, topLeftInsideCardIcon.path, topLeftInsideCardIcon.color, "topLeftInsideCard"));
}
}
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
@ -274,6 +475,30 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
/>
)
}
{
widgetMetaData.type === "alert" && widgetData[i]?.html && !widgetData[i]?.hideWidget && (
<Widget
omitPadding={true}
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<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>
)
}
{
widgetMetaData.type === "usaMap" && (
<USMapWidget
@ -294,6 +519,20 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
/>
)
}
{
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
@ -302,6 +541,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<StackedBarChart data={widgetData[i]?.chartData} chartSubheaderData={widgetData[i]?.chartSubheaderData} />
</Widget>
@ -314,6 +554,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
showReloadControl={false}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<div className="widgetProcessMidDiv" style={{height: "100%"}}>
<ProcessRun process={widgetData[i]?.processMetaData} defaultProcessValues={widgetData[i]?.defaultValues} isWidget={true} forceReInit={widgetCounter} />
@ -327,6 +569,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<Box sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px"}}>
<Box padding="1rem" sx={{width: "100%"}}>
@ -342,6 +586,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetMetaData={widgetMetaData}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
widgetData={widgetData[i]}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<Box>
<MDTypography component="div" variant="button" color="text" fontWeight="light">
@ -373,8 +619,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
isChild={areChildren}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<StatisticsCard
widgetMetaData={widgetMetaData}
data={widgetData[i]}
increaseIsGood={true}
/>
@ -414,6 +663,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<div>
<PieChart
@ -427,9 +677,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
}
{
widgetMetaData.type === "divider" && (
<Box>
<DividerWidget />
</Box>
<DividerWidget />
)
}
{
@ -449,6 +697,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<DefaultLineChart sx={{alignItems: "center"}}
data={widgetData[i]?.chartData}
@ -461,8 +711,15 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
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]}
parentRecord={record}
/>
)
@ -477,6 +734,34 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
/>
)
}
{
widgetMetaData.type === "composite" && (
<Widget
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<CompositeWidget widgetMetaData={widgetMetaData} data={widgetData[i]} actionCallback={actionCallback} values={values} />
</Widget>
)
}
{
widgetMetaData.type === "block" && (
<Widget
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<WidgetBlock widgetMetaData={widgetMetaData} block={widgetData[i]} />
</Widget>
)
}
{
widgetMetaData.type === "dataBagViewer" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&
@ -493,11 +778,33 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
</Widget>
)
}
{
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>
);
};
if(wrapWidgetsInTabPanels)
if (wrapWidgetsInTabPanels)
{
omitWrappingGridContainer = true;
}
@ -512,8 +819,28 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
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>);
}
@ -523,14 +850,13 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
renderedWidget = (<TabPanel index={i} value={selectedTab} style={{
padding: 0,
margin: "-1rem",
marginBottom: "-3.5rem",
width: "calc(100% + 2rem)"
}}>
{renderedWidget}
</TabPanel>);
}
return (<React.Fragment key={`${widgetMetaData.name}-${i}`}>{renderedWidget}</React.Fragment>)
return (<React.Fragment key={`${widgetMetaData.name}-${i}`}>{renderedWidget}</React.Fragment>);
})
}
</>
@ -538,7 +864,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
const tabs = widgetMetaDataList && wrapWidgetsInTabPanels ?
<Tabs
sx={{m: 0, mb: 1.5, ml: -2, mr: -2, mt: -3,
sx={{
m: 0, mb: 1.5, ml: -2, mr: -2, mt: -3,
"& .MuiTabs-scroller": {
ml: 0
}
@ -551,7 +878,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
<Tab key={widgetMetaData.name} label={widgetMetaData.label} />
))}
</Tabs>
: <></>
: <></>;
return (
widgetCount > 0 ? (
@ -564,6 +891,22 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
</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

@ -33,6 +33,7 @@ import Client from "qqq/utils/qqq/Client";
//////////////////////////////////////////////
export interface ParentWidgetData
{
label?: string;
dropdownLabelList: string[];
dropdownNameList: string[];
dropdownDataList: {
@ -42,6 +43,7 @@ export interface ParentWidgetData
childWidgetNameList: string[];
dropdownNeedsSelectedText?: string;
storeDropdownSelections?: boolean;
csvData?: any[][];
icon?: string;
layoutType: string;
}
@ -64,7 +66,8 @@ interface Props
const qController = Client.getInstance();
function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidgetCallback, entityPrimaryKey, tableName, storeDropdownSelections}: Props, ): JSX.Element
function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidgetCallback, entityPrimaryKey, tableName, storeDropdownSelections}: Props,): JSX.Element
{
const [childUrlParams, setChildUrlParams] = useState((urlParams) ? urlParams : "");
const [qInstance, setQInstance] = useState(null as QInstance);
@ -81,27 +84,27 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
useEffect(() =>
{
if(qInstance && data && data.childWidgetNameList)
if (qInstance && data && data.childWidgetNameList)
{
let widgetMetaDataList = [] as QWidgetMetaData[];
data?.childWidgetNameList.forEach((widgetName: string) =>
{
widgetMetaDataList.push(qInstance.widgets.get(widgetName));
})
});
setWidgets(widgetMetaDataList);
}
}, [qInstance, data, childUrlParams]);
useEffect(() =>
{
setChildUrlParams(urlParams)
setChildUrlParams(urlParams);
}, [urlParams]);
const parentReloadWidgetCallback = (data: string) =>
{
setChildUrlParams(data);
reloadWidgetCallback(data);
}
};
///////////////////////////////////////////////////////////////////////////////////////////
// if this parent widget is in card form, and its children are too, then we need some px //
@ -125,7 +128,7 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
omitPadding={omitPadding}
>
<Box sx={{height: "100%", width: "100%"}} px={px}>
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData} wrapWidgetsInTabPanels={data.layoutType == "TABS"}/>
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData} wrapWidgetsInTabPanels={data.layoutType?.toLowerCase() == "tabs"} />
</Box>
</Widget>
) : null

View File

@ -21,17 +21,22 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
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";
import Switch from "@mui/material/Switch";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import parse from "html-react-parser";
import React, {useEffect, useState} from "react";
import {NavigateFunction, useNavigate} from "react-router-dom";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import WidgetDropdownMenu, {DropdownOption} from "qqq/components/widgets/components/WidgetDropdownMenu";
import {WidgetUtils} from "qqq/components/widgets/WidgetUtils";
import HtmlUtils from "qqq/utils/HtmlUtils";
import React, {useContext, useEffect, useState} from "react";
import {NavigateFunction, useNavigate} from "react-router-dom";
export interface WidgetData
{
@ -56,6 +61,7 @@ interface Props
labelAdditionalComponentsLeft: LabelComponent[];
labelAdditionalElementsLeft: JSX.Element[];
labelAdditionalComponentsRight: LabelComponent[];
labelAdditionalElementsRight: JSX.Element[];
labelBoxAdditionalSx?: any;
widgetMetaData?: QWidgetMetaData;
widgetData?: WidgetData;
@ -76,6 +82,7 @@ Widget.defaultProps = {
labelAdditionalComponentsLeft: [],
labelAdditionalElementsLeft: [],
labelAdditionalComponentsRight: [],
labelAdditionalElementsRight: [],
labelBoxAdditionalSx: {},
omitPadding: false,
};
@ -109,16 +116,18 @@ export class HeaderIcon extends LabelComponent
iconPath: string;
color: string;
coloredBG: boolean;
role: string;
iconColor: string;
bgColor: string;
constructor(iconName: string, iconPath: string, color: string, coloredBG: boolean = true)
constructor(iconName: string, iconPath: string, color: string, role?: string, coloredBG: boolean = true)
{
super();
this.iconName = iconName;
this.iconPath = iconPath;
this.color = color;
this.role = role;
this.coloredBG = coloredBG;
this.iconColor = this.coloredBG ? "#FFFFFF" : this.color;
@ -128,7 +137,7 @@ export class HeaderIcon extends LabelComponent
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
const styles = {
const styles: any = {
width: "1.75rem",
height: "1.75rem",
color: this.iconColor,
@ -136,6 +145,12 @@ export class HeaderIcon extends LabelComponent
borderRadius: "0.25rem"
};
if (this.role == "topLeftInsideCard")
{
styles["order"] = -1;
styles["marginRight"] = "0.5rem";
}
if (this.iconPath)
{
return (<Box sx={{textAlign: "center", ...styles}}><img src={this.iconPath} width="16" height="16" /></Box>);
@ -148,6 +163,76 @@ export class HeaderIcon extends LabelComponent
}
/*******************************************************************************
** a link (actually a button) for in a widget's header
*******************************************************************************/
interface HeaderLinkButtonComponentProps
{
label: string;
onClickCallback: () => void;
disabled?: boolean;
disabledTooltip?: string;
}
HeaderLinkButtonComponent.defaultProps = {
disabled: false,
disabledTooltip: null
};
export function HeaderLinkButtonComponent({label, onClickCallback, disabled, disabledTooltip}: HeaderLinkButtonComponentProps): JSX.Element
{
return (
<Tooltip title={disabledTooltip}>
<span>
<Button disabled={disabled} onClick={() => onClickCallback()} sx={{p: 0}} disableRipple>
<Typography display="inline" textTransform="none" fontSize={"1.125rem"}>
{label}
</Typography>
</Button>
</span>
</Tooltip>
);
}
/*******************************************************************************
**
*******************************************************************************/
interface HeaderToggleComponentProps
{
label: string;
getValue: () => boolean;
onClickCallback: () => void;
disabled?: boolean;
disabledTooltip?: string;
}
HeaderToggleComponent.defaultProps = {
disabled: false,
disabledTooltip: null
};
export function HeaderToggleComponent({label, getValue, onClickCallback, disabled, disabledTooltip}: HeaderToggleComponentProps): JSX.Element
{
const onClick = () =>
{
onClickCallback();
};
return (
<Box alignItems="baseline" mr="-0.75rem">
<Tooltip title={disabledTooltip}>
<span>
<InputLabel sx={{fontSize: "1.125rem", px: "0 !important", cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.65 : 1}} unselectable="on">
{label} <Switch disabled={disabled} checked={getValue()} onClick={onClick} />
</InputLabel>
</span>
</Tooltip>
</Box>
);
}
/*******************************************************************************
**
*******************************************************************************/
@ -157,15 +242,17 @@ export class AddNewRecordButton extends LabelComponent
label: string;
defaultValues: any;
disabledFields: any;
addNewRecordCallback?: () => void;
constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues)
constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues, addNewRecordCallback?: () => void)
{
super();
this.table = table;
this.label = label;
this.defaultValues = defaultValues;
this.disabledFields = disabledFields;
this.addNewRecordCallback = addNewRecordCallback;
}
openEditForm = (navigate: any, table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any) =>
@ -177,7 +264,7 @@ export class AddNewRecordButton extends LabelComponent
{
return (
<Typography variant="body2" p={2} pr={0} display="inline" position="relative" top="-0.5rem">
<Button sx={{mt: 0.75}} onClick={() => this.openEditForm(args.navigate, this.table, null, this.defaultValues, this.disabledFields)}>{this.label}</Button>
<Button sx={{mt: 0.75}} onClick={() => this.addNewRecordCallback ? this.addNewRecordCallback() : this.openEditForm(args.navigate, this.table, null, this.defaultValues, this.disabledFields)}>{this.label}</Button>
</Typography>
);
};
@ -223,22 +310,30 @@ export class Dropdown extends LabelComponent
try
{
const localStorageOption = JSON.parse(localStorage.getItem(localStorageKey));
if(localStorageOption)
if (localStorageOption)
{
const id = localStorageOption.id;
for (let i = 0; i < this.options.length; i++)
if (this.dropdownMetaData.type == "DATE_PICKER")
{
if (this.options[i].id == id)
defaultValue = id;
}
else
{
for (let i = 0; i < this.options.length; i++)
{
defaultValue = this.options[i]
args.dropdownData[args.componentIndex] = defaultValue?.id;
if (this.options[i].id == id)
{
defaultValue = this.options[i];
args.dropdownData[args.componentIndex] = defaultValue?.id;
}
}
}
}
}
catch(e)
catch (e)
{
console.log(`Error getting default value for dropdown [${this.dropdownName}] from local storage`, e)
console.log(`Error getting default value for dropdown [${this.dropdownName}] from local storage`, e);
}
}
@ -249,7 +344,7 @@ export class Dropdown extends LabelComponent
{
for (let i = 0; i < this.options.length; i++)
{
if(this.options[i].id == this.dropdownDefaultValue)
if (this.options[i].id == this.dropdownDefaultValue)
{
defaultValue = this.options[i];
args.dropdownData[args.componentIndex] = defaultValue?.id;
@ -285,6 +380,7 @@ export class Dropdown extends LabelComponent
<Box mb={2} sx={{float: "right"}}>
<WidgetDropdownMenu
name={this.dropdownName}
type={this.dropdownMetaData.type}
defaultValue={defaultValue}
sx={{marginLeft: "1rem"}}
label={label}
@ -317,11 +413,13 @@ export class ReloadControl extends LabelComponent
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (
<Typography variant="body2" py={2} px={0} display="inline" position="relative" top="-0.175rem">
<Tooltip title="Refresh"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}><Icon sx={{color: colors.gray.main, fontSize: 1.125}}>refresh</Icon></Button></Tooltip>
</Typography>
);
return (<Typography key={1} variant="body2" py={0} px={0} display="inline" position="relative" top="-0.25rem">
<Tooltip title="Refresh">
<Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}>
<Icon sx={{color: colors.gray.main, fontSize: 1.125}}>refresh</Icon>
</Button>
</Tooltip>
</Typography>);
};
}
@ -336,15 +434,31 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{
const navigate = useNavigate();
const [dropdownData, setDropdownData] = useState([]);
const [fullScreenWidgetClassName, setFullScreenWidgetClassName] = useState("");
const [reloading, setReloading] = useState(false);
const [dropdownDataJSON, setDropdownDataJSON] = useState("");
const [labelComponentsLeft, setLabelComponentsLeft] = useState([] as LabelComponent[]);
const [labelComponentsRight, setLabelComponentsRight] = useState([] as LabelComponent[]);
////////////////////////////////////////////////////////////////////////////////////////////////////////
// support for using widget (data) label as page header, w/o it disappearing if dropdowns are changed //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const [lastSeenLabel, setLastSeenLabel] = useState("");
const [usingLabelAsTitle, setUsingLabelAsTitle] = useState(false);
const {helpHelpActive} = useContext(QContext);
function renderComponent(component: LabelComponent, componentIndex: number)
{
return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload});
if (component && component.render)
{
return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload});
}
else
{
console.log("Request to render a null component or component without a render function...");
console.log(JSON.stringify(component));
return (<></>);
}
}
useEffect(() =>
@ -371,7 +485,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
// for initial render, put right-components from props into the state variable //
/////////////////////////////////////////////////////////////////////////////////
const stateLabelComponentsRight = [] as LabelComponent[];
// console.log(`${props.widgetMetaData.name} init'ing right-components`);
// console.log(`${props.widgetMetaData.name} initiating right-components`);
if (props.labelAdditionalComponentsRight)
{
props.labelAdditionalComponentsRight.map((component) => stateLabelComponentsRight.push(component));
@ -405,11 +519,14 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{
// console.log(`${props.widgetMetaData.name} building a Dropdown, data is: ${dropdownData}`);
let defaultValue = null;
if(props.widgetData.dropdownDefaultValueList && props.widgetData.dropdownDefaultValueList.length >= index)
if (props.widgetData.dropdownDefaultValueList && props.widgetData.dropdownDefaultValueList.length >= index)
{
defaultValue = props.widgetData.dropdownDefaultValueList[index];
}
updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], props.widgetMetaData.dropdowns[index], dropdownData, defaultValue, props.widgetData.dropdownNameList[index], handleDataChange));
if (props.widgetData?.dropdownLabelList && props.widgetData?.dropdownLabelList[index] && props.widgetMetaData?.dropdowns && props.widgetMetaData?.dropdowns[index] && props.widgetData?.dropdownNameList && props.widgetData?.dropdownNameList[index])
{
updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], props.widgetMetaData.dropdowns[index], dropdownData, defaultValue, props.widgetData.dropdownNameList[index], handleDataChange));
}
});
setLabelComponentsRight(updatedStateLabelComponentsRight);
}
@ -500,18 +617,37 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
};
const toggleFullScreenWidget = () =>
const onExportClick = () =>
{
if (fullScreenWidgetClassName)
if (props.widgetData?.csvData)
{
setFullScreenWidgetClassName("");
const csv = WidgetUtils.widgetCsvDataToString(props.widgetData);
const fileName = WidgetUtils.makeExportFileName(props.widgetData, props.widgetMetaData);
HtmlUtils.download(fileName, csv);
}
else
{
setFullScreenWidgetClassName("fullScreenWidget");
alert("There is no data available to export.");
}
};
///////////////////////////////////////////////////////////////////////////////////////////////////////
// add the export button to the label's left elements, if the meta-data says to show it //
// don't do this for 2 types which themselves add the button (and have custom code to do the export) //
///////////////////////////////////////////////////////////////////////////////////////////////////////
let localLabelAdditionalElementsLeft = [...props.labelAdditionalElementsLeft];
if (props.widgetMetaData?.showExportButton && props.widgetMetaData?.type !== "table" && props.widgetMetaData?.type !== "childRecordList")
{
if (!localLabelAdditionalElementsLeft)
{
localLabelAdditionalElementsLeft = [];
}
localLabelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick));
}
let localLabelAdditionalElementsRight = [...props.labelAdditionalElementsRight];
const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true;
const isSet = (v: any): boolean =>
@ -526,85 +662,130 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
if (hasPermission)
{
needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0);
needLabelBox ||= (props.labelAdditionalElementsLeft && props.labelAdditionalElementsLeft.length > 0);
needLabelBox ||= (localLabelAdditionalElementsLeft && localLabelAdditionalElementsLeft.length > 0);
needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0);
needLabelBox ||= isSet(props.widgetMetaData?.icon);
needLabelBox ||= (localLabelAdditionalElementsRight && localLabelAdditionalElementsRight.length > 0);
needLabelBox ||= isSet(props.widgetData?.icon);
needLabelBox ||= isSet(props.widgetData?.label);
needLabelBox ||= isSet(props.widgetMetaData?.label);
}
//////////////////////////////////////////////////////////////////////////////////////////
// first look for a label in the widget data, which would override that in the metadata //
// note - previously this had a ?: and one was pl={2}, the other was pl={3}... //
//////////////////////////////////////////////////////////////////////////////////////////
const labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label;
const isParentWidget = props.widgetMetaData.type == "parentWidget"; // todo - do we need to know top-level parent, vs. a nested parent?
let labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label;
if (!labelToUse)
{
/////////////////////////////////////////////////////////////////////////////////////////////
// prevent the label from disappearing, especially when it's being used as the page header //
/////////////////////////////////////////////////////////////////////////////////////////////
if (lastSeenLabel && isParentWidget && usingLabelAsTitle)
{
labelToUse = lastSeenLabel;
}
}
let labelElement = (
<Typography sx={{cursor: "default", pl: "auto", pt: props.widgetMetaData.type == "parentWidget" ? "1rem" : "auto", fontWeight: 600}} variant="h6" display="inline">
<Typography sx={{cursor: "default", pl: "auto", fontWeight: 600}} variant={isParentWidget && (props.widgetData.isLabelPageTitle || usingLabelAsTitle) ? "h3" : "h6"} display="inline">
{labelToUse}
</Typography>
);
if (props.widgetMetaData.tooltip)
let sublabelElement = (
<Box key="sublabel" height="20px">
<Typography sx={{position: "relative", top: "-18px"}} variant="caption">
{props.widgetData?.sublabel}
</Typography>
</Box>
);
if (labelToUse && labelToUse != lastSeenLabel)
{
labelElement = <Tooltip title={props.widgetMetaData.tooltip} arrow={false} followCursor={true} placement="bottom-start">{labelElement}</Tooltip>;
setLastSeenLabel(labelToUse);
setUsingLabelAsTitle(props.widgetData.isLabelPageTitle);
}
const helpRoles = ["ALL_SCREENS"];
const slotName = "label";
const showHelp = helpHelpActive || hasHelpContent(props.widgetMetaData?.helpContent?.get(slotName), helpRoles);
if (showHelp)
{
const formattedHelpContent = <HelpContent helpContents={props.widgetMetaData?.helpContent?.get(slotName)} roles={helpRoles} helpContentKey={`widget:${props.widgetMetaData?.name};slot:${slotName}`} />;
labelElement = <Tooltip title={formattedHelpContent} arrow={true} placement="bottom-start">{labelElement}</Tooltip>;
}
else if (props.widgetMetaData?.tooltip)
{
labelElement = <Tooltip title={props.widgetMetaData.tooltip} arrow={true} placement="bottom-start">{labelElement}</Tooltip>;
}
const isTable = props.widgetMetaData.type == "table";
const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true;
const widgetContent =
<Box sx={{width: "100%", height: "100%", minHeight: props.widgetMetaData?.minHeight ? props.widgetMetaData?.minHeight : "initial"}}>
{
needLabelBox &&
<Box display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%", ...props.labelBoxAdditionalSx}} minHeight={"2.5rem"}>
<Box>
{
hasPermission ?
props.widgetMetaData?.icon && (
<Box ml={1} mr={2} mt={-4} sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "64px",
height: "64px",
borderRadius: "8px",
background: colors.info.main,
color: "#ffffff",
float: "left"
}}
>
<Icon fontSize="medium" color="inherit">
{props.widgetMetaData.icon}
</Icon>
</Box>
) :
(
<Box ml={3} mt={-4} sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "64px",
height: "64px",
borderRadius: "8px",
background: colors.info.main,
color: "#ffffff",
float: "left"
}}
>
<Icon fontSize="medium" color="inherit">lock</Icon>
</Box>
<Box display="flex" flexDirection="column">
<Box display="flex" alignItems="baseline">
{
hasPermission ?
props.widgetMetaData?.icon && (
<Box ml={1} mr={2} mt={-4} sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "64px",
height: "64px",
borderRadius: "8px",
background: colors.info.main,
color: "#ffffff",
float: "left"
}}
>
<Icon fontSize="medium" color="inherit">
{props.widgetMetaData.icon}
</Icon>
</Box>
) :
(
<Box ml={3} mt={-4} sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "64px",
height: "64px",
borderRadius: "8px",
background: colors.info.main,
color: "#ffffff",
float: "left"
}}
>
<Icon fontSize="medium" color="inherit">lock</Icon>
</Box>
)
}
{
hasPermission && labelToUse && (labelElement)
}
{
hasPermission && (
labelComponentsLeft.map((component, i) =>
{
return (<React.Fragment key={i}>{renderComponent(component, i)}</React.Fragment>);
})
)
}
{
hasPermission && labelToUse && (labelElement)
}
{
hasPermission && (
labelComponentsLeft.map((component, i) =>
{
return (<span key={i}>{renderComponent(component, i)}</span>);
})
)
}
{props.labelAdditionalElementsLeft}
}
{localLabelAdditionalElementsLeft}
</Box>
<Box key="sublabelContainer" display="flex">
{
hasPermission && props.widgetData?.sublabel && (sublabelElement)
}
</Box>
</Box>
<Box>
{
@ -615,6 +796,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
})
)
}
{localLabelAdditionalElementsRight}
</Box>
</Box>
}
@ -650,17 +832,27 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
{
!errorLoading && props?.footerHTML && (
<Box mt={1} ml={3} mr={3} mb={2} sx={{fontWeight: 300, color: "#7b809a", display: "flex", alignContent: "flex-end", fontSize: "14px"}}>{parse(props.footerHTML)}</Box>
<Box mt={isTable ? "36px" : 1} ml={isTable ? 0 : 3} mr={isTable ? 0 : 3} mb={isTable ? "-12px" : 2} sx={{fontWeight: 300, color: "#7b809a", display: "flex", alignContent: "flex-end", fontSize: "14px"}}>{parse(props.footerHTML)}</Box>
)
}
</Box>;
const padding = props.omitPadding ? "auto" : "24px 16px";
///////////////////////////////////////////////////
// try to make tables fill their entire "parent" //
///////////////////////////////////////////////////
let noCardMarginBottom = "unset";
if (isTable)
{
noCardMarginBottom = "-8px";
}
return props.widgetMetaData?.isCard
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%", p: padding}} className={fullScreenWidgetClassName}>
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%", p: padding}} className="widget inCard">
{widgetContent}
</Card>
: <span style={{width: "100%", padding: padding}}>{widgetContent}</span>;
: <span style={{width: "100%", padding: padding, marginBottom: noCardMarginBottom}} className="widget noCard">{widgetContent}</span>;
}
export default Widget;

View File

@ -0,0 +1,104 @@
/*
* 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 {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";
import DividerBlock from "qqq/components/widgets/blocks/DividerBlock";
import NumberIconBadgeBlock from "qqq/components/widgets/blocks/NumberIconBadgeBlock";
import ProgressBarBlock from "qqq/components/widgets/blocks/ProgressBarBlock";
import TableSubRowDetailRowBlock from "qqq/components/widgets/blocks/TableSubRowDetailRowBlock";
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, actionCallback, values}: WidgetBlockProps): JSX.Element
{
if(!block)
{
return (<Skeleton />);
}
if(!block.values)
{
block.values = {};
}
if(!block.styles)
{
block.styles = {};
}
if(block.blockTypeName == "COMPOSITE")
{
// @ts-ignore - special case for composite type block...
return (<CompositeWidget widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} values={values} />);
}
switch(block.blockTypeName)
{
case "TEXT":
return (<TextBlock widgetMetaData={widgetMetaData} data={block} />);
case "NUMBER_ICON_BADGE":
return (<NumberIconBadgeBlock widgetMetaData={widgetMetaData} data={block} />);
case "UP_OR_DOWN_NUMBER":
return (<UpOrDownNumberBlock widgetMetaData={widgetMetaData} data={block} />);
case "TABLE_SUB_ROW_DETAIL_ROW":
return (<TableSubRowDetailRowBlock widgetMetaData={widgetMetaData} data={block} />);
case "PROGRESS_BAR":
return (<ProgressBarBlock widgetMetaData={widgetMetaData} data={block} />);
case "DIVIDER":
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

@ -0,0 +1,113 @@
/*
* 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 {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 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
**
*******************************************************************************/
export class WidgetUtils
{
/*******************************************************************************
**
*******************************************************************************/
public static generateExportButton = (onExportClick: () => void): JSX.Element =>
{
return (<Typography key={1} variant="body2" py={0} px={0} display="inline" position="relative" top="-0.25rem">
<Tooltip title="Export">
<Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={false}>
<Icon sx={{color: colors.gray.main, fontSize: 1.125}}>save_alt</Icon>
</Button>
</Tooltip>
</Typography>);
};
/*******************************************************************************
**
*******************************************************************************/
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>);
};
/*******************************************************************************
**
*******************************************************************************/
public static widgetCsvDataToString = (data: WidgetData): string =>
{
function isNumeric(x: any)
{
return !isNaN(Number(x));
}
let csv = "";
for (let i = 0; i < data.csvData.length; i++)
{
for (let j = 0; j < data.csvData[i].length; j++)
{
if (j > 0)
{
csv += ",";
}
let cell = data.csvData[i][j];
if (cell && isNumeric(String(cell)))
{
csv += cell;
}
else
{
csv += `"${ValueUtils.cleanForCsv(cell)}"`;
}
}
csv += "\n";
}
return (csv);
};
/*******************************************************************************
**
*******************************************************************************/
public static makeExportFileName = (data: WidgetData, widgetMetaData: QWidgetMetaData): string =>
{
const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
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

@ -0,0 +1,67 @@
/*
* 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 BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders ... a big number, optionally with some other stuff.
**
** ${heading}
** ${number} ${context}
*******************************************************************************/
export default function BigNumberBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
let flexJustifyContent = "normal";
let flexAlignItems = "baseline";
return (
<div style={{width: data.styles.width ?? "auto"}}>
<div style={{fontWeight: "700", fontSize: "0.875rem", color: "#3D3D3D", marginBottom: "-0.5rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="heading">
<span>{data.values.heading}</span>
</BlockElementWrapper>
</div>
<div style={{display: "flex", alignItems: flexAlignItems, justifyContent: flexJustifyContent}}>
<div style={{display: "flex", alignItems: "baseline"}}>
<div style={{fontWeight: "700", fontSize: "2rem", marginRight: "0.25rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="number">
<span style={{color: data.styles.numberColor}}>{data.values.number}</span>
</BlockElementWrapper>
</div>
{
data.values.context &&
<div style={{fontWeight: "500", fontSize: "0.875rem", color: "#7b809a"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="context">
<span>{data.values.context}</span>
</BlockElementWrapper>
</div>
}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,120 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
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;
linkProps?: any;
children: ReactElement;
}
/*******************************************************************************
** For Blocks - wrap their "slot" elements with an optional tooltip and/or link
*******************************************************************************/
export default function BlockElementWrapper({data, metaData, slot, linkProps, children}: BlockElementWrapperProps): JSX.Element
{
const {helpHelpActive} = useContext(QContext);
let link: BlockLink;
let tooltip: BlockTooltip;
if (slot)
{
link = data.linkMap && data.linkMap[slot.toUpperCase()];
if (!link)
{
link = data.link;
}
tooltip = data.tooltipMap && data.tooltipMap[slot.toUpperCase()];
if (!tooltip)
{
tooltip = data.tooltip;
}
}
else
{
link = data.link;
tooltip = data.tooltip;
}
if (!tooltip)
{
const helpRoles = ["ALL_SCREENS"];
///////////////////////////////////////////////////////////////////////////////////////////////
// the full keys in the helpContent table will look like: //
// widget:MyCoolWidget;slot=myBlockId,label (if the block has a blockId in data) //
// widget:MyCoolWidget;slot=label (no blockId; note, label is slot name here) //
// in the widget metaData, the map of helpContent will just have the "slot" portion as a key //
///////////////////////////////////////////////////////////////////////////////////////////////
const key = data.blockId ? `${data.blockId},${slot}` : slot;
const showHelp = helpHelpActive || hasHelpContent(metaData?.helpContent?.get(key), helpRoles);
if (showHelp)
{
const formattedHelpContent = <HelpContent helpContents={metaData?.helpContent?.get(key)} roles={helpRoles} helpContentKey={`widget:${metaData?.name};slot:${key}`} />;
tooltip = {title: formattedHelpContent, placement: "bottom"};
}
}
let rs = children;
if (link && link.href)
{
rs = <Link to={link.href} target={link.target} style={{color: "#546E7A"}} {...linkProps}>{rs}</Link>;
}
if (tooltip)
{
let placement = tooltip.placement ? tooltip.placement.toLowerCase() : "bottom";
// @ts-ignore - placement possible values
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

@ -0,0 +1,64 @@
/*
* 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 {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {CompositeData} from "qqq/components/widgets/CompositeWidget";
export interface BlockData
{
blockId?: string;
blockTypeName: string;
tooltip?: BlockTooltip;
link?: 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;
}
export interface BlockLink
{
href: string;
target: string;
}
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,33 @@
/*
* 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 {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders a simple dividing line
** margins & width are set such that it covers the padding of a card.
** if we need to use it differently, a style attribute should be added to its backend data.
*******************************************************************************/
export default function DividerBlock({}: StandardBlockComponentProps): JSX.Element
{
return (<div style={{margin: "1rem -1rem", width: "calc(100% + 2rem)", borderBottom: "1px solid #E0E0E0"}} />);
}

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

@ -0,0 +1,48 @@
/*
* 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 Icon from "@mui/material/Icon";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders ... a number, and an icon, like a badge.
**
** ${number} ${icon}
*******************************************************************************/
export default function NumberIconBadgeBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<div style={{display: "inline-block", whiteSpace: "nowrap", color: data.styles.color}}>
{
data.values.number &&
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="number">
<span style={{color: data.styles.color, fontSize: "0.875rem"}}>{data.values.number}</span>
</BlockElementWrapper>
}
{
data.values.iconName &&
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="icon">
<Icon style={{color: data.styles.color, fontSize: "1rem", marginLeft: "2px", position: "relative", top: "4px"}}>{data.values.iconName}</Icon>
</BlockElementWrapper>
}
</div>);
}

View File

@ -0,0 +1,70 @@
/*
* 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 Typography from "@mui/material/Typography";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders a progress bar!
**
** Values:
** ${heading}
** [${percent}===___] ${value ?? percent}
**
** Slots:
** ${heading}
** ${bar} ${value}
*******************************************************************************/
export default function ProgressBarBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<Typography component="div" variant="button" color="text" fontWeight="light" sx={{textTransform: "none"}}>
{
data.values.heading &&
<div style={{marginBottom: "0.25rem", fontWeight: 500, color: "#3D3D3D"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="heading">
<span>{data.values.heading}</span>
</BlockElementWrapper>
</div>
}
<div style={{display: "flex", alignItems: "center", marginBottom: "0.75rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="bar" linkProps={{style: {width: "100%"}}}>
<div style={{background: "#E0E0E0", width: "100%", borderRadius: "0.5rem", height: "1rem"}}>
{
data.values.percent > 0 ? <div style={{background: data.styles.barColor ?? "#0062ff", minWidth: "1rem", width: `${data.values.percent}%`, borderRadius: "0.5rem", height: "1rem"}}></div> : <></>
}
</div>
</BlockElementWrapper>
<div style={{width: "60px", textAlign: "right", fontWeight: 600, color: "#3D3D3D"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="value">
<span>{data.values.value ?? `${(data.values.percent as number).toFixed(1)}%`}</span>
</BlockElementWrapper>
</div>
</div>
</Typography>);
}

View File

@ -0,0 +1,54 @@
/*
* 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 BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders a label & value, meant to be used as a detail-row in a
** sub-row within a table widget
**
** ${label} ${value}
*******************************************************************************/
export default function TableSubRowDetailRowBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<div style={{display: "flex", maxWidth: "calc(100% - 24px)", justifyContent: "space-between"}}>
{
data.values.label &&
<div style={{overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="label">
<span style={{color: data.styles.labelColor}}>{data.values.label}</span>
</BlockElementWrapper>
</div>
}
{
data.values.value &&
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="value">
<span style={{color: data.styles.valueColor}}>{data.values.value}</span>
</BlockElementWrapper>
}
</div>
);
}

View File

@ -0,0 +1,164 @@
/*
* 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 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.
**
** ${text}
*******************************************************************************/
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="">
<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

@ -0,0 +1,81 @@
/*
* 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 Icon from "@mui/material/Icon";
import React from "react";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders an up/down icon, a number, and some context
**
** ${icon} ${number} ${context}
*
** or, if style.isStacked:
*
** ${icon} ${number}
** ${context}
*******************************************************************************/
export default function UpOrDownNumberBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
if (!data.styles)
{
data.styles = {};
}
if (!data.values)
{
data.values = {};
}
const UP_ICON = "arrow_drop_up";
const DOWN_ICON = "arrow_drop_down";
const defaultGreenColor = "#2BA83F";
const defaultRedColor = "#FB4141";
const goodOrBadColor = data.styles.colorOverride ?? (data.values.isGood ? defaultGreenColor : defaultRedColor);
const iconName = data.values.isUp ? UP_ICON : DOWN_ICON;
return (
<>
<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">
<>
<Icon sx={{color: goodOrBadColor, alignSelf: "flex-end", fontSize: "2.25rem !important", lineHeight: "0.875rem", height: "1rem", width: "2rem",}}>{iconName}</Icon>
<span style={{color: goodOrBadColor}}>{data.values.number}</span>
</>
</BlockElementWrapper>
</div>
<div style={{fontWeight: 500, fontSize: "0.875rem", color: "#7b809a", marginLeft: "0.25rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="context">
<span>{data.values.context}</span>
</BlockElementWrapper>
</div>
</div>
</>
);
}

View File

@ -39,65 +39,79 @@ ChartJS.register(
Legend
);
export const options = {
maintainAspectRatio: false,
responsive: true,
animation: {
duration: 0
},
elements: {
bar: {
borderRadius: 4
}
},
plugins: {
tooltip: {
// todo - some configs around this
callbacks: {
title: function(context: any)
{
return ("");
},
label: function(context: any)
{
if(context.dataset.label.startsWith(context.label))
{
return `${context.label}: ${context.formattedValue}`;
}
else
export const makeOptions = (data: DefaultChartData) =>
{
return({
maintainAspectRatio: false,
responsive: true,
animation: {
duration: 0
},
elements: {
bar: {
borderRadius: 4
}
},
onHover: function (event: any, elements: any[], chart: any)
{
if(event.type == "mousemove" && elements.length > 0 && data.urls && data.urls.length > elements[0].index && data.urls[elements[0].index])
{
chart.canvas.style.cursor = "pointer";
}
else
{
chart.canvas.style.cursor = "default";
}
},
plugins: {
tooltip: {
// todo - some configs around this
callbacks: {
title: function(context: any)
{
return ("");
},
label: function(context: any)
{
if(context.dataset.label.startsWith(context.label))
{
return `${context.label}: ${context.formattedValue}`;
}
else
{
return ("");
}
}
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
boxHeight: 6,
boxWidth: 6,
padding: 12,
font: {
size: 14
}
}
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
boxHeight: 6,
boxWidth: 6,
padding: 12,
font: {
size: 14
}
}
}
},
scales: {
x: {
stacked: true,
grid: {display: false},
ticks: {autoSkip: false, maxRotation: 90}
scales: {
x: {
stacked: true,
grid: {display: false},
ticks: {autoSkip: false, maxRotation: 90}
},
y: {
stacked: true,
position: "right",
ticks: {precision: 0}
},
},
y: {
stacked: true,
position: "right",
ticks: {precision: 0}
},
},
};
});
}
interface Props
{
@ -151,7 +165,7 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
<Box>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
<Box width="100%" height="300px">
<Bar data={data} options={options} getElementsAtEvent={handleClick} />
<Bar data={data} options={makeOptions(data)} getElementsAtEvent={handleClick} />
</Box>
</Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} />;

View File

@ -70,7 +70,7 @@ function PieChart({description, chartData, chartSubheaderData}: Props): JSX.Elem
chartData.dataset.backgroundColors = chartColors;
}
}
const {data, options} = configs(chartData?.labels || [], chartData?.dataset || {});
const {data, options} = configs(chartData?.labels || [], chartData?.dataset || {}, chartData?.dataset?.urls);
useEffect(() =>
{

View File

@ -23,7 +23,7 @@ import colors from "qqq/assets/theme/base/colors";
const {gradients, dark} = colors;
function configs(labels: any, datasets: any)
function configs(labels: any, datasets: any, urls: string[] | undefined)
{
const backgroundColors = [];
@ -66,6 +66,17 @@ function configs(labels: any, datasets: any)
options: {
maintainAspectRatio: false,
responsive: true,
onHover: function (event: any, elements: any[], chart: any)
{
if(event.type == "mousemove" && elements.length > 0 && urls && urls.length > elements[0].index && urls[elements[0].index])
{
chart.canvas.style.cursor = "pointer";
}
else
{
chart.canvas.style.cursor = "default";
}
},
plugins: {
tooltip: {
callbacks: {

View File

@ -19,18 +19,23 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Collapse, Theme, InputAdornment} from "@mui/material";
import {CalendarTodayOutlined} from "@mui/icons-material";
import {Collapse, InputAdornment, Theme} from "@mui/material";
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 TextField from "@mui/material/TextField";
import {SxProps} from "@mui/system";
import {DatePicker, DateValidationError, LocalizationProvider, PickerChangeHandlerContext, PickerValidDate} from "@mui/x-date-pickers";
import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs";
import {Field, Form, Formik} from "formik";
import React, {useState} from "react";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import MDInput from "qqq/components/legacy/MDInput";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext, useEffect, useState} from "react";
export interface DropdownOption
@ -45,6 +50,7 @@ export interface DropdownOption
interface Props
{
name: string;
type?: string;
defaultValue?: any;
label?: string;
startIcon?: string;
@ -96,7 +102,7 @@ function makeBackendValuesFromFrontendValues(frontendDefaultValues: StartAndEndD
return (backendTimeValues);
}
function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disableClearable, allowBackAndForth, backAndForthInverted, dropdownOptions, onChangeCallback, sx}: Props): JSX.Element
function WidgetDropdownMenu({name, type, defaultValue, label, startIcon, width, disableClearable, allowBackAndForth, backAndForthInverted, dropdownOptions, onChangeCallback, sx}: Props): JSX.Element
{
const [customTimesVisible, setCustomTimesVisible] = useState(defaultValue && defaultValue.id && defaultValue.id.startsWith("custom,"));
const [customTimeValuesFrontend, setCustomTimeValuesFrontend] = useState(parseCustomTimeValuesFromDefaultValue(defaultValue) as StartAndEndDate);
@ -105,16 +111,27 @@ function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disabl
const [isOpen, setIsOpen] = useState(false);
const [value, setValue] = useState(defaultValue);
const [dateValue, setDateValue] = useState(defaultValue);
const [inputValue, setInputValue] = useState("");
const [backDisabled, setBackDisabled] = useState(false);
const [forthDisabled, setForthDisabled] = useState(false);
const {accentColor} = useContext(QContext);
const doForceOpen = (event: React.MouseEvent<HTMLDivElement>) =>
{
setIsOpen(true);
};
useEffect(() =>
{
if (type == "DATE_PICKER")
{
handleOnChange(null, defaultValue, null);
}
}, [defaultValue]);
function getSelectedIndex(value: DropdownOption)
{
let currentIndex = null;
@ -129,9 +146,19 @@ function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disabl
return currentIndex;
}
const navigateBackAndForth = (event: React.MouseEvent, direction: -1 | 1) =>
const navigateBackAndForth = (event: React.MouseEvent, direction: -1 | 1, type: string) =>
{
event.stopPropagation();
if (type == "DATE_PICKER")
{
let currentDate = new Date(dateValue);
currentDate.setDate(currentDate.getDate() + direction);
handleOnChange(null, currentDate, null);
return;
}
let currentIndex = getSelectedIndex(value);
if (currentIndex == null)
@ -156,9 +183,26 @@ function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disabl
};
const handleDatePickerOnChange = (value: PickerValidDate, context: PickerChangeHandlerContext<DateValidationError>) =>
{
if (value.isValid())
{
handleOnChange(null, value.toDate(), null);
}
};
const handleOnChange = (event: any, newValue: any, reason: string) =>
{
setValue(newValue);
if (type == "DATE_PICKER")
{
setDateValue(newValue);
newValue = {"id": new Date(newValue).toLocaleDateString()};
}
else
{
setValue(newValue);
}
const isTimeframeCustom = name == "timeframe" && newValue && newValue.id == "custom";
setCustomTimesVisible(isTimeframeCustom);
@ -250,86 +294,123 @@ function WidgetDropdownMenu({name, defaultValue, label, startIcon, width, disabl
const fontSize = "1rem";
let optionPaddingLeftRems = 0.75;
if(startIcon)
if (startIcon)
{
optionPaddingLeftRems += allowBackAndForth ? 1.5 : 1.75
optionPaddingLeftRems += allowBackAndForth ? 1.5 : 1.75;
}
if(allowBackAndForth)
if (allowBackAndForth)
{
optionPaddingLeftRems += 2.5;
}
return (
dropdownOptions ? (
<Box sx={{whiteSpace: "nowrap", display: "flex",
"& .MuiPopperUnstyled-root": {
border: `1px solid ${colors.grayLines.main}`,
borderTop: "none",
borderRadius: "0 0 0.75rem 0.75rem",
padding: 0,
}, "& .MuiPaper-rounded": {
borderRadius: "0 0 0.75rem 0.75rem",
}
}} className="dashboardDropdownMenu">
<Autocomplete
id={`${label}-combo-box`}
defaultValue={defaultValue}
value={value}
onChange={handleOnChange}
inputValue={inputValue}
onInputChange={handleOnInputChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
open={isOpen}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
size="small"
disablePortal
disableClearable={disableClearable}
options={dropdownOptions}
sx={{
...sx,
cursor: "pointer",
display: "inline-block",
"& .MuiOutlinedInput-notchedOutline": {
border: "none"
},
}}
renderInput={(params: any) =>
<>
<Box sx={{width: `${width}px`, background: "white", borderRadius: isOpen ? "0.75rem 0.75rem 0 0" : "0.75rem", border: `1px solid ${colors.grayLines.main}`, "& *": {cursor: "pointer"}}} display="flex" alignItems="center" onClick={(event) => doForceOpen(event)}>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? 1 : -1)} disabled={backDisabled}><Icon>navigate_before</Icon></IconButton>}
<TextField {...params} placeholder={label} sx={{
"& .MuiInputBase-input": {
fontSize: fontSize
}
}} InputProps={{...params.InputProps, startAdornment: startAdornment/*, endAdornment: endAdornment*/}}
/>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? -1 : 1)} disabled={forthDisabled}><Icon>navigate_next</Icon></IconButton>}
</Box>
</>
}
renderOption={(props, option: DropdownOption) => (
<li {...props} style={{whiteSpace: "normal", fontSize: fontSize, paddingLeft: `${optionPaddingLeftRems}rem`}}>{option.label}</li>
)}
noOptionsText={<Box fontSize={fontSize}>No options found</Box>}
slotProps={{
popper: {
sx: {
width: `${width}px!important`
}
}
}}
/>
{customTimes}
if (type == "DATE_PICKER")
{
return (
<Box sx={{
...sx,
background: "white",
width: "250px",
borderRadius: "0.75rem !important",
border: `1px solid ${colors.grayLines.main}`,
"& *": {cursor: "pointer"}
}} display="flex" alignItems="center" onClick={(event) => doForceOpen(event)}>
{allowBackAndForth && <IconButton sx={{padding: 0, margin: "8px"}} onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? 1 : -1, type)} disabled={backDisabled}><Icon>navigate_before</Icon></IconButton>}
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
sx={{paddingRight: "8px"}}
defaultValue={dayjs(defaultValue)}
name={name}
value={dayjs(dateValue)}
onChange={handleDatePickerOnChange}
slots={{
openPickerIcon: CalendarTodayOutlined
}}
slotProps={{
openPickerIcon: {sx: {fontSize: "1.25rem !important", color: "#757575"}},
actionBar: {actions: ["today"]},
textField: {variant: "standard", InputProps: {sx: {fontSize: "16px", color: "#495057"}, disableUnderline: true}}
}}
/>
</LocalizationProvider>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? -1 : 1, type)} disabled={forthDisabled}><Icon>navigate_next</Icon></IconButton>}
</Box>
) : null
);
);
}
else
{
return (
dropdownOptions ? (
<Box sx={{
whiteSpace: "nowrap", display: "flex",
"& .MuiPopperUnstyled-root": {
border: `1px solid ${colors.grayLines.main}`,
borderTop: "none",
borderRadius: "0 0 0.75rem 0.75rem",
padding: 0,
}, "& .MuiPaper-rounded": {
borderRadius: "0 0 0.75rem 0.75rem",
}
}} className="dashboardDropdownMenu">
<Autocomplete
id={`${label}-combo-box`}
defaultValue={defaultValue}
value={value}
onChange={handleOnChange}
inputValue={inputValue}
onInputChange={handleOnInputChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
open={isOpen}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
size="small"
disablePortal
disableClearable={disableClearable}
options={dropdownOptions}
sx={{
...sx,
cursor: "pointer",
display: "inline-block",
"& .MuiOutlinedInput-notchedOutline": {
border: "none"
},
}}
renderInput={(params: any) =>
<>
<Box sx={{width: `${width}px`, background: "white", borderRadius: isOpen ? "0.75rem 0.75rem 0 0" : "0.75rem", border: `1px solid ${colors.grayLines.main}`, "& *": {cursor: "pointer"}}} display="flex" alignItems="center" onClick={(event) => doForceOpen(event)}>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? 1 : -1, type)} disabled={backDisabled}><Icon>navigate_before</Icon></IconButton>}
<TextField {...params} placeholder={label} sx={{
"& .MuiInputBase-input": {
fontSize: fontSize
}
}} InputProps={{...params.InputProps, startAdornment: startAdornment/*, endAdornment: endAdornment*/}}
/>
{allowBackAndForth && <IconButton onClick={(event) => navigateBackAndForth(event, backAndForthInverted ? -1 : 1, type)} disabled={forthDisabled}><Icon>navigate_next</Icon></IconButton>}
</Box>
</>
}
renderOption={(props, option: DropdownOption) => (
<li {...props} style={{whiteSpace: "normal", fontSize: fontSize, paddingLeft: `${optionPaddingLeftRems}rem`}}>{option.label}</li>
)}
noOptionsText={<Box fontSize={fontSize}>No options found</Box>}
slotProps={{
popper: {
sx: {
width: `${width}px!important`
}
}
}}
/>
{customTimes}
</Box>
) : null
);
}
}
export default WidgetDropdownMenu;

View File

@ -25,14 +25,13 @@ 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 {Chip} from "@mui/material";
import Alert from "@mui/material/Alert";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemAvatar from "@mui/material/ListItemAvatar";
@ -42,8 +41,6 @@ import Snackbar from "@mui/material/Snackbar";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import Typography from "@mui/material/Typography";
import React, {useReducer, useState} from "react";
import AceEditor from "react-ace";
import DataBagDataEditor, {DataBagDataEditorProps} from "qqq/components/databags/DataBagDataEditor";
import DataBagPreview from "qqq/components/databags/DataBagPreview";
import TabPanel from "qqq/components/misc/TabPanel";
@ -53,10 +50,13 @@ 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";
import "ace-builds/src-noconflict/theme-github";
import React, {useReducer, useState} from "react";
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/ext-language_tools";
const qController = Client.getInstance();
@ -64,12 +64,11 @@ const qController = Client.getInstance();
// Declaring props types for ViewForm
interface Props
{
dataBagId: number
dataBagId: number;
}
DataBagViewer.defaultProps =
{
};
{};
export default function DataBagViewer({dataBagId}: Props): JSX.Element
{
@ -77,12 +76,12 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
const [versionRecordList, setVersionRecordList] = useState(null as QRecord[]);
const [selectedVersionRecord, setSelectedVersionRecord] = useState(null as QRecord);
const [currentVersionId , setCurrentVersionId] = useState(null as number);
const [currentVersionId, setCurrentVersionId] = useState(null as number);
const [notFoundMessage, setNotFoundMessage] = useState(null);
const [selectedTab, setSelectedTab] = useState(0);
const [editorProps, setEditorProps] = useState(null as DataBagDataEditorProps);
const [successText, setSuccessText] = useState(null as string);
const [failText, setFailText] = useState(null as string)
const [failText, setFailText] = useState(null as string);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [loadingSelectedVersion, _] = useState(new LoadingState(forceUpdate, "loading"));
@ -100,13 +99,13 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
const criteria = [new QFilterCriteria("dataBagId", QCriteriaOperator.EQUALS, [dataBagId])];
const orderBys = [new QFilterOrderBy("sequenceNo", false)];
const filter = new QQueryFilter(criteria, orderBys, "AND", 0, 25);
const filter = new QQueryFilter(criteria, orderBys, null, "AND", 0, 25);
const versions = await qController.query("dataBagVersion", filter);
console.log("Fetched versions:");
console.log(versions);
setVersionRecordList(versions);
if(versions && versions.length > 0)
if (versions && versions.length > 0)
{
setCurrentVersionId(versions[0].values.get("id"));
const latestVersion = await qController.get("dataBagVersion", versions[0].values.get("id"));
@ -121,7 +120,7 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
{
if (e instanceof QException)
{
if ((e as QException).status === "404")
if ((e as QException).status === 404)
{
setNotFoundMessage("Data bag data could not be found.");
return;
@ -362,7 +361,7 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
<Typography variant="h6" pl={3}>Data Preview (Version {selectedVersionRecord?.values?.get("sequenceNo")})</Typography>
</Box>
<Box height="400px" overflow="auto" ml={1} fontSize="14px">
{loadingSelectedVersion.isNotLoading() && selectedTab == 1 && selectedVersionRecord?.values?.get("data") && <DataBagPreview json={selectedVersionRecord?.values?.get("data")} /> }
{loadingSelectedVersion.isNotLoading() && selectedTab == 1 && selectedVersionRecord?.values?.get("data") && <DataBagPreview json={selectedVersionRecord?.values?.get("data")} />}
{loadingSelectedVersion.isLoadingSlow() && <Box pl={3}>Loading...</Box>}
</Box>
</Grid>
@ -377,7 +376,7 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
<Modal open={editorProps !== null} onClose={(event, reason) => closeEditingScript(event, reason)}>
<DataBagDataEditor
closeCallback={closeEditingScript}
{... editorProps}
{...editorProps}
/>
</Modal>
}

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

@ -0,0 +1,466 @@
/*
* 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 {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";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Modal from "@mui/material/Modal";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import AdvancedQueryPreview from "qqq/components/query/AdvancedQueryPreview";
import {getCurrentSortIndicator} from "qqq/components/query/BasicAndAdvancedQueryControls";
import Widget, {HeaderLinkButtonComponent} from "qqq/components/widgets/Widget";
import QQueryColumns, {Column} from "qqq/models/query/QQueryColumns";
import RecordQuery from "qqq/pages/records/query/RecordQuery";
import Client from "qqq/utils/qqq/Client";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {useContext, useEffect, useRef, useState} from "react";
interface FilterAndColumnsSetupWidgetProps
{
isEditable: boolean,
widgetMetaData: QWidgetMetaData,
widgetData: any,
recordValues: { [name: string]: any },
onSaveCallback?: (values: { [name: string]: any }) => void,
label?: string
}
FilterAndColumnsSetupWidget.defaultProps = {
onSaveCallback: null
};
export const buttonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.75rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
paddingLeft: "1rem",
paddingRight: "1rem",
opacity: "1",
color: colors.dark.main,
"&:hover": {color: colors.dark.main},
"&:focus": {color: colors.dark.main},
"&:focus:not(:hover)": {color: colors.dark.main},
};
export const unborderedButtonSX = Object.assign({}, buttonSX);
unborderedButtonSX.border = "none !important";
unborderedButtonSX.opacity = "0.7";
const qController = Client.getInstance();
/*******************************************************************************
** Component for editing the main setup of a report - that is: filter & columns
*******************************************************************************/
export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback, label}: FilterAndColumnsSetupWidgetProps): JSX.Element
{
const [modalOpen, setModalOpen] = useState(false);
const [hideColumns] = useState(widgetData?.hideColumns);
const [hidePreview] = useState(widgetData?.hidePreview);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [filterFieldName] = useState(widgetData?.filterFieldName ?? "queryFilterJson");
const [columnsFieldName] = useState(widgetData?.columnsFieldName ?? "columnsJson");
const [alertContent, setAlertContent] = useState(null as string);
//////////////////////////////////////////////////////////////////////////////////////////////////
// we'll actually keep 2 copies of the query filter around here - //
// the one in the record (as json) is one that the backend likes (e.g., possible values as ids) //
// this "frontend" one is one that the frontend can use (possible values as objects w/ labels). //
//////////////////////////////////////////////////////////////////////////////////////////////////
const [frontendQueryFilter, setFrontendQueryFilter] = useState(null as QQueryFilter);
const {helpHelpActive} = useContext(QContext);
const recordQueryRef = useRef();
/////////////////////////////
// load values from record //
/////////////////////////////
let columns: QQueryColumns = null;
let usingDefaultEmptyFilter = false;
let queryFilter = recordValues[filterFieldName] && JSON.parse(recordValues[filterFieldName]) as QQueryFilter;
const defaultFilterFields = widgetData?.filterDefaultFieldNames;
if (!queryFilter)
{
queryFilter = new QQueryFilter();
if (defaultFilterFields?.length == 0)
{
usingDefaultEmptyFilter = true;
}
}
else
{
queryFilter = Object.assign(new QQueryFilter(), queryFilter);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
// 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[columnsFieldName])
{
columns = QQueryColumns.buildFromJSON(recordValues[columnsFieldName]);
}
//////////////////////////////////////////////////////////////////////
// load tableMetaData initially, and if/when selected table changes //
//////////////////////////////////////////////////////////////////////
useEffect(() =>
{
////////////////////////////////////////////////////////////////////////////////////////
// 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(tableName);
setTableMetaData(tableMetaData);
const queryFilterForFrontend = Object.assign({}, queryFilter);
await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilterForFrontend);
setFrontendQueryFilter(queryFilterForFrontend);
})();
}
}, [JSON.stringify(recordValues)]);
/*******************************************************************************
**
*******************************************************************************/
function openEditor()
{
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);
}
}
/*******************************************************************************
**
*******************************************************************************/
function saveClicked()
{
if (!onSaveCallback)
{
console.log("onSaveCallback was not defined");
return;
}
// @ts-ignore possibly 'undefined'.
const view = recordQueryRef?.current?.getCurrentView();
view.queryColumns.sortColumnsFixingPinPositions();
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// keep the query filter that came from the recordQuery screen as the front-end version (w/ possible value objects) //
// but prep a copy of it for the backend, to stringify as json in the record being edited //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
setFrontendQueryFilter(view.queryFilter);
const filter = FilterUtils.prepQueryFilterForBackend(tableMetaData, view.queryFilter);
const rs: { [key: string]: any } = {};
rs[filterFieldName] = JSON.stringify(filter);
rs[columnsFieldName] = JSON.stringify(view.queryColumns);
onSaveCallback(rs);
closeEditor();
}
/*******************************************************************************
**
*******************************************************************************/
function closeEditor(event?: {}, reason?: "backdropClick" | "escapeKeyDown")
{
if (reason == "backdropClick" || reason == "escapeKeyDown")
{
return;
}
setModalOpen(false);
}
/*******************************************************************************
**
*******************************************************************************/
function renderColumn(column: Column): JSX.Element
{
const [field, table] = FilterUtils.getField(tableMetaData, column.name);
if (!column || !column.isVisible || column.name == "__check__" || !field)
{
return (<React.Fragment />);
}
const tableLabelPart = table.name != tableMetaData.name ? table.label + ": " : "";
return (<Box mr="0.375rem" mb="0.5rem" border={`1px solid ${colors.grayLines.main}`} borderRadius="0.75rem" p="0.25rem 0.75rem">
{tableLabelPart}{field.label}
</Box>);
}
/*******************************************************************************
**
*******************************************************************************/
function mayShowQuery(): boolean
{
if (tableMetaData)
{
if (frontendQueryFilter?.criteria?.length > 0 || frontendQueryFilter?.subFilters?.length > 0)
{
return (true);
}
}
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
function mayShowColumns(): boolean
{
if (tableMetaData)
{
for (let i = 0; i < columns?.columns?.length; i++)
{
if (columns.columns[i].isVisible && columns.columns[i].name != "__check__")
{
return (true);
}
}
}
return (false);
}
const helpRoles = isEditable ? [recordValues["id"] ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] : ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
/*******************************************************************************
**
*******************************************************************************/
function showHelp(slot: string)
{
return (helpHelpActive || hasHelpContent(widgetMetaData?.helpContent?.get(slot), helpRoles));
}
/*******************************************************************************
**
*******************************************************************************/
function getHelpContent(slot: string)
{
const key = `widget:${widgetMetaData.name};slot:${slot}`;
return <HelpContent helpContents={widgetMetaData?.helpContent?.get(slot)} roles={helpRoles} helpContentKey={key} />;
}
/////////////////////////////////////////////////
// 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)
{
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} />);
}
}
return (<Widget widgetMetaData={widgetMetaData} labelAdditionalElementsRight={labelAdditionalElementsRight}>
<React.Fragment>
{
showHelp("sectionSubhead") &&
<Box color={colors.gray.main} pb={"0.5rem"} fontSize={"0.875rem"}>
{getHelpContent("sectionSubhead")}
</Box>
}
<Collapse in={Boolean(alertContent)}>
<Alert severity="error" sx={{mt: 1.5, mb: 0.5}} onClose={() => setAlertContent(null)}>{alertContent}</Alert>
</Collapse>
<Box pt="0.5rem">
<Box display="flex" justifyContent="space-between" alignItems="center">
<h5>{label ?? "Query Filter"}</h5>
<Box fontSize="0.75rem" fontWeight="700">{mayShowQuery() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>
</Box>
{
mayShowQuery() &&
<AdvancedQueryPreview tableMetaData={tableMetaData} queryFilter={frontendQueryFilter} isEditable={false} isQueryTooComplex={frontendQueryFilter.subFilters?.length > 0} removeCriteriaByIndexCallback={null} />
}
{
!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 &&
<Tooltip title={selectTableFirstTooltipTitle}>
<span><Button disabled={!recordValues["tableName"]} sx={{mb: "0.125rem", ...unborderedButtonSX}} onClick={openEditor}>+ Add Filters</Button></span>
</Tooltip>
}
{
!isEditable && <Box color={colors.gray.main}>No filters are configured.</Box>
}
</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>
)}
{!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)}>
<div>
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
<Card sx={{m: "2rem", p: "2rem"}}>
<h3>Edit Filters and Columns</h3>
{
showHelp("modalSubheader") &&
<Box color={colors.gray.main} pb={"0.5rem"}>
{getHelpContent("modalSubheader")}
</Box>
}
{
tableMetaData && <RecordQuery
allowVariables={widgetData?.allowVariables}
ref={recordQueryRef}
table={tableMetaData}
usage="reportSetup"
isModal={true}
initialQueryFilter={usingDefaultEmptyFilter ? null : frontendQueryFilter}
initialColumns={columns}
/>
}
<Box>
<Box display="flex" justifyContent="flex-end">
<QCancelButton disabled={false} onClickHandler={closeEditor} />
<QSaveButton label="OK" iconName="check" disabled={false} onClickHandler={saveClicked} />
</Box>
</Box>
</Card>
</Box>
</div>
</Modal>
}
</React.Fragment>
</Widget>);
}

View File

@ -0,0 +1,209 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import type {Identifier, XYCoord} from "dnd-core";
import colors from "qqq/assets/theme/base/colors";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import {DragItemTypes, fieldAutoCompleteTextFieldSX, getSelectedFieldForAutoComplete, xIconButtonSX} from "qqq/components/widgets/misc/PivotTableSetupWidget";
import {PivotTableDefinition, PivotTableGroupBy} from "qqq/models/misc/PivotTableDefinitionModels";
import React, {FC, useRef} from "react";
import {useDrag, useDrop} from "react-dnd";
/*******************************************************************************
** component props
*******************************************************************************/
export interface PivotTableGroupByElementProps
{
id: string;
index: number;
dragCallback: (rowsOrColumns: "rows" | "columns", dragIndex: number, hoverIndex: number) => void;
metaData: QInstance;
tableMetaData: QTableMetaData;
pivotTableDefinition: PivotTableDefinition;
usedGroupByFieldNames: string[];
availableFieldNames: string[];
isEditable: boolean;
groupBy: PivotTableGroupBy;
rowsOrColumns: "rows" | "columns";
callback: () => void;
attemptedSubmit?: boolean;
}
/*******************************************************************************
** item to support react-dnd
*******************************************************************************/
interface DragItem
{
index: number;
id: string;
type: string;
}
/*******************************************************************************
**
*******************************************************************************/
export const PivotTableGroupByElement: FC<PivotTableGroupByElementProps> = ({id, index, dragCallback, rowsOrColumns, metaData, tableMetaData, pivotTableDefinition, groupBy, usedGroupByFieldNames, availableFieldNames, isEditable, callback, attemptedSubmit}) =>
{
////////////////////////////////////////////////////////////////////////////
// credit: https://react-dnd.github.io/react-dnd/examples/sortable/simple //
////////////////////////////////////////////////////////////////////////////
const ref = useRef<HTMLDivElement>(null);
const [{handlerId}, drop] = useDrop<DragItem, void, { handlerId: Identifier | null }>(
{
accept: rowsOrColumns == "rows" ? DragItemTypes.ROW : DragItemTypes.COLUMN,
collect(monitor)
{
return {
handlerId: monitor.getHandlerId(),
};
},
hover(item: DragItem, monitor)
{
if (!ref.current)
{
return;
}
const dragIndex = item.index;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex)
{
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY)
{
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY)
{
return;
}
// Time to actually perform the action
dragCallback(rowsOrColumns, dragIndex, hoverIndex);
// Note: we're mutating the monitor item here! Generally it's better to avoid mutations,
// but it's good here for the sake of performance to avoid expensive index searches.
item.index = hoverIndex;
},
});
const [{isDragging}, drag, preview] = useDrag({
type: rowsOrColumns == "rows" ? DragItemTypes.ROW : DragItemTypes.COLUMN,
item: () =>
{
return {id, index};
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging(),
}),
});
/*******************************************************************************
**
*******************************************************************************/
const handleFieldChange = (event: any, newValue: any, reason: string) =>
{
groupBy.fieldName = newValue ? newValue.fieldName : null;
callback();
};
/*******************************************************************************
**
*******************************************************************************/
function removeGroupBy(index: number, rowsOrColumns: "rows" | "columns")
{
pivotTableDefinition[rowsOrColumns].splice(index, 1);
callback();
}
if (!isEditable)
{
const selectedField = getSelectedFieldForAutoComplete(tableMetaData, groupBy.fieldName);
if (selectedField)
{
const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label;
return (<Box><Box display="inline-block" mr="0.375rem" mb="0.5rem" border={`1px solid ${colors.grayLines.main}`} borderRadius="0.75rem" p="0.25rem 0.75rem">{label}</Box></Box>);
}
return (<React.Fragment />);
}
preview(drop(ref));
const showError = attemptedSubmit && !groupBy.fieldName;
return (<Box ref={ref} display="flex" p="0.5rem" pl="0" gap="0.5rem" alignItems="center" sx={{backgroundColor: "white", opacity: isDragging ? 0 : 1}} data-handler-id={handlerId}>
<Box>
<Icon ref={drag} sx={{cursor: "ns-resize"}}>drag_indicator</Icon>
</Box>
<Box width="100%">
<FieldAutoComplete
id={`${rowsOrColumns}-${index}`}
label={null}
variant="outlined"
textFieldSX={fieldAutoCompleteTextFieldSX}
metaData={metaData}
tableMetaData={tableMetaData}
handleFieldChange={handleFieldChange}
hiddenFieldNames={usedGroupByFieldNames}
availableFieldNames={availableFieldNames}
defaultValue={getSelectedFieldForAutoComplete(tableMetaData, groupBy.fieldName)}
hasError={showError}
noOptionsText="There are no fields available."
/>
</Box>
<Box>
<Button sx={xIconButtonSX} onClick={() => removeGroupBy(index, rowsOrColumns)}><Icon>clear</Icon></Button>
</Box>
</Box>);
};

View File

@ -0,0 +1,870 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import Alert from "@mui/material/Alert";
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 Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import QContext from "QContext";
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 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";
import Client from "qqq/utils/qqq/Client";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {useCallback, useContext, useEffect, useReducer, useState} from "react";
import {DndProvider} from "react-dnd";
import {HTML5Backend} from "react-dnd-html5-backend";
export const DragItemTypes =
{
ROW: "row",
COLUMN: "column",
VALUE: "value"
};
export const xIconButtonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.75rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
width: "40px",
minWidth: "40px",
paddingLeft: 0,
paddingRight: 0,
color: colors.error.main,
"&:hover": {color: colors.error.main},
"&:focus": {color: colors.error.main},
"&:focus:not(:hover)": {color: colors.error.main},
};
export const fieldAutoCompleteTextFieldSX =
{
"& .MuiInputBase-input": {fontSize: "1rem", padding: "0 !important"}
};
/*******************************************************************************
**
*******************************************************************************/
export function getSelectedFieldForAutoComplete(tableMetaData: QTableMetaData, fieldName: string)
{
if (fieldName)
{
let [field, fieldTable] = FilterUtils.getField(tableMetaData, fieldName);
if (field && fieldTable)
{
return ({field: field, table: fieldTable, fieldName: fieldName});
}
}
return (null);
}
/*******************************************************************************
** component props
*******************************************************************************/
interface PivotTableSetupWidgetProps
{
isEditable: boolean;
widgetMetaData: QWidgetMetaData;
recordValues: { [name: string]: any };
onSaveCallback?: (values: { [name: string]: any }) => void;
}
/*******************************************************************************
** default values for props
*******************************************************************************/
PivotTableSetupWidget.defaultProps = {
onSaveCallback: null
};
const qController = Client.getInstance();
/*******************************************************************************
** Component to edit the setup of a Pivot Table - rows, columns, values!
*******************************************************************************/
export default function PivotTableSetupWidget({isEditable, widgetMetaData, recordValues, onSaveCallback}: PivotTableSetupWidgetProps): JSX.Element
{
const [metaData, setMetaData] = useState(null as QInstance);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [modalOpen, setModalOpen] = useState(false);
const [enabled, setEnabled] = useState(!!recordValues["usePivotTable"]);
const [attemptedSubmit, setAttemptedSubmit] = useState(false);
const [errorAlert, setErrorAlert] = useState(null as string);
const [pivotTableDefinition, setPivotTableDefinition] = useState(null as PivotTableDefinition);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
///////////////////////////////////////////////////////////////////////////////////
// this is a copy of pivotTableDefinition, that we'll render in the modal. //
// then on-save, we'll move it to pivotTableDefinition, e.g., the actual record. //
///////////////////////////////////////////////////////////////////////////////////
const [modalPivotTableDefinition, setModalPivotTableDefinition] = useState(null as PivotTableDefinition);
const [usedGroupByFieldNames, setUsedGroupByFieldNames] = useState([] as string[]);
const [usedValueFieldNames, setUsedValueByFieldNames] = useState([] as string[]);
const [availableFieldNames, setAvailableFieldNames] = useState([] as string[]);
const {helpHelpActive} = useContext(QContext);
//////////////////
// initial load //
//////////////////
useEffect(() =>
{
if (!pivotTableDefinition)
{
let originalPivotTableDefinition = recordValues["pivotTableJson"] && JSON.parse(recordValues["pivotTableJson"]) as PivotTableDefinition;
if (originalPivotTableDefinition)
{
setEnabled(true);
}
else if (!originalPivotTableDefinition)
{
originalPivotTableDefinition = new PivotTableDefinition();
}
for (let i = 0; i < originalPivotTableDefinition?.rows?.length; i++)
{
if (!originalPivotTableDefinition?.rows[i].key)
{
originalPivotTableDefinition.rows[i].key = PivotObjectKey.next();
}
}
for (let i = 0; i < originalPivotTableDefinition?.columns?.length; i++)
{
if (!originalPivotTableDefinition?.columns[i].key)
{
originalPivotTableDefinition.columns[i].key = PivotObjectKey.next();
}
}
for (let i = 0; i < originalPivotTableDefinition?.values?.length; i++)
{
if (!originalPivotTableDefinition?.values[i].key)
{
originalPivotTableDefinition.values[i].key = PivotObjectKey.next();
}
}
setPivotTableDefinition(originalPivotTableDefinition);
updateUsedGroupByFieldNames(originalPivotTableDefinition);
updateUsedValueFieldNames(modalPivotTableDefinition);
}
if (recordValues["columnsJson"])
{
updateAvailableFieldNames(JSON.parse(recordValues["columnsJson"]) as QQueryColumns);
}
(async () =>
{
setMetaData(await qController.loadMetaData());
})();
});
/////////////////////////////////////////////////////////////////////
// handle the table name changing - load current table's meta-data //
/////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"]))
{
(async () =>
{
const tableMetaData = await qController.loadTableMetaData(recordValues["tableName"]);
setTableMetaData(tableMetaData);
})();
}
}, [recordValues]);
const helpRoles = isEditable ? [recordValues["id"] ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] : ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
/*******************************************************************************
**
*******************************************************************************/
function showHelp(slot: string)
{
return (helpHelpActive || hasHelpContent(widgetMetaData?.helpContent?.get(slot), helpRoles));
}
/*******************************************************************************
**
*******************************************************************************/
function getHelpContent(slot: string)
{
const key = `widget:${widgetMetaData.name};slot:${slot}`;
return <HelpContent helpContents={widgetMetaData?.helpContent?.get(slot)} roles={helpRoles} helpContentKey={key} />;
}
/*******************************************************************************
**
*******************************************************************************/
function toggleEnabled()
{
const newEnabled = !!!getEnabled();
setEnabled(newEnabled);
onSaveCallback({usePivotTable: newEnabled});
if (!newEnabled)
{
onSaveCallback({pivotTableJson: null});
}
}
/*******************************************************************************
**
*******************************************************************************/
function getEnabled()
{
return (enabled);
}
/*******************************************************************************
**
*******************************************************************************/
function addGroupBy(rowsOrColumns: "rows" | "columns")
{
if (!modalPivotTableDefinition[rowsOrColumns])
{
modalPivotTableDefinition[rowsOrColumns] = [];
}
modalPivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy());
validateForm();
forceUpdate();
}
/*******************************************************************************
**
*******************************************************************************/
function childElementChangedCallback()
{
updateUsedGroupByFieldNames(modalPivotTableDefinition);
updateUsedValueFieldNames(modalPivotTableDefinition);
validateForm();
forceUpdate();
}
/*******************************************************************************
**
*******************************************************************************/
function addValue()
{
if (!modalPivotTableDefinition.values)
{
modalPivotTableDefinition.values = [];
}
modalPivotTableDefinition.values.push(new PivotTableValue());
validateForm();
forceUpdate();
}
/*******************************************************************************
**
*******************************************************************************/
function removeValue(index: number)
{
modalPivotTableDefinition.values.splice(index, 1);
validateForm();
forceUpdate();
}
/*******************************************************************************
**
*******************************************************************************/
function updateUsedGroupByFieldNames(ptd: PivotTableDefinition = pivotTableDefinition)
{
const usedFieldNames: string[] = [];
for (let i = 0; i < ptd?.rows?.length; i++)
{
usedFieldNames.push(ptd?.rows[i].fieldName);
}
for (let i = 0; i < ptd?.columns?.length; i++)
{
usedFieldNames.push(ptd?.columns[i].fieldName);
}
setUsedGroupByFieldNames(usedFieldNames);
}
/*******************************************************************************
**
*******************************************************************************/
function updateUsedValueFieldNames(ptd: PivotTableDefinition = pivotTableDefinition)
{
const usedFieldNames: string[] = [];
for (let i = 0; i < ptd?.values?.length; i++)
{
usedFieldNames.push(ptd?.values[i].fieldName);
}
setUsedValueByFieldNames(usedFieldNames);
}
/*******************************************************************************
**
*******************************************************************************/
function updateAvailableFieldNames(columns: QQueryColumns)
{
const fieldNames: string[] = [];
for (let i = 0; i < columns?.columns?.length; i++)
{
if (columns.columns[i].isVisible)
{
fieldNames.push(columns.columns[i].name);
}
}
setAvailableFieldNames(fieldNames);
}
/*******************************************************************************
**
*******************************************************************************/
function renderOneValue(value: PivotTableValue, index: number)
{
if (!isEditable)
{
const selectedField = getSelectedFieldForAutoComplete(tableMetaData, value.fieldName);
if (selectedField && value.function)
{
const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label;
return (<Box mr="0.375rem" mb="0.5rem" border={`1px solid ${colors.grayLines.main}`} borderRadius="0.75rem" p="0.25rem 0.75rem">{pivotTableFunctionLabels[value.function]} of {label}</Box>);
}
return (<React.Fragment />);
}
const handleFieldChange = (event: any, newValue: any, reason: string) =>
{
value.fieldName = newValue ? newValue.fieldName : null;
};
const handleFunctionChange = (event: any, newValue: any, reason: string) =>
{
value.function = newValue ? newValue.id : null;
};
const functionOptions: any[] = [];
let defaultFunctionValue = null;
for (let pivotTableFunctionKey in PivotTableFunction)
{
// @ts-ignore any?
const label = "" + pivotTableFunctionLabels[pivotTableFunctionKey];
const option = {id: pivotTableFunctionKey, label: label};
functionOptions.push(option);
if (option.id == value.function)
{
defaultFunctionValue = option;
}
}
// maybe cursor:grab (and then change to "grabbing")
return (<Box display="flex" p="0.5rem" pl="0" gap="0.5rem" alignItems="center">
<Box>
<Icon sx={{cursor: "ns-resize"}}>drag_indicator</Icon>
</Box>
<Box width="100%">
<FieldAutoComplete
id={`values-field-${index}`}
label={null}
variant="outlined"
textFieldSX={fieldAutoCompleteTextFieldSX}
metaData={metaData}
tableMetaData={tableMetaData}
handleFieldChange={handleFieldChange}
defaultValue={getSelectedFieldForAutoComplete(tableMetaData, value.fieldName)}
/>
</Box>
<Box width="330px">
<Autocomplete
id={`values-field-${index}`}
renderInput={(params) => (<TextField {...params} label={null} variant="outlined" sx={fieldAutoCompleteTextFieldSX} autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
// @ts-ignore
defaultValue={defaultFunctionValue}
options={functionOptions}
onChange={handleFunctionChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option) => option.label}
// todo? renderOption={(props, option, state) => renderFieldOption(props, option, state)}
autoSelect={true}
autoHighlight={true}
disableClearable
// slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
// {...alsoOpen}
/>
</Box>
<Box>
<Button sx={xIconButtonSX} onClick={() => removeValue(index)}><Icon>clear</Icon></Button>
</Box>
</Box>);
}
/*******************************************************************************
** drag & drop callback to move one of the pivot-table group-bys (rows/columns)
*******************************************************************************/
const moveGroupBy = useCallback((rowsOrColumns: "rows" | "columns", dragIndex: number, hoverIndex: number) =>
{
const array = modalPivotTableDefinition[rowsOrColumns];
const dragItem = array[dragIndex];
array.splice(dragIndex, 1);
array.splice(hoverIndex, 0, dragItem);
forceUpdate();
}, [modalPivotTableDefinition]);
/*******************************************************************************
** drag & drop callback to move one of the pivot-table values
*******************************************************************************/
const moveValue = useCallback((dragIndex: number, hoverIndex: number) =>
{
const array = modalPivotTableDefinition.values;
const dragItem = array[dragIndex];
array.splice(dragIndex, 1);
array.splice(hoverIndex, 0, dragItem);
forceUpdate();
}, [modalPivotTableDefinition]);
const noTable = (tableMetaData == null);
const noColumns = (!availableFieldNames || availableFieldNames.length == 0);
const selectTableFirstTooltipTitle = noTable ? "You must select a table before you can set up your pivot table" : null;
const selectColumnsFirstTooltipTitle = noColumns ? "You must set up your report's Columns before you can set up your Pivot Table" : null;
const editPopupDisabled = noTable || noColumns;
/////////////////////////////////////////////////////////////
// add toggle component to widget header for editable mode //
/////////////////////////////////////////////////////////////
const labelAdditionalElementsRight: JSX.Element[] = [];
if (isEditable)
{
labelAdditionalElementsRight.push(<HeaderToggleComponent key="pivotTableHeader" disabled={editPopupDisabled} disabledTooltip={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle} label="Use Pivot Table?" getValue={() => enabled} onClickCallback={toggleEnabled} />);
}
/*******************************************************************************
** render a group-by (row or column)
*******************************************************************************/
const renderGroupBy = useCallback((groupBy: PivotTableGroupBy, rowsOrColumns: "rows" | "columns", index: number, forModal: boolean) =>
{
return (
<PivotTableGroupByElement
key={groupBy.fieldName}
index={index}
id={`${groupBy.key}`}
dragCallback={moveGroupBy}
metaData={metaData}
tableMetaData={tableMetaData}
pivotTableDefinition={forModal ? modalPivotTableDefinition : pivotTableDefinition}
usedGroupByFieldNames={[...usedGroupByFieldNames, ...usedValueFieldNames]}
availableFieldNames={availableFieldNames}
isEditable={isEditable && forModal}
groupBy={groupBy}
rowsOrColumns={rowsOrColumns}
callback={childElementChangedCallback}
attemptedSubmit={attemptedSubmit}
/>
);
},
[tableMetaData, usedGroupByFieldNames, availableFieldNames],
);
/*******************************************************************************
** render a pivot-table value (row or column)
*******************************************************************************/
const renderValue = useCallback((value: PivotTableValue, index: number, forModal: boolean) =>
{
return (
<PivotTableValueElement
key={value.key}
index={index}
id={`${value.key}`}
dragCallback={moveValue}
metaData={metaData}
tableMetaData={tableMetaData}
pivotTableDefinition={forModal ? modalPivotTableDefinition : pivotTableDefinition}
availableFieldNames={availableFieldNames}
usedGroupByFieldNames={usedGroupByFieldNames}
isEditable={isEditable && forModal}
value={value}
callback={childElementChangedCallback}
attemptedSubmit={attemptedSubmit}
/>
);
},
[tableMetaData, usedGroupByFieldNames, availableFieldNames],
);
/*******************************************************************************
**
*******************************************************************************/
function openEditor()
{
if (recordValues["tableName"])
{
setModalPivotTableDefinition(Object.assign({}, pivotTableDefinition));
setModalOpen(true);
setAttemptedSubmit(false);
}
}
/*******************************************************************************
**
*******************************************************************************/
function closeEditor(event?: {}, reason?: "backdropClick" | "escapeKeyDown")
{
if (reason == "backdropClick" || reason == "escapeKeyDown")
{
return;
}
setModalOpen(false);
}
/*******************************************************************************
**
*******************************************************************************/
function renderGroupBys(forModal: boolean, rowsOrColumns: "rows" | "columns")
{
const ptd = forModal ? modalPivotTableDefinition : pivotTableDefinition;
return <>
<h5>{rowsOrColumns == "rows" ? "Rows" : "Columns"}</h5>
<Box fontSize="1rem">
{
tableMetaData && (<div>{ptd[rowsOrColumns]?.map((groupBy, i) => renderGroupBy(groupBy, rowsOrColumns, i, forModal))}</div>)
}
</Box>
{
(forModal || (isEditable && !ptd[rowsOrColumns]?.length)) &&
<Box mt={forModal ? "0.5rem" : "0"} mb="1rem">
<Tooltip title={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle}>
<span><Button disabled={editPopupDisabled} sx={forModal ? buttonSX : unborderedButtonSX} onClick={() => forModal ? addGroupBy(rowsOrColumns) : openEditor()}>+ Add new {rowsOrColumns == "rows" ? "row" : "column"}</Button></span>
</Tooltip>
</Box>
}
{
!isEditable && !forModal && !ptd[rowsOrColumns]?.length &&
<Box color={colors.gray.main} fontSize="1rem">Your pivot table has no {rowsOrColumns}.</Box>
}
</>;
}
/*******************************************************************************
**
*******************************************************************************/
function renderValues(forModal: boolean)
{
const ptd = forModal ? modalPivotTableDefinition : pivotTableDefinition;
return <>
<h5>Values</h5>
<Box fontSize="1rem">
{
tableMetaData && (<div>{ptd?.values?.map((value, i) => renderValue(value, i, forModal))}</div>)
}
</Box>
{
(forModal || (isEditable && !ptd?.values?.length)) &&
<Box mt={forModal ? "0.5rem" : "0"} mb="1rem">
<Tooltip title={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle}>
<span><Button disabled={editPopupDisabled} sx={forModal ? buttonSX : unborderedButtonSX} onClick={() => forModal ? addValue() : openEditor()}>+ Add new value</Button></span>
</Tooltip>
</Box>
}
{
!isEditable && !forModal && !ptd?.values?.length &&
<Box color={colors.gray.main} fontSize="1rem">Your pivot table has no values.</Box>
}
</>;
}
/*******************************************************************************
**
*******************************************************************************/
function validateForm(submitting: boolean = false)
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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)
{
return;
}
let missingValues = 0;
for (let i = 0; i < modalPivotTableDefinition?.rows?.length; i++)
{
if (!modalPivotTableDefinition.rows[i].fieldName)
{
missingValues++;
}
}
for (let i = 0; i < modalPivotTableDefinition?.columns?.length; i++)
{
if (!modalPivotTableDefinition.columns[i].fieldName)
{
missingValues++;
}
}
for (let i = 0; i < modalPivotTableDefinition?.values?.length; i++)
{
if (!modalPivotTableDefinition.values[i].fieldName)
{
missingValues++;
}
if (!modalPivotTableDefinition.values[i].function)
{
missingValues++;
}
}
if (missingValues == 0)
{
setErrorAlert(null);
////////////////////////////////////////////////////////////////////////////////////
// this is to catch the case of - user attempted to submit, and there were errors //
// 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)
{
setAttemptedSubmit(false);
}
return (false);
}
setErrorAlert(`Missing value in ${missingValues} field${missingValues == 1 ? "" : "s"}.`);
return (true);
}
/*******************************************************************************
**
*******************************************************************************/
function saveClicked()
{
setAttemptedSubmit(true);
if (validateForm(true))
{
return;
}
if (!onSaveCallback)
{
console.log("onSaveCallback was not defined");
return;
}
setPivotTableDefinition(Object.assign({}, modalPivotTableDefinition));
updateUsedGroupByFieldNames(modalPivotTableDefinition);
updateUsedValueFieldNames(modalPivotTableDefinition);
onSaveCallback({pivotTableJson: JSON.stringify(modalPivotTableDefinition)});
closeEditor();
}
////////////
// render //
////////////
return (<Widget widgetMetaData={widgetMetaData} labelAdditionalElementsRight={labelAdditionalElementsRight}>
{
<React.Fragment>
<DndProvider backend={HTML5Backend}>
{
enabled &&
<Box display="flex" justifyContent="space-between">
<Box>
{
showHelp("sectionSubhead") &&
<Box color={colors.gray.main} pb={"0.5rem"} fontSize={"0.875rem"}>
{getHelpContent("sectionSubhead")}
</Box>
}
</Box>
{
isEditable &&
<Tooltip title={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle}>
<span>
<Button disabled={editPopupDisabled} onClick={() => openEditor()} sx={{p: 0}} disableRipple>
<Typography display="inline" textTransform="none" fontSize={"1.125rem"}>
Edit Pivot Table
</Typography>
</Button>
</span>
</Tooltip>
}
</Box>
}
{
(!enabled || !pivotTableDefinition) && !isEditable &&
<Box fontSize="1rem">Your report does not use a Pivot Table.</Box>
}
{
enabled && pivotTableDefinition &&
<>
<Grid container spacing="16">
<Grid item lg={4} md={6} xs={12}>{renderGroupBys(false, "rows")}</Grid>
<Grid item lg={4} md={6} xs={12}>{renderGroupBys(false, "columns")}</Grid>
<Grid item lg={4} md={6} xs={12}>{renderValues(false)}</Grid>
</Grid>
{
modalOpen &&
<Modal open={modalOpen} onClose={(event, reason) => closeEditor(event, reason)}>
<div>
<Box sx={{position: "absolute", width: "100%"}}>
<Card sx={{m: "2rem", p: "2rem", overflowY: "auto", height: "calc(100vh - 4rem)"}}>
<h3>Edit Pivot Table</h3>
{
showHelp("modalSubheader") &&
<Box color={colors.gray.main}>
{getHelpContent("modalSubheader")}
</Box>
}
{
errorAlert && <Alert icon={<Icon>error_outline</Icon>} color="error" onClose={() => setErrorAlert(null)}>{errorAlert}</Alert>
}
<Grid container spacing="16" overflow="auto" mt="0.5rem" mb="1rem" height="100%">
<Grid item lg={4} md={6} xs={12}>{renderGroupBys(true, "rows")}</Grid>
<Grid item lg={4} md={6} xs={12}>{renderGroupBys(true, "columns")}</Grid>
<Grid item lg={4} md={6} xs={12}>{renderValues(true)}</Grid>
</Grid>
<Box>
<Box display="flex" justifyContent="flex-end">
<QCancelButton disabled={false} onClickHandler={closeEditor} />
<QSaveButton label="OK" iconName="check" disabled={false} onClickHandler={saveClicked} />
</Box>
</Box>
</Card>
</Box>
</div>
</Modal>
}
</>
}
</DndProvider>
</React.Fragment>
}
</Widget>);
}
/* this was a rough-draft of what a preview of a pivot could look like...
<Box mt={"1rem"}>
<h5>Preview</h5>
<table>
<tr>
<th style={{textAlign: "left", fontSize: "0.875rem"}}></th>
<th style={{textAlign: "left", fontSize: "0.875rem"}}>Column Labels</th>
</tr>
{
pivotTableDefinition?.columns?.map((column, i) =>
(
<tr key={column.key}>
<th style={{textAlign: "left", fontSize: "0.875rem"}}></th>
<th style={{textAlign: "left", fontSize: "0.875rem"}}>{column.fieldName}</th>
</tr>
))
}
<tr>
<th style={{textAlign: "left", fontSize: "0.875rem"}}>Row Labels</th>
{
pivotTableDefinition?.values?.map((value, i) =>
(
<th key={value.key} style={{textAlign: "left", fontSize: "0.875rem"}}>{value.function} of {value.fieldName}</th>
))
}
</tr>
{
pivotTableDefinition?.rows?.map((row, i) =>
(
<tr key={row.key}>
<th style={{textAlign: "left", fontSize: "0.875rem", paddingLeft: (i * 1) + "rem"}}>{row.fieldName}</th>
</tr>
))
}
</table>
</Box>
*/

View File

@ -0,0 +1,319 @@
/*
* 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 {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import TextField from "@mui/material/TextField";
import type {Identifier, XYCoord} from "dnd-core";
import colors from "qqq/assets/theme/base/colors";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import {DragItemTypes, fieldAutoCompleteTextFieldSX, getSelectedFieldForAutoComplete, xIconButtonSX} from "qqq/components/widgets/misc/PivotTableSetupWidget";
import {functionsPerFieldType, PivotTableDefinition, pivotTableFunctionLabels, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels";
import React, {FC, useReducer, useRef, useState} from "react";
import {useDrag, useDrop} from "react-dnd";
/*******************************************************************************
** component props
*******************************************************************************/
export interface PivotTableValueElementProps
{
id: string;
index: number;
dragCallback: (dragIndex: number, hoverIndex: number) => void;
metaData: QInstance;
tableMetaData: QTableMetaData;
pivotTableDefinition: PivotTableDefinition;
availableFieldNames: string[];
usedGroupByFieldNames: string[];
isEditable: boolean;
value: PivotTableValue;
callback: () => void;
attemptedSubmit?: boolean;
}
/*******************************************************************************
** item to support react-dnd
*******************************************************************************/
interface DragItem
{
index: number;
id: string;
type: string;
}
/*******************************************************************************
** Element to render 1 pivot-table value.
*******************************************************************************/
export const PivotTableValueElement: FC<PivotTableValueElementProps> = ({id, index, dragCallback, metaData, tableMetaData, pivotTableDefinition, availableFieldNames, usedGroupByFieldNames, value, isEditable, callback, attemptedSubmit}) =>
{
const [defaultFunctionValue, setDefaultFunctionValue] = useState(null);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
////////////////////////////////////////////////////////////////////////////
// credit: https://react-dnd.github.io/react-dnd/examples/sortable/simple //
////////////////////////////////////////////////////////////////////////////
const ref = useRef<HTMLDivElement>(null);
const [{handlerId}, drop] = useDrop<DragItem, void, { handlerId: Identifier | null }>(
{
accept: DragItemTypes.VALUE,
collect(monitor)
{
return {
handlerId: monitor.getHandlerId(),
};
},
hover(item: DragItem, monitor)
{
if (!ref.current)
{
return;
}
const dragIndex = item.index;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex)
{
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY)
{
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY)
{
return;
}
// Time to actually perform the action
dragCallback(dragIndex, hoverIndex);
// Note: we're mutating the monitor item here! Generally it's better to avoid mutations,
// but it's good here for the sake of performance to avoid expensive index searches.
item.index = hoverIndex;
},
});
const [{isDragging}, drag] = useDrag({
type: DragItemTypes.VALUE,
item: () =>
{
return {id, index};
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging(),
}),
});
/*******************************************************************************
**
*******************************************************************************/
function getFunctionsForField(field: QFieldMetaData)
{
if(field)
{
let type = field.type;
if (field.possibleValueSourceName)
{
type = QFieldType.STRING;
}
if(functionsPerFieldType[type])
{
return (functionsPerFieldType[type]);
}
}
//////////////////////////////////////
// return broadest list if no field //
//////////////////////////////////////
return (functionsPerFieldType[QFieldType.INTEGER]);
}
/*******************************************************************************
** event handler for user selecting a field
*******************************************************************************/
function handleFieldChange(event: any, newValue: any, reason: string)
{
value.fieldName = newValue ? newValue.fieldName : null;
if(newValue)
{
/////////////////////////////////////////////////////////////////////////////////////////
// if newly selected field doesn't have the currently selected function, then clear it //
/////////////////////////////////////////////////////////////////////////////////////////
const newSelectedField = getSelectedFieldForAutoComplete(tableMetaData, newValue.fieldName);
if (newSelectedField)
{
if(getFunctionsForField(newSelectedField.field).indexOf(value.function) == -1)
{
setDefaultFunctionValue(null);
handleFunctionChange(null, null, null);
forceUpdate();
}
}
}
callback();
}
/*******************************************************************************
** event handler for user selecting a function
*******************************************************************************/
function handleFunctionChange(event: any, newValue: any, reason: string)
{
value.function = newValue ? newValue.id : null;
callback();
}
/*******************************************************************************
** event handler for clicking remove button
*******************************************************************************/
function removeValue(index: number)
{
pivotTableDefinition.values.splice(index, 1);
callback();
}
const selectedField = getSelectedFieldForAutoComplete(tableMetaData, value.fieldName);
/////////////////////////////////////////////////////////////////////
// if we're not on an edit screen, return a simpler read-only view //
/////////////////////////////////////////////////////////////////////
if (!isEditable)
{
let label = "--";
if (selectedField && value.function)
{
label = pivotTableFunctionLabels[value.function] + " of " + (selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label);
}
return (<Box><Box display="inline-block" mr="0.375rem" mb="0.5rem" border={`1px solid ${colors.grayLines.main}`} borderRadius="0.75rem" p="0.25rem 0.75rem">{label}</Box></Box>);
}
///////////////////////////////////////////////////////////////////////////////
// figure out functions to display in drop down, plus selected/default value //
///////////////////////////////////////////////////////////////////////////////
const functionOptions: any[] = [];
const availableFunctions = getFunctionsForField(selectedField?.field);
for (let pivotTableFunction of availableFunctions)
{
const label = pivotTableFunctionLabels[pivotTableFunction];
const option = {id: pivotTableFunction, label: label};
functionOptions.push(option);
if (option.id == value.function && JSON.stringify(option) != JSON.stringify(defaultFunctionValue))
{
setDefaultFunctionValue(option);
}
}
drag(drop(ref));
const showValueError = attemptedSubmit && !value.fieldName;
const showFunctionError = attemptedSubmit && !value.function;
return (<Box ref={ref} display="flex" p="0.5rem" pl="0" gap="0.5rem" alignItems="center" sx={{backgroundColor: "white", opacity: isDragging ? 0 : 1}} data-handler-id={handlerId}>
<Box>
<Icon sx={{cursor: "ns-resize"}}>drag_indicator</Icon>
</Box>
<Box width="100%">
<FieldAutoComplete
id={`values-field-${index}`}
label={null}
variant="outlined"
textFieldSX={fieldAutoCompleteTextFieldSX}
metaData={metaData}
tableMetaData={tableMetaData}
handleFieldChange={handleFieldChange}
availableFieldNames={availableFieldNames}
hiddenFieldNames={usedGroupByFieldNames}
defaultValue={selectedField}
hasError={showValueError}
noOptionsText="There are no fields available."
/>
</Box>
<Box width="370px">
<Autocomplete
id={`values-function-${index}`}
renderInput={(params) =>
{
const inputProps = params.InputProps;
const originalEndAdornment = inputProps.endAdornment;
inputProps.endAdornment = <Box>
{showFunctionError && <Icon color="error">error_outline</Icon>}
{originalEndAdornment}
</Box>;
return (<TextField {...params} label={null} variant="outlined" sx={fieldAutoCompleteTextFieldSX} autoComplete="off" type="search" InputProps={inputProps} />)
}}
// @ts-ignore
value={defaultFunctionValue}
inputValue={defaultFunctionValue?.label ?? ""}
options={functionOptions}
onChange={handleFunctionChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option) => option.label}
autoSelect={true}
autoHighlight={true}
disableClearable
/>
</Box>
<Box>
<Button sx={xIconButtonSX} onClick={() => removeValue(index)}><Icon>clear</Icon></Button>
</Box>
</Box>);
};

View File

@ -25,34 +25,64 @@ import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import {DataGridPro, GridCallbackDetails, GridEventListener, GridFilterModel, gridPreferencePanelStateSelector, GridRowParams, GridSelectionModel, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, MuiEvent, useGridApiContext, useGridApiEventHandler, useGridSelector} from "@mui/x-data-grid-pro";
import React, {useEffect, useRef, useState} from "react";
import {useNavigate, Link} from "react-router-dom";
import Widget, {AddNewRecordButton, LabelComponent} from "qqq/components/widgets/Widget";
import {DataGridPro, GridCallbackDetails, GridDensity, GridEventListener, GridRenderCellParams, GridRowParams, GridToolbarContainer, MuiEvent, useGridApiContext, useGridApiEventHandler} from "@mui/x-data-grid-pro";
import Widget, {AddNewRecordButton, LabelComponent, WidgetData} from "qqq/components/widgets/Widget";
import DataGridUtils from "qqq/utils/DataGridUtils";
import HtmlUtils from "qqq/utils/HtmlUtils";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useEffect, useRef, useState} from "react";
import {Link, useNavigate} from "react-router-dom";
export interface ChildRecordListData extends WidgetData
{
title?: string;
queryOutput?: { records: { values: any, displayValues?: any } [] };
childTableMetaData?: QTableMetaData;
tablePath?: string;
viewAllLink?: string;
totalRows?: number;
canAddChildRecord?: boolean;
defaultValuesForNewChildRecords?: { [fieldName: string]: any };
disabledFieldsForNewChildRecords?: { [fieldName: string]: any };
defaultValuesForNewChildRecordsFromParentFields?: { [fieldName: string]: string };
}
interface Props
{
widgetMetaData: QWidgetMetaData;
data: any;
widgetMetaData: QWidgetMetaData,
data: ChildRecordListData,
addNewRecordCallback?: () => void,
disableRowClick: boolean,
allowRecordEdit: boolean,
editRecordCallback?: (rowIndex: number) => void,
allowRecordDelete: boolean,
deleteRecordCallback?: (rowIndex: number) => void,
gridOnly?: boolean,
gridDensity?: GridDensity,
parentRecord?: QRecord
}
RecordGridWidget.defaultProps = {};
RecordGridWidget.defaultProps =
{
disableRowClick: false,
allowRecordEdit: false,
allowRecordDelete: false,
gridOnly: false,
};
const qController = Client.getInstance();
function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback, gridOnly, gridDensity, parentRecord}: Props): JSX.Element
{
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);
@ -69,12 +99,19 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
{
for (let i = 0; i < queryOutputRecords.length; i++)
{
records.push(new QRecord(queryOutputRecords[i]));
if (queryOutputRecords[i] instanceof QRecord)
{
records.push(queryOutputRecords[i] as QRecord);
}
else
{
records.push(new QRecord(queryOutputRecords[i]));
}
}
}
const tableMetaData = new QTableMetaData(data.childTableMetaData);
const rows = DataGridUtils.makeRows(records, tableMetaData);
const tableMetaData = data.childTableMetaData instanceof QTableMetaData ? data.childTableMetaData as QTableMetaData : new QTableMetaData(data.childTableMetaData);
const rows = DataGridUtils.makeRows(records, tableMetaData, undefined, true);
/////////////////////////////////////////////////////////////////////////////////
// note - tablePath may be null, if the user doesn't have access to the table. //
@ -85,32 +122,54 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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--;
}
}
}
////////////////////////////////////
// add actions cell, if available //
////////////////////////////////////
if (allowRecordEdit || allowRecordDelete)
{
columns.unshift({
field: "_actions",
type: "string",
headerName: "Actions",
sortable: false,
filterable: false,
width: allowRecordEdit && allowRecordDelete ? 80 : 50,
renderCell: ((params: GridRenderCellParams) =>
{
return <Box>
{allowRecordEdit && <IconButton onClick={() => editRecordCallback(params.row.__rowIndex)}><Icon>edit</Icon></IconButton>}
{allowRecordDelete && <IconButton onClick={() => deleteRecordCallback(params.row.__rowIndex)}><Icon>delete</Icon></IconButton>}
</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";
@ -118,8 +177,8 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
{
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";
}
@ -129,19 +188,19 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
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>
)
);
}
///////////////////
@ -153,10 +212,10 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
{
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.";
}
@ -165,21 +224,21 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
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" py={2} 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>
);
}
@ -187,15 +246,30 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
////////////////////
// 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))
const defaultValuesForNewChildRecords = data.defaultValuesForNewChildRecords || {};
///////////////////////////////////////////////////////////////////////////////////////
// copy values from specified fields in the parent record down into the child record //
///////////////////////////////////////////////////////////////////////////////////////
if (data.defaultValuesForNewChildRecordsFromParentFields)
{
for (let childField in data.defaultValuesForNewChildRecordsFromParentFields)
{
const parentField = data.defaultValuesForNewChildRecordsFromParentFields[childField];
defaultValuesForNewChildRecords[childField] = parentRecord?.values?.get(parentField);
}
}
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback));
}
@ -204,13 +278,18 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
/////////////////////////////////////////////////////////////////
const handleRowClick = (params: GridRowParams, event: MuiEvent<React.MouseEvent>, details: GridCallbackDetails) =>
{
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.id}`;
tablePath = `${tablePath}/${params.row[data.childTableMetaData.primaryKeyField]}`;
DataGridUtils.handleRowClick(tablePath, event, gridMouseDownX, gridMouseDownY, navigate, instance);
}
})();
@ -224,7 +303,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
*******************************************************************************/
function CustomToolbar()
{
const handleMouseDown: GridEventListener<"cellMouseDown"> = ( params, event, details ) =>
const handleMouseDown: GridEventListener<"cellMouseDown"> = (params, event, details) =>
{
setGridMouseDownX(event.clientX);
setGridMouseDownY(event.clientY);
@ -243,6 +322,62 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
return (<GridToolbarContainer />);
}
let containerPadding = -3;
if (data?.isInProcess)
{
containerPadding = 0;
}
const grid = (
<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={gridDensity ?? "standard"}
// loading={loading}
// filterModel={filterModel}
// onFilterModelChange={handleFilterChange}
// columnVisibilityModel={columnVisibilityModel}
// onColumnVisibilityModelChange={handleColumnVisibilityChange}
// onColumnOrderChange={handleColumnOrderChange}
// onSelectionModelChange={selectionChanged}
// onSortModelChange={handleSortChange}
// sortingOrder={[ "asc", "desc" ]}
// sortModel={columnSortModel}
/>
);
if (gridOnly)
{
return (grid);
}
return (
<Widget
@ -252,48 +387,10 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
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}
// 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>
{grid}
</Box>
</Box>
</Widget>
);

View File

@ -46,9 +46,6 @@ import Snackbar from "@mui/material/Snackbar";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import Typography from "@mui/material/Typography";
import React, {useReducer, useState} from "react";
import AceEditor from "react-ace";
import {Link} from "react-router-dom";
import TabPanel from "qqq/components/misc/TabPanel";
import ScriptDocsForm from "qqq/components/scripts/ScriptDocsForm";
import ScriptEditor, {ScriptEditorProps} from "qqq/components/scripts/ScriptEditor";
@ -65,6 +62,9 @@ import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-velocity";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/theme-github";
import React, {useReducer, useState} from "react";
import AceEditor from "react-ace";
import {Link} from "react-router-dom";
import "ace-builds/src-noconflict/ext-language_tools";
const qController = Client.getInstance();
@ -97,16 +97,16 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
const [versionRecordList, setVersionRecordList] = useState(null as QRecord[]);
const [selectedVersionRecord, setSelectedVersionRecord] = useState(null as QRecord);
const [scriptLogs, setScriptLogs] = useState({} as any);
const [scriptTypeRecord, setScriptTypeRecord] = useState(null as QRecord)
const [scriptTypeFileSchemaList, setScriptTypeFileSchemaList] = useState(null as QRecord[])
const [scriptTypeRecord, setScriptTypeRecord] = useState(null as QRecord);
const [scriptTypeFileSchemaList, setScriptTypeFileSchemaList] = useState(null as QRecord[]);
const [availableFileNames, setAvailableFileNames] = useState([] as string[]);
const [selectedFileName, setSelectedFileName] = useState("");
const [currentVersionId , setCurrentVersionId] = useState(null as number);
const [currentVersionId, setCurrentVersionId] = useState(null as number);
const [notFoundMessage, setNotFoundMessage] = useState(null);
const [selectedTab, setSelectedTab] = useState(0);
const [editorProps, setEditorProps] = useState(null as ScriptEditorProps);
const [successText, setSuccessText] = useState(null as string);
const [failText, setFailText] = useState(null as string)
const [failText, setFailText] = useState(null as string);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [loadingSelectedVersion, _] = useState(new LoadingState(forceUpdate, "loading"));
@ -129,13 +129,13 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
let fileMode = scriptTypeRecord.values.get("fileMode");
let scriptTypeFileSchemaList: QRecord[] = null;
if(fileMode == 1) // SINGLE
if (fileMode == 1) // SINGLE
{
scriptTypeFileSchemaList = [new QRecord({values: {name: "Script.js", fileType: "javascript"}})];
}
else if(fileMode == 2) // MULTI_PRE_DEFINED
else if (fileMode == 2) // MULTI_PRE_DEFINED
{
const filter = new QQueryFilter([new QFilterCriteria("scriptTypeId", QCriteriaOperator.EQUALS, [scriptRecord.values.get("scriptTypeId")])], [new QFilterOrderBy("id")])
const filter = new QQueryFilter([new QFilterCriteria("scriptTypeId", QCriteriaOperator.EQUALS, [scriptRecord.values.get("scriptTypeId")])], [new QFilterOrderBy("id")]);
scriptTypeFileSchemaList = await qController.query("scriptTypeFileSchema", filter);
}
else // MULTI AD_HOC
@ -145,22 +145,22 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
}
setScriptTypeFileSchemaList(scriptTypeFileSchemaList);
if(scriptTypeFileSchemaList)
if (scriptTypeFileSchemaList)
{
const availableFileNames = scriptTypeFileSchemaList.map((fileSchemaRecord) => fileSchemaRecord.values.get("name"))
const availableFileNames = scriptTypeFileSchemaList.map((fileSchemaRecord) => fileSchemaRecord.values.get("name"));
setAvailableFileNames(availableFileNames);
setSelectedFileName(availableFileNames[0])
setSelectedFileName(availableFileNames[0]);
}
const criteria = [new QFilterCriteria("scriptId", QCriteriaOperator.EQUALS, [scriptId])];
const orderBys = [new QFilterOrderBy("sequenceNo", false)];
const filter = new QQueryFilter(criteria, orderBys, "AND", 0, 25);
const filter = new QQueryFilter(criteria, orderBys, null, "AND", 0, 25);
const versions = await qController.query("scriptRevision", filter);
console.log("Fetched versions:");
console.log(versions);
setVersionRecordList(versions);
if(versions && versions.length > 0)
if (versions && versions.length > 0)
{
selectVersion(versions[0]);
}
@ -169,7 +169,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
{
if (e instanceof QException)
{
if ((e as QException).status === "404")
if ((e as QException).status === 404)
{
setNotFoundMessage("Script code could not be found.");
return;
@ -253,31 +253,31 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
const handleSelectFile = (event: SelectChangeEvent) =>
{
setSelectedFileName(event.target.value);
}
};
const getSelectedFileCode = (): string =>
{
return (getSelectedVersionCode()[selectedFileName] ?? "");
}
};
const getSelectedFileType = (): string =>
{
for (let i = 0; i < scriptTypeFileSchemaList.length; i++)
{
let name = scriptTypeFileSchemaList[i].values.get("name");
if(name == selectedFileName)
if (name == selectedFileName)
{
return (scriptTypeFileSchemaList[i].values.get("fileType"));
}
}
return ("javascript"); // have some default...
}
};
const getSelectedVersionCode = (): {[name: string]: string} =>
const getSelectedVersionCode = (): { [name: string]: string } =>
{
let rs: {[name: string]: string} = {}
let files = selectedVersionRecord?.associatedRecords?.get("files")
let rs: { [name: string]: string } = {};
let files = selectedVersionRecord?.associatedRecords?.get("files");
for (let j = 0; j < files?.length; j++)
{
@ -286,7 +286,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
}
return (rs);
}
};
function getVersionsList(versionRecordList: QRecord[], selectedVersionRecord: QRecord)
{
@ -344,11 +344,11 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
const getScriptLogs = (scriptRevisionId: number) =>
{
if(!scriptLogs[scriptRevisionId])
if (!scriptLogs[scriptRevisionId])
{
(async () =>
{
let filter = new QQueryFilter([new QFilterCriteria("scriptRevisionId", QCriteriaOperator.EQUALS, [scriptRevisionId])], [new QFilterOrderBy("id", false)], "AND", 0, 100);
let filter = new QQueryFilter([new QFilterCriteria("scriptRevisionId", QCriteriaOperator.EQUALS, [scriptRevisionId])], [new QFilterOrderBy("id", false)], null, "AND", 0, 100);
scriptLogs[scriptRevisionId] = await qController.query("scriptLog", filter);
setScriptLogs(scriptLogs);
forceUpdate();
@ -368,7 +368,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
}
return (<ScriptLogsView logs={logs} />);
}
};
let editButtonTooltip = "";
let editButtonText = "Create New Version";
@ -393,7 +393,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
}
return (
<Grid container className="scriptViewer" m={-2} pt={4} width={"calc(100% + 2rem)"}>
<Grid container className="scriptViewer" my={-3} mx={-3} pt={4} width={"calc(100% + 3rem)"}>
<Grid item xs={12}>
<Box>
{
@ -530,7 +530,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
</TabPanel>
<TabPanel index={2} value={selectedTab}>
<Box sx={{height: "455px"}} px={2} pb={1}>
<Box sx={{height: "455px"}} px={2} pt={1}>
<ScriptTestForm scriptId={scriptId}
scriptType={scriptTypeRecord}
tableName={associatedScriptTableName}
@ -543,7 +543,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
</TabPanel>
<TabPanel index={3} value={selectedTab}>
<Box sx={{height: "455px"}} px={2} pb={1}>
<Box sx={{height: "455px"}} px={2} pt={1}>
<ScriptDocsForm helpText={scriptTypeRecord?.values.get("helpText")} exampleCode={scriptTypeRecord?.values.get("sampleCode")} />
</Box>
</TabPanel>
@ -556,7 +556,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
<Modal open={editorProps !== null} onClose={(event, reason) => closeEditingScript(event, reason)}>
<ScriptEditor
closeCallback={closeEditingScript}
{... editorProps}
{...editorProps}
/>
</Modal>
}

View File

@ -18,28 +18,28 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {tooltipClasses, TooltipProps} from "@mui/material";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
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 {useEffect, useMemo, useState} from "react";
import {useAsyncDebounce, useGlobalFilter, usePagination, useSortBy, useTable, useExpanded} 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";
import MDTypography from "qqq/components/legacy/MDTypography";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
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 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
{
@ -57,6 +57,7 @@ interface Props
};
isSorted?: boolean;
noEndBorder?: boolean;
widgetMetaData: QWidgetMetaData;
}
DataTable.defaultProps = {
@ -92,6 +93,7 @@ function DataTable({
pagination,
isSorted,
noEndBorder,
widgetMetaData
}: Props): JSX.Element
{
let defaultValue: any;
@ -101,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++)
{
@ -124,7 +126,7 @@ function DataTable({
}
const columnsToMemo = [...table.columns];
if(showExpandColumn)
if (showExpandColumn)
{
widths.push("60px");
columnsToMemo.push(
@ -161,13 +163,24 @@ 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)
{
for (let column of columnsToMemo)
{
if (table.columnHeaderTooltips[column.accessor])
{
column.tooltip = table.columnHeaderTooltips[column.accessor];
}
}
}
const columns = useMemo<any>(() => columnsToMemo, [table]);
const data = useMemo<any>(() => table.rows, [table]);
const gridTemplateColumns = widths.join(" ");
@ -280,128 +293,164 @@ function DataTable({
entriesEnd = pageSize * (pageIndex + 1);
}
let visibleFooterRows = 1;
if (expanded && expanded[`${table.rows.length - 1}`])
{
//////////////////////////////////////////////////
// todo - should count how many are expanded... //
//////////////////////////////////////////////////
visibleFooterRows = 2;
}
function getTable(includeHead: boolean, rows: any, isFooter: boolean)
{
let boxStyle = {};
if(fixedStickyLastRow)
if (fixedStickyLastRow)
{
boxStyle = isFooter
? {borderTop: `0.0625rem solid ${colors.grayLines.main};`, overflow: "auto", scrollbarGutter: "stable"}
: {height: fixedHeight ? `${fixedHeight}px` : "360px", overflowY: "scroll", scrollbarGutter: "stable", marginBottom: "-1px"};
? {borderTop: `0.0625rem solid ${colors.grayLines.main};`, backgroundColor: "#EEEEEE"}
: {height: fixedHeight ? `${fixedHeight}px` : "auto", flexGrow: 1, overflowY: "scroll", scrollbarGutter: "stable", marginBottom: "-1px"};
}
return <Box sx={boxStyle}>
<Table {...getTableProps()}>
let innerBoxStyle = {};
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()} component="div" sx={{display: "grid", gridTemplateRows: "auto", gridTemplateColumns: gridTemplateColumns}}>
{
includeHead && (
<Box component="thead" sx={{position: "sticky", top: 0, background: "white"}}>
{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)}
>
{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;
}
return (
<TableRow sx={{verticalAlign: "top", display: "grid", gridTemplateColumns: gridTemplateColumns, background: (row.depth > 0 ? "#FAFAFA" : "initial")}} key={key} {...row.getRowProps()}>
{row.cells.map((cell: any) => (
cell.column.type !== "hidden" && (
<DataTableBodyCell
key={key}
noBorder={noEndBorder || overrideNoEndBorder}
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 === "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>
);
})}
let background = "initial";
if (isFooter)
{
background = "#EEEEEE";
}
else if (row.depth > 0 || row.isExpanded)
{
background = "#FAFAFA";
}
</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>;
}
return (
<TableContainer sx={{boxShadow: "none"}}>
<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) && (
@ -448,14 +497,16 @@ function DataTable({
</Box>
) : null}
{
fixedStickyLastRow ? (
<>
{getTable(true, page.slice(0, page.length -1), false)}
{getTable(false, page.slice(page.length-1), true)}
</>
) : getTable(true, page, false)
}
<Box display="flex" justifyContent="space-between" flexDirection="column" height="100%">
{
fixedStickyLastRow ? (
<>
{getTable(true, page.slice(0, page.length - visibleFooterRows), false)}
{getTable(false, page.slice(page.length - visibleFooterRows), true)}
</>
) : getTable(true, page, false)
}
</Box>
<Box
display="flex"

Some files were not shown because too many files have changed in this diff Show More