@visns-studio/visns-components 6.0.5 → 6.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +40 -0
- package/src/components/Navigation.jsx +59 -3
- package/src/components/auth/Login.jsx +2 -2
- package/src/components/generic/GenericDashboard.jsx +181 -1
- package/src/components/generic/GenericFormBuilder.jsx +374 -14
- package/src/components/generic/OutstandingRuleEditor.jsx +313 -0
- package/src/components/styles/GenericDashboard.module.scss +155 -0
- package/src/components/styles/GenericFormBuilder.module.scss +60 -0
- package/src/components/styles/Login.module.scss +29 -3
package/package.json
CHANGED
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"react-dom": "^17.0.0 || ^18.0.0"
|
|
92
92
|
},
|
|
93
93
|
"name": "@visns-studio/visns-components",
|
|
94
|
-
"version": "6.
|
|
94
|
+
"version": "6.1.1",
|
|
95
95
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
96
96
|
"main": "src/index.js",
|
|
97
97
|
"files": [
|
|
@@ -1305,6 +1305,11 @@ const DataGrid = forwardRef(
|
|
|
1305
1305
|
|
|
1306
1306
|
const handleSettingClick = (s, d) => {
|
|
1307
1307
|
switch (s.id) {
|
|
1308
|
+
case 'cloudDownload':
|
|
1309
|
+
// File download: open the URL directly and let the
|
|
1310
|
+
// browser handle the response's content disposition.
|
|
1311
|
+
window.open(`${s.url}/${d[s.key]}`, '_blank');
|
|
1312
|
+
break;
|
|
1308
1313
|
case 'activate':
|
|
1309
1314
|
case 'arrowCycle':
|
|
1310
1315
|
case 'cloudUpload':
|
|
@@ -1818,6 +1823,23 @@ const DataGrid = forwardRef(
|
|
|
1818
1823
|
)
|
|
1819
1824
|
);
|
|
1820
1825
|
|
|
1826
|
+
/**
|
|
1827
|
+
* A settings entry reserved for the platform owner.
|
|
1828
|
+
*
|
|
1829
|
+
* `roles` cannot express this: it matches Spatie role *names*, and
|
|
1830
|
+
* super admin is deliberately not a role — roles are handed out from
|
|
1831
|
+
* the console by the very administrators this privilege sits above.
|
|
1832
|
+
* Navigation.jsx already reads `superAdminOnly` on menu entries the
|
|
1833
|
+
* same way; this is the row-action twin of it.
|
|
1834
|
+
*
|
|
1835
|
+
* Presentation only, like every check in this file: it takes the icon
|
|
1836
|
+
* away, it does not protect the endpoint behind it. A setting without
|
|
1837
|
+
* the flag is unaffected, so this is dormant for every config that
|
|
1838
|
+
* does not opt in.
|
|
1839
|
+
*/
|
|
1840
|
+
const settingAllowedBySuperAdmin = (s) =>
|
|
1841
|
+
s?.superAdminOnly !== true || Boolean(userProfile?.is_super_admin);
|
|
1842
|
+
|
|
1821
1843
|
const shouldShowGroupAction = (iconConfig, groupValue) => {
|
|
1822
1844
|
// `showAll` is the every-row form of `show`: the icon appears only
|
|
1823
1845
|
// when the condition holds for the whole group (e.g. close a tag
|
|
@@ -2929,6 +2951,12 @@ const DataGrid = forwardRef(
|
|
|
2929
2951
|
}
|
|
2930
2952
|
}
|
|
2931
2953
|
|
|
2954
|
+
// Owner-only actions (import a form template, etc.) — see
|
|
2955
|
+
// settingAllowedBySuperAdmin
|
|
2956
|
+
if (!settingAllowedBySuperAdmin(s)) {
|
|
2957
|
+
allow = false;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2932
2960
|
if (s.active) {
|
|
2933
2961
|
// Helper function to get nested values for main active condition (searches for target value)
|
|
2934
2962
|
const getNestedValueForMain = (data, path, targetValue) => {
|
|
@@ -3231,6 +3259,8 @@ const DataGrid = forwardRef(
|
|
|
3231
3259
|
return getIconComponent(RefreshCw);
|
|
3232
3260
|
case 'cloudUpload':
|
|
3233
3261
|
return getIconComponent(Upload);
|
|
3262
|
+
case 'cloudDownload':
|
|
3263
|
+
return getIconComponent(DownloadIcon);
|
|
3234
3264
|
case 'oauth2':
|
|
3235
3265
|
return getIconComponent(Lock);
|
|
3236
3266
|
case 'clone':
|
|
@@ -4179,6 +4209,16 @@ const DataGrid = forwardRef(
|
|
|
4179
4209
|
if (actionColumnSettingIds.has(setting.id)) {
|
|
4180
4210
|
return false;
|
|
4181
4211
|
}
|
|
4212
|
+
// Owner-only actions never render for anyone else,
|
|
4213
|
+
// so they must not reserve width either — a grid
|
|
4214
|
+
// whose only actions are super-admin ones gets no
|
|
4215
|
+
// Action column at all.
|
|
4216
|
+
if (
|
|
4217
|
+
setting.superAdminOnly === true &&
|
|
4218
|
+
!userProfile?.is_super_admin
|
|
4219
|
+
) {
|
|
4220
|
+
return false;
|
|
4221
|
+
}
|
|
4182
4222
|
if (
|
|
4183
4223
|
setting.roles &&
|
|
4184
4224
|
Array.isArray(setting.roles) &&
|
|
@@ -361,6 +361,7 @@ function Navigation({
|
|
|
361
361
|
<ul className={styles.navDropdown}>
|
|
362
362
|
{n.children.map(
|
|
363
363
|
(child, childKey) =>
|
|
364
|
+
child.hidden !== true &&
|
|
364
365
|
child.permission === true && (
|
|
365
366
|
<li key={`nav-child-${childKey}`}>
|
|
366
367
|
<Link
|
|
@@ -422,11 +423,57 @@ function Navigation({
|
|
|
422
423
|
return [];
|
|
423
424
|
};
|
|
424
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Feature switches, if the consuming app publishes any.
|
|
428
|
+
*
|
|
429
|
+
* A platform owner can retire a half-finished feature from the menu
|
|
430
|
+
* without a deploy, so the profile response may carry a map of feature
|
|
431
|
+
* ids that are switched off. Apps that publish nothing get `null` and
|
|
432
|
+
* behave exactly as before — this filter is additive, and every unknown
|
|
433
|
+
* id stays visible, so a stale or missing map can never empty a menu.
|
|
434
|
+
*/
|
|
435
|
+
const getFeatureFlags = () => {
|
|
436
|
+
const flags = userProfile?.feature_visibility;
|
|
437
|
+
|
|
438
|
+
if (!flags || typeof flags !== 'object') {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// The map is keyed by app; this component only ever draws the console.
|
|
443
|
+
const appFlags = flags.backend;
|
|
444
|
+
|
|
445
|
+
return appFlags && typeof appFlags === 'object' ? appFlags : null;
|
|
446
|
+
};
|
|
447
|
+
|
|
425
448
|
useEffect(() => {
|
|
426
449
|
const permissions = getUserPermissions();
|
|
450
|
+
const featureFlags = getFeatureFlags();
|
|
451
|
+
|
|
452
|
+
// Carried as its own flag rather than folded into `permission`: an
|
|
453
|
+
// item with an empty permissionKey renders whatever its permission
|
|
454
|
+
// says, and this state has to be reversible — switching a feature
|
|
455
|
+
// back on must restore the menu item without a page reload, which
|
|
456
|
+
// dropping it from the list would not.
|
|
457
|
+
const isSwitchedOff = (nav) =>
|
|
458
|
+
featureFlags !== null &&
|
|
459
|
+
Boolean(nav.id) &&
|
|
460
|
+
featureFlags[nav.id] === false;
|
|
427
461
|
|
|
428
462
|
setNavData((prevNavData) => {
|
|
429
463
|
const updatePermissions = (nav) => {
|
|
464
|
+
const hidden = isSwitchedOff(nav);
|
|
465
|
+
|
|
466
|
+
// Reserved for the platform owner — a privilege that sits
|
|
467
|
+
// above the roles an administrator can hand out, so it is
|
|
468
|
+
// never expressed as a permission key.
|
|
469
|
+
if (nav.superAdminOnly === true) {
|
|
470
|
+
return {
|
|
471
|
+
...nav,
|
|
472
|
+
hidden,
|
|
473
|
+
permission: Boolean(userProfile?.is_super_admin),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
430
477
|
if (nav.children && nav.children.length > 0) {
|
|
431
478
|
const updatedChildren = nav.children
|
|
432
479
|
.map(updatePermissions)
|
|
@@ -439,12 +486,14 @@ function Navigation({
|
|
|
439
486
|
);
|
|
440
487
|
return {
|
|
441
488
|
...nav,
|
|
489
|
+
hidden,
|
|
442
490
|
permission: matchingPermission,
|
|
443
491
|
children: updatedChildren,
|
|
444
492
|
};
|
|
445
493
|
} else {
|
|
446
494
|
return {
|
|
447
495
|
...nav,
|
|
496
|
+
hidden,
|
|
448
497
|
permission: childPermission,
|
|
449
498
|
children: updatedChildren,
|
|
450
499
|
};
|
|
@@ -457,12 +506,14 @@ function Navigation({
|
|
|
457
506
|
);
|
|
458
507
|
return {
|
|
459
508
|
...nav,
|
|
509
|
+
hidden,
|
|
460
510
|
permission: matchingPermission,
|
|
461
511
|
};
|
|
462
512
|
}
|
|
463
513
|
|
|
464
514
|
return {
|
|
465
515
|
...nav,
|
|
516
|
+
hidden,
|
|
466
517
|
permission: true,
|
|
467
518
|
};
|
|
468
519
|
};
|
|
@@ -620,8 +671,9 @@ function Navigation({
|
|
|
620
671
|
<nav className={styles.navwrap} ref={navWrapRef}>
|
|
621
672
|
<ul className={appNavClasses}>
|
|
622
673
|
{navData.navigations.map((nav, navKey) =>
|
|
623
|
-
nav.
|
|
624
|
-
nav.
|
|
674
|
+
nav.hidden !== true &&
|
|
675
|
+
(nav.permission === true ||
|
|
676
|
+
nav.permissionKey === '') ? (
|
|
625
677
|
<React.Fragment key={`nav-item-${navKey}`}>
|
|
626
678
|
{renderNav(nav)}
|
|
627
679
|
</React.Fragment>
|
|
@@ -671,6 +723,7 @@ function Navigation({
|
|
|
671
723
|
>
|
|
672
724
|
<ul>
|
|
673
725
|
{navData.settings.map((setting, settingKey) =>
|
|
726
|
+
setting.hidden !== true &&
|
|
674
727
|
setting.permission === true ? (
|
|
675
728
|
<React.Fragment
|
|
676
729
|
key={`setting-${settingKey}`}
|
|
@@ -711,7 +764,9 @@ function Navigation({
|
|
|
711
764
|
<div className={styles.navwrap} ref={navWrapRef}>
|
|
712
765
|
<ul className={appNavClasses}>
|
|
713
766
|
{navData.navigations.map((nav, navKey) =>
|
|
714
|
-
nav.
|
|
767
|
+
nav.hidden !== true &&
|
|
768
|
+
(nav.permission === true ||
|
|
769
|
+
nav.permissionKey === '') ? (
|
|
715
770
|
<React.Fragment key={`nav-item-${navKey}`}>
|
|
716
771
|
{renderNav(nav)}
|
|
717
772
|
</React.Fragment>
|
|
@@ -729,6 +784,7 @@ function Navigation({
|
|
|
729
784
|
>
|
|
730
785
|
<ul>
|
|
731
786
|
{navData.settings.map((setting, settingKey) =>
|
|
787
|
+
setting.hidden !== true &&
|
|
732
788
|
setting.permission === true ? (
|
|
733
789
|
<React.Fragment key={`setting-${settingKey}`}>
|
|
734
790
|
{renderSetting(setting)}
|
|
@@ -190,8 +190,8 @@ const Login = ({
|
|
|
190
190
|
aria-hidden="true"
|
|
191
191
|
>
|
|
192
192
|
<div className={styles.brandInk}>
|
|
193
|
-
<p className={styles.eyebrow}>
|
|
194
|
-
<p className={styles.brandLine}>Prime
|
|
193
|
+
<p className={styles.eyebrow}>Construction Management</p>
|
|
194
|
+
<p className={styles.brandLine}>Prime Builders</p>
|
|
195
195
|
<p className={styles.brandSub}>
|
|
196
196
|
Job tracking, inspections, site forms and labour
|
|
197
197
|
scheduling — in one place.
|
|
@@ -575,6 +575,9 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
|
|
|
575
575
|
)
|
|
576
576
|
);
|
|
577
577
|
const [data, setData] = useState({});
|
|
578
|
+
// Per-widget load state ('loading' | 'ready' | 'failed') keyed by widget.id.
|
|
579
|
+
// Tracked per widget so one slow/failed request only affects its own tile.
|
|
580
|
+
const [widgetStatus, setWidgetStatus] = useState({});
|
|
578
581
|
const [dropdowns, setDropdowns] = useState({});
|
|
579
582
|
const [filters, setFilters] = useState({});
|
|
580
583
|
const [editMode, setEditMode] = useState(false);
|
|
@@ -631,8 +634,23 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
|
|
|
631
634
|
updateDashboardSetting(dashboardSetting);
|
|
632
635
|
}, [dashboardSetting]);
|
|
633
636
|
|
|
637
|
+
// A widget only fetches when it has both a url and a method — anything else
|
|
638
|
+
// never loads, so it must never be marked 'loading'.
|
|
639
|
+
const widgetFetches = (widget) => Boolean(widget.api?.url && widget.api?.method);
|
|
640
|
+
|
|
634
641
|
const fetchData = async (appliedFilters = {}) => {
|
|
635
642
|
try {
|
|
643
|
+
const loadingStatus = dashboardSetting.widgets.reduce(
|
|
644
|
+
(acc, widget) => {
|
|
645
|
+
acc[widget.id] = widgetFetches(widget)
|
|
646
|
+
? 'loading'
|
|
647
|
+
: 'ready';
|
|
648
|
+
return acc;
|
|
649
|
+
},
|
|
650
|
+
{}
|
|
651
|
+
);
|
|
652
|
+
setWidgetStatus((prev) => ({ ...prev, ...loadingStatus }));
|
|
653
|
+
|
|
636
654
|
const promises = dashboardSetting.widgets.map(async (widget) => {
|
|
637
655
|
const widgetFilters = appliedFilters[widget.id] || {};
|
|
638
656
|
|
|
@@ -714,9 +732,40 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
|
|
|
714
732
|
},
|
|
715
733
|
{}
|
|
716
734
|
);
|
|
735
|
+
// Resolve each widget's status from the same settled results: a
|
|
736
|
+
// rejected promise or an API-error payload is a failure, anything
|
|
737
|
+
// else has finished loading.
|
|
738
|
+
const nextStatus = dashboardSetting.widgets.reduce(
|
|
739
|
+
(acc, widget, index) => {
|
|
740
|
+
if (!widgetFetches(widget)) {
|
|
741
|
+
acc[widget.id] = 'ready';
|
|
742
|
+
return acc;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const result = results[index];
|
|
746
|
+
acc[widget.id] =
|
|
747
|
+
result?.status === 'rejected' ||
|
|
748
|
+
result?.value?.data?.value
|
|
749
|
+
? 'failed'
|
|
750
|
+
: 'ready';
|
|
751
|
+
return acc;
|
|
752
|
+
},
|
|
753
|
+
{}
|
|
754
|
+
);
|
|
755
|
+
|
|
717
756
|
setData(fetchedData);
|
|
757
|
+
setWidgetStatus((prev) => ({ ...prev, ...nextStatus }));
|
|
718
758
|
} catch (error) {
|
|
719
759
|
console.error('Error fetching dashboard data:', error);
|
|
760
|
+
// Never leave a widget stuck on a skeleton — a hard failure of the
|
|
761
|
+
// whole fetch marks everything still in flight as failed.
|
|
762
|
+
setWidgetStatus((prev) =>
|
|
763
|
+
Object.keys(prev).reduce((acc, widgetId) => {
|
|
764
|
+
acc[widgetId] =
|
|
765
|
+
prev[widgetId] === 'loading' ? 'failed' : prev[widgetId];
|
|
766
|
+
return acc;
|
|
767
|
+
}, {})
|
|
768
|
+
);
|
|
720
769
|
}
|
|
721
770
|
};
|
|
722
771
|
|
|
@@ -1085,6 +1134,137 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
|
|
|
1085
1134
|
);
|
|
1086
1135
|
};
|
|
1087
1136
|
|
|
1137
|
+
// Placeholder shaped like the widget's real content. Every block reserves
|
|
1138
|
+
// the dimensions the loaded content occupies so nothing shifts on arrival.
|
|
1139
|
+
const renderWidgetSkeleton = (widget) => {
|
|
1140
|
+
const chartHeight = widget.height || '600px';
|
|
1141
|
+
const block = (extraClass, style, key) => (
|
|
1142
|
+
<div
|
|
1143
|
+
key={key}
|
|
1144
|
+
className={`${styles.skeletonBlock} ${extraClass}`}
|
|
1145
|
+
style={style}
|
|
1146
|
+
/>
|
|
1147
|
+
);
|
|
1148
|
+
|
|
1149
|
+
const renderBody = () => {
|
|
1150
|
+
switch (widget.type) {
|
|
1151
|
+
case 'counter':
|
|
1152
|
+
return (
|
|
1153
|
+
<>
|
|
1154
|
+
<div className={styles.skeletonCounter}>
|
|
1155
|
+
{block(styles.skeletonCounterValue)}
|
|
1156
|
+
</div>
|
|
1157
|
+
{widget.button && (
|
|
1158
|
+
<div className={styles.skeletonButtonRow}>
|
|
1159
|
+
{block(styles.skeletonButton)}
|
|
1160
|
+
</div>
|
|
1161
|
+
)}
|
|
1162
|
+
</>
|
|
1163
|
+
);
|
|
1164
|
+
case 'pie':
|
|
1165
|
+
return (
|
|
1166
|
+
<div
|
|
1167
|
+
className={styles.skeletonChart}
|
|
1168
|
+
style={{ height: chartHeight }}
|
|
1169
|
+
>
|
|
1170
|
+
{block(styles.skeletonCircle)}
|
|
1171
|
+
</div>
|
|
1172
|
+
);
|
|
1173
|
+
case 'bar':
|
|
1174
|
+
case 'line':
|
|
1175
|
+
return (
|
|
1176
|
+
<div
|
|
1177
|
+
className={`${styles.skeletonChart} ${styles.skeletonChartStrips}`}
|
|
1178
|
+
style={{ height: chartHeight }}
|
|
1179
|
+
>
|
|
1180
|
+
{[70, 45, 88, 58, 34].map((width, index) =>
|
|
1181
|
+
block(
|
|
1182
|
+
styles.skeletonStrip,
|
|
1183
|
+
{ width: `${width}%` },
|
|
1184
|
+
`${widget.id}-skeleton-strip-${index}`
|
|
1185
|
+
)
|
|
1186
|
+
)}
|
|
1187
|
+
</div>
|
|
1188
|
+
);
|
|
1189
|
+
case 'table':
|
|
1190
|
+
return (
|
|
1191
|
+
<div
|
|
1192
|
+
className={styles.skeletonTable}
|
|
1193
|
+
style={
|
|
1194
|
+
widget.height
|
|
1195
|
+
? {
|
|
1196
|
+
maxHeight: widget.height,
|
|
1197
|
+
overflow: 'hidden',
|
|
1198
|
+
}
|
|
1199
|
+
: undefined
|
|
1200
|
+
}
|
|
1201
|
+
>
|
|
1202
|
+
{block(styles.skeletonTableHeader)}
|
|
1203
|
+
{[0, 1, 2, 3, 4].map((index) =>
|
|
1204
|
+
block(
|
|
1205
|
+
styles.skeletonTableRow,
|
|
1206
|
+
undefined,
|
|
1207
|
+
`${widget.id}-skeleton-row-${index}`
|
|
1208
|
+
)
|
|
1209
|
+
)}
|
|
1210
|
+
</div>
|
|
1211
|
+
);
|
|
1212
|
+
case 'list':
|
|
1213
|
+
return (
|
|
1214
|
+
<div className={styles.skeletonList}>
|
|
1215
|
+
{[0, 1, 2, 3].map((index) =>
|
|
1216
|
+
block(
|
|
1217
|
+
styles.skeletonListRow,
|
|
1218
|
+
undefined,
|
|
1219
|
+
`${widget.id}-skeleton-list-${index}`
|
|
1220
|
+
)
|
|
1221
|
+
)}
|
|
1222
|
+
</div>
|
|
1223
|
+
);
|
|
1224
|
+
case 'timeline':
|
|
1225
|
+
return block(styles.skeletonTimeline, {
|
|
1226
|
+
height: widget.height || '500px',
|
|
1227
|
+
});
|
|
1228
|
+
default:
|
|
1229
|
+
return block(styles.skeletonGeneric);
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
return (
|
|
1234
|
+
<div
|
|
1235
|
+
className={styles.skeleton}
|
|
1236
|
+
role="status"
|
|
1237
|
+
aria-busy="true"
|
|
1238
|
+
aria-label={`Loading ${widget.title || 'widget'}`}
|
|
1239
|
+
>
|
|
1240
|
+
{renderBody()}
|
|
1241
|
+
</div>
|
|
1242
|
+
);
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
// Skeletons show only until a widget has data for the first time: a
|
|
1246
|
+
// background auto-refresh must never blank a value someone is reading.
|
|
1247
|
+
const renderWidgetBody = (widget) => {
|
|
1248
|
+
const status = widgetStatus[widget.id];
|
|
1249
|
+
const hasLoadedOnce = data[widget.id] !== undefined;
|
|
1250
|
+
|
|
1251
|
+
if (!hasLoadedOnce) {
|
|
1252
|
+
if (status === 'loading') {
|
|
1253
|
+
return renderWidgetSkeleton(widget);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (status === 'failed') {
|
|
1257
|
+
return (
|
|
1258
|
+
<div className={styles.noData}>
|
|
1259
|
+
Couldn't load this widget.
|
|
1260
|
+
</div>
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
return renderWidget(widget);
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1088
1268
|
const renderWidget = (widget) => {
|
|
1089
1269
|
const widgetData = data[widget.id] || [];
|
|
1090
1270
|
|
|
@@ -1914,7 +2094,7 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
|
|
|
1914
2094
|
)}
|
|
1915
2095
|
</div>
|
|
1916
2096
|
{renderFilters(widget)}
|
|
1917
|
-
<div>{
|
|
2097
|
+
<div>{renderWidgetBody(widget)}</div>
|
|
1918
2098
|
{editMode && (
|
|
1919
2099
|
<div className={styles.widgetTools}>
|
|
1920
2100
|
<div className={styles.resizeTools}>
|