@visns-studio/visns-components 6.0.5 → 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.
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +7 -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 +185 -12
- 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/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
|
|
94
|
+
"version": "6.1.0",
|
|
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':
|
|
@@ -3231,6 +3236,8 @@ const DataGrid = forwardRef(
|
|
|
3231
3236
|
return getIconComponent(RefreshCw);
|
|
3232
3237
|
case 'cloudUpload':
|
|
3233
3238
|
return getIconComponent(Upload);
|
|
3239
|
+
case 'cloudDownload':
|
|
3240
|
+
return getIconComponent(DownloadIcon);
|
|
3234
3241
|
case 'oauth2':
|
|
3235
3242
|
return getIconComponent(Lock);
|
|
3236
3243
|
case 'clone':
|
|
@@ -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}>
|
|
@@ -50,6 +50,16 @@ import Breadcrumb from '../Breadcrumb';
|
|
|
50
50
|
import CustomFetch from '../Fetch';
|
|
51
51
|
import SketchConfig from '../sketch/json/config.json';
|
|
52
52
|
import ConditionalDisplayEditor from './ConditionalDisplayEditor';
|
|
53
|
+
import {
|
|
54
|
+
OutstandingOptionToggle,
|
|
55
|
+
OutstandingRuleHint,
|
|
56
|
+
applyOutstandingRule,
|
|
57
|
+
deriveOutstandingSelection,
|
|
58
|
+
isOutstandingConfigurable,
|
|
59
|
+
remapOutstandingWhen,
|
|
60
|
+
staleOutstandingIds,
|
|
61
|
+
summariseOutstandingRule,
|
|
62
|
+
} from './OutstandingRuleEditor';
|
|
53
63
|
|
|
54
64
|
import styles from '../styles/GenericFormBuilder.module.scss'; // Import the CSS module
|
|
55
65
|
|
|
@@ -57,8 +67,19 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
57
67
|
const editorRef = useRef(null);
|
|
58
68
|
const routeParams = useParams();
|
|
59
69
|
|
|
60
|
-
const {
|
|
61
|
-
|
|
70
|
+
const {
|
|
71
|
+
dynamicDropdowns,
|
|
72
|
+
fetchUrl,
|
|
73
|
+
fields,
|
|
74
|
+
parentUrl,
|
|
75
|
+
formTitle,
|
|
76
|
+
/**
|
|
77
|
+
* Opt-in, per-question "outstanding item" rules. Absent for every app
|
|
78
|
+
* that does not model outstanding items, in which case none of the
|
|
79
|
+
* `outstanding_when` handling below does anything at all.
|
|
80
|
+
*/
|
|
81
|
+
outstandingItems,
|
|
82
|
+
} = setting;
|
|
62
83
|
|
|
63
84
|
const { dataId } = useParams();
|
|
64
85
|
const [data, setData] = useState({});
|
|
@@ -115,6 +136,17 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
115
136
|
key: '',
|
|
116
137
|
});
|
|
117
138
|
const [modalFormShow, setModalFormShow] = useState(false);
|
|
139
|
+
/**
|
|
140
|
+
* The open field modal's outstanding-item switch. `touched` is false until
|
|
141
|
+
* the author actually flips one, and until then the displayed state is
|
|
142
|
+
* derived from the field on every render — so a brand-new question picks up
|
|
143
|
+
* the standard rule as soon as its answers exist, rather than being frozen
|
|
144
|
+
* at whatever was true when the modal opened.
|
|
145
|
+
*/
|
|
146
|
+
const [outstandingRule, setOutstandingRule] = useState({
|
|
147
|
+
touched: false,
|
|
148
|
+
id: null,
|
|
149
|
+
});
|
|
118
150
|
const [roles, setRoles] = useState([]);
|
|
119
151
|
const [hoveredField, setHoveredField] = useState(null);
|
|
120
152
|
const [activeId, setActiveId] = useState(null);
|
|
@@ -148,6 +180,37 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
148
180
|
*/
|
|
149
181
|
const dynamicFields = data.field_source === 'dynamic';
|
|
150
182
|
|
|
183
|
+
/** Does the open field carry an editable outstanding-item rule at all? */
|
|
184
|
+
const outstandingEnabled = isOutstandingConfigurable(
|
|
185
|
+
outstandingItems,
|
|
186
|
+
dataField.type
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Which answer currently has its "Outstanding" switch on. Always sanitised
|
|
191
|
+
* against the answers the field offers right now, so renaming or deleting
|
|
192
|
+
* an answer can never leave a dangling id to be saved.
|
|
193
|
+
*/
|
|
194
|
+
const outstandingSelection = useMemo(() => {
|
|
195
|
+
if (!outstandingEnabled) return null;
|
|
196
|
+
|
|
197
|
+
const candidate = outstandingRule.touched
|
|
198
|
+
? outstandingRule.id
|
|
199
|
+
: deriveOutstandingSelection(dataField);
|
|
200
|
+
|
|
201
|
+
return (dataField.options || []).some(
|
|
202
|
+
(option) => option && option.id === candidate
|
|
203
|
+
)
|
|
204
|
+
? candidate
|
|
205
|
+
: null;
|
|
206
|
+
}, [outstandingEnabled, outstandingRule, dataField]);
|
|
207
|
+
|
|
208
|
+
/** Stored ids the field no longer offers — drives the amber warning. */
|
|
209
|
+
const outstandingStaleIds = useMemo(
|
|
210
|
+
() => (outstandingEnabled ? staleOutstandingIds(dataField) : []),
|
|
211
|
+
[outstandingEnabled, dataField]
|
|
212
|
+
);
|
|
213
|
+
|
|
151
214
|
// Generate compact field info for display in the center top
|
|
152
215
|
const getFieldInfo = (field) => {
|
|
153
216
|
const fieldTypeLabel =
|
|
@@ -182,6 +245,14 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
182
245
|
info.push('Conditional');
|
|
183
246
|
}
|
|
184
247
|
|
|
248
|
+
// Only surfaced for apps that opted into outstanding-item rules.
|
|
249
|
+
if (outstandingItems?.enabled) {
|
|
250
|
+
const outstandingSummary = summariseOutstandingRule(field);
|
|
251
|
+
if (outstandingSummary) {
|
|
252
|
+
info.push(outstandingSummary);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
185
256
|
return info.join(' • ');
|
|
186
257
|
};
|
|
187
258
|
|
|
@@ -229,6 +300,13 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
229
300
|
tooltip += `Show when "${field.conditional_field}" = "${field.conditional_value}"\n`;
|
|
230
301
|
}
|
|
231
302
|
|
|
303
|
+
if (outstandingItems?.enabled) {
|
|
304
|
+
const outstandingSummary = summariseOutstandingRule(field);
|
|
305
|
+
if (outstandingSummary) {
|
|
306
|
+
tooltip += `\nOutstanding rule: ${outstandingSummary}\n`;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
232
310
|
// Add other conditional properties if they exist
|
|
233
311
|
if (field.conditionalOperator) {
|
|
234
312
|
tooltip += `Operator: ${field.conditionalOperator}\n`;
|
|
@@ -1027,7 +1105,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1027
1105
|
textareaHeight: 'normal',
|
|
1028
1106
|
conditional_field: '',
|
|
1029
1107
|
conditional_value: '',
|
|
1108
|
+
// `outstanding_when` is deliberately NOT seeded here: absent must
|
|
1109
|
+
// stay absent so a field that was never configured never gains the
|
|
1110
|
+
// key just by being opened.
|
|
1030
1111
|
}));
|
|
1112
|
+
setOutstandingRule({ touched: false, id: null });
|
|
1031
1113
|
setModalType(() => ({
|
|
1032
1114
|
type: 'create',
|
|
1033
1115
|
key: '',
|
|
@@ -1047,10 +1129,28 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1047
1129
|
const { name, value } = e.target;
|
|
1048
1130
|
const updatedValue = name === 'id' ? slugify(value) : value;
|
|
1049
1131
|
|
|
1050
|
-
setDataField((items) =>
|
|
1051
|
-
...items,
|
|
1052
|
-
|
|
1053
|
-
|
|
1132
|
+
setDataField((items) => {
|
|
1133
|
+
const next = { ...items, [name]: updatedValue };
|
|
1134
|
+
|
|
1135
|
+
// Switching to a type that cannot carry an outstanding rule
|
|
1136
|
+
// would strand the key with no UI left to edit it.
|
|
1137
|
+
if (
|
|
1138
|
+
name === 'type' &&
|
|
1139
|
+
'outstanding_when' in next &&
|
|
1140
|
+
!isOutstandingConfigurable(outstandingItems, updatedValue)
|
|
1141
|
+
) {
|
|
1142
|
+
delete next.outstanding_when;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
return next;
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
if (
|
|
1149
|
+
name === 'type' &&
|
|
1150
|
+
!isOutstandingConfigurable(outstandingItems, updatedValue)
|
|
1151
|
+
) {
|
|
1152
|
+
setOutstandingRule({ touched: false, id: null });
|
|
1153
|
+
}
|
|
1054
1154
|
|
|
1055
1155
|
if (name === 'label') {
|
|
1056
1156
|
setDataField((items) => ({
|
|
@@ -1080,6 +1180,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1080
1180
|
...detail[key],
|
|
1081
1181
|
}));
|
|
1082
1182
|
|
|
1183
|
+
// Untouched: the switch shows whatever the stored rule — or the
|
|
1184
|
+
// standard rule — implies, recomputed as the author edits.
|
|
1185
|
+
setOutstandingRule({ touched: false, id: null });
|
|
1186
|
+
|
|
1083
1187
|
setModalType(() => ({
|
|
1084
1188
|
type: 'update',
|
|
1085
1189
|
key: key,
|
|
@@ -1158,6 +1262,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1158
1262
|
|
|
1159
1263
|
field.id = formatFieldId(field.id);
|
|
1160
1264
|
|
|
1265
|
+
// No-op unless the consuming app enabled outstanding-item rules. Writes
|
|
1266
|
+
// `outstanding_when` only when the switch differs from the standard
|
|
1267
|
+
// rule, so opening and saving an untouched field changes nothing.
|
|
1268
|
+
field = applyOutstandingRule(field, outstandingSelection, outstandingItems);
|
|
1269
|
+
|
|
1161
1270
|
let errorMessage = validateField(field);
|
|
1162
1271
|
|
|
1163
1272
|
if (errorMessage === '') {
|
|
@@ -2378,6 +2487,14 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2378
2487
|
</span>
|
|
2379
2488
|
)}
|
|
2380
2489
|
</div>
|
|
2490
|
+
{outstandingEnabled ? (
|
|
2491
|
+
<OutstandingRuleHint
|
|
2492
|
+
config={outstandingItems}
|
|
2493
|
+
staleIds={
|
|
2494
|
+
outstandingStaleIds
|
|
2495
|
+
}
|
|
2496
|
+
/>
|
|
2497
|
+
) : null}
|
|
2381
2498
|
{dataField.options.length >
|
|
2382
2499
|
0 ? (
|
|
2383
2500
|
<div
|
|
@@ -2415,6 +2532,15 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2415
2532
|
onChange={(
|
|
2416
2533
|
e
|
|
2417
2534
|
) => {
|
|
2535
|
+
// Captured before the id is regenerated so any
|
|
2536
|
+
// outstanding rule pointing at it can follow.
|
|
2537
|
+
const previousOptionId =
|
|
2538
|
+
dataField
|
|
2539
|
+
.options[
|
|
2540
|
+
optionKey
|
|
2541
|
+
]
|
|
2542
|
+
?.id;
|
|
2543
|
+
const nextOptionId = `${slugify(e.target.value)}-`;
|
|
2418
2544
|
const newOptions =
|
|
2419
2545
|
[
|
|
2420
2546
|
...dataField.options,
|
|
@@ -2426,18 +2552,65 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2426
2552
|
newOptions[
|
|
2427
2553
|
optionKey
|
|
2428
2554
|
].id =
|
|
2429
|
-
|
|
2555
|
+
nextOptionId;
|
|
2430
2556
|
setDataField(
|
|
2431
2557
|
(
|
|
2432
2558
|
items
|
|
2433
|
-
) =>
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2559
|
+
) =>
|
|
2560
|
+
remapOutstandingWhen(
|
|
2561
|
+
{
|
|
2562
|
+
...items,
|
|
2563
|
+
options:
|
|
2564
|
+
newOptions,
|
|
2565
|
+
},
|
|
2566
|
+
previousOptionId,
|
|
2567
|
+
nextOptionId
|
|
2568
|
+
)
|
|
2569
|
+
);
|
|
2570
|
+
|
|
2571
|
+
// An unsaved switch has to follow the
|
|
2572
|
+
// rename too, or it would be sanitised
|
|
2573
|
+
// away as a dangling id.
|
|
2574
|
+
setOutstandingRule(
|
|
2575
|
+
(
|
|
2576
|
+
rule
|
|
2577
|
+
) =>
|
|
2578
|
+
rule.touched &&
|
|
2579
|
+
rule.id ===
|
|
2580
|
+
previousOptionId
|
|
2581
|
+
? {
|
|
2582
|
+
...rule,
|
|
2583
|
+
id: nextOptionId,
|
|
2584
|
+
}
|
|
2585
|
+
: rule
|
|
2438
2586
|
);
|
|
2439
2587
|
}}
|
|
2440
2588
|
/>
|
|
2589
|
+
{outstandingEnabled ? (
|
|
2590
|
+
<OutstandingOptionToggle
|
|
2591
|
+
optionId={
|
|
2592
|
+
option.id
|
|
2593
|
+
}
|
|
2594
|
+
label={
|
|
2595
|
+
option.label ||
|
|
2596
|
+
option.id
|
|
2597
|
+
}
|
|
2598
|
+
checked={
|
|
2599
|
+
outstandingSelection ===
|
|
2600
|
+
option.id
|
|
2601
|
+
}
|
|
2602
|
+
onToggle={(
|
|
2603
|
+
id
|
|
2604
|
+
) =>
|
|
2605
|
+
setOutstandingRule(
|
|
2606
|
+
{
|
|
2607
|
+
touched: true,
|
|
2608
|
+
id,
|
|
2609
|
+
}
|
|
2610
|
+
)
|
|
2611
|
+
}
|
|
2612
|
+
/>
|
|
2613
|
+
) : null}
|
|
2441
2614
|
<button
|
|
2442
2615
|
className={
|
|
2443
2616
|
styles.optionDelete
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import styles from '../styles/GenericFormBuilder.module.scss';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-question "outstanding item" rules.
|
|
6
|
+
*
|
|
7
|
+
* OPT-IN for consuming apps that model outstanding/defect follow-up items.
|
|
8
|
+
* Everything here is inert unless the app's builder config carries
|
|
9
|
+
* `outstandingItems: { enabled: true, ... }`, so no other project is affected.
|
|
10
|
+
*
|
|
11
|
+
* The stored schema is one optional per-field key, `outstanding_when`:
|
|
12
|
+
*
|
|
13
|
+
* key absent the app's own legacy convention applies (see legacyTriggerId)
|
|
14
|
+
* [] this question can never raise an outstanding item
|
|
15
|
+
* ['no-'] outstanding when the answer is this OPTION ID
|
|
16
|
+
*
|
|
17
|
+
* Option ids are stored, never labels, because editing an option label
|
|
18
|
+
* regenerates its id — exactly as `conditional_value` does.
|
|
19
|
+
*
|
|
20
|
+
* The UI is a single "Outstanding" switch on each row of the field's existing
|
|
21
|
+
* options editor, with radio semantics: at most one answer may be marked.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Field types the editor offers when the config does not name any. */
|
|
25
|
+
export const DEFAULT_OUTSTANDING_TYPES = ['checkbox'];
|
|
26
|
+
|
|
27
|
+
/** Types the legacy (unconfigured) convention applies to. */
|
|
28
|
+
export const LEGACY_OUTSTANDING_TYPES = ['checkbox'];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The one label whose sense is inverted under the legacy convention: a "Yes"
|
|
32
|
+
* here means there IS something else affecting the build.
|
|
33
|
+
*
|
|
34
|
+
* Mirrors App\Support\OutstandingRule::SPECIAL_LABEL / ::SPECIAL_SIMILARITY.
|
|
35
|
+
*/
|
|
36
|
+
export const SPECIAL_LABEL =
|
|
37
|
+
'Other features affecting the build (provide details and photos)';
|
|
38
|
+
export const SPECIAL_SIMILARITY = 85;
|
|
39
|
+
|
|
40
|
+
// --------------------------------------------------------------- similar_text
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Longest common substring, PHP's php_similar_str() — first-found wins on ties,
|
|
44
|
+
* scanning the first string outermost.
|
|
45
|
+
*/
|
|
46
|
+
const similarStr = (s1, s2) => {
|
|
47
|
+
let max = 0;
|
|
48
|
+
let count = 0;
|
|
49
|
+
let pos1 = 0;
|
|
50
|
+
let pos2 = 0;
|
|
51
|
+
|
|
52
|
+
for (let p = 0; p < s1.length; p++) {
|
|
53
|
+
for (let q = 0; q < s2.length; q++) {
|
|
54
|
+
let l = 0;
|
|
55
|
+
while (
|
|
56
|
+
p + l < s1.length &&
|
|
57
|
+
q + l < s2.length &&
|
|
58
|
+
s1[p + l] === s2[q + l]
|
|
59
|
+
) {
|
|
60
|
+
l++;
|
|
61
|
+
}
|
|
62
|
+
if (l > max) {
|
|
63
|
+
max = l;
|
|
64
|
+
count++;
|
|
65
|
+
pos1 = p;
|
|
66
|
+
pos2 = q;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { pos1, pos2, max, count };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** PHP's php_similar_char(): the common substring, then recurse either side. */
|
|
75
|
+
const similarChar = (s1, s2) => {
|
|
76
|
+
const { pos1, pos2, max, count } = similarStr(s1, s2);
|
|
77
|
+
let sum = max;
|
|
78
|
+
|
|
79
|
+
if (sum) {
|
|
80
|
+
if (pos1 && pos2 && count > 1) {
|
|
81
|
+
sum += similarChar(s1.slice(0, pos1), s2.slice(0, pos2));
|
|
82
|
+
}
|
|
83
|
+
if (pos1 + max < s1.length && pos2 + max < s2.length) {
|
|
84
|
+
sum += similarChar(s1.slice(pos1 + max), s2.slice(pos2 + max));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return sum;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* PHP's similar_text($first, $second, $percent) percentage.
|
|
93
|
+
*
|
|
94
|
+
* Verified byte-for-byte against PHP 8.5 over the real label corpus plus 400
|
|
95
|
+
* random strings. The argument order is load-bearing — the function is not
|
|
96
|
+
* symmetric — and must match the backend's call exactly.
|
|
97
|
+
*
|
|
98
|
+
* (PHP compares bytes; this compares UTF-16 code units. Identical for the
|
|
99
|
+
* ASCII labels in use, and only ever used against an ASCII constant.)
|
|
100
|
+
*/
|
|
101
|
+
export const similarTextPercent = (first, second) => {
|
|
102
|
+
const s1 = typeof first === 'string' ? first : '';
|
|
103
|
+
const s2 = typeof second === 'string' ? second : '';
|
|
104
|
+
const total = s1.length + s2.length;
|
|
105
|
+
|
|
106
|
+
if (total === 0) return 0;
|
|
107
|
+
|
|
108
|
+
return (similarChar(s1, s2) * 2 * 100) / total;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------- the config
|
|
112
|
+
|
|
113
|
+
export const outstandingTypesOf = (config) => {
|
|
114
|
+
const types = config?.types;
|
|
115
|
+
return Array.isArray(types) ? types : DEFAULT_OUTSTANDING_TYPES;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The single gate: config must enable it AND the type must store one option id. */
|
|
119
|
+
export const isOutstandingConfigurable = (config, fieldType) =>
|
|
120
|
+
!!(config && config.enabled) &&
|
|
121
|
+
outstandingTypesOf(config).includes(fieldType);
|
|
122
|
+
|
|
123
|
+
// ------------------------------------------------------------- the rule state
|
|
124
|
+
|
|
125
|
+
const optionsOf = (field) =>
|
|
126
|
+
(Array.isArray(field?.options) ? field.options : []).filter(Boolean);
|
|
127
|
+
|
|
128
|
+
const storedIds = (field) =>
|
|
129
|
+
Array.isArray(field?.outstanding_when) ? field.outstanding_when : null;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The legacy trigger for a field: 'yes-' for the special "Other features
|
|
133
|
+
* affecting the build" question, 'no-' for any other checkbox, and null for
|
|
134
|
+
* every other type (the legacy convention never reached them).
|
|
135
|
+
*
|
|
136
|
+
* Mirrors App\Support\OutstandingRule::legacyTrigger().
|
|
137
|
+
*/
|
|
138
|
+
export const legacyTriggerId = (field) => {
|
|
139
|
+
if (!LEGACY_OUTSTANDING_TYPES.includes(field?.type)) return null;
|
|
140
|
+
|
|
141
|
+
return similarTextPercent(field?.label, SPECIAL_LABEL) >=
|
|
142
|
+
SPECIAL_SIMILARITY
|
|
143
|
+
? 'yes-'
|
|
144
|
+
: 'no-';
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What the legacy convention would show as toggled, which is only meaningful
|
|
149
|
+
* when the field actually offers that answer. A checkbox with no 'no-' option
|
|
150
|
+
* (or no options yet) has no legacy toggle, and the backend agrees: its trigger
|
|
151
|
+
* can never match a stored value.
|
|
152
|
+
*/
|
|
153
|
+
export const legacyDefaultOptionId = (field) => {
|
|
154
|
+
const trigger = legacyTriggerId(field);
|
|
155
|
+
if (!trigger) return null;
|
|
156
|
+
|
|
157
|
+
return optionsOf(field).some((option) => option.id === trigger)
|
|
158
|
+
? trigger
|
|
159
|
+
: null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Which option should show toggled when the modal opens.
|
|
164
|
+
*
|
|
165
|
+
* Configured → the first stored id the field still offers. (No template today
|
|
166
|
+
* carries more than one; a multi-entry array shows its first and is normalised
|
|
167
|
+
* to a single id on save.) An explicit `[]` shows nothing toggled.
|
|
168
|
+
* Unconfigured → whatever the legacy convention would flag.
|
|
169
|
+
*/
|
|
170
|
+
export const deriveOutstandingSelection = (field) => {
|
|
171
|
+
const stored = storedIds(field);
|
|
172
|
+
|
|
173
|
+
if (stored) {
|
|
174
|
+
const options = optionsOf(field);
|
|
175
|
+
return (
|
|
176
|
+
stored.find((id) => options.some((option) => option.id === id)) ??
|
|
177
|
+
null
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return legacyDefaultOptionId(field);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Stored ids the field no longer offers — the rename/delete hazard. */
|
|
185
|
+
export const staleOutstandingIds = (field) => {
|
|
186
|
+
const stored = storedIds(field);
|
|
187
|
+
if (!stored) return [];
|
|
188
|
+
|
|
189
|
+
const options = optionsOf(field);
|
|
190
|
+
return stored.filter((id) => !options.some((option) => option.id === id));
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fold the toggle state back into the field.
|
|
195
|
+
*
|
|
196
|
+
* The key is written only when the outcome differs from what the legacy
|
|
197
|
+
* convention would produce, so a field nobody configured never gains it just
|
|
198
|
+
* because its modal was opened and saved:
|
|
199
|
+
*
|
|
200
|
+
* selection === legacy default → key deleted (including both being null)
|
|
201
|
+
* an answer toggled → ['<id>']
|
|
202
|
+
* nothing toggled, legacy would → [] (an explicit "never")
|
|
203
|
+
*
|
|
204
|
+
* A stale stored id is not toggleable, so saving normalises it to [] (checkbox)
|
|
205
|
+
* or an absent key (dropdown) — both meaning "never flags", which is precisely
|
|
206
|
+
* what an id no answer can produce already meant.
|
|
207
|
+
*/
|
|
208
|
+
export const applyOutstandingRule = (field, selectedId, config) => {
|
|
209
|
+
if (!isOutstandingConfigurable(config, field?.type)) return field;
|
|
210
|
+
|
|
211
|
+
const selection = selectedId || null;
|
|
212
|
+
const next = { ...field };
|
|
213
|
+
|
|
214
|
+
if (selection === legacyDefaultOptionId(field)) {
|
|
215
|
+
delete next.outstanding_when;
|
|
216
|
+
} else {
|
|
217
|
+
next.outstanding_when = selection ? [selection] : [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return next;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* One-line summary for field-list chips and tooltips. Returns null when the
|
|
225
|
+
* field carries no rule, so callers can skip falsy results.
|
|
226
|
+
*/
|
|
227
|
+
export const summariseOutstandingRule = (field) => {
|
|
228
|
+
const stored = storedIds(field);
|
|
229
|
+
if (!stored) return null;
|
|
230
|
+
if (stored.length === 0) return 'Never outstanding';
|
|
231
|
+
|
|
232
|
+
const options = optionsOf(field);
|
|
233
|
+
const labels = stored.map((id) => {
|
|
234
|
+
const match = options.find((option) => option.id === id);
|
|
235
|
+
return match ? match.label || match.id : `${id} (missing)`;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return `Outstanding: ${labels.join(', ')}`;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Remap a stored id after an option's id was regenerated from an edited label.
|
|
243
|
+
* Returns the field untouched (same reference) when there is nothing to remap.
|
|
244
|
+
*/
|
|
245
|
+
export const remapOutstandingWhen = (field, previousId, nextId) => {
|
|
246
|
+
const stored = storedIds(field);
|
|
247
|
+
|
|
248
|
+
if (!stored || previousId === nextId) return field;
|
|
249
|
+
if (!stored.includes(previousId)) return field;
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
...field,
|
|
253
|
+
outstanding_when: stored.map((id) => (id === previousId ? nextId : id)),
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// ------------------------------------------------------------------------ UI
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The per-row switch. Radio semantics live in the caller: `onToggle` receives
|
|
261
|
+
* the id to select, or null when the row was switched off.
|
|
262
|
+
*/
|
|
263
|
+
export function OutstandingOptionToggle({ optionId, label, checked, onToggle }) {
|
|
264
|
+
return (
|
|
265
|
+
<label
|
|
266
|
+
className={`${styles.outstandingFlag} ${
|
|
267
|
+
checked ? styles.outstandingFlagOn : ''
|
|
268
|
+
}`}
|
|
269
|
+
title={
|
|
270
|
+
checked
|
|
271
|
+
? `An answer of "${label}" raises an outstanding item`
|
|
272
|
+
: `Mark "${label}" as the answer that raises an outstanding item`
|
|
273
|
+
}
|
|
274
|
+
>
|
|
275
|
+
<input
|
|
276
|
+
type="checkbox"
|
|
277
|
+
checked={checked}
|
|
278
|
+
onChange={() => onToggle(checked ? null : optionId)}
|
|
279
|
+
/>
|
|
280
|
+
<span>Outstanding</span>
|
|
281
|
+
</label>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The hint shown once above the option rows, plus the amber stale-reference
|
|
287
|
+
* warning. Renders nothing when there is nothing to say.
|
|
288
|
+
*/
|
|
289
|
+
export function OutstandingRuleHint({ config, staleIds = [] }) {
|
|
290
|
+
const help = config?.help;
|
|
291
|
+
|
|
292
|
+
if (!help && staleIds.length === 0) return null;
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<>
|
|
296
|
+
{help ? (
|
|
297
|
+
<div className={styles.outstandingHint}>{help}</div>
|
|
298
|
+
) : null}
|
|
299
|
+
|
|
300
|
+
{staleIds.length > 0 ? (
|
|
301
|
+
<div className={styles.outstandingStale}>
|
|
302
|
+
{staleIds.length === 1
|
|
303
|
+
? 'The answer this question’s outstanding rule refers to no longer exists'
|
|
304
|
+
: 'Answers this question’s outstanding rule refers to no longer exist'}{' '}
|
|
305
|
+
({staleIds.join(', ')}) — editing an answer’s label gives it
|
|
306
|
+
a new id. Set the switch on the right answer, or leave every
|
|
307
|
+
switch off to mean “never outstanding”. Saving tidies this
|
|
308
|
+
up.
|
|
309
|
+
</div>
|
|
310
|
+
) : null}
|
|
311
|
+
</>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
@@ -814,3 +814,158 @@
|
|
|
814
814
|
color: #888;
|
|
815
815
|
padding: 20px;
|
|
816
816
|
}
|
|
817
|
+
|
|
818
|
+
/* ---------------------------------------------------------------------------
|
|
819
|
+
Loading skeletons
|
|
820
|
+
Neutral greys only (no brand colours) so every tenant gets a safe default.
|
|
821
|
+
Keyframes are uniquely prefixed to avoid colliding with host app globals.
|
|
822
|
+
--------------------------------------------------------------------------- */
|
|
823
|
+
@keyframes visns-dash-skeleton-sweep {
|
|
824
|
+
0% {
|
|
825
|
+
background-position: 150% 0;
|
|
826
|
+
}
|
|
827
|
+
100% {
|
|
828
|
+
background-position: -150% 0;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
.skeleton {
|
|
833
|
+
width: 100%;
|
|
834
|
+
box-sizing: border-box;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
.skeletonBlock {
|
|
838
|
+
display: block;
|
|
839
|
+
box-sizing: border-box;
|
|
840
|
+
border-radius: var(--br, 5px);
|
|
841
|
+
background-color: rgba(0, 0, 0, 0.06);
|
|
842
|
+
background-image: linear-gradient(
|
|
843
|
+
90deg,
|
|
844
|
+
rgba(0, 0, 0, 0) 0%,
|
|
845
|
+
rgba(0, 0, 0, 0.05) 50%,
|
|
846
|
+
rgba(0, 0, 0, 0) 100%
|
|
847
|
+
);
|
|
848
|
+
background-repeat: no-repeat;
|
|
849
|
+
background-size: 150% 100%;
|
|
850
|
+
animation: visns-dash-skeleton-sweep 1.4s ease-in-out infinite;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/* Counter: matches .widgetNo (2rem bottom padding, 3em value) */
|
|
854
|
+
.skeletonCounter {
|
|
855
|
+
width: 100%;
|
|
856
|
+
padding: 0 0 2rem 0;
|
|
857
|
+
box-sizing: border-box;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
.skeletonCounterValue {
|
|
861
|
+
width: 55%;
|
|
862
|
+
max-width: 220px;
|
|
863
|
+
height: 3.6rem;
|
|
864
|
+
margin: 0 auto;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/* Counter button: matches .widgetLink .btn */
|
|
868
|
+
.skeletonButtonRow {
|
|
869
|
+
width: 100%;
|
|
870
|
+
display: block;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
.skeletonButton {
|
|
874
|
+
width: 180px;
|
|
875
|
+
max-width: 100%;
|
|
876
|
+
height: 34px;
|
|
877
|
+
margin: 0 auto;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/* Charts: the wrapper takes widget.height (or the 600px chart default) inline */
|
|
881
|
+
.skeletonChart {
|
|
882
|
+
width: 100%;
|
|
883
|
+
display: flex;
|
|
884
|
+
flex-direction: column;
|
|
885
|
+
align-items: center;
|
|
886
|
+
justify-content: center;
|
|
887
|
+
box-sizing: border-box;
|
|
888
|
+
padding: 20px 0;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
.skeletonChartStrips {
|
|
892
|
+
align-items: flex-start;
|
|
893
|
+
gap: 14px;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
.skeletonStrip {
|
|
897
|
+
height: 16px;
|
|
898
|
+
min-height: 16px;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
.skeletonCircle {
|
|
902
|
+
width: 60%;
|
|
903
|
+
max-width: 260px;
|
|
904
|
+
aspect-ratio: 1 / 1;
|
|
905
|
+
border-radius: 50%;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/* Table: header 44px / rows 38px, matching .table-container th and td */
|
|
909
|
+
.skeletonTable {
|
|
910
|
+
width: 100%;
|
|
911
|
+
display: flex;
|
|
912
|
+
flex-direction: column;
|
|
913
|
+
gap: 1px;
|
|
914
|
+
box-sizing: border-box;
|
|
915
|
+
border: 1px solid #dadce0;
|
|
916
|
+
border-radius: var(--br, 5px);
|
|
917
|
+
overflow: hidden;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.skeletonTableHeader {
|
|
921
|
+
width: 100%;
|
|
922
|
+
height: 44px;
|
|
923
|
+
border-radius: 0;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
.skeletonTableRow {
|
|
927
|
+
width: 100%;
|
|
928
|
+
height: 38px;
|
|
929
|
+
border-radius: 0;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/* List: rows sized like .dashList li button */
|
|
933
|
+
.skeletonList {
|
|
934
|
+
width: 100%;
|
|
935
|
+
display: flex;
|
|
936
|
+
flex-direction: column;
|
|
937
|
+
gap: 1px;
|
|
938
|
+
font-size: 0.875rem;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
.skeletonListRow {
|
|
942
|
+
width: 100%;
|
|
943
|
+
height: 30px;
|
|
944
|
+
border-radius: 0;
|
|
945
|
+
|
|
946
|
+
&:first-child {
|
|
947
|
+
border-top-left-radius: var(--br, 5px);
|
|
948
|
+
border-top-right-radius: var(--br, 5px);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
&:last-child {
|
|
952
|
+
border-bottom-left-radius: var(--br, 5px);
|
|
953
|
+
border-bottom-right-radius: var(--br, 5px);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.skeletonTimeline {
|
|
958
|
+
width: 100%;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
.skeletonGeneric {
|
|
962
|
+
width: 100%;
|
|
963
|
+
height: 120px;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/* Keep the shape, drop the motion */
|
|
967
|
+
@media (prefers-reduced-motion: reduce) {
|
|
968
|
+
.skeletonBlock {
|
|
969
|
+
animation: none;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
@@ -2275,6 +2275,66 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
2275
2275
|
}
|
|
2276
2276
|
}
|
|
2277
2277
|
|
|
2278
|
+
// Outstanding item rule — a per-answer switch inside the options editor, plus
|
|
2279
|
+
// its hint and stale-reference warning. All of it renders only when the
|
|
2280
|
+
// consuming app enables `outstandingItems` in its builder config.
|
|
2281
|
+
.outstandingHint {
|
|
2282
|
+
font-size: 0.78rem;
|
|
2283
|
+
line-height: 1.45;
|
|
2284
|
+
color: rgba(var(--paragraph-color-rgb), 0.65);
|
|
2285
|
+
padding: 0 0 6px;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
.outstandingStale {
|
|
2289
|
+
background: #fff8e1;
|
|
2290
|
+
border: 1px solid #f4c544;
|
|
2291
|
+
border-radius: 6px;
|
|
2292
|
+
padding: 8px 12px;
|
|
2293
|
+
margin-bottom: 6px;
|
|
2294
|
+
color: #7a5c00;
|
|
2295
|
+
font-size: 0.8rem;
|
|
2296
|
+
line-height: 1.45;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
.outstandingFlag {
|
|
2300
|
+
flex-shrink: 0;
|
|
2301
|
+
display: inline-flex;
|
|
2302
|
+
align-items: center;
|
|
2303
|
+
gap: 5px;
|
|
2304
|
+
padding: 4px 9px;
|
|
2305
|
+
border: 1px solid rgba(var(--primary-rgb), 0.15);
|
|
2306
|
+
border-radius: 6px;
|
|
2307
|
+
background: white;
|
|
2308
|
+
font-size: 0.72rem;
|
|
2309
|
+
font-weight: 600;
|
|
2310
|
+
text-transform: uppercase;
|
|
2311
|
+
letter-spacing: 0.04em;
|
|
2312
|
+
color: rgba(var(--paragraph-color-rgb), 0.45);
|
|
2313
|
+
cursor: pointer;
|
|
2314
|
+
user-select: none;
|
|
2315
|
+
transition: all 0.2s ease;
|
|
2316
|
+
|
|
2317
|
+
&:hover {
|
|
2318
|
+
border-color: var(--primary-color);
|
|
2319
|
+
color: var(--paragraph-color);
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// The modal forces a 44px min-height on every input; a tick box must opt out.
|
|
2323
|
+
input[type='checkbox'] {
|
|
2324
|
+
margin: 0;
|
|
2325
|
+
width: auto !important;
|
|
2326
|
+
height: auto !important;
|
|
2327
|
+
min-height: 0 !important;
|
|
2328
|
+
cursor: pointer;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
.outstandingFlagOn {
|
|
2333
|
+
border-color: #e0a800;
|
|
2334
|
+
background: #fff8e1;
|
|
2335
|
+
color: #7a5c00;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2278
2338
|
.btnCancel {
|
|
2279
2339
|
background: rgba(var(--paragraph-color-rgb), 0.08) !important;
|
|
2280
2340
|
color: var(--paragraph-color) !important;
|