@visns-studio/visns-components 6.0.4 → 6.1.0

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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/src/components/DataGrid.jsx +37 -12
  3. package/src/components/Navigation.jsx +59 -3
  4. package/src/components/auth/Login.jsx +203 -175
  5. package/src/components/columns/ColumnRenderers.jsx +34 -0
  6. package/src/components/generic/GenericDashboard.jsx +181 -1
  7. package/src/components/generic/GenericFormBuilder.jsx +735 -37
  8. package/src/components/generic/GenericIndex.jsx +15 -5
  9. package/src/components/generic/OutstandingRuleEditor.jsx +313 -0
  10. package/src/components/styles/DataGrid.module.scss +38 -22
  11. package/src/components/styles/Form.module.scss +32 -33
  12. package/src/components/styles/GenericDashboard.module.scss +155 -0
  13. package/src/components/styles/GenericDetail.module.scss +28 -30
  14. package/src/components/styles/GenericDynamic.module.scss +7 -21
  15. package/src/components/styles/GenericEditableTable.module.scss +5 -22
  16. package/src/components/styles/GenericFormBuilder.module.scss +605 -30
  17. package/src/components/styles/GenericIndex.module.scss +17 -32
  18. package/src/components/styles/GenericMain.module.scss +18 -6
  19. package/src/components/styles/GenericQuote.module.scss +7 -22
  20. package/src/components/styles/GenericReport.module.scss +5 -37
  21. package/src/components/styles/Login.module.scss +415 -239
  22. package/src/components/styles/Navigation.module.scss +124 -35
  23. package/src/components/styles/Profile.module.scss +5 -26
  24. package/src/components/styles/QuickAction.module.scss +34 -29
  25. package/src/components/styles/_controls.scss +139 -0
  26. package/src/components/styles/global.css +54 -15
@@ -152,6 +152,40 @@ export const renderBooleanColumn = ({
152
152
  render: ({ data }) => {
153
153
  const rawValue = data[column.id];
154
154
  const formattedValue = formatCellContent(rawValue, { ...column, type: 'boolean' });
155
+
156
+ // Coloured pill variant — opt in via column.badge, matching the
157
+ // option renderer. A yes/no column rendered as plain text reads
158
+ // the same as every other cell, so the state has to be read
159
+ // rather than scanned. Without column.badge nothing changes.
160
+ if (column.badge) {
161
+ const on = Boolean(rawValue) && rawValue !== '0';
162
+ const palette = on
163
+ ? (column.badgeOn || { bg: '#eaf6ef', fg: '#1f6b41' })
164
+ : (column.badgeOff || { bg: '#f1f1ee', fg: '#6b7688' });
165
+
166
+ return (
167
+ <CellWithTooltip value={rawValue} columnType="boolean">
168
+ <span
169
+ style={{
170
+ display: 'inline-block',
171
+ padding: '2px 10px',
172
+ borderRadius: '999px',
173
+ fontSize: '11px',
174
+ fontWeight: 600,
175
+ lineHeight: 1.6,
176
+ letterSpacing: '0.02em',
177
+ textTransform: 'uppercase',
178
+ whiteSpace: 'nowrap',
179
+ backgroundColor: palette.bg,
180
+ color: palette.fg,
181
+ }}
182
+ >
183
+ {formattedValue}
184
+ </span>
185
+ </CellWithTooltip>
186
+ );
187
+ }
188
+
155
189
  return (
156
190
  <CellWithTooltip value={rawValue} columnType="boolean">
157
191
  <span>{formattedValue}</span>
@@ -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}>