@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 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.0.5",
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.permission === true ||
624
- nav.permissionKey === '' ? (
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.permission === true || nav.permissionKey === '' ? (
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}>Prime Builders</p>
194
- <p className={styles.brandLine}>Prime Projects</p>
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&apos;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>{renderWidget(widget)}</div>
2097
+ <div>{renderWidgetBody(widget)}</div>
1918
2098
  {editMode && (
1919
2099
  <div className={styles.widgetTools}>
1920
2100
  <div className={styles.resizeTools}>