@openg2p/registry-widgets 1.1.0-dev.0 → 1.1.0-dev.2

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/dist/index.js CHANGED
@@ -1005,6 +1005,19 @@ const getApiDataSource = async (dataSource, allValues, dataSourceRequestHandler,
1005
1005
  // If not found and doesn't contain dots, try as widget-id
1006
1006
  if ((depValue === null || depValue === undefined) && !dataSource.dependsOn.includes('.')) {
1007
1007
  depValue = allValues[dataSource.dependsOn];
1008
+ // Smart resolution: If not found at top level, try to find the dependency in the same nested object
1009
+ // by looking for other keys in allValues that might contain the dependency.
1010
+ // We look for objects that contain both the current widget's path (if we can guess it) and the dependency.
1011
+ // But since we don't know the current widget's path here, we search for any object that has this dependency key.
1012
+ if (depValue === null || depValue === undefined || depValue === '') {
1013
+ for (const val of Object.values(allValues)) {
1014
+ if (val && typeof val === 'object' && !Array.isArray(val) && dataSource.dependsOn in val) {
1015
+ depValue = val[dataSource.dependsOn];
1016
+ if (depValue !== null && depValue !== undefined && depValue !== '')
1017
+ break;
1018
+ }
1019
+ }
1020
+ }
1008
1021
  }
1009
1022
  if (depValue === null || depValue === undefined || depValue === '') {
1010
1023
  // If dependency is empty, return empty array
@@ -1048,8 +1061,8 @@ const getApiDataSource = async (dataSource, allValues, dataSourceRequestHandler,
1048
1061
  }
1049
1062
  }
1050
1063
  else if (staticParams.level_id) {
1051
- // First level has no parent
1052
- requestParams.parent_level_value_id = null;
1064
+ // First level has no parent, send empty string as many OpenG2P APIs expect it
1065
+ requestParams.parent_level_value_id = "";
1053
1066
  }
1054
1067
  // Get service mnemonic and endpoint (required)
1055
1068
  const service = dataSource.service;
@@ -1118,10 +1131,12 @@ const transformDataSourceOptions = (data, valueKey, labelKey) => {
1118
1131
  return { value: item, label: String(item) };
1119
1132
  });
1120
1133
  }
1121
- return data.map((item) => ({
1122
- value: item[valueKey],
1123
- label: item[labelKey] || String(item[valueKey]),
1124
- }));
1134
+ return data.map((item) => {
1135
+ const value = item[valueKey];
1136
+ // Try multiple common label keys if the primary one is missing
1137
+ const label = item[labelKey] || item.name || item.label || item.mnemonic || item.level_value_mnemonic || String(value);
1138
+ return { value, label };
1139
+ });
1125
1140
  };
1126
1141
 
1127
1142
  const WidgetEventBusContext = React.createContext(null);
@@ -1899,46 +1914,71 @@ const useBaseWidget = (options) => {
1899
1914
  const userHasSetValueRef = React.useRef(false);
1900
1915
  // Use ref for values to avoid stale closures in handleChange
1901
1916
  const valuesRef = React.useRef(values);
1917
+ const loadingRef = React.useRef(loading);
1918
+ const dataSourceOptionsRef = React.useRef(dataSourceOptions);
1902
1919
  React.useEffect(() => {
1903
1920
  valuesRef.current = values;
1904
- }, [values]);
1921
+ loadingRef.current = loading;
1922
+ dataSourceOptionsRef.current = dataSourceOptions;
1923
+ }, [values, loading, dataSourceOptions]);
1905
1924
  // Track last dispatched value to prevent duplicate dispatches
1906
1925
  const lastDispatchedValueRef = React.useRef(null);
