@@ -49,6 +49,32 @@ class ProjectsController < ApplicationController
4949 fields_map . freeze
5050 end
5151
52+ # When editing a specific project there are two instance variables to track
53+ # automation highlights: @automated_fields and @overridden_fields.
54+ # These instance variables track which project fields were changed by
55+ # automation (Chief or query-string proposals) so the edit form can
56+ # highlight them for the user.
57+ #
58+ # Both are Hash{Symbol => Hash} keyed by field symbol
59+ # (e.g. :contribution_status):
60+ # - @automated_fields (yellow highlight): fields that were unknown/blank
61+ # (as old values) and then got filled in by automation.
62+ # Values: { new_value:, explanation: }
63+ # - @overridden_fields (orange highlight): fields that had a real user
64+ # value (not '?' or blank) and were forcibly changed by Chief.
65+ # Values: { old_value:, new_value:, explanation: }
66+ #
67+ # They are populated by categorize_automation_changes (first-edit),
68+ # apply_query_string_automation (URL proposals), run_save_automation
69+ # (save-time Chief), and parse_and_validate_field_list (redirect params).
70+ # Consumed by the edit view (via projects_helper automated_field_set /
71+ # overridden_field_set), format_override_details, and
72+ # build_automation_metadata (JSON API).
73+ #
74+ # The external query parameters automated_fields_list and
75+ # overridden_fields_list will set the keys, with the value {} if
76+ # there is nothing more specific, to highlight those fields.
77+
5278 # The 'badge', 'baseline_badge', and 'show_json' actions are special and
5379 # do NOT take a locale.
5480 skip_before_action :redir_missing_locale , only : %i[ badge baseline_badge show_json ]
@@ -566,6 +592,9 @@ def edit
566592 return if @criteria_level == 'permissions'
567593
568594 # Run first-edit automation if this level hasn't been edited yet
595+ # (`SECTION_saved` is false). Only do this on "first edit of this section"
596+ # because automation *can* take a while or the remote system could
597+ # crash; we don't want to slow down simple edits from users.
569598 run_first_edit_automation_if_needed
570599
571600 # Always consume query string proposals, even on revisits, so we
@@ -575,10 +604,13 @@ def edit
575604 # Results merge into @automated_fields for highlighting.
576605 apply_query_string_automation
577606
578- # Restore override/automated highlighting from redirect query params
579- # (handle_overridden_fields_redirect passes overridden= and automated=)
580- @overridden_fields ||= parse_and_validate_field_list ( params [ :overridden ] )
581- @automated_fields ||= parse_and_validate_field_list ( params [ :automated ] )
607+ # Merge override/automated highlighting from redirect query params.
608+ # (handle_overridden_fields_redirect passes overridden_fields_list= and automated_fields_list=)
609+ # Existing entries (from automation) take priority over URL params.
610+ @overridden_fields =
611+ merge_field_lists ( params [ :overridden_fields_list ] , @overridden_fields )
612+ @automated_fields =
613+ merge_field_lists ( params [ :automated_fields_list ] , @automated_fields )
582614
583615 return unless @project . notify_for_static_analysis? ( '0' )
584616
@@ -1652,7 +1684,7 @@ def apply_query_string_automation
16521684 # Track which proposals actually changed values.
16531685 # Map _justification fields to their _status counterpart so the
16541686 # view (which highlights by status symbol) can find them.
1655- @automated_fields ||= [ ]
1687+ @automated_fields ||= { }
16561688 modified . each do |field_name |
16571689 new_value = @project . public_send ( field_name )
16581690 next if original_values [ field_name ] == new_value
@@ -1663,8 +1695,8 @@ def apply_query_string_automation
16631695 else
16641696 field_name
16651697 end
1666- @automated_fields << {
1667- field : highlight_field , new_value : new_value , explanation : nil
1698+ @automated_fields [ highlight_field ] = {
1699+ new_value : new_value , explanation : nil
16681700 }
16691701 end
16701702 end
@@ -1733,10 +1765,10 @@ def capture_original_values
17331765 # - Non-criteria field filled from blank: automated
17341766 # Both lists are filtered to the current section.
17351767 # @param original_values [Hash] Field name => value before automation
1736- # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
1768+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
17371769 def categorize_automation_changes ( original_values )
1738- automated = [ ]
1739- overridden = [ ]
1770+ automated = { }
1771+ overridden = { }
17401772
17411773 original_values . each do |field_name , old_value |
17421774 new_value = @project . public_send ( field_name )
@@ -1749,21 +1781,21 @@ def categorize_automation_changes(original_values)
17491781 )
17501782 elsif field_str . end_with? ( '_justification' )
17511783 status_field = field_str . sub ( '_justification' , '_status' ) . to_sym
1752- automated << {
1753- field : status_field , new_value : new_value , explanation : new_value
1784+ automated [ status_field ] = {
1785+ new_value : new_value , explanation : new_value
17541786 }
17551787 elsif ALWAYS_AUTOMATABLE . include? ( field_name ) &&
17561788 old_value . blank? && new_value . present?
1757- automated << {
1758- field : field_name , new_value : new_value , explanation : nil
1789+ automated [ field_name ] = {
1790+ new_value : new_value , explanation : nil
17591791 }
17601792 end
17611793 end
17621794
17631795 @automated_fields = filter_to_current_section ( automated )
17641796 @overridden_fields = filter_to_current_section ( overridden )
17651797 end
1766- # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
1798+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
17671799
17681800 # Categorize a status field change as automated or overridden.
17691801 # Unknown -> real value is automated (yellow); real -> real is
@@ -1778,14 +1810,14 @@ def categorize_status_automation(
17781810 if @project . respond_to? ( justification_field )
17791811 @project . public_send ( justification_field )
17801812 end
1781- info = {
1782- field : field_name , new_value : new_value , explanation : explanation
1783- }
17841813 if old_value == CriterionStatus ::UNKNOWN
1785- automated << info
1814+ automated [ field_name ] = {
1815+ new_value : new_value , explanation : explanation
1816+ }
17861817 else
1787- info [ :old_value ] = old_value
1788- overridden << info
1818+ overridden [ field_name ] = {
1819+ old_value : old_value , new_value : new_value , explanation : explanation
1820+ }
17891821 end
17901822 end
17911823 # rubocop:enable Metrics/MethodLength
@@ -1836,34 +1868,46 @@ def parse_status_value(value)
18361868 end
18371869 end
18381870
1839- # Parse comma-separated field list and validate field names
1840- # Only accepts fields valid for the current section being edited
1871+ # Merge field list from URL params with existing field data.
1872+ # URL params provide field names (for highlighting), while existing_fields
1873+ # may have rich metadata (old_value, new_value, explanation).
1874+ # Rich metadata always takes priority over empty {} from URL params.
1875+ # If we're just highlighting we don't *need* the rich metadata
1876+ # (mere presence is enough), but we don't want to lose the rich metadata,
1877+ # in case later changes to our code use the rich metadata.
1878+ # @param field_list_param [String, nil] Comma-separated field names from URL
1879+ # @param existing_fields [Hash, nil] Existing field data with metadata
1880+ # @return [Hash{Symbol => Hash}] Merged fields preserving rich data
1881+ def merge_field_lists ( field_list_param , existing_fields )
1882+ url_fields = parse_and_validate_field_list ( field_list_param )
1883+ return existing_fields || { } if url_fields . empty?
1884+ return url_fields if existing_fields . blank?
1885+
1886+ # Merge: existing rich data takes priority over URL's empty {} values
1887+ url_fields . merge ( existing_fields )
1888+ end
1889+
1890+ # Parse comma-separated field list and validate field names.
1891+ # Only accepts fields valid for the current section being edited.
18411892 # @param field_string [String, nil] Comma-separated field names
1842- # @return [Array< Hash>] Array of validated field hashes with : field key
1893+ # @return [Hash{Symbol => Hash}] Validated fields keyed by field symbol
18431894 def parse_and_validate_field_list ( field_string )
1844- return [ ] if field_string . blank?
1895+ return { } if field_string . blank?
18451896
18461897 # Split by comma, strip whitespace, remove empty strings
18471898 field_names = field_string . split ( ',' ) . map ( &:strip ) . compact_blank
18481899
18491900 # Get valid fields for current section
18501901 valid_fields = fields_for_current_section
1851- return [ ] if valid_fields . nil? # Invalid section level
1902+ return { } if valid_fields . nil? # Invalid section level
18521903
1853- # Validate each field name
1854- validated_fields = [ ]
1904+ # Validate each field name; silently skip invalid ones
1905+ validated = { }
18551906 field_names . each do |field_name |
1856- # Convert to symbol
18571907 field_sym = field_name . to_sym
1858-
1859- # Only accept if field is in the valid set for this section
1860- if valid_fields . include? ( field_sym )
1861- validated_fields << { field : field_sym }
1862- end
1863- # Silently skip invalid field names (don't raise error, just ignore)
1908+ validated [ field_sym ] = { } if valid_fields . include? ( field_sym )
18641909 end
1865-
1866- validated_fields
1910+ validated
18671911 end
18681912
18691913 # Categorize a single Chief proposal (override vs automated)
@@ -1881,17 +1925,15 @@ def categorize_chief_proposal(field, data, user_value, track_automated = true)
18811925 if data [ :forced ] && user_value . present? && user_value != CriterionStatus ::UNKNOWN
18821926 # Forced Chief override of user's real value - ORANGE
18831927 # Always track overrides (needed for warning messages on any save)
1884- @overridden_fields << {
1885- field : field ,
1928+ @overridden_fields [ field ] = {
18861929 old_value : user_value ,
18871930 new_value : chief_value ,
18881931 explanation : data [ :explanation ]
18891932 }
18901933 elsif ( user_value == CriterionStatus ::UNKNOWN || user_value . blank? ) && track_automated
18911934 # Chief filling an unknown value - YELLOW
18921935 # Only track on save-and-continue so user reviews before accepting
1893- @automated_fields << {
1894- field : field ,
1936+ @automated_fields [ field ] = {
18951937 new_value : chief_value ,
18961938 explanation : data [ :explanation ]
18971939 }
@@ -1918,8 +1960,8 @@ def run_save_automation(changed_fields, user_set_values, chief_instance: nil, tr
19181960 # Categorize all proposals BEFORE applying changes.
19191961 # Forced overrides of real user values are always tracked (for warnings).
19201962 # Non-forced fills are tracked only on save-and-continue (for highlighting).
1921- @overridden_fields = [ ]
1922- @automated_fields = [ ]
1963+ @overridden_fields = { }
1964+ @automated_fields = { }
19231965
19241966 current_section_changes . each do |field , data |
19251967 categorize_chief_proposal ( field , data , user_set_values [ field ] , track_automated )
@@ -1944,22 +1986,16 @@ def run_save_automation(changed_fields, user_set_values, chief_instance: nil, tr
19441986 end
19451987 # rubocop:enable Metrics/MethodLength
19461988
1947- # Filter a collection to only fields in the current section.
1948- # Accepts a Hash (Chief proposals keyed by field name) or an Array
1949- # of hashes with a :field key (automated/overridden field lists).
1950- # @param collection [Hash, Array<Hash>] Items to filter
1951- # @return [Hash, Array<Hash>] Filtered collection (same type as input)
1989+ # Filter a Hash to only include fields in the current section.
1990+ # @param collection [Hash] Items keyed by field symbol
1991+ # @return [Hash] Filtered to fields valid for the current section
19521992 def filter_to_current_section ( collection )
19531993 valid_fields = fields_for_current_section
19541994
19551995 # If we don't have a valid field set, don't filter (safety fallback)
19561996 return collection if valid_fields . nil?
19571997
1958- if collection . is_a? ( Hash )
1959- collection . slice ( *valid_fields )
1960- else
1961- collection . select { |entry | valid_fields . include? ( entry [ :field ] ) }
1962- end
1998+ collection . slice ( *valid_fields )
19631999 end
19642000
19652001 # Handle Chief failures during save
@@ -2013,16 +2049,16 @@ def status_value_to_string(value)
20132049 def format_override_details
20142050 criteria_level_to_internal ( @criteria_level )
20152051 details =
2016- @overridden_fields . map do |r |
2052+ @overridden_fields . map do |field , data |
20172053 # Derive criterion key from status field: license_location_status -> license_location
2018- criterion_key = r [ : field] . to_s . delete_suffix ( '_status' ) . to_sym
2054+ criterion_key = field . to_s . delete_suffix ( '_status' ) . to_sym
20192055 # Show user-visible criterion ID (e.g., license_location or OSPS-LE-03.01)
20202056 display_id = helpers . baseline_id_to_display ( criterion_key )
20212057 t ( 'projects.edit.automation.override_detail' ,
20222058 criterion : display_id ,
2023- old : status_value_to_string ( r [ :old_value ] ) ,
2024- new : status_value_to_string ( r [ :new_value ] ) ,
2025- explanation : r [ :explanation ] )
2059+ old : status_value_to_string ( data [ :old_value ] ) ,
2060+ new : status_value_to_string ( data [ :new_value ] ) ,
2061+ explanation : data [ :explanation ] )
20262062 end
20272063 details . join ( "\n " )
20282064 end
@@ -2040,21 +2076,21 @@ def handle_overridden_fields_redirect(section)
20402076 # Log overrides for monitoring
20412077 Rails . logger . info (
20422078 "Chief override: project=#{ @project . id } user=#{ current_user &.id } " \
2043- "fields=#{ @overridden_fields . pluck ( :field ) . join ( ',' ) } "
2079+ "fields=#{ @overridden_fields . keys . join ( ',' ) } "
20442080 )
20452081
20462082 # Redirect with highlight parameters
2047- overridden_field_names = @overridden_fields . map { | r | r [ :field ] . to_s }
2048- automated_field_names = @automated_fields . map { | r | r [ :field ] . to_s }
2083+ overridden_field_names = @overridden_fields . keys . map ( & : to_s)
2084+ automated_field_names = @automated_fields . keys . map ( & : to_s)
20492085 first_overridden = overridden_field_names . first
20502086
20512087 redirect_to edit_project_section_path (
20522088 @project ,
20532089 section ,
20542090 locale : params [ :locale ] ,
20552091 anchor : first_overridden ,
2056- overridden : overridden_field_names . join ( ',' ) ,
2057- automated : automated_field_names . join ( ',' )
2092+ overridden_fields_list : overridden_field_names . join ( ',' ) ,
2093+ automated_fields_list : automated_field_names . join ( ',' )
20582094 )
20592095 end
20602096 # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -2078,8 +2114,8 @@ def perform_html_redirect_after_save(section)
20782114 flash [ :info ] = t ( 'projects.edit.successfully_updated' )
20792115 redirect_params = { locale : params [ :locale ] }
20802116 if @automated_fields &.any?
2081- redirect_params [ :automated ] =
2082- @automated_fields . map { | r | r [ :field ] . to_s } . join ( ',' )
2117+ redirect_params [ :automated_fields_list ] =
2118+ @automated_fields . keys . join ( ',' )
20832119 end
20842120 redirect_to edit_project_section_path ( @project , section ,
20852121 **redirect_params ) + url_anchor
@@ -2097,19 +2133,19 @@ def perform_html_redirect_after_save(section)
20972133 # rubocop:disable Metrics/MethodLength
20982134 def build_automation_metadata
20992135 {
2100- overridden : @overridden_fields &.map do |r |
2136+ overridden : @overridden_fields &.map do |field , data |
21012137 {
2102- field : r [ : field] ,
2103- old_value : r [ :old_value ] ,
2104- new_value : r [ :new_value ] ,
2105- explanation : r [ :explanation ]
2138+ field : field ,
2139+ old_value : data [ :old_value ] ,
2140+ new_value : data [ :new_value ] ,
2141+ explanation : data [ :explanation ]
21062142 }
21072143 end || [ ] ,
2108- automated : @automated_fields &.map do |r |
2144+ automated : @automated_fields &.map do |field , data |
21092145 {
2110- field : r [ : field] ,
2111- value : r [ :new_value ] ,
2112- explanation : r [ :explanation ]
2146+ field : field ,
2147+ value : data [ :new_value ] ,
2148+ explanation : data [ :explanation ]
21132149 }
21142150 end || [ ]
21152151 }
0 commit comments