1907
- // Get current value
1908
- const currentValue = React.useMemo(() => {
1909
- if (isLayoutWidget) {
1910
- return undefined; // Layout widgets don't have values
1926
+ // Helper to extract displayable value from object (especially geo hierarchy objects)
1927
+ const extractValueFromObject = React.useCallback((obj) => {
1928
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
1929
+ return obj;
1911
1930
  }
1912
- // Helper to extract displayable value from object (especially geo hierarchy objects)
1913
- const extractValueFromObject = (obj) => {
1914
- if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
1915
- return obj;
1916
- }
1917
- // Check for geo hierarchy structure first
1918
- if ('geo_code_hierarchy_json' in obj || 'geo_lowest_level_value_id' in obj) {
1919
- if ('geo_lowest_level_value_id' in obj) {
1920
- return obj.geo_lowest_level_value_id;
1931
+ // Check for geo hierarchy structure first
1932
+ const geoConfig = config['widget-geo-config'];
1933
+ if (geoConfig) {
1934
+ // If we have a geo hierarchy object, extract the value for this specific level
1935
+ const hierarchy = obj.hierarchy || obj.geo_code_hierarchy_json?.hierarchy;
1936
+ if (Array.isArray(hierarchy)) {
1937
+ const levelData = hierarchy.find((l) => l.level === geoConfig.level);
1938
+ if (levelData) {
1939
+ return levelData.level_value_id;
1921
1940
  }
1922
- // If it's a geo hierarchy object but no extractable ID, return undefined to avoid rendering object
1923
- return undefined;
1924
1941
  }
1925
- // Try common value fields
1926
- if ('value' in obj) {
1927
- return obj.value;
1942
+ }
1943
+ if ('geo_code_hierarchy_json' in obj || 'geo_lowest_level_value_id' in obj || 'hierarchy' in obj) {
1944
+ if ('geo_lowest_level_value_id' in obj) {
1945
+ return obj.geo_lowest_level_value_id;
1928
1946
  }
1929
- if ('id' in obj) {
1930
- return obj.id;
1947
+ if ('lowest_level_value_id' in obj) {
1948
+ return obj.lowest_level_value_id;
1931
1949
  }
1932
- if ('label' in obj) {
1933
- return obj.label;
1950
+ // Fallback for nested geo_code_hierarchy_json
1951
+ if (obj.geo_code_hierarchy_json?.lowest_level_value_id) {
1952
+ return obj.geo_code_hierarchy_json.lowest_level_value_id;
1934
1953
  }
1935
- if ('name' in obj) {
1936
- return obj.name;
1954
+ if (obj.geo_code_hierarchy_json?.geo_lowest_level_value_id) {
1955
+ return obj.geo_code_hierarchy_json.geo_lowest_level_value_id;
1937
1956
  }
1938
- // If no extractable value found, return undefined to avoid rendering object as React child
1939
- // This prevents "Objects are not valid as a React child" errors
1957
+ // If it's a geo hierarchy object but no extractable ID, return undefined to avoid rendering object
1940
1958
  return undefined;
1941
- };
1959
+ }
1960
+ // Try common value fields
1961
+ if ('value' in obj) {
1962
+ return obj.value;
1963
+ }
1964
+ if ('id' in obj) {
1965
+ return obj.id;
1966
+ }
1967
+ if ('label' in obj) {
1968
+ return obj.label;
1969
+ }
1970
+ if ('name' in obj) {
1971
+ return obj.name;
1972
+ }
1973
+ // If no extractable value found, return undefined to avoid rendering object as React child
1974
+ // This prevents "Objects are not valid as a React child" errors
1975
+ return undefined;
1976
+ }, [config]);
1977
+ // Get current value
1978
+ const currentValue = React.useMemo(() => {
1979
+ if (isLayoutWidget) {
1980
+ return undefined; // Layout widgets don't have values
1981
+ }
1942
1982
  // Try to get value from widgetId first (this should have the actual selected value)
1943
1983
  // For geo widgets with dataPath, widgetId stores the actual ID, while dataPath stores the hierarchy object
1944
1984
  let value = values[widgetId];
@@ -1979,6 +2019,33 @@ const useBaseWidget = (options) => {
1979
2019
  }
1980
2020
  return value !== undefined ? value : config['widget-data-default'];
1981
2021
  }, [values, config, widgetId, isLayoutWidget]);
2022
+ // Track the last value we attempted to mirror to prevent infinite loops
2023
+ const lastMirroredValueRef = React.useRef(null);
2024
+ // Mirror value from dataPath to widgetId in Redux state if it's not already there.
2025
+ // This is essential for widgets that depend on this widget via 'dependsOn' using its widgetId,
2026
+ // especially when the actual data is stored in a nested path.
2027
+ // CRITICAL: This ensures that dependencies are resolved correctly when entering Edit mode.
2028
+ React.useEffect(() => {
2029
+ if (isLayoutWidget || !config['widget-data-path']) {
2030
+ return;
2031
+ }
2032
+ const rawValue = getWidgetValue(values, config['widget-data-path'], widgetId);
2033
+ if (rawValue !== undefined && rawValue !== null) {
2034
+ const extractedValue = extractValueFromObject(rawValue);
2035
+ // Only mirror if:
2036
+ // 1. The top-level value is undefined (initial load or entering edit mode)
2037
+ // 2. We haven't already tried to mirror this specific value (prevents loops if dispatch is ignored or delayed)
2038
+ // 3. The extracted value is valid
2039
+ if (values[widgetId] === undefined &&
2040
+ extractedValue !== undefined &&
2041
+ extractedValue !== null &&
2042
+ lastMirroredValueRef.current !== extractedValue) {
2043
+ lastMirroredValueRef.current = extractedValue;
2044
+ dispatch(setValue({ widgetId, value: extractedValue }));
2045
+ }
2046
+ }
2047
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2048
+ }, [values, config['widget-data-path'], widgetId, isLayoutWidget]);
1982
2049
  // Initialize default value only once on mount (skip for layout widgets)
1983
2050
  React.useEffect(() => {
1984
2051
  if (isLayoutWidget) {
@@ -2001,6 +2068,20 @@ const useBaseWidget = (options) => {
2001
2068
  if (currentValue === newValue) {
2002
2069
  return;
2003
2070
  }
2071
+ // CRITICAL FIX: Ignore auto-clears (empty string or undefined) from UI components
2072
+ // when the widget's data source is currently loading OR if options are empty.
2073
+ // This prevents data disappearance when switching to Edit mode and components
2074
+ // incorrectly clear values before options load or if handler is temporarily missing.
2075
+ if (newValue === '' || newValue === null || newValue === undefined) {
2076
+ if (loadingRef.current) {
2077
+ console.warn(`[useBaseWidget] Ignoring empty value for ${widgetId} because data source is loading`);
2078
+ return;
2079
+ }
2080
+ if (config['widget-data-source']?.type === 'api' && dataSourceOptionsRef.current.length === 0) {
2081
+ console.warn(`[useBaseWidget] Ignoring empty value for ${widgetId} because API options are empty`);
2082
+ return;
2083
+ }
2084
+ }
2004
2085
  // Mark that user has set a value (unless this is the default initialization)
2005
2086
  if (newValue !== config['widget-data-default'] || userHasSetValueRef.current) {
2006
2087
  userHasSetValueRef.current = true;
@@ -2018,6 +2099,13 @@ const useBaseWidget = (options) => {
2018
2099
  }
2019
2100
  else {
2020
2101
  // Has dataPath: update both widgetId and dataPath
2102
+ // CRITICAL: For geo widgets, we do NOT want to overwrite the shared hierarchy dataPath
2103
+ // with a primitive value (the selected ID). The hierarchy object is managed by useGeoWidgetCascade.
2104
+ if (config['widget-geo-config']) {
2105
+ dispatch(setValue({ widgetId, value: newValue }));
2106
+ return;
2107
+ }
2108
+ // For non-geo widgets, update both widgetId and dataPath
2021
2109
  // CRITICAL: Create updated values object with newValue already set
2022
2110
  // This prevents setWidgetValue from reading stale values
2023
2111
  const currentValuesWithUpdate = {
@@ -2112,6 +2200,17 @@ const useBaseWidget = (options) => {
2112
2200
  const apiService = dataSource?.type === 'api' ? dataSource.service : '';
2113
2201
  const apiEndpoint = dataSource?.type === 'api' ? dataSource.endpoint : '';
2114
2202
  const configKey = `${widgetId}-${isReadonly}-${dataSource?.type || 'none'}-${apiService}-${apiEndpoint}`;
2203
+ // Extract dependency value using a granular selector to prevent unnecessary re-renders
2204
+ // and infinite loops when other unrelated values in the state change.
2205
+ const dependencyValue = reactRedux.useSelector((state) => {
2206
+ if (dataSource?.type !== 'api' || !dataSource.dependsOn) {
2207
+ return null;
2208
+ }
2209
+ if (dataSource.dependsOn.includes('.')) {
2210
+ return getWidgetValue(state.widget.values, dataSource.dependsOn, '');
2211
+ }
2212
+ return state.widget.values[dataSource.dependsOn];
2213
+ });
2115
2214
  // Handle data source loading
2116
2215
  React.useEffect(() => {
2117
2216
  if (!dataSource) {
@@ -2132,6 +2231,17 @@ const useBaseWidget = (options) => {
2132
2231
  }
2133
2232
  else {
2134
2233
  depValue = values[dataSource.dependsOn];
2234
+ // Smart resolution: If not found at top level, and current widget has a nested dataPath,
2235
+ // try to find the dependency in the same nested object.
2236
+ if ((depValue === undefined || depValue === null || depValue === '') &&
2237
+ typeof config['widget-data-path'] === 'string' &&
2238
+ config['widget-data-path'].includes('.')) {
2239
+ const pathParts = config['widget-data-path'].split('.');
2240
+ pathParts.pop(); // Remove current field name
2241
+ const prefix = pathParts.join('.');
2242
+ const tryPath = `${prefix}.${dataSource.dependsOn}`;
2243
+ depValue = getWidgetValue(values, tryPath, '');
2244
+ }
2135
2245
  }
2136
2246
  // If dependency is empty, don't load (will load when dependency has value)
2137
2247
  if (depValue === null || depValue === undefined || depValue === '') {
@@ -2165,7 +2275,7 @@ const useBaseWidget = (options) => {
2165
2275
  }
2166
2276
  // Extract level_id from widget-geo-config.level if available
2167
2277
  const levelId = geoConfig?.level;
2168
- data = await getApiDataSource(dataSource, values, currentHandler, levelId);
2278
+ data = await getApiDataSource(dataSource, valuesRef.current, currentHandler, levelId);
2169
2279
  }
2170
2280
  else if (dataSource.type === 'schema') {
2171
2281
  data = getSchemaDataSource(dataSource, schemaData || {});
@@ -2200,9 +2310,9 @@ const useBaseWidget = (options) => {
2200
2310
  }
2201
2311
  };
2202
2312
  loadDataSource();
2203
- // Use configKey to ensure effect runs when readonly state changes
2313
+ // Use configKey and dependencyValue to ensure effect runs only when relevant state changes
2204
2314
  // eslint-disable-next-line react-hooks/exhaustive-deps
2205
- }, [configKey, values, dataSourceRequestHandler, schemaData, widgetId, dispatch]);
2315
+ }, [configKey, dependencyValue, dataSourceRequestHandler, schemaData, widgetId, dispatch]);
2206
2316
  return {
2207
2317
  widgetId,
2208
2318
  value: currentValue,
@@ -2403,6 +2513,9 @@ const useGeoWidgetCascade = (options) => {
2403
2513
  const geoConfig = config['widget-geo-config'];
2404
2514
  const dataSource = config['widget-data-source'];
2405
2515
  const dataPath = config['widget-data-path'];
2516
+ const groupId = typeof dataPath === 'string' && dataPath.includes('.')
2517
+ ? dataPath.split('.').slice(0, -1).join('.')
2518
+ : 'default';
2406
2519
  const valuesRef = React.useRef(values);
2407
2520
  const handlerRef = React.useRef(dataSourceRequestHandler);
2408
2521
  // Keep refs updated
@@ -2412,10 +2525,36 @@ const useGeoWidgetCascade = (options) => {
2412
2525
  }, [values, dataSourceRequestHandler]);
2413
2526
  // Get current value and data source options
2414
2527
  const currentValue = reactRedux.useSelector((state) => {
2415
- if (!dataPath) {
2416
- return state.widget.values[widgetId];
2528
+ // Try to get value from widgetId first (most recent selection)
2529
+ let value = state.widget.values[widgetId];
2530
+ // If not found in widgetId, try dataPath
2531
+ if (value === undefined && dataPath) {
2532
+ value = getWidgetValue(state.widget.values, dataPath, widgetId);
2533
+ }
2534
+ // Extract value if it's a geo hierarchy object
2535
+ if (value && typeof value === 'object' && !Array.isArray(value) && geoConfig) {
2536
+ const hierarchy = value.hierarchy || value.geo_code_hierarchy_json?.hierarchy;
2537
+ if (Array.isArray(hierarchy)) {
2538
+ const levelData = hierarchy.find((l) => l.level === geoConfig.level);
2539
+ if (levelData) {
2540
+ return levelData.level_value_id;
2541
+ }
2542
+ }
2543
+ // Extended fallbacks (matching useBaseWidget)
2544
+ if ('geo_lowest_level_value_id' in value) {
2545
+ return value.geo_lowest_level_value_id;
2546
+ }
2547
+ if ('lowest_level_value_id' in value) {
2548
+ return value.lowest_level_value_id;
2549
+ }
2550
+ if (value.geo_code_hierarchy_json?.lowest_level_value_id) {
2551
+ return value.geo_code_hierarchy_json.lowest_level_value_id;
2552
+ }
2553
+ if (value.geo_code_hierarchy_json?.geo_lowest_level_value_id) {
2554
+ return value.geo_code_hierarchy_json.geo_lowest_level_value_id;
2555
+ }
2417
2556
  }
2418
- return getWidgetValue(state.widget.values, dataPath, widgetId);
2557
+ return value;
2419
2558
  });
2420
2559
  // Memoize selector to avoid returning new array reference
2421
2560
  const dataSourceOptions = reactRedux.useSelector((state) => state.widget.dataSources[widgetId] ?? EMPTY_DATA_SOURCE);
@@ -2435,11 +2574,17 @@ const useGeoWidgetCascade = (options) => {
2435
2574
  await new Promise(resolve => setTimeout(resolve, 0));
2436
2575
  const currentValues = valuesRef.current;
2437
2576
  const currentHandler = handlerRef.current;
2438
- // CRITICAL: Get the parent value from Redux state, not from the event
2439
- // The event.value might be stale, but Redux state is always current
2440
- const parentValue = currentValues[parentWidgetId];
2577
+ // CRITICAL: Try to get parent value from event first, then from Redux
2578
+ let parentValue = event.value;
2579
+ if (parentValue === undefined || parentValue === null) {
2580
+ parentValue = currentValues[parentWidgetId];
2581
+ // If not found in top-level values, try to find it via dataPath or dependsOn
2582
+ if (parentValue === undefined && dataSource.dependsOn) {
2583
+ parentValue = getWidgetValue(currentValues, dataSource.dependsOn, '');
2584
+ }
2585
+ }
2441
2586
  // Remove this level and all below from hierarchy
2442
- geoHierarchyBuilder.removeLevelAndBelow(level);
2587
+ geoHierarchyBuilder.removeLevelAndBelow(level, groupId);
2443
2588
  // Clear this widget's value
2444
2589
  // CRITICAL: Only dispatch setValue for THIS widget, not for parent or other widgets
2445
2590
  // setWidgetValue returns the entire updated state, but we only want to update this widget
@@ -2494,7 +2639,8 @@ const useGeoWidgetCascade = (options) => {
2494
2639
  }
2495
2640
  }
2496
2641
  else {
2497
- // If parent value is cleared, clear the data source
2642
+ // If parent value is cleared, clear the data source and hierarchy
2643
+ geoHierarchyBuilder.removeLevelAndBelow(level, groupId);
2498
2644
  dispatch(setDataSource({ widgetId, data: [] }));
2499
2645
  }
2500
2646
  };
@@ -2509,25 +2655,49 @@ const useGeoWidgetCascade = (options) => {
2509
2655
  if (!geoConfig) {
2510
2656
  return;
2511
2657
  }
2512
- // Skip if value is empty/null (but allow 0 and false)
2513
- if (currentValue === null || currentValue === undefined || currentValue === '') {
2514
- // If value was cleared, remove this level and below from hierarchy
2658
+ // Skip if value is undefined (it might still be loading or rehydrating)
2659
+ // ONLY clear hierarchy if the value is explicitly null or empty string (user action)
2660
+ if (currentValue === null || currentValue === '') {
2515
2661
  const { level } = geoConfig;
2516
- geoHierarchyBuilder.removeLevelAndBelow(level);
2662
+ geoHierarchyBuilder.removeLevelAndBelow(level, groupId);
2663
+ // If we have a dataPath, we need to update Redux with the cleared hierarchy
2664
+ if (dataPath) {
2665
+ const hierarchyJson = geoHierarchyBuilder.buildHierarchyJson(groupId);
2666
+ let finalUpdatedValues = valuesRef.current;
2667
+ // Use logic similar to the build section below to update the dataPath
2668
+ if (typeof dataPath === 'string' && dataPath.endsWith('.geo_code_hierarchy_json')) {
2669
+ const prefix = dataPath.substring(0, dataPath.lastIndexOf('.'));
2670
+ finalUpdatedValues = setWidgetValue(finalUpdatedValues, dataPath, widgetId, hierarchyJson?.geo_code_hierarchy_json);
2671
+ finalUpdatedValues = setWidgetValue(finalUpdatedValues, `${prefix}.geo_lowest_level_value_id`, widgetId, hierarchyJson?.geo_lowest_level_value_id);
2672
+ }
2673
+ else {
2674
+ finalUpdatedValues = setWidgetValue(finalUpdatedValues, dataPath, widgetId, hierarchyJson?.geo_code_hierarchy_json);
2675
+ }
2676
+ dispatch(setValues(finalUpdatedValues));
2677
+ }
2517
2678
  return;
2518
2679
  }
2680
+ if (currentValue === undefined) {
2681
+ return; // Skip if undefined (still initializing)
2682
+ }
2519
2683
  const { level, isLastLevel } = geoConfig;
2520
- // For last level, check if hierarchy is already built to prevent endless loops
2521
- if (isLastLevel && dataPath) {
2684
+ // Check if hierarchy is already built to prevent endless loops
2685
+ if (dataPath) {
2522
2686
  const currentHierarchy = getWidgetValue(valuesRef.current, dataPath, widgetId);
2523
2687
  // If hierarchy JSON is already set and matches current value, skip rebuilding
2524
- if (currentHierarchy && typeof currentHierarchy === 'object' && currentHierarchy.geo_code_hierarchy_json) {
2525
- // Check if the lowest level value matches
2526
- const currentLevelValue = typeof currentValue === 'object'
2527
- ? (currentValue.level_value_id || currentValue.id || currentValue.value)
2528
- : currentValue;
2529
- if (currentHierarchy.geo_lowest_level_value_id === currentLevelValue) {
2530
- return; // Hierarchy already built for this value, skip
2688
+ if (currentHierarchy && typeof currentHierarchy === 'object') {
2689
+ // Check if this specific level's value matches the hierarchy
2690
+ const hierarchyArray = currentHierarchy.hierarchy || currentHierarchy.geo_code_hierarchy_json?.hierarchy;
2691
+ if (Array.isArray(hierarchyArray)) {
2692
+ const currentLevelValue = typeof currentValue === 'object'
2693
+ ? (currentValue.level_value_id || currentValue.id || currentValue.value)
2694
+ : currentValue;
2695
+ const levelData = hierarchyArray.find((l) => l.level === geoConfig.level);
2696
+ // If this level is already correctly represented in the hierarchy, skip rebuilding
2697
+ // String conversion ensures comparison works for mixed types
2698
+ if (levelData && String(levelData.level_value_id) === String(currentLevelValue)) {
2699
+ return;
2700
+ }
2531
2701
  }
2532
2702
  }
2533
2703
  }
@@ -2550,23 +2720,31 @@ const useGeoWidgetCascade = (options) => {
2550
2720
  return;
2551
2721
  }
2552
2722
  // When a widget's own value changes, remove this level and all below from hierarchy first
2553
- // This ensures that when level 1 changes, we clear the hierarchy and rebuild from scratch
2554
- // The addLevel method already handles removing existing levels, but we explicitly clear to be safe
2555
- geoHierarchyBuilder.removeLevelAndBelow(level);
2723
+ geoHierarchyBuilder.removeLevelAndBelow(level, groupId);
2556
2724
  // Add level to hierarchy
2557
- geoHierarchyBuilder.addLevel(level, level_value_id, level_value_mnemonic);
2558
- // If this is the last level, build and store hierarchy JSON
2559
- if (isLastLevel && dataPath) {
2560
- const hierarchyJson = geoHierarchyBuilder.buildHierarchyJson();
2725
+ geoHierarchyBuilder.addLevel(level, level_value_id, level_value_mnemonic, groupId);
2726
+ // Build and store hierarchy JSON on every change
2727
+ if (dataPath) {
2728
+ const hierarchyJson = geoHierarchyBuilder.buildHierarchyJson(groupId);
2561
2729
  if (hierarchyJson) {
2562
- // Store both geo_lowest_level_value_id and geo_code_hierarchy_json
2563
- const updatedValues = setWidgetValue(valuesRef.current, dataPath, widgetId, {
2564
- geo_lowest_level_value_id: hierarchyJson.geo_lowest_level_value_id,
2565
- geo_code_hierarchy_json: hierarchyJson.geo_code_hierarchy_json,
2566
- });
2567
- Object.entries(updatedValues).forEach(([key, value]) => {
2568
- dispatch(setValue({ widgetId: key, value }));
2569
- });
2730
+ // Fix: Avoid double nesting of geo_code_hierarchy_json
2731
+ // If dataPath ends with .geo_code_hierarchy_json, we want to save the content directly to it
2732
+ // and save the lowest level ID as a sibling
2733
+ let finalUpdatedValues = valuesRef.current;
2734
+ if (typeof dataPath === 'string' && dataPath.endsWith('.geo_code_hierarchy_json')) {
2735
+ const prefix = dataPath.substring(0, dataPath.lastIndexOf('.'));
2736
+ // Save hierarchy JSON content directly to dataPath (avoiding double nesting)
2737
+ finalUpdatedValues = setWidgetValue(finalUpdatedValues, dataPath, widgetId, hierarchyJson.geo_code_hierarchy_json);
2738
+ // Save lowest level ID as sibling
2739
+ finalUpdatedValues = setWidgetValue(finalUpdatedValues, `${prefix}.geo_lowest_level_value_id`, widgetId, hierarchyJson.geo_lowest_level_value_id);
2740
+ }
2741
+ else {
2742
+ // Fallback if path doesn't follow the naming convention
2743
+ finalUpdatedValues = setWidgetValue(finalUpdatedValues, dataPath, widgetId, hierarchyJson.geo_code_hierarchy_json);
2744
+ }
2745
+ // CRITICAL: Use setValues for deep merge instead of replacing root keys with setValue
2746
+ // setWidgetValue returns the complete updated state object with all keys preserved
2747
+ dispatch(setValues(finalUpdatedValues));
2570
2748
  }
2571
2749
  }
2572
2750
  }, [geoConfig, currentValue, widgetId, dataPath, dispatch, dataSourceOptions]);
@@ -3671,6 +3849,39 @@ const sectionValidate = (section, currentSchemaData, dispatch) => {
3671
3849
  return isValid;
3672
3850
  };
3673
3851
 
3852
+ /** Root class on readonly label/value rows; SectionRenderer scopes overflow/ellipsis rules here. */
3853
+ const READONLY_VALUE_ROW_ROOT_CLASSES = [
3854
+ 'TextDisplayWidget',
3855
+ 'TextAreaDisplayWidget',
3856
+ 'SelectDisplayWidget',
3857
+ 'PhoneDisplayWidget',
3858
+ 'NumberDisplayWidget',
3859
+ 'CurrencyDisplayWidget',
3860
+ 'RadioDisplayWidget',
3861
+ 'DateDisplayWidget',
3862
+ 'DateTimeDisplayWidget',
3863
+ 'CheckboxDisplayWidget',
3864
+ 'BooleanDisplayWidget',
3865
+ 'FileDisplayWidget',
3866
+ 'DisplayFieldWidget',
3867
+ ];
3868
+ /** Rows whose value is one line in .flex-1 > .text-gray-900 (ellipsis; full string via title on the element). */
3869
+ const READONLY_SINGLE_LINE_VALUE_ROW_CLASSES = [
3870
+ 'TextDisplayWidget',
3871
+ 'SelectDisplayWidget',
3872
+ 'PhoneDisplayWidget',
3873
+ 'NumberDisplayWidget',
3874
+ 'CurrencyDisplayWidget',
3875
+ 'RadioDisplayWidget',
3876
+ 'DateDisplayWidget',
3877
+ 'DateTimeDisplayWidget',
3878
+ 'CheckboxDisplayWidget',
3879
+ 'BooleanDisplayWidget',
3880
+ 'DisplayFieldWidget',
3881
+ ];
3882
+ function scopedClassSelectors(sectionClassId, classNames) {
3883
+ return classNames.map((c) => `.${sectionClassId} .${c}`).join(',\n ');
3884
+ }
3674
3885
  /**
3675
3886
  * Renders a section with its panels
3676
3887
  *
@@ -3699,41 +3910,22 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
3699
3910
  }
3700
3911
  return section;
3701
3912
  }, [section, namespace]);
3702
- // Create namespaced schemaData if namespace is provided
3703
- // This ensures widgets can read initial values from schemaData at namespaced paths
3913
+ // Create namespaced schemaData if namespace is provided.
3914
+ // Widgets with namespaced data-paths (e.g. "rv-section-0.a1a4d25a.birth_date")
3915
+ // need a nested object at values[namespace] so getValueByPath can traverse it.
3704
3916
  const namespacedSchemaData = React.useMemo(() => {
3705
3917
  if (!namespace || !currentSchemaData) {
3706
3918
  return schemaData;
3707
3919
  }
3708
- // Create a namespaced version of schemaData by copying values to namespaced paths
3709
- const namespaced = { ...currentSchemaData };
3710
- // Copy all top-level keys to namespaced paths
3711
- Object.keys(currentSchemaData).forEach(key => {
3712
- const namespacedKey = `${namespace}.${key}`;
3713
- if (!(namespacedKey in namespaced)) {
3714
- namespaced[namespacedKey] = currentSchemaData[key];
3715
- }
3716
- });
3717
- // Also handle nested objects - copy nested values to namespaced paths
3718
- const copyNestedValues = (obj, prefix = '') => {
3719
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
3720
- Object.keys(obj).forEach(key => {
3721
- const fullPath = prefix ? `${prefix}.${key}` : key;
3722
- const namespacedPath = `${namespace}.${fullPath}`;
3723
- if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
3724
- copyNestedValues(obj[key], fullPath);
3725
- // Also set the nested object at the namespaced path
3726
- setValueByPath(namespaced, namespacedPath, obj[key]);
3727
- }
3728
- else {
3729
- setValueByPath(namespaced, namespacedPath, obj[key]);
3730
- }
3731
- });
3732
- }
3733
- };
3734
- copyNestedValues(currentSchemaData);
3735
- return namespaced;
3920
+ return { ...currentSchemaData, [namespace]: currentSchemaData };
3736
3921
  }, [namespace, schemaData, currentSchemaData]);
3922
+ // Populate the store with namespaced schema data so that namespaced widgets
3923
+ // can read their initial values via getValueByPath on the namespaced paths.
3924
+ React.useEffect(() => {
3925
+ if (namespace && namespacedSchemaData) {
3926
+ dispatch(setValues(namespacedSchemaData));
3927
+ }
3928
+ }, [namespace, namespacedSchemaData, dispatch]);
3737
3929
  const crViewData = React.useMemo(() => {
3738
3930
  if (mode !== 'CRView')
3739
3931
  return null;
@@ -3756,6 +3948,9 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
3756
3948
  const sectionId = sectionToRender['section-id'];
3757
3949
  const gridId = `section-panels-${sectionId}`;
3758
3950
  const sectionClassId = `section-${sectionId}`;
3951
+ const readonlyValueRowRootsCss = React.useMemo(() => scopedClassSelectors(sectionClassId, READONLY_VALUE_ROW_ROOT_CLASSES), [sectionClassId]);
3952
+ const readonlyValueRowFlex1Css = React.useMemo(() => READONLY_VALUE_ROW_ROOT_CLASSES.map((c) => `.${sectionClassId} .${c} > .flex-1`).join(',\n '), [sectionClassId]);
3953
+ const readonlySingleLineValueTextCss = React.useMemo(() => READONLY_SINGLE_LINE_VALUE_ROW_CLASSES.map((c) => `.${sectionClassId} .${c} > .flex-1 > .text-gray-900`).join(',\n '), [sectionClassId]);
3759
3954
  // IntakeForm mode: accordion expand/collapse state (supports toggle)
3760
3955
  const [standaloneExpanded, setStandaloneExpanded] = React.useState(true); // For sectionIndex undefined (standalone use)
3761
3956
  const isExpandedFromContainer = typeof sectionIndex === 'number' && expandedSectionIndex === sectionIndex;
@@ -4108,6 +4303,8 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
4108
4303
  const baselineSnapshotRef = React.useRef(null);
4109
4304
  // IntakeForm only: increment when baseline is updated after save - forces badge to update (refs don't trigger re-renders)
4110
4305
  const [intakeFormBaselineTrigger, setIntakeFormBaselineTrigger] = React.useState(0);
4306
+ // IntakeForm only: tracks whether the user has actually saved this section (prevents "Saved" badge on initial load)
4307
+ const [hasBeenSavedByUser, setHasBeenSavedByUser] = React.useState(false);
4111
4308
  // IntakeForm: treat as edit mode for dirty tracking when isDraft. RegistryView: use isEditMode.
4112
4309
  const effectiveEditModeForDirty = mode === 'IntakeForm' ? (isDraft !== false) : isEditMode;
4113
4310
  // Compute isDirty: compare current store state to baseline (only when in edit mode)
@@ -4150,18 +4347,68 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
4150
4347
  const intakeFormSectionStatus = React.useMemo(() => {
4151
4348
  if (mode !== 'IntakeForm' || isDraft === false)
4152
4349
  return null;
4153
- const hasValue = (v) => v !== undefined && v !== null && (typeof v !== 'string' || v.trim().length > 0);
4154
- const currentSnapshot = buildSectionSnapshot(storeValues, namespace);
4155
- const record = currentSnapshot.records?.[0];
4156
- const hasData = record &&
4157
- typeof record === 'object' &&
4158
- Object.values(record).some((v) => hasValue(v));
4159
4350
  if (isDirty)
4160
4351
  return 'modified';
4161
- if (hasData)
4352
+ if (hasBeenSavedByUser)
4162
4353
  return 'saved';
4163
4354
  return null;
4164
- }, [mode, isDirty, storeValues, namespace, buildSectionSnapshot]);
4355
+ }, [mode, isDirty, hasBeenSavedByUser]);
4356
+ // Revert store values to the original schemaData for this section's widgets.
4357
+ // Used by both handleSave (RegistryView raises a CR, so values should not persist)
4358
+ // and handleCancel.
4359
+ const revertToOriginalValues = React.useCallback(() => {
4360
+ const sectionWidgets = collectWidgets(originalSection.panels);
4361
+ const oldSchemaData = schemaData || contextSchemaData;
4362
+ const currentStoreValues = store.getState().widget.values;
4363
+ let newStoreValues = currentStoreValues;
4364
+ sectionWidgets.forEach(widget => {
4365
+ const originalWidgetId = widget['widget-id'];
4366
+ const namespacedWidgetId = namespace ? `${namespace}__${originalWidgetId}` : originalWidgetId;
4367
+ const widgetId = namespacedWidgetId;
4368
+ const originalDataPath = widget['widget-data-path'];
4369
+ const storeDataPath = namespace && originalDataPath
4370
+ ? (typeof originalDataPath === 'string'
4371
+ ? `${namespace}.${originalDataPath}`
4372
+ : Object.fromEntries(Object.entries(originalDataPath).map(([key, path]) => [key, `${namespace}.${path}`])))
4373
+ : originalDataPath;
4374
+ if (widgetId && originalDataPath) {
4375
+ let oldValue;
4376
+ if (typeof originalDataPath === 'object') {
4377
+ oldValue = {};
4378
+ Object.entries(originalDataPath).forEach(([key, path]) => {
4379
+ if (typeof path === 'string') {
4380
+ oldValue[key] = getValueByPath(oldSchemaData, path);
4381
+ }
4382
+ });
4383
+ }
4384
+ else if (typeof originalDataPath === 'string') {
4385
+ oldValue = getValueByPath(oldSchemaData, originalDataPath);
4386
+ }
4387
+ if (oldValue !== undefined) {
4388
+ newStoreValues = setWidgetValue(newStoreValues, storeDataPath, widgetId, oldValue);
4389
+ // Also revert the widgetId-based entry — useBaseWidget.handleChange
4390
+ // sets values[widgetId] during editing, and useBaseWidget.currentValue
4391
+ // reads values[widgetId] first before falling through to the dataPath.
4392
+ newStoreValues = { ...newStoreValues, [widgetId]: oldValue };
4393
+ }
4394
+ }
4395
+ });
4396
+ if (hasSupportingDocuments) {
4397
+ const originalSupportingDocuments = originalSection['section-supporting-documents'] || [];
4398
+ originalSupportingDocuments.forEach((doc, index) => {
4399
+ const widgetId = `supporting-doc-${sectionId}-${index}`;
4400
+ const originalDataPath = doc['document-data-path'];
4401
+ const storeDataPath = namespace && originalDataPath
4402
+ ? `${namespace}.${originalDataPath}`
4403
+ : originalDataPath;
4404
+ const oldValue = getValueByPath(oldSchemaData, originalDataPath);
4405
+ newStoreValues = setWidgetValue(newStoreValues, storeDataPath, widgetId, oldValue);
4406
+ });
4407
+ }
4408
+ if (newStoreValues !== currentStoreValues) {
4409
+ dispatch(setValues(newStoreValues));
4410
+ }
4411
+ }, [originalSection, schemaData, contextSchemaData, store, namespace, hasSupportingDocuments, sectionId, dispatch]);
4165
4412
  // Handle save button click
4166
4413
  const handleSave = async () => {
4167
4414
  if (!store || !onSectionSave) {
@@ -4198,12 +4445,24 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
4198
4445
  });
4199
4446
  }
4200
4447
  if (JSON.stringify(oldSchemaData) !== JSON.stringify(newSchemaData)) {
4448
+ let profileImage = null;
4449
+ for (const record of newSchemaData) {
4450
+ if (typeof record === 'object' && record !== null) {
4451
+ for (const [key, value] of Object.entries(record)) {
4452
+ if (value instanceof File) {
4453
+ profileImage = value;
4454
+ record[key] = '';
4455
+ }
4456
+ }
4457
+ }
4458
+ }
4201
4459
  try {
4202
4460
  const sectionchanges = {
4203
4461
  section_id: dbSectionId ?? originalSection['section-id'],
4204
4462
  section_register_id: sectionRegisterId,
4205
4463
  records: [...newSchemaData],
4206
- files: [...sectionFiles]
4464
+ files: [...sectionFiles],
4465
+ ...(profileImage ? { image: profileImage } : {}),
4207
4466
  };
4208
4467
  await onSectionSave(sectionchanges);
4209
4468
  }
@@ -4211,6 +4470,12 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
4211
4470
  console.error('Section Changes Save failed', error);
4212
4471
  }
4213
4472
  }
4473
+ // In RegistryView, save raises a CR — the actual data update follows a
4474
+ // separate approval workflow, so revert the displayed values to the
4475
+ // originals so the view doesn't show unapproved edits.
4476
+ if (mode === 'RegistryView') {
4477
+ revertToOriginalValues();
4478
+ }
4214
4479
  setIsEditMode(false);
4215
4480
  onEditModeChange?.(originalSectionId, false);
4216
4481
  };
@@ -4240,12 +4505,24 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
4240
4505
  });
4241
4506
  }
4242
4507
  if (JSON.stringify(oldSchemaData) !== JSON.stringify(newSchemaData)) {
4508
+ let profileImage = null;
4509
+ for (const record of newSchemaData) {
4510
+ if (typeof record === 'object' && record !== null) {
4511
+ for (const [key, value] of Object.entries(record)) {
4512
+ if (value instanceof File) {
4513
+ profileImage = value;
4514
+ record[key] = '';
4515
+ }
4516
+ }
4517
+ }
4518
+ }
4243
4519
  try {
4244
4520
  await onSectionSave({
4245
4521
  section_id: dbSectionId ?? originalSection['section-id'],
4246
4522
  section_register_id: sectionRegisterId,
4247
4523
  records: [...newSchemaData],
4248
4524
  files: [...sectionFiles],
4525
+ ...(profileImage ? { image: profileImage } : {}),
4249
4526
  });
4250
4527
  }
4251
4528
  catch (error) {
@@ -4256,6 +4533,7 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
4256
4533
  if (mode === 'IntakeForm') {
4257
4534
  baselineSnapshotRef.current = buildSectionSnapshot(currentSchemaData, namespace);
4258
4535
  setIntakeFormBaselineTrigger((prev) => prev + 1);
4536
+ setHasBeenSavedByUser(true);
4259
4537
  }
4260
4538
  onSectionDirtyChange?.(sectionId, false);
4261
4539
  }
@@ -4264,64 +4542,7 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
4264
4542
  }, [store, onSectionSave, onSectionSaveSuccess, sectionIndex, originalSection, schemaData, contextSchemaData, namespace, hasSupportingDocuments, dbSectionId, sectionRegisterId, dispatch, buildSectionSnapshot, sectionId, onSectionDirtyChange, mode, isDraft]);
4265
4543
  // Handle cancel button click
4266
4544
  const handleCancel = () => {
4267
- // Revert values in store to original schema data
4268
- // Use original section (without namespace) for collecting widgets
4269
- const sectionWidgets = collectWidgets(originalSection.panels);
4270
- const oldSchemaData = schemaData || contextSchemaData;
4271
- const currentStoreValues = store.getState().widget.values;
4272
- let newStoreValues = currentStoreValues;
4273
- sectionWidgets.forEach(widget => {
4274
- const originalWidgetId = widget['widget-id'];
4275
- // If namespace was used, we need to use namespaced widget ID and data path
4276
- const namespacedWidgetId = namespace ? `${namespace}__${originalWidgetId}` : originalWidgetId;
4277
- const widgetId = namespacedWidgetId;
4278
- const originalDataPath = widget['widget-data-path'];
4279
- // If namespace was used, data path in store is namespaced, but we read from original schema using original path
4280
- const storeDataPath = namespace && originalDataPath
4281
- ? (typeof originalDataPath === 'string'
4282
- ? `${namespace}.${originalDataPath}`
4283
- : Object.fromEntries(Object.entries(originalDataPath).map(([key, path]) => [key, `${namespace}.${path}`])))
4284
- : originalDataPath;
4285
- if (widgetId && originalDataPath) {
4286
- // Handle multi-path (object) or single path (string)
4287
- // Read from original schema data using original paths
4288
- let oldValue;
4289
- if (typeof originalDataPath === 'object') {
4290
- // Multi-path: get values for each path
4291
- oldValue = {};
4292
- Object.entries(originalDataPath).forEach(([key, path]) => {
4293
- if (typeof path === 'string') {
4294
- oldValue[key] = getValueByPath(oldSchemaData, path);
4295
- }
4296
- });
4297
- }
4298
- else if (typeof originalDataPath === 'string') {
4299
- oldValue = getValueByPath(oldSchemaData, originalDataPath);
4300
- }
4301
- // Set in store using namespaced data path (if namespace was used)
4302
- if (oldValue !== undefined) {
4303
- newStoreValues = setWidgetValue(newStoreValues, storeDataPath, widgetId, oldValue);
4304
- }
4305
- }
4306
- });
4307
- // Also revert supporting documents if any
4308
- if (hasSupportingDocuments) {
4309
- // Use original section's supporting documents to get original data paths
4310
- const originalSupportingDocuments = originalSection['section-supporting-documents'] || [];
4311
- originalSupportingDocuments.forEach((doc, index) => {
4312
- const widgetId = `supporting-doc-${sectionId}-${index}`;
4313
- const originalDataPath = doc['document-data-path'];
4314
- // If namespace was used, data path in store is namespaced
4315
- const storeDataPath = namespace && originalDataPath
4316
- ? `${namespace}.${originalDataPath}`
4317
- : originalDataPath;
4318
- const oldValue = getValueByPath(oldSchemaData, originalDataPath);
4319
- newStoreValues = setWidgetValue(newStoreValues, storeDataPath, widgetId, oldValue);
4320
- });
4321
- }
4322
- if (newStoreValues !== currentStoreValues) {
4323
- dispatch(setValues(newStoreValues));
4324
- }
4545
+ revertToOriginalValues();
4325
4546
  setIsEditMode(false);
4326
4547
  onEditModeChange?.(originalSectionId, false);
4327
4548
  };
@@ -4372,20 +4593,27 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
4372
4593
  white-space: nowrap !important;
4373
4594
  }
4374
4595
  /* Readonly: prevent flex row from overflowing panel */
4375
- .${sectionClassId} .TextDisplayWidget {
4596
+ ${readonlyValueRowRootsCss} {
4376
4597
  min-width: 0 !important;
4377
4598
  overflow: hidden !important;
4378
4599
  }
4379
- .${sectionClassId} .TextDisplayWidget > .flex-1 {
4600
+ ${readonlyValueRowFlex1Css} {
4380
4601
  min-width: 0 !important;
4381
4602
  overflow: hidden !important;
4382
4603
  }
4383
- /* Readonly value text truncation */
4384
- .${sectionClassId} .TextDisplayWidget > .flex-1 > .text-gray-900 {
4604
+ /* Readonly value: single-line ellipsis; full value via title on the value node */
4605
+ ${readonlySingleLineValueTextCss} {
4385
4606
  overflow: hidden;
4386
4607
  text-overflow: ellipsis;
4387
4608
  white-space: nowrap;
4388
4609
  }
4610
+ /* Readonly textarea: break unbroken long tokens; title on pre keeps full text on hover */
4611
+ .${sectionClassId} .TextAreaDisplayWidget > .flex-1 > pre {
4612
+ min-width: 0;
4613
+ max-width: 100%;
4614
+ overflow-wrap: anywhere;
4615
+ word-break: break-word;
4616
+ }
4389
4617
 
4390
4618
  /* Only apply fixed height when in edit mode */
4391
4619
  .${sectionClassId}[data-edit-mode="true"] {
@@ -4509,6 +4737,10 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
4509
4737
  gap: 0.5rem;
4510
4738
  }
4511
4739
 
4740
+
4741
+
4742
+
4743
+
4512
4744
  /* IntakeForm accordion */
4513
4745
  .${sectionClassId}.intake-form-accordion-item {
4514
4746
  border-color: var(--owt-color-border-light, #E4E4E4);
@@ -4713,7 +4945,7 @@ const SectionRenderer = ({ section, dataSourceRequestHandler: propDataSourceRequ
4713
4945
  }, children: crViewData.approvedDate }))] })] })] })), mode === 'RegistryView' && !hideEditButton && (jsxRuntimeExports.jsx("hr", { className: "w-full", style: { height: '1px', marginTop: !isEditMode ? '10px' : 0, marginBottom: '14px', border: 'none', backgroundColor: 'var(--owt-color-border, #C4C4C4)' } })), mode === 'RegistryView' && !isEditMode && !hideEditButton && (jsxRuntimeExports.jsx("div", { className: "flex justify-center items-center", style: { marginBottom: '20px' }, children: jsxRuntimeExports.jsxs("button", { onClick: handleEdit, className: "font-normal inline-flex items-center gap-2 bg-transparent border-0 p-0 cursor-pointer hover:opacity-80", style: {
4714
4946
  fontFamily: 'Roboto, sans-serif',
4715
4947
  fontSize: '16px',
4716
- color: 'var(--owt-color-text-muted, #727474)'
4948
+ color: 'var(--owt-color-text-muted, #727474)',
4717
4949
  }, children: [translate('common.editDetails') || 'Edit Details', jsxRuntimeExports.jsx("img", { src: img$8, alt: "right-arrow", className: "w-3.5 h-3.5 brightness-0 opacity-50" })] }) }))] })] })) })] }));
4718
4950
  };
4719
4951
 
@@ -4887,6 +5119,9 @@ const SectionsContainer = ({ sections, dataSourceRequestHandler: propDataSourceR
4887
5119
  }, []);
4888
5120
  const safeSections = sections ?? [];
4889
5121
  const prevSectionsLengthRef = React.useRef(safeSections.length);
5122
+ // Stable ref for namespace so formHandle useMemo doesn't depend on the (possibly inline) function identity
5123
+ const namespaceRef = React.useRef(namespace);
5124
+ namespaceRef.current = namespace;
4890
5125
  // Track dirty (unsaved changes) per section for form handle validation
4891
5126
  const sectionDirtyMapRef = React.useRef({});
4892
5127
  const handleSectionDirtyChange = React.useCallback((sectionId, isDirty) => {
@@ -4932,11 +5167,14 @@ const SectionsContainer = ({ sections, dataSourceRequestHandler: propDataSourceR
4932
5167
  // Form handle for onFormReady - allows host to validate and get all section data from its own Submit button
4933
5168
  const formHandle = React.useMemo(() => {
4934
5169
  const getValues = () => store.getState().widget?.values || {};
4935
- const getNamespace = (section, index) => namespace
4936
- ? typeof namespace === 'string'
4937
- ? namespace
4938
- : namespace(section['section-id'], index)
4939
- : undefined;
5170
+ const getNamespace = (section, index) => {
5171
+ const ns = namespaceRef.current;
5172
+ return ns
5173
+ ? typeof ns === 'string'
5174
+ ? ns
5175
+ : ns(section['section-id'], index)
5176
+ : undefined;
5177
+ };
4940
5178
  const checkNoUnsavedChanges = () => {
4941
5179
  const hasDirty = Object.values(sectionDirtyMapRef.current).some(Boolean);
4942
5180
  if (hasDirty) {
@@ -4986,7 +5224,7 @@ const SectionsContainer = ({ sections, dataSourceRequestHandler: propDataSourceR
4986
5224
  return results;
4987
5225
  },
4988
5226
  };
4989
- }, [store, dispatch, safeSections, namespace]);
5227
+ }, [store, dispatch, safeSections]);
4990
5228
  // Call onFormReady when form is ready (sections loaded)
4991
5229
  React.useEffect(() => {
4992
5230
  if (onFormReady && safeSections.length > 0) {
@@ -8270,10 +8508,10 @@ const DisplayWidget = ({ config }) => {
8270
8508
  const label = translateConfig(widgetConfig['widget-label']);
8271
8509
  // If no label, render as paragraph text
8272
8510
  if (!label || label.trim() === '') {
8273
- return (jsxRuntimeExports.jsx("div", { className: "mb-3 text-base text-gray-700", title: String(displayValue ?? ''), children: displayValue }));
8511
+ return (jsxRuntimeExports.jsx("div", { className: "DisplayFieldWidget mb-3 min-w-0 w-full overflow-hidden text-ellipsis whitespace-nowrap text-base text-gray-700", title: String(displayValue ?? ''), children: displayValue }));
8274
8512
  }
8275
- // With label, render as key-value pair
8276
- return (jsxRuntimeExports.jsxs("div", { className: "mb-[10px] flex flex-col sm:flex-row sm:items-start", children: [jsxRuntimeExports.jsxs("div", { className: "text-base text-gray-600 font-medium md:min-w-[120px] sm:pr-4 mb-1 sm:mb-0", style: { fontFamily: 'Roboto, sans-serif' }, title: label, children: [label, ":"] }), jsxRuntimeExports.jsx("div", { className: "flex-1 text-base text-gray-900 font-medium", title: String(displayValue ?? ''), children: displayValue })] }));
8513
+ // With label, render as key-value pair (structure matches other readonly widgets for SectionRenderer ellipsis)
8514
+ return (jsxRuntimeExports.jsxs("div", { className: "mb-[10px] DisplayFieldWidget flex flex-col sm:flex-row sm:items-start", children: [jsxRuntimeExports.jsxs("div", { className: "text-base text-gray-600 font-medium md:min-w-[120px] sm:pr-4 mb-1 sm:mb-0", style: { fontFamily: 'Roboto, sans-serif' }, title: label, children: [label, ":"] }), jsxRuntimeExports.jsx("div", { className: "flex-1 min-w-0", children: jsxRuntimeExports.jsx("div", { className: "text-base text-gray-900 font-medium", title: String(displayValue ?? ''), children: displayValue }) })] }));
8277
8515
  };
8278
8516
 
8279
8517
  const TableCellSelect = ({ config, value, onValueChange }) => {
@@ -9311,15 +9549,43 @@ const HeaderSectionWidget = ({ config }) => {
9311
9549
  result = searchIn(schemaData);
9312
9550
  return result;
9313
9551
  }, [paths, values, schemaData]);
9314
- const imageUrl = findValue('image') || null;
9552
+ const imageVal = findValue('image');
9553
+ const imageUrlVal = findValue('imageUrl');
9554
+ const [previewUrl, setPreviewUrl] = React.useState(null);
9555
+ React.useEffect(() => {
9556
+ if (imageVal instanceof File) {
9557
+ const url = URL.createObjectURL(imageVal);
9558
+ setPreviewUrl(url);
9559
+ return () => URL.revokeObjectURL(url);
9560
+ }
9561
+ setPreviewUrl(null);
9562
+ }, [imageVal]);
9563
+ const displayImageUrl = previewUrl || (typeof imageUrlVal === 'string' && imageUrlVal ? imageUrlVal : null);
9315
9564
  const displayName = findValue('name') || '';
9316
9565
  const functionalId = findValue('functionalId') || '';
9317
9566
  const statusValue = findValue('status') || '';
9318
9567
  const statusReason = findValue('statusReason') || '';
9568
+ const completionScoreRaw = findValue('completionScore');
9569
+ const idealScoreRaw = findValue('idealScore');
9319
9570
  const createdBy = findValue('createdBy') || '';
9320
9571
  const createdAt = findValue('createdAt') || '';
9321
9572
  const lastApprovedBy = findValue('lastApprovedBy') || '';
9322
9573
  const lastApprovedAt = findValue('lastApprovedAt') || '';
9574
+ const score = React.useMemo(() => {
9575
+ const toNum = (v) => {
9576
+ if (v === null || v === undefined || String(v).trim() === '')
9577
+ return null;
9578
+ const n = typeof v === 'number' ? v : Number(String(v));
9579
+ return Number.isFinite(n) ? n : null;
9580
+ };
9581
+ const completion = toNum(completionScoreRaw);
9582
+ const ideal = toNum(idealScoreRaw);
9583
+ if (completion === null || ideal === null || ideal <= 0)
9584
+ return null;
9585
+ const ratio = completion / ideal;
9586
+ const percent = Math.max(0, Math.min(100, Math.round(ratio * 100)));
9587
+ return { completion, ideal, percent };
9588
+ }, [completionScoreRaw, idealScoreRaw]);
9323
9589
  // ── Format options ────────────────────────────────────────────
9324
9590
  const format = (widgetConfig['widget-data-format'] || {});
9325
9591
  const imageSize = format.imageSize || 120;
@@ -9344,18 +9610,20 @@ const HeaderSectionWidget = ({ config }) => {
9344
9610
  return opt ? opt.label : String(statusValue);
9345
9611
  }, [statusValue, statusOptions]);
9346
9612
  const statusColor = statusColors[String(statusValue).toLowerCase()] || 'var(--owt-color-text-muted, #6B7280)';
9613
+ // ── Image edit helpers ───────────────────────────────────────
9614
+ const fileInputRef = React.useRef(null);
9615
+ const handleImageUpload = React.useCallback((e) => {
9616
+ const file = e.target.files?.[0];
9617
+ if (!file)
9618
+ return;
9619
+ updateFieldValue('image', file);
9620
+ e.target.value = '';
9621
+ }, [updateFieldValue]);
9622
+ const handleImageDelete = React.useCallback(() => {
9623
+ updateFieldValue('image', '');
9624
+ }, [updateFieldValue]);
9347
9625
  // ── Scoped class for CSS isolation ────────────────────────────
9348
9626
  const cls = `header-section-widget-${widgetConfig['widget-id']}`;
9349
- // ── Indicator dot component ───────────────────────────────────
9350
- const Dot = ({ color }) => (jsxRuntimeExports.jsx("span", { style: {
9351
- display: 'inline-block',
9352
- width: 8,
9353
- height: 8,
9354
- borderRadius: '50%',
9355
- backgroundColor: color,
9356
- flexShrink: 0,
9357
- marginTop: 6,
9358
- } }));
9359
9627
  // ── RENDER ────────────────────────────────────────────────────
9360
9628
  return (jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [jsxRuntimeExports.jsx("style", { children: `
9361
9629
  .${cls} {
@@ -9384,6 +9652,58 @@ const HeaderSectionWidget = ({ config }) => {
9384
9652
  min-width: 220px;
9385
9653
  }
9386
9654
 
9655
+ .${cls} .hdr-right-top {
9656
+ display: flex;
9657
+ align-items: flex-start;
9658
+ justify-content: space-between;
9659
+ gap: 14px;
9660
+ width: 100%;
9661
+ }
9662
+
9663
+ .${cls} .hdr-meta-col {
9664
+ display: flex;
9665
+ flex-direction: column;
9666
+ gap: 0.5rem;
9667
+ flex: 1 1 auto;
9668
+ min-width: 0;
9669
+ }
9670
+
9671
+ .${cls} .hdr-score-ring {
9672
+ --ring-size: 54px;
9673
+ --ring-thickness: 7px;
9674
+ --ring-color: var(--owt-color-primary-dark, #F07B1A);
9675
+ --ring-track: rgba(2, 6, 23, 0.10);
9676
+ width: var(--ring-size);
9677
+ height: var(--ring-size);
9678
+ border-radius: 50%;
9679
+ background: conic-gradient(
9680
+ var(--ring-color) calc(var(--pct) * 1%),
9681
+ var(--ring-track) 0
9682
+ );
9683
+ position: relative;
9684
+ flex: 0 0 auto;
9685
+ }
9686
+
9687
+ .${cls} .hdr-score-ring::before {
9688
+ content: "";
9689
+ position: absolute;
9690
+ inset: var(--ring-thickness);
9691
+ border-radius: 50%;
9692
+ background: var(--owt-color-bg, #FFFFFF);
9693
+ }
9694
+
9695
+ .${cls} .hdr-score-value {
9696
+ position: absolute;
9697
+ inset: 0;
9698
+ display: flex;
9699
+ align-items: center;
9700
+ justify-content: center;
9701
+ font-size: 20px;
9702
+ font-weight: 700;
9703
+ color: var(--owt-color-text, #011627);
9704
+ font-family: Roboto, sans-serif;
9705
+ }
9706
+
9387
9707
  .${cls} .hdr-avatar {
9388
9708
  width: ${imageSize}px;
9389
9709
  height: ${imageSize}px;
@@ -9414,6 +9734,56 @@ const HeaderSectionWidget = ({ config }) => {
9414
9734
  border-radius: 8px;
9415
9735
  }
9416
9736
 
9737
+ .${cls} .hdr-avatar-wrapper {
9738
+ position: relative;
9739
+ width: ${imageSize}px;
9740
+ height: ${imageSize}px;
9741
+ flex-shrink: 0;
9742
+ }
9743
+
9744
+ .${cls} .hdr-avatar-overlay {
9745
+ position: absolute;
9746
+ inset: 0;
9747
+ border-radius: 8px;
9748
+ background: rgba(0, 0, 0, 0.55);
9749
+ display: flex;
9750
+ flex-direction: column;
9751
+ align-items: center;
9752
+ justify-content: center;
9753
+ gap: 6px;
9754
+ opacity: 0;
9755
+ transition: opacity 0.2s;
9756
+ }
9757
+
9758
+ .${cls} .hdr-avatar-wrapper:hover .hdr-avatar-overlay {
9759
+ opacity: 1;
9760
+ }
9761
+
9762
+ .${cls} .hdr-avatar-action {
9763
+ display: flex;
9764
+ align-items: center;
9765
+ gap: 5px;
9766
+ padding: 5px 14px;
9767
+ border: none;
9768
+ border-radius: 4px;
9769
+ background: rgba(255, 255, 255, 0.92);
9770
+ color: #374151;
9771
+ font-size: 0.7rem;
9772
+ font-weight: 500;
9773
+ cursor: pointer;
9774
+ font-family: Roboto, sans-serif;
9775
+ transition: background 0.15s;
9776
+ white-space: nowrap;
9777
+ }
9778
+
9779
+ .${cls} .hdr-avatar-action:hover {
9780
+ background: #fff;
9781
+ }
9782
+
9783
+ .${cls} .hdr-avatar-action--delete {
9784
+ color: #DC2626;
9785
+ }
9786
+
9417
9787
  .${cls} .hdr-info {
9418
9788
  display: flex;
9419
9789
  flex-direction: column;
@@ -9518,17 +9888,24 @@ const HeaderSectionWidget = ({ config }) => {
9518
9888
  min-width: 0;
9519
9889
  }
9520
9890
  }
9521
- ` }), jsxRuntimeExports.jsxs("div", { className: cls, children: [jsxRuntimeExports.jsxs("div", { className: "hdr-left", children: [jsxRuntimeExports.jsxs("div", { children: [imageUrl ? (jsxRuntimeExports.jsx("img", { src: imageUrl, alt: displayName || 'Profile', className: "hdr-avatar", onError: (e) => {
9891
+ ` }), jsxRuntimeExports.jsxs("div", { className: cls, children: [jsxRuntimeExports.jsxs("div", { className: "hdr-left", children: [jsxRuntimeExports.jsxs("div", { className: "hdr-avatar-wrapper", children: [displayImageUrl ? (jsxRuntimeExports.jsx("img", { src: displayImageUrl, alt: displayName || 'Profile', className: "hdr-avatar", onError: (e) => {
9522
9892
  e.target.style.display = 'none';
9523
9893
  const placeholder = e.target
9524
9894
  .parentElement?.querySelector('.hdr-avatar-placeholder');
9525
9895
  if (placeholder)
9526
9896
  placeholder.style.display = 'flex';
9527
- } })) : null, jsxRuntimeExports.jsx("div", { className: "hdr-avatar-placeholder", style: { display: imageUrl ? 'none' : 'flex' }, children: jsxRuntimeExports.jsx("img", { src: img$f, alt: "Profile Placeholder" }) })] }), jsxRuntimeExports.jsxs("div", { className: "hdr-info", children: [displayName && jsxRuntimeExports.jsx("div", { className: "hdr-name", children: displayName }), jsxRuntimeExports.jsxs("div", { className: "hdr-field-row", children: [jsxRuntimeExports.jsx(Dot, { color: "#9CA3AF" }), jsxRuntimeExports.jsxs("span", { className: "hdr-field-label", children: [getLabel('functionalId'), " :"] }), jsxRuntimeExports.jsx("span", { className: "hdr-field-value", children: functionalId || '-' })] }), jsxRuntimeExports.jsxs("div", { className: "hdr-field-row", children: [jsxRuntimeExports.jsx(Dot, { color: isReadonly ? statusColor : '#F59E0B' }), jsxRuntimeExports.jsx("span", { className: "hdr-field-label", children: getLabel('status') }), isReadonly ? (statusLabel ? (jsxRuntimeExports.jsx("span", { className: "hdr-status-badge", style: { backgroundColor: statusColor }, children: statusLabel })) : (jsxRuntimeExports.jsx("span", { className: "hdr-field-value", children: "-" }))) : (jsxRuntimeExports.jsxs("select", { className: "hdr-select", value: statusValue, onChange: (e) => updateFieldValue('status', e.target.value), children: [jsxRuntimeExports.jsx("option", { value: "", children: getLabel('select') }), statusOptions.map((opt) => (jsxRuntimeExports.jsx("option", { value: opt.value, children: opt.label }, opt.value)))] }))] }), jsxRuntimeExports.jsxs("div", { className: "hdr-field-row", children: [jsxRuntimeExports.jsx(Dot, { color: isReadonly ? '#9CA3AF' : '#F59E0B' }), jsxRuntimeExports.jsxs("span", { className: "hdr-field-label", children: [getLabel('statusReason'), " :"] }), isReadonly ? (jsxRuntimeExports.jsx("span", { className: "hdr-field-value", children: statusReason || '-' })) : (jsxRuntimeExports.jsx("input", { type: "text", className: "hdr-input", value: statusReason, placeholder: getLabel('enterReason'), onChange: (e) => updateFieldValue('statusReason', e.target.value) }))] })] })] }), jsxRuntimeExports.jsxs("div", { className: "hdr-right", children: [jsxRuntimeExports.jsxs("div", { className: "hdr-meta-row", children: [jsxRuntimeExports.jsxs("span", { className: "hdr-meta-label", children: [getLabel('createdBy'), " :"] }), jsxRuntimeExports.jsx("span", { className: "hdr-meta-value", children: createdBy || '-' })] }), jsxRuntimeExports.jsxs("div", { className: "hdr-meta-row", children: [jsxRuntimeExports.jsxs("span", { className: "hdr-meta-label", children: [getLabel('createdAt'), " :"] }), jsxRuntimeExports.jsx("span", { className: "hdr-meta-value", children: createdAt || '-' })] }), jsxRuntimeExports.jsxs("div", { className: "hdr-meta-row", children: [jsxRuntimeExports.jsxs("span", { className: "hdr-meta-label", children: [getLabel('lastApprovedBy'), " :"] }), jsxRuntimeExports.jsx("span", { className: "hdr-meta-value", children: lastApprovedBy || '-' })] }), jsxRuntimeExports.jsxs("div", { className: "hdr-meta-row", children: [jsxRuntimeExports.jsxs("span", { className: "hdr-meta-label", children: [getLabel('lastApprovedAt'), " :"] }), jsxRuntimeExports.jsx("span", { className: "hdr-meta-value", children: lastApprovedAt || '-' })] })] })] })] }));
9897
+ } })) : null, jsxRuntimeExports.jsx("div", { className: "hdr-avatar-placeholder", style: { display: displayImageUrl ? 'none' : 'flex' }, children: jsxRuntimeExports.jsx("img", { src: img$f, alt: "Profile Placeholder" }) }), !isReadonly && (jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [jsxRuntimeExports.jsxs("div", { className: "hdr-avatar-overlay", children: [jsxRuntimeExports.jsxs("button", { type: "button", className: "hdr-avatar-action", onClick: () => fileInputRef.current?.click(), children: [jsxRuntimeExports.jsxs("svg", { width: "13", height: "13", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [jsxRuntimeExports.jsx("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }), jsxRuntimeExports.jsx("polyline", { points: "17 8 12 3 7 8" }), jsxRuntimeExports.jsx("line", { x1: "12", y1: "3", x2: "12", y2: "15" })] }), "Upload"] }), jsxRuntimeExports.jsxs("button", { type: "button", className: "hdr-avatar-action hdr-avatar-action--delete", onClick: handleImageDelete, children: [jsxRuntimeExports.jsxs("svg", { width: "13", height: "13", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [jsxRuntimeExports.jsx("polyline", { points: "3 6 5 6 21 6" }), jsxRuntimeExports.jsx("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" })] }), "Delete"] })] }), jsxRuntimeExports.jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", style: { display: 'none' }, onChange: handleImageUpload })] }))] }), jsxRuntimeExports.jsxs("div", { className: "hdr-info", children: [displayName && jsxRuntimeExports.jsx("div", { className: "hdr-name", children: displayName }), jsxRuntimeExports.jsxs("div", { className: "hdr-field-row", children: [jsxRuntimeExports.jsxs("span", { className: "hdr-field-label", children: [getLabel('functionalId'), " :"] }), jsxRuntimeExports.jsx("span", { className: "hdr-field-value", children: functionalId || '-' })] }), jsxRuntimeExports.jsxs("div", { className: "hdr-field-row", children: [jsxRuntimeExports.jsx("span", { className: "hdr-field-label", children: getLabel('status') }), isReadonly ? (statusLabel ? (jsxRuntimeExports.jsx("span", { className: "hdr-status-badge", style: { backgroundColor: statusColor }, children: statusLabel })) : (jsxRuntimeExports.jsx("span", { className: "hdr-field-value", children: "-" }))) : (jsxRuntimeExports.jsxs("select", { className: "hdr-select", value: statusValue, onChange: (e) => updateFieldValue('status', e.target.value), children: [jsxRuntimeExports.jsx("option", { value: "", children: getLabel('select') }), statusOptions.map((opt) => (jsxRuntimeExports.jsx("option", { value: opt.value, children: opt.label }, opt.value)))] }))] }), jsxRuntimeExports.jsxs("div", { className: "hdr-field-row", children: [jsxRuntimeExports.jsxs("span", { className: "hdr-field-label", children: [getLabel('statusReason'), " :"] }), isReadonly ? (jsxRuntimeExports.jsx("span", { className: "hdr-field-value", children: statusReason || '-' })) : (jsxRuntimeExports.jsx("input", { type: "text", className: "hdr-input", value: statusReason, placeholder: getLabel('enterReason'), onChange: (e) => updateFieldValue('statusReason', e.target.value) }))] })] })] }), jsxRuntimeExports.jsx("div", { className: "hdr-right", children: jsxRuntimeExports.jsxs("div", { className: "hdr-right-top", children: [jsxRuntimeExports.jsxs("div", { className: "hdr-meta-col", children: [jsxRuntimeExports.jsxs("div", { className: "hdr-meta-row", children: [jsxRuntimeExports.jsxs("span", { className: "hdr-meta-label", children: [getLabel('createdBy'), " :"] }), jsxRuntimeExports.jsx("span", { className: "hdr-meta-value", children: createdBy || '-' })] }), jsxRuntimeExports.jsxs("div", { className: "hdr-meta-row", children: [jsxRuntimeExports.jsxs("span", { className: "hdr-meta-label", children: [getLabel('createdAt'), " :"] }), jsxRuntimeExports.jsx("span", { className: "hdr-meta-value", children: createdAt || '-' })] }), jsxRuntimeExports.jsxs("div", { className: "hdr-meta-row", children: [jsxRuntimeExports.jsxs("span", { className: "hdr-meta-label", children: [getLabel('lastApprovedBy'), " :"] }), jsxRuntimeExports.jsx("span", { className: "hdr-meta-value", children: lastApprovedBy || '-' })] }), jsxRuntimeExports.jsxs("div", { className: "hdr-meta-row", children: [jsxRuntimeExports.jsxs("span", { className: "hdr-meta-label", children: [getLabel('lastApprovedAt'), " :"] }), jsxRuntimeExports.jsx("span", { className: "hdr-meta-value", children: lastApprovedAt || '-' })] })] }), score ? (jsxRuntimeExports.jsx("div", { className: "hdr-score-ring", style: { ['--pct']: score.percent }, "aria-label": `Completion score ${score.completion} of ${score.ideal} (${score.percent}%)`, title: `${score.completion} / ${score.ideal} (${score.percent}%)`, children: jsxRuntimeExports.jsx("div", { className: "hdr-score-value", children: String(score.completion) }) })) : null] }) })] })] }));
9528
9898
  };
9529
9899
 
9530
- function tryFormatDateTime(value) {
9531
- if (typeof value !== 'string' || !value)
9900
+ function getValueByPathOrKey(obj, path) {
9901
+ if (!obj || !path)
9902
+ return undefined;
9903
+ if (Object.prototype.hasOwnProperty.call(obj, path))
9904
+ return obj[path];
9905
+ return getValueByPath(obj, path);
9906
+ }
9907
+ function tryFormatDateTime$1(value) {
9908
+ if (typeof value !== 'string' || !value)
9532
9909
  return value ? String(value) : '-';
9533
9910
  const d = new Date(value);
9534
9911
  if (Number.isNaN(d.getTime()))
@@ -9546,233 +9923,677 @@ function tryFormatDateTime(value) {
9546
9923
  return value;
9547
9924
  }
9548
9925
  }
9549
- function pickLatestScore(scores) {
9550
- if (!scores || scores.length === 0)
9551
- return null;
9926
+ function sortScores(scores) {
9552
9927
  const withTime = scores
9553
- .map((s) => {
9928
+ .map((s, idx) => {
9554
9929
  const t = typeof s?.computed_at === 'string' ? new Date(s.computed_at).getTime() : NaN;
9555
- return { s, t };
9930
+ return { s, t, idx };
9556
9931
  })
9557
- .filter((x) => !Number.isNaN(x.t));
9558
- if (withTime.length === 0)
9559
- return scores[0] || null;
9560
- withTime.sort((a, b) => b.t - a.t);
9561
- return withTime[0]?.s || null;
9932
+ .sort((a, b) => {
9933
+ const aHas = !Number.isNaN(a.t);
9934
+ const bHas = !Number.isNaN(b.t);
9935
+ if (aHas && bHas)
9936
+ return b.t - a.t;
9937
+ if (aHas)
9938
+ return -1;
9939
+ if (bHas)
9940
+ return 1;
9941
+ return a.idx - b.idx;
9942
+ });
9943
+ return withTime.map((x) => x.s);
9562
9944
  }
9563
9945
  /**
9564
- * Scores Display Widget - full-width, view-only widget
9946
+ * Scores Display Widget - full-width, view-only widget (list)
9565
9947
  *
9566
9948
  * Expected config (reference):
9567
9949
  * {
9568
9950
  * "widget": "scores-display",
9569
9951
  * "widget-type": "group",
9570
9952
  * "widget-id": "record-scores",
9571
- * "widget-data-source": {
9572
- * "type": "api",
9573
- * "service": "staff-portal-api",
9574
- * "endpoint": "get_scores",
9575
- * "method": "POST",
9576
- * "params": { "internal_record_id_path": "internal_record_id" }
9577
- * }
9953
+ * "widget-data-path": "scores"
9578
9954
  * }
9579
- *
9580
- * The host's `dataSourceRequestHandler` is invoked with:
9581
- * - service: config.widget-data-source.service
9582
- * - endpoint: config.widget-data-source.endpoint
9583
- * - method: config.widget-data-source.method (default POST)
9584
- * - params: { internal_record_id: <resolved from internal_record_id_path> }
9585
9955
  */
9586
- const ScoresDisplayWidget = ({ config, dataSourceRequestHandler: propHandler, schemaData: propSchemaData, }) => {
9587
- const { dataSourceRequestHandler: ctxHandler, schemaData: ctxSchemaData } = useWidgetContext();
9588
- const handler = propHandler || ctxHandler;
9589
- const schemaData = propSchemaData || ctxSchemaData || {};
9956
+ const ScoresDisplayWidget = ({ config, schemaData: propSchemaData, }) => {
9957
+ const { schemaData: ctxSchemaData } = useWidgetContext();
9958
+ const schemaData = (propSchemaData || ctxSchemaData || {});
9590
9959
  const values = reactRedux.useSelector((state) => state.widget.values);
9591
- const api = config['widget-data-source'];
9592
- const isApi = api?.type === 'api';
9593
- const apiDs = isApi ? api : null;
9594
- const internalIdPath = React.useMemo(() => {
9595
- if (!apiDs)
9596
- return undefined;
9597
- const p = apiDs.params || {};
9598
- const fromParams = p.internal_record_id_path || p.internalRecordIdPath;
9599
- const fromConfig = typeof config.internal_record_id_path === 'string'
9600
- ? config.internal_record_id_path
9601
- : undefined;
9602
- return fromParams || fromConfig;
9603
- }, [apiDs, config]);
9604
- const internalRecordId = React.useMemo(() => {
9605
- if (!internalIdPath)
9960
+ const dataPath = config['widget-data-path'];
9961
+ const rawScores = React.useMemo(() => {
9962
+ if (!dataPath || typeof dataPath !== 'string')
9606
9963
  return undefined;
9607
- const fromValues = getValueByPath(values || {}, internalIdPath);
9608
- if (fromValues !== undefined && fromValues !== null && String(fromValues).trim() !== '') {
9609
- return String(fromValues);
9964
+ const valuesObj = values;
9965
+ const tryResolve = (path) => {
9966
+ const fromValues = getValueByPathOrKey(valuesObj, path);
9967
+ if (fromValues !== undefined)
9968
+ return fromValues;
9969
+ return getValueByPathOrKey(schemaData, path);
9970
+ };
9971
+ // 1) Try exact path (works when schema/store is already namespaced)
9972
+ const direct = tryResolve(dataPath);
9973
+ if (direct !== undefined)
9974
+ return direct;
9975
+ // 2) If section/widget config has been namespaced (e.g. "rv-section-0.scores"),
9976
+ // fall back to the original path ("scores") so examples still work even when
9977
+ // schemaData/store are not namespaced.
9978
+ if (dataPath.includes('.')) {
9979
+ const unNamespaced = dataPath.split('.').slice(1).join('.');
9980
+ const fallback = tryResolve(unNamespaced);
9981
+ if (fallback !== undefined)
9982
+ return fallback;
9983
+ }
9984
+ return undefined;
9985
+ }, [dataPath, values, schemaData]);
9986
+ const scores = React.useMemo(() => {
9987
+ if (!rawScores)
9988
+ return [];
9989
+ if (Array.isArray(rawScores))
9990
+ return rawScores;
9991
+ if (typeof rawScores === 'object') {
9992
+ const maybe = rawScores.scores;
9993
+ if (Array.isArray(maybe))
9994
+ return maybe;
9995
+ }
9996
+ return [];
9997
+ }, [rawScores]);
9998
+ const sortedScores = React.useMemo(() => sortScores(scores), [scores]);
9999
+ const cls = `scores-display-widget-${config['widget-id']}`;
10000
+ return (jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [jsxRuntimeExports.jsx("style", { children: `
10001
+ .${cls} {
10002
+ width: 100%;
10003
+ font-family: Roboto, sans-serif;
10004
+ padding: 0;
10005
+ display: flex;
10006
+ flex-direction: column;
10007
+ gap: 8px;
10008
+ }
10009
+
10010
+ .${cls} .scores-subtle {
10011
+ font-size: 13px;
10012
+ color: var(--owt-color-text-muted, #727474);
10013
+ font-weight: 400;
10014
+ }
10015
+
10016
+ .${cls} .scores-grid {
10017
+ width: 100%;
10018
+ display: grid;
10019
+ grid-template-columns: repeat(3, minmax(220px, 1fr));
10020
+ gap: 16px;
10021
+ }
10022
+
10023
+ .${cls} .scores-card {
10024
+ border: 1px solid var(--owt-color-border-light, #E4E4E4);
10025
+ border-radius: 10px;
10026
+ background: var(--owt-color-bg, #FFFFFF);
10027
+ padding: 14px 14px;
10028
+ display: flex;
10029
+ flex-direction: column;
10030
+ gap: 10px;
10031
+ min-width: 0;
10032
+ box-shadow: 0 1px 2px rgba(1, 22, 39, 0.06), 0 6px 16px rgba(1, 22, 39, 0.06);
10033
+ }
10034
+
10035
+ .${cls} .scores-type {
10036
+ font-size: 16px;
10037
+ font-weight: 800;
10038
+ color: var(--owt-color-primary-dark, #F07B1A);
10039
+ line-height: 1.2;
10040
+ word-break: break-word;
10041
+ }
10042
+
10043
+ .${cls} .scores-value {
10044
+ font-size: 34px;
10045
+ font-weight: 800;
10046
+ color: var(--owt-color-text, #011627);
10047
+ line-height: 1.05;
10048
+ letter-spacing: -0.25px;
10049
+ }
10050
+
10051
+ .${cls} .scores-separator {
10052
+ height: 1px;
10053
+ width: 100%;
10054
+ background-color: var(--owt-color-border-light, #E4E4E4);
10055
+ border: none;
10056
+ margin: 2px 0;
10057
+ }
10058
+
10059
+ .${cls} .scores-value .scores-muted {
10060
+ font-size: 18px;
10061
+ font-weight: 600;
10062
+ color: var(--owt-color-text-muted, #727474);
10063
+ margin-left: 6px;
10064
+ }
10065
+
10066
+ .${cls} .scores-meta {
10067
+ display: flex;
10068
+ flex-direction: column;
10069
+ gap: 4px;
10070
+ }
10071
+
10072
+ .${cls} .scores-meta-line {
10073
+ font-size: 13px;
10074
+ color: var(--owt-color-text-muted, #727474);
10075
+ font-weight: 500;
9610
10076
  }
9611
- const fromSchema = getValueByPath(schemaData || {}, internalIdPath);
9612
- if (fromSchema !== undefined && fromSchema !== null && String(fromSchema).trim() !== '') {
9613
- return String(fromSchema);
10077
+
10078
+ .${cls} .scores-meta-line strong {
10079
+ color: var(--owt-color-text, #011627);
10080
+ font-weight: 700;
10081
+ }
10082
+
10083
+ @media (max-width: 1024px) {
10084
+ .${cls} .scores-grid {
10085
+ grid-template-columns: repeat(2, minmax(220px, 1fr));
10086
+ }
10087
+ }
10088
+
10089
+ @media (max-width: 640px) {
10090
+ .${cls} .scores-grid {
10091
+ grid-template-columns: 1fr;
10092
+ }
9614
10093
  }
10094
+ ` }), jsxRuntimeExports.jsx("div", { className: cls, children: sortedScores.length === 0 ? (jsxRuntimeExports.jsx("div", { className: "scores-subtle", children: "No scores available." })) : (jsxRuntimeExports.jsx("div", { className: "scores-grid", children: sortedScores.map((s, idx) => {
10095
+ const scoreType = s?.score_type ? String(s.score_type) : '-';
10096
+ const scoreValue = s?.computed_score !== undefined &&
10097
+ s?.computed_score !== null &&
10098
+ String(s.computed_score) !== ''
10099
+ ? String(s.computed_score)
10100
+ : '-';
10101
+ const computedAt = tryFormatDateTime$1(s?.computed_at);
10102
+ const key = `${scoreType}-${String(s?.computed_at || '')}-${idx}`;
10103
+ return (jsxRuntimeExports.jsxs("div", { className: "scores-card", "aria-live": idx === 0 ? 'polite' : undefined, children: [jsxRuntimeExports.jsx("div", { className: "scores-type", children: scoreType }), jsxRuntimeExports.jsx("div", { className: "scores-value", children: scoreValue }), jsxRuntimeExports.jsx("hr", { className: "scores-separator" }), jsxRuntimeExports.jsx("div", { className: "scores-meta", children: jsxRuntimeExports.jsxs("div", { className: "scores-meta-line", children: ["Computed at: ", jsxRuntimeExports.jsx("strong", { children: computedAt })] }) })] }, key));
10104
+ }) })) })] }));
10105
+ };
10106
+
10107
+ function tryFormatDateTime(value) {
10108
+ if (typeof value !== 'string' || !value)
10109
+ return value ? String(value) : '-';
10110
+ const d = new Date(value);
10111
+ if (Number.isNaN(d.getTime()))
10112
+ return value;
10113
+ try {
10114
+ return d.toLocaleString(undefined, {
10115
+ year: 'numeric',
10116
+ month: 'short',
10117
+ day: '2-digit',
10118
+ hour: '2-digit',
10119
+ minute: '2-digit',
10120
+ });
10121
+ }
10122
+ catch {
10123
+ return value;
10124
+ }
10125
+ }
10126
+ function tryFormatDate(value) {
10127
+ if (typeof value !== 'string' || !value)
10128
+ return value ? String(value) : '-';
10129
+ const d = new Date(value);
10130
+ if (Number.isNaN(d.getTime()))
10131
+ return value;
10132
+ try {
10133
+ return d.toLocaleDateString(undefined, {
10134
+ year: 'numeric',
10135
+ month: 'short',
10136
+ day: '2-digit',
10137
+ });
10138
+ }
10139
+ catch {
10140
+ return value;
10141
+ }
10142
+ }
10143
+ function displayText(value) {
10144
+ if (value === null || value === undefined || String(value).trim() === '')
10145
+ return '-';
10146
+ return String(value);
10147
+ }
10148
+ function normalizeStatus(raw) {
10149
+ if (raw === null || raw === undefined || String(raw).trim() === '')
10150
+ return 'unknown';
10151
+ const v = String(raw).trim().toLowerCase();
10152
+ if (v === 'success' || v === 'succeeded' || v === 'ok')
10153
+ return 'success';
10154
+ if (v === 'failure' || v === 'failed' || v === 'error')
10155
+ return 'failure';
10156
+ if (v === 'not done' || v === 'not_done' || v === 'not-done' || v === 'pending')
10157
+ return 'not_done';
10158
+ return 'unknown';
10159
+ }
10160
+ /** Large enough for eSignet / OIDC login; clamped so it always fits the current screen. */
10161
+ function getCenteredPopupFeatures(width, height) {
10162
+ const dualScreenLeft = window.screenLeft ?? window.screenX ?? 0;
10163
+ const dualScreenTop = window.screenTop ?? window.screenY ?? 0;
10164
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth || (typeof screen !== 'undefined' ? screen.width : width);
10165
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight || (typeof screen !== 'undefined' ? screen.height : height);
10166
+ const maxW = Math.max(320, Math.floor(viewportWidth * 0.92));
10167
+ const maxH = Math.max(400, Math.floor(viewportHeight * 0.92));
10168
+ const w = Math.max(320, Math.min(width, maxW));
10169
+ const h = Math.max(400, Math.min(height, maxH));
10170
+ const left = Math.max(0, Math.floor(viewportWidth / 2 - w / 2 + dualScreenLeft));
10171
+ const top = Math.max(0, Math.floor(viewportHeight / 2 - h / 2 + dualScreenTop));
10172
+ return [
10173
+ 'popup=yes',
10174
+ 'noopener=yes',
10175
+ 'noreferrer=yes',
10176
+ `width=${w}`,
10177
+ `height=${h}`,
10178
+ `left=${left}`,
10179
+ `top=${top}`,
10180
+ 'scrollbars=yes',
10181
+ 'resizable=yes',
10182
+ ].join(',');
10183
+ }
10184
+ function pickAuthorizationUrl(resp, explicitKey) {
10185
+ if (!resp)
10186
+ return null;
10187
+ const tryKey = (k) => {
10188
+ const v = resp?.[k];
10189
+ if (typeof v === 'string' && v)
10190
+ return v;
10191
+ return null;
10192
+ };
10193
+ if (explicitKey) {
10194
+ const v = tryKey(explicitKey);
10195
+ if (v)
10196
+ return v;
10197
+ }
10198
+ return (tryKey('authorization_url') ||
10199
+ tryKey('authorizationUrl') ||
10200
+ tryKey('auth_url') ||
10201
+ tryKey('authUrl') ||
10202
+ tryKey('url') ||
10203
+ null);
10204
+ }
10205
+ function resolveValueFromSources(path, values, schemaData) {
10206
+ if (!path)
9615
10207
  return undefined;
9616
- }, [internalIdPath, values, schemaData]);
9617
- const [loading, setLoading] = React.useState(false);
9618
- const [error, setError] = React.useState(null);
9619
- const [response, setResponse] = React.useState(null);
10208
+ const fromValues = getValueByPath(values, path);
10209
+ if (fromValues !== undefined)
10210
+ return fromValues;
10211
+ return getValueByPath(schemaData, path);
10212
+ }
10213
+ async function fetchProviderAuthorizationUrl(authConfig, dataSourceRequestHandler, _values, _schemaData) {
10214
+ const response = await dataSourceRequestHandler(authConfig.service, authConfig.endpoint, authConfig.method || 'GET', {});
10215
+ const payload = response?.response_body?.response_payload && typeof response.response_body.response_payload === 'object'
10216
+ ? response.response_body.response_payload
10217
+ : response;
10218
+ return pickAuthorizationUrl(payload, authConfig.authorizationUrlKey);
10219
+ }
10220
+ const IdAuthenticationWidget = ({ config, schemaData: propSchemaData }) => {
10221
+ const { dataSourceRequestHandler, schemaData: ctxSchemaData } = useWidgetContext();
10222
+ const values = reactRedux.useSelector((state) => state.widget.values);
10223
+ const schemaData = (propSchemaData || ctxSchemaData || {});
10224
+ const widgetId = config['widget-id'];
10225
+ const dataPath = config['widget-data-path'];
10226
+ const paths = React.useMemo(() => {
10227
+ if (!dataPath || typeof dataPath !== 'object')
10228
+ return {};
10229
+ return dataPath;
10230
+ }, [dataPath]);
10231
+ const authConfig = config['widget-auth-config'];
10232
+ const foundationalId = resolveValueFromSources(paths.foundationalId, values, schemaData);
10233
+ const lastAuthenticatedOn = resolveValueFromSources(paths.lastAuthenticatedOn, values, schemaData);
10234
+ const lastAuthStatusRaw = resolveValueFromSources(paths.lastAuthenticationStatus, values, schemaData);
10235
+ const expiryDate = resolveValueFromSources(paths.expiryDate, values, schemaData);
10236
+ const psut = resolveValueFromSources(paths.authenticationToken, values, schemaData);
10237
+ const status = React.useMemo(() => normalizeStatus(lastAuthStatusRaw), [lastAuthStatusRaw]);
10238
+ /** URL from prefetch (or default); used when opening the OIDC / eSignet popup */
10239
+ const [resolvedAuthUrl, setResolvedAuthUrl] = React.useState(null);
10240
+ const [providerLoading, setProviderLoading] = React.useState(false);
10241
+ const [authActionLoading, setAuthActionLoading] = React.useState(false);
10242
+ const [authError, setAuthError] = React.useState(null);
10243
+ const popupRef = React.useRef(null);
10244
+ const pollTimerRef = React.useRef(null);
10245
+ const emitHostEvent = React.useCallback((detail) => {
10246
+ if (typeof window === 'undefined')
10247
+ return;
10248
+ window.dispatchEvent(new CustomEvent('openg2p:id-authentication', {
10249
+ detail: {
10250
+ widgetId,
10251
+ ...detail,
10252
+ },
10253
+ }));
10254
+ }, [widgetId]);
10255
+ const cleanupPopup = React.useCallback(() => {
10256
+ if (pollTimerRef.current) {
10257
+ window.clearInterval(pollTimerRef.current);
10258
+ pollTimerRef.current = null;
10259
+ }
10260
+ popupRef.current = null;
10261
+ }, []);
9620
10262
  React.useEffect(() => {
9621
- let cancelled = false;
9622
- const load = async () => {
9623
- if (!apiDs) {
9624
- setError('Scores widget requires an API data source.');
9625
- setResponse(null);
9626
- return;
9627
- }
9628
- if (!handler) {
9629
- setError(null);
9630
- setResponse(null);
9631
- return;
10263
+ return () => {
10264
+ cleanupPopup();
10265
+ try {
10266
+ popupRef.current?.close?.();
9632
10267
  }
9633
- const service = apiDs.service;
9634
- const endpoint = apiDs.endpoint;
9635
- const method = apiDs.method || 'POST';
9636
- if (!service || !endpoint) {
9637
- setError('Scores widget API data source is missing service/endpoint.');
9638
- setResponse(null);
9639
- return;
10268
+ catch {
10269
+ // ignore
9640
10270
  }
9641
- if (!internalRecordId) {
9642
- setError(null);
9643
- setResponse(null);
9644
- return;
10271
+ };
10272
+ }, [cleanupPopup]);
10273
+ const prefetchKey = React.useMemo(() => {
10274
+ if (!authConfig)
10275
+ return 'no-config';
10276
+ return JSON.stringify({
10277
+ s: authConfig.service,
10278
+ e: authConfig.endpoint,
10279
+ m: authConfig.method,
10280
+ def: authConfig.defaultAuthorizationUrl,
10281
+ prefetch: authConfig.prefetchOnMount,
10282
+ });
10283
+ }, [authConfig]);
10284
+ // Prefetch provider login URL on mount (and when params / config change)
10285
+ React.useEffect(() => {
10286
+ if (!authConfig) {
10287
+ setResolvedAuthUrl(null);
10288
+ setProviderLoading(false);
10289
+ return;
10290
+ }
10291
+ if (authConfig.prefetchOnMount === false) {
10292
+ setResolvedAuthUrl(authConfig.defaultAuthorizationUrl || null);
10293
+ setProviderLoading(false);
10294
+ return;
10295
+ }
10296
+ const def = authConfig.defaultAuthorizationUrl;
10297
+ if (def) {
10298
+ setResolvedAuthUrl(def);
10299
+ }
10300
+ const canCallApi = Boolean(dataSourceRequestHandler && authConfig.service && authConfig.endpoint);
10301
+ if (!canCallApi) {
10302
+ setProviderLoading(false);
10303
+ if (!def) {
10304
+ setResolvedAuthUrl(null);
9645
10305
  }
10306
+ return;
10307
+ }
10308
+ let cancelled = false;
10309
+ setProviderLoading(true);
10310
+ (async () => {
9646
10311
  try {
9647
- setLoading(true);
9648
- setError(null);
9649
- const rawParams = apiDs.params || {};
9650
- // Never pass the path helper through to the API.
9651
- const { internal_record_id_path, internalRecordIdPath, ...rest } = rawParams;
9652
- void internal_record_id_path;
9653
- void internalRecordIdPath;
9654
- const params = {
9655
- ...rest,
9656
- internal_record_id: internalRecordId,
9657
- };
9658
- const res = await handler(service, endpoint, method, params, {
9659
- headers: apiDs.headers,
9660
- });
10312
+ const url = await fetchProviderAuthorizationUrl(authConfig, dataSourceRequestHandler, values, schemaData);
9661
10313
  if (cancelled)
9662
10314
  return;
9663
- // Accept either direct payload or OpenG2P wrapper objects.
9664
- const envelope = res && typeof res === 'object' ? res : null;
9665
- const payload = envelope?.response_body?.response_payload ?? envelope?.data ?? res;
9666
- if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
9667
- setResponse(payload);
10315
+ if (url) {
10316
+ setResolvedAuthUrl(url);
9668
10317
  }
9669
- else {
9670
- setResponse({ scores: Array.isArray(payload) ? payload : [] });
10318
+ else if (!def) {
10319
+ setResolvedAuthUrl(null);
9671
10320
  }
9672
10321
  }
9673
- catch (e) {
10322
+ catch {
9674
10323
  if (cancelled)
9675
10324
  return;
9676
- const maybeErr = e;
9677
- const msg = maybeErr && typeof maybeErr === 'object' && typeof maybeErr.message === 'string'
9678
- ? maybeErr.message
9679
- : 'Failed to load scores.';
9680
- setError(msg);
9681
- setResponse(null);
10325
+ if (!def) {
10326
+ setResolvedAuthUrl(null);
10327
+ }
9682
10328
  }
9683
10329
  finally {
9684
10330
  if (!cancelled)
9685
- setLoading(false);
10331
+ setProviderLoading(false);
9686
10332
  }
9687
- };
9688
- load();
10333
+ })();
9689
10334
  return () => {
9690
10335
  cancelled = true;
9691
10336
  };
9692
- }, [apiDs, handler, internalRecordId]);
9693
- const latest = React.useMemo(() => pickLatestScore(response?.scores), [response]);
9694
- const cls = `scores-display-widget-${config['widget-id']}`;
9695
- const scoreType = latest?.score_type ? String(latest.score_type) : '-';
9696
- const scoreValue = latest?.computed_score !== undefined && latest?.computed_score !== null && String(latest.computed_score) !== ''
9697
- ? String(latest.computed_score)
9698
- : '-';
9699
- const computedAt = tryFormatDateTime(latest?.computed_at);
10337
+ }, [authConfig, dataSourceRequestHandler, prefetchKey, values, schemaData]);
10338
+ const openAuthPopup = React.useCallback((authUrl) => {
10339
+ const pw = authConfig?.popupWidth ?? 1024;
10340
+ const ph = authConfig?.popupHeight ?? 800;
10341
+ const features = getCenteredPopupFeatures(pw, ph);
10342
+ const popup = window.open(authUrl, `${widgetId}-oidc`, features);
10343
+ if (!popup) {
10344
+ setAuthError('Popup blocked. Please allow popups and try again.');
10345
+ return;
10346
+ }
10347
+ popupRef.current = popup;
10348
+ popup.focus?.();
10349
+ setAuthError(null);
10350
+ emitHostEvent({ type: 'popup_opened' });
10351
+ if (pollTimerRef.current) {
10352
+ window.clearInterval(pollTimerRef.current);
10353
+ pollTimerRef.current = null;
10354
+ }
10355
+ pollTimerRef.current = window.setInterval(() => {
10356
+ try {
10357
+ const closed = !popupRef.current || popupRef.current.closed;
10358
+ if (closed) {
10359
+ cleanupPopup();
10360
+ emitHostEvent({ type: 'popup_closed' });
10361
+ }
10362
+ }
10363
+ catch {
10364
+ // ignore
10365
+ }
10366
+ }, 500);
10367
+ }, [authConfig, cleanupPopup, emitHostEvent, widgetId]);
10368
+ const onAuthenticate = React.useCallback(async () => {
10369
+ setAuthError(null);
10370
+ if (!authConfig) {
10371
+ setAuthError('Missing widget-auth-config.');
10372
+ return;
10373
+ }
10374
+ const def = authConfig.defaultAuthorizationUrl;
10375
+ const canCallApi = Boolean(dataSourceRequestHandler && authConfig.service && authConfig.endpoint);
10376
+ let url = resolvedAuthUrl;
10377
+ if (!url && canCallApi) {
10378
+ setAuthActionLoading(true);
10379
+ try {
10380
+ const fetched = await fetchProviderAuthorizationUrl(authConfig, dataSourceRequestHandler, values, schemaData);
10381
+ url = fetched || def || null;
10382
+ if (fetched) {
10383
+ setResolvedAuthUrl(fetched);
10384
+ }
10385
+ }
10386
+ catch (e) {
10387
+ url = def || null;
10388
+ if (!url) {
10389
+ setAuthError(e?.message || 'Could not load provider URL.');
10390
+ return;
10391
+ }
10392
+ }
10393
+ finally {
10394
+ setAuthActionLoading(false);
10395
+ }
10396
+ }
10397
+ else if (!url) {
10398
+ url = def || null;
10399
+ }
10400
+ if (!url) {
10401
+ setAuthError('No authorization URL. Set widget-auth-config (service + endpoint, or defaultAuthorizationUrl) and dataSourceRequestHandler on WidgetProvider if using the API.');
10402
+ return;
10403
+ }
10404
+ openAuthPopup(url);
10405
+ }, [authConfig, dataSourceRequestHandler, openAuthPopup, resolvedAuthUrl, values, schemaData]);
10406
+ React.useEffect(() => {
10407
+ const successType = authConfig?.successMessageType || 'openg2p:oidc:success';
10408
+ const handler = (event) => {
10409
+ const data = event?.data;
10410
+ if (!data || typeof data !== 'object')
10411
+ return;
10412
+ if (data.type !== successType)
10413
+ return;
10414
+ if (data.widgetId && data.widgetId !== widgetId)
10415
+ return;
10416
+ emitHostEvent({ type: 'authenticated', payload: data });
10417
+ try {
10418
+ popupRef.current?.close?.();
10419
+ }
10420
+ catch {
10421
+ // ignore
10422
+ }
10423
+ cleanupPopup();
10424
+ if (authConfig?.reloadOnSuccess) {
10425
+ window.location.reload();
10426
+ }
10427
+ };
10428
+ window.addEventListener('message', handler);
10429
+ return () => window.removeEventListener('message', handler);
10430
+ }, [authConfig?.reloadOnSuccess, authConfig?.successMessageType, cleanupPopup, emitHostEvent, widgetId]);
10431
+ const cls = `id-auth-widget-${widgetId}`;
10432
+ const statusLabel = React.useMemo(() => {
10433
+ if (status === 'success')
10434
+ return 'Success';
10435
+ if (status === 'failure')
10436
+ return 'Failure';
10437
+ if (status === 'not_done')
10438
+ return 'Not done';
10439
+ return 'Unknown';
10440
+ }, [status]);
10441
+ const statusColor = React.useMemo(() => {
10442
+ if (status === 'success')
10443
+ return 'var(--owt-color-success, #16A34A)';
10444
+ if (status === 'failure')
10445
+ return 'var(--owt-color-danger, #DC2626)';
10446
+ if (status === 'not_done')
10447
+ return 'var(--owt-color-warning, #D97706)';
10448
+ return 'var(--owt-color-text-muted, #6B7280)';
10449
+ }, [status]);
10450
+ const buttonBusy = authActionLoading ||
10451
+ (providerLoading && !resolvedAuthUrl && !authConfig?.defaultAuthorizationUrl);
10452
+ const buttonDisabled = !authConfig || buttonBusy;
9700
10453
  return (jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [jsxRuntimeExports.jsx("style", { children: `
9701
10454
  .${cls} {
9702
10455
  width: 100%;
9703
10456
  font-family: Roboto, sans-serif;
9704
- padding: 0;
9705
- display: flex;
9706
- flex-direction: column;
9707
- gap: 8px;
9708
10457
  }
9709
10458
 
9710
- .${cls} .scores-subtle {
9711
- font-size: 13px;
9712
- color: var(--owt-color-text-muted, #727474);
9713
- font-weight: 400;
10459
+ /* Two-column field grid; primary action in a bottom band (matches section save/edit pattern). */
10460
+ .${cls} .auth-content {
10461
+ display: flex;
10462
+ flex-direction: column;
10463
+ gap: 0;
10464
+ min-width: 0;
9714
10465
  }
9715
10466
 
9716
- .${cls} .scores-card {
9717
- width: 100%;
9718
- border: none;
9719
- border-radius: 0;
9720
- background: transparent;
9721
- padding: 0;
10467
+ .${cls} .auth-grid {
9722
10468
  display: grid;
9723
- grid-template-columns: 1fr 1fr 1fr;
9724
- gap: 16px;
9725
- align-items: center;
10469
+ grid-template-columns: repeat(2, minmax(0, 1fr));
10470
+ gap: 16px 24px;
10471
+ min-width: 0;
9726
10472
  }
9727
10473
 
9728
- .${cls} .scores-col {
9729
- min-width: 0;
10474
+ /* Each field: label (left) + value (right), same as DisplayWidget readonly */
10475
+ .${cls} .auth-cell {
9730
10476
  display: flex;
9731
- flex-direction: column;
9732
- gap: 6px;
10477
+ flex-direction: row;
10478
+ align-items: flex-start;
10479
+ gap: 12px 16px;
10480
+ min-width: 0;
9733
10481
  }
9734
10482
 
9735
- .${cls} .scores-label {
9736
- font-size: 12px;
9737
- color: var(--owt-color-text-muted, #727474);
9738
- font-weight: 600;
9739
- letter-spacing: 0.25px;
9740
- text-transform: uppercase;
10483
+ .${cls} .auth-cell.auth-cell--full {
10484
+ grid-column: 1 / -1;
9741
10485
  }
9742
10486
 
9743
- .${cls} .scores-value {
10487
+ .${cls} .auth-label {
10488
+ flex: 0 0 auto;
10489
+ min-width: 200px;
10490
+ max-width: 40%;
9744
10491
  font-size: 16px;
9745
- font-weight: 600;
9746
- color: var(--owt-color-text, #011627);
9747
- line-height: 1.25;
10492
+ color: rgba(0, 0, 0, 0.6);
10493
+ font-weight: 500;
10494
+ line-height: 1.45;
10495
+ margin: 0;
9748
10496
  word-break: break-word;
9749
10497
  }
9750
10498
 
9751
- .${cls} .scores-value--highlight {
9752
- font-weight: 800;
9753
- color: var(--owt-color-primary-dark, #F07B1A);
10499
+ .${cls} .auth-value {
10500
+ flex: 1 1 auto;
10501
+ min-width: 0;
10502
+ font-size: 16px;
10503
+ color: var(--owt-color-text, #111827);
10504
+ font-weight: 500;
10505
+ line-height: 1.45;
10506
+ word-break: break-word;
9754
10507
  }
9755
10508
 
9756
- .${cls} .scores-value-wrap {
10509
+ .${cls} .auth-bottom-actions {
10510
+ display: flex;
10511
+ flex-direction: column;
10512
+ align-items: flex-start;
10513
+ justify-content: flex-start;
10514
+ gap: 8px;
10515
+ width: 100%;
10516
+ margin-top: 20px;
10517
+ margin-bottom: 0;
10518
+ }
10519
+
10520
+ .${cls} .auth-status {
9757
10521
  display: inline-flex;
9758
- align-items: baseline;
9759
- gap: 10px;
9760
- flex-wrap: wrap;
10522
+ align-items: center;
10523
+ gap: 8px;
10524
+ width: fit-content;
10525
+ padding: 4px 10px;
10526
+ border-radius: 999px;
10527
+ background: rgba(2, 6, 23, 0.04);
10528
+ border: 1px solid rgba(2, 6, 23, 0.08);
10529
+ font-size: 13px;
10530
+ font-weight: 700;
10531
+ color: var(--owt-color-text, #011627);
9761
10532
  }
9762
10533
 
9763
- .${cls} .scores-value-badge { display: inline; }
10534
+ .${cls} .auth-dot {
10535
+ width: 8px;
10536
+ height: 8px;
10537
+ border-radius: 50%;
10538
+ background: ${statusColor};
10539
+ }
9764
10540
 
9765
- .${cls} .scores-statusline {
9766
- grid-column: 1 / -1;
9767
- margin-top: 2px;
10541
+ .${cls} .auth-token {
10542
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
10543
+ font-size: 14px;
10544
+ font-weight: 500;
10545
+ color: var(--owt-color-text, #011627);
10546
+ background: transparent;
10547
+ border: none;
10548
+ border-radius: 0;
10549
+ padding: 0;
10550
+ word-break: break-all;
9768
10551
  }
9769
10552
 
9770
- @media (max-width: 768px) {
9771
- .${cls} .scores-card {
10553
+ .${cls} .auth-button {
10554
+ /* Match SectionRegistryView Save CTA (SectionRenderer) */
10555
+ font-size: 14px;
10556
+ font-weight: 500;
10557
+ padding: 8px 24px;
10558
+ line-height: 1.5;
10559
+ border-radius: var(--owt-btn-border-radius, 10px);
10560
+ border: 1px solid var(--owt-btn-primary-border, #F07B1A);
10561
+ background-color: var(--owt-color-primary, #F5BB1A);
10562
+ color: var(--owt-color-bg, #FFFFFF);
10563
+ font-family: Roboto, sans-serif;
10564
+ cursor: pointer;
10565
+ transition: opacity 0.15s ease;
10566
+ }
10567
+
10568
+ .${cls} .auth-button:disabled {
10569
+ opacity: 0.5;
10570
+ cursor: not-allowed;
10571
+ }
10572
+
10573
+ .${cls} .auth-error {
10574
+ font-size: 12px;
10575
+ color: var(--owt-color-danger, #DC2626);
10576
+ font-weight: 700;
10577
+ line-height: 1.3;
10578
+ text-align: left;
10579
+ max-width: 100%;
10580
+ }
10581
+
10582
+ @media (max-width: 640px) {
10583
+ .${cls} .auth-grid {
9772
10584
  grid-template-columns: 1fr;
9773
10585
  }
10586
+ .${cls} .auth-cell {
10587
+ flex-direction: column;
10588
+ align-items: stretch;
10589
+ gap: 4px 0;
10590
+ }
10591
+ .${cls} .auth-label {
10592
+ min-width: 0;
10593
+ max-width: none;
10594
+ }
9774
10595
  }
9775
- ` }), jsxRuntimeExports.jsx("div", { className: cls, children: jsxRuntimeExports.jsxs("div", { className: "scores-card", children: [jsxRuntimeExports.jsxs("div", { className: "scores-col", "aria-live": "polite", children: [jsxRuntimeExports.jsx("div", { className: "scores-label", children: "Score Type" }), jsxRuntimeExports.jsx("div", { className: "scores-value-wrap", children: jsxRuntimeExports.jsx("span", { className: "scores-value-badge", children: jsxRuntimeExports.jsx("span", { className: "scores-value scores-value--highlight", children: scoreType }) }) })] }), jsxRuntimeExports.jsxs("div", { className: "scores-col", children: [jsxRuntimeExports.jsx("div", { className: "scores-label", children: "Score" }), jsxRuntimeExports.jsx("div", { className: "scores-value-wrap", children: jsxRuntimeExports.jsx("span", { className: "scores-value-badge", children: jsxRuntimeExports.jsx("span", { className: "scores-value scores-value--highlight", children: scoreValue }) }) })] }), jsxRuntimeExports.jsxs("div", { className: "scores-col", children: [jsxRuntimeExports.jsx("div", { className: "scores-label", children: "Computed at" }), jsxRuntimeExports.jsx("div", { className: "scores-value", children: computedAt })] }), jsxRuntimeExports.jsx("div", { className: "scores-statusline", children: loading ? (jsxRuntimeExports.jsx("div", { className: "scores-subtle", children: "Loading scores\u2026" })) : error ? (jsxRuntimeExports.jsx("div", { className: "scores-subtle", style: { color: 'var(--owt-color-error, #B91C1C)' }, children: error })) : !latest ? (jsxRuntimeExports.jsx("div", { className: "scores-subtle", children: "No scores available." })) : null })] }) })] }));
10596
+ ` }), jsxRuntimeExports.jsx("div", { className: cls, children: jsxRuntimeExports.jsxs("div", { className: "auth-content", children: [jsxRuntimeExports.jsxs("div", { className: "auth-grid", children: [jsxRuntimeExports.jsxs("div", { className: "auth-cell", children: [jsxRuntimeExports.jsx("div", { className: "auth-label", children: "Foundational ID:" }), jsxRuntimeExports.jsx("div", { className: "auth-value", children: displayText(foundationalId) })] }), jsxRuntimeExports.jsxs("div", { className: "auth-cell", children: [jsxRuntimeExports.jsx("div", { className: "auth-label", children: "Last authenticated on:" }), jsxRuntimeExports.jsx("div", { className: "auth-value", children: tryFormatDateTime(lastAuthenticatedOn) })] }), jsxRuntimeExports.jsxs("div", { className: "auth-cell", children: [jsxRuntimeExports.jsx("div", { className: "auth-label", children: "Last authentication status:" }), jsxRuntimeExports.jsx("div", { className: "auth-value", children: jsxRuntimeExports.jsxs("div", { className: "auth-status", "aria-label": `Authentication status: ${statusLabel}`, children: [jsxRuntimeExports.jsx("span", { className: "auth-dot" }), jsxRuntimeExports.jsx("span", { children: statusLabel })] }) })] }), jsxRuntimeExports.jsxs("div", { className: "auth-cell", children: [jsxRuntimeExports.jsx("div", { className: "auth-label", children: "Expiry date:" }), jsxRuntimeExports.jsx("div", { className: "auth-value", children: tryFormatDate(expiryDate) })] }), jsxRuntimeExports.jsxs("div", { className: "auth-cell auth-cell--full", children: [jsxRuntimeExports.jsx("div", { className: "auth-label", children: "Authentication token (PSUT):" }), jsxRuntimeExports.jsx("div", { className: "auth-value", children: jsxRuntimeExports.jsx("div", { className: "auth-token", children: psut ? String(psut) : '-' }) })] })] }), jsxRuntimeExports.jsxs("div", { className: "auth-bottom-actions", children: [jsxRuntimeExports.jsx("button", { type: "button", className: "auth-button", onClick: onAuthenticate, disabled: buttonDisabled, children: buttonBusy ? 'Loading…' : 'Authenticate' }), authError ? jsxRuntimeExports.jsx("div", { className: "auth-error", children: authError }) : null] })] }) })] }));
9776
10597
  };
9777
10598
 
9778
10599
  /**
@@ -9818,6 +10639,8 @@ const registerDefaultWidgets = () => {
9818
10639
  widgetRegistry.register({ widget: 'header-section', component: HeaderSectionWidget });
9819
10640
  // Scores display widget for full-width computed scores display (view-only)
9820
10641
  widgetRegistry.register({ widget: 'scores-display', component: ScoresDisplayWidget });
10642
+ // ID Authentication widget for OIDC-based foundational ID authentication (view-only + action)
10643
+ widgetRegistry.register({ widget: 'id-authentication', component: IdAuthenticationWidget });
9821
10644
  };
9822
10645
  // Auto-register on import
9823
10646
  registerDefaultWidgets();
@@ -10189,6 +11012,7 @@ exports.DateTimeInputWidget = DateTimeInputWidget;
10189
11012
  exports.DisplayWidget = DisplayWidget;
10190
11013
  exports.FileInputWidget = FileInputWidget;
10191
11014
  exports.HeaderSectionWidget = HeaderSectionWidget;
11015
+ exports.IdAuthenticationWidget = IdAuthenticationWidget;
10192
11016
  exports.IterableAccordionWidget = IterableAccordionWidget;
10193
11017
  exports.JSONEditorPanel = JSONEditorPanel;
10194
11018
  exports.NumberInputWidget = NumberInputWidget;