From e51d723d007098f8f21e5c2941476cdcfc65c985 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Tue, 8 Sep 2026 23:51:45 +0200 Subject: [PATCH] Add searchable multi-selection list widget https://forum.virtualmin.com/t/new-ui-widgets-for-webmin-modules/137932/12?u=ilia --- WebminCore.pm | 2 +- lang/en | 5 + t/ui-lib-widgets.t | 241 ++++++++++++++++++++++++++++ ui-lib.pl | 285 ++++++++++++++++++++++++++++++++- unauthenticated/css/ui-lib.css | 155 +++++++++++++++++- unauthenticated/js/ui-lib.js | 218 +++++++++++++++++++++++-- 6 files changed, 891 insertions(+), 15 deletions(-) diff --git a/WebminCore.pm b/WebminCore.pm index 1c364b3ea..d644da784 100644 --- a/WebminCore.pm +++ b/WebminCore.pm @@ -23,7 +23,7 @@ $main::export_to_caller = 1; # Add functions in web-lib-funcs.pl # Generated with : # grep -h "^sub " web-lib-funcs.pl ui-lib.pl | sed -e 's/sub //' | xargs echo -@EXPORT = qw(read_file read_file_cached read_file_cached_with_stat write_file html_escape html_unescape html_strip quote_escape quote_literal_escape quote_javascript default_webmin_temp_dir tempname_dir tempname_dir_sys webmin_temp_dir_name webmin_temp_dir_path tempname transname transname_timestamped trunc indexof indexoflc sysprint check_ipaddress check_ip6address is_non_public_ipaddress ipaddress_matches_network get_download_address_callback check_download_address generate_icon urlize un_urlize include copydata copydata_len ReadParseMime ReadParse read_fully read_parse_mime_callback read_parse_mime_javascript PrintHeader header get_html_title get_html_framed_title get_html_status_line popup_header footer popup_footer load_module_preferences load_theme_library redirect kill_byname kill_byname_logged find_byname error error_stderr popup_error register_error_handler call_error_handlers error_setup wait_for fast_wait_for has_command make_date make_date_relative file_chooser_button popup_window_button popup_window_link read_acl acl_filename acl_check get_miniserv_config_file get_miniserv_config put_miniserv_config restart_miniserv reload_miniserv check_os_support http_download complete_http_download http_post ftp_download ftp_upload no_proxy open_socket download_timeout ftp_command to_ipaddress to_ip6address to_hostname icons_table replace_meta replace_file_line read_file_lines flush_file_lines unflush_file_lines unix_user_input unix_group_input hlink user_chooser_button group_chooser_button foreign_check foreign_exists foreign_available foreign_require foreign_call foreign_config foreign_installed foreign_defined get_system_hostname get_webmin_version get_webmin_version_release get_webmin_full_version get_module_acl get_group_module_acl save_module_acl save_group_module_acl init_config load_language_auto load_language text_subs text encode_base64 decode_base64 encode_base32 decode_base32 get_module_info get_all_module_infos list_themes get_theme_info list_locales list_languages safe_language read_env_file write_env_file lock_file unlock_file test_lock unlock_all_files can_lock_file webmin_log additional_log var_dump webmin_debug_log system_logged backquote_logged backquote_with_timeout backquote_command kill_logged rename_logged rename_file symlink_logged symlink_file link_file make_dir make_dir_recursive set_ownership_permissions unlink_logged unlink_file copy_permissions_source_dest copy_source_dest move_source_dest remote_session_name verify_session_id remote_foreign_require remote_foreign_call remote_foreign_check remote_foreign_config remote_eval remote_write remote_read remote_finished remote_error_setup remote_rpc_call remote_multi_callback remote_multi_callback_error serialise_variable unserialise_variable other_groups date_chooser_button help_file read_help_file seed_random disk_usage_kb recursive_disk_usage help_search_link can_use_http_ssl make_http_connection validate_ssl_connection read_http_connection write_http_connection close_http_connection read_http_headers get_http_auth_reason clean_environment reset_environment clean_language progress_callback switch_to_remote_user switch_to_unix_user eval_as_unix_user create_user_config_dirs create_missing_homedir filter_javascript resolve_links simplify_path same_file flush_webmin_caches list_usermods available_usermods get_available_module_infos get_visible_module_infos get_visible_modules_categories is_under_directory parse_http_url check_clicks_function load_entities_map entities_to_ascii get_product_name get_charset get_display_hostname save_module_config save_user_module_config nice_size get_perl_path get_goto_module select_all_link select_invert_link select_rows_link check_pid_file get_mod_lib module_root_directory list_mime_types guess_mime_type open_tempfile close_tempfile print_tempfile is_selinux_enabled get_clear_file_attributes reset_file_attributes cleanup_tempnames open_lock_tempfile END month_to_number number_to_month supports_ipv6 execute_command execute_command_logged open_readfile open_execute_command translate_filename translate_command register_filename_callback register_command_callback capture_function_output capture_function_output_tempfile modules_chooser_button substitute_template substitute_pattern running_in_zone running_in_vserver running_in_xen running_in_openvz list_categories is_readonly_mode command_as_user list_osdn_mirrors convert_osdn_url get_current_dir supports_users supports_symlinks quote_path get_windows_root read_file_contents write_file_contents read_file_contents_limit unix_crypt split_quoted_string write_to_http_cache check_in_http_cache clear_http_cache supports_javascript get_module_name get_module_variable clear_time_locale reset_time_locale callers_package web_libs_package get_userdb_string connect_userdb disconnect_userdb split_userdb_string uniquelc list_combined_webmin_menu list_modules_webmin_menu module_to_menu_item list_combined_system_info shell_is_bash compare_version_numbers convert_to_json convert_from_json print_json get_referer_relative get_webmin_email_url get_webmin_browser_url trim ui_link ui_help ui_img ui_link_button ui_table_start ui_table_end ui_table_row ui_table_hr ui_table_span ui_columns_start ui_columns_row ui_columns_header ui_checked_columns_row ui_radio_columns_row ui_columns_end ui_columns_table ui_form_columns_table ui_form_elements_wrapper ui_form_start ui_form_end ui_form_end_side_by_side ui_form_grouped_buttons ui_textbox ui_filebox ui_bytesbox ui_upload ui_password ui_hidden ui_select ui_multi_select ui_multi_select_javascript ui_radio ui_yesno_radio ui_radio_row ui_checkbox ui_oneradio ui_textarea ui_user_textbox ui_users_textbox ui_group_textbox ui_groups_textbox ui_opt_textbox ui_submit ui_reset ui_button ui_date_input ui_buttons_start ui_buttons_end ui_buttons_row ui_buttons_hr ui_post_header ui_pre_footer ui_print_header ui_print_unbuffered_header ui_print_footer ui_config_link ui_print_endpage ui_subheading ui_links_row ui_hidden_javascript ui_hidden_start ui_hidden_end ui_hidden_table_row_start ui_hidden_table_row_end ui_hidden_table_start ui_hidden_table_end ui_tabs_start ui_tabs_end ui_tabs_start_tab ui_tabs_start_tabletab ui_tabs_end_tab ui_tabs_end_tabletab ui_max_text_width ui_radio_selector ui_radio_selector_javascript ui_switch_theme_javascript ui_grid_table ui_radio_table ui_up_down_arrows ui_hr ui_nav_link ui_confirmation_form ui_text_color ui_alert_box js_disable_inputs ui_page_flipper ui_form_field_state_javascript js_checkbox_disable js_redirect ui_webmin_link ui_line_break_double ui_page_refresh ui_details ui_div_row ui_space ui_newline ui_text_wrap ui_element_inline ui_paginations ui_hide_outside_of_viewport ui_read_file_contents_limit ui_note ui_brh ui_tag_start ui_tag_content ui_tag_end ui_tag ui_alert ui_button_icon ui_link_icon ui_icon ui_br ui_p ui_div ui_text_mask get_python_cmd get_buffer_size get_buffer_size_binary get_webprefix get_sub_ref_name setvar getvar delvar print_call_stack webmin_user_can_rpc webmin_user_login_mode webmin_user_is_admin webmin_user_is get_current_theme_info_cached miniserv_using_default_cert is_int float is_float parse_accepted_language get_default_system_locale get_http_redirect get_http_cookie create_wrapper get_lock_links_dir generate_miniserv_websocket_token allocate_miniserv_websocket get_miniserv_websocket_url remove_miniserv_websocket cleanup_miniserv_websockets get_miniserv_websockets_modules get_webmin_base_url encrypt_phrase decrypt_phrase is_encrypt_phrase ui_svg_icon ui_code ui_tip ui_page_assets ui_page_start ui_page_end ui_grid ui_stack ui_cluster ui_card ui_card_start ui_card_end ui_stat ui_stats ui_dl ui_badge ui_chip ui_list ui_feed ui_empty_state ui_progress ui_toggle ui_search ui_choice ui_radio_list ui_select_switch); +@EXPORT = qw(read_file read_file_cached read_file_cached_with_stat write_file html_escape html_unescape html_strip quote_escape quote_literal_escape quote_javascript default_webmin_temp_dir tempname_dir tempname_dir_sys webmin_temp_dir_name webmin_temp_dir_path tempname transname transname_timestamped trunc indexof indexoflc sysprint check_ipaddress check_ip6address is_non_public_ipaddress ipaddress_matches_network get_download_address_callback check_download_address generate_icon urlize un_urlize include copydata copydata_len ReadParseMime ReadParse read_fully read_parse_mime_callback read_parse_mime_javascript PrintHeader header get_html_title get_html_framed_title get_html_status_line popup_header footer popup_footer load_module_preferences load_theme_library redirect kill_byname kill_byname_logged find_byname error error_stderr popup_error register_error_handler call_error_handlers error_setup wait_for fast_wait_for has_command make_date make_date_relative file_chooser_button popup_window_button popup_window_link read_acl acl_filename acl_check get_miniserv_config_file get_miniserv_config put_miniserv_config restart_miniserv reload_miniserv check_os_support http_download complete_http_download http_post ftp_download ftp_upload no_proxy open_socket download_timeout ftp_command to_ipaddress to_ip6address to_hostname icons_table replace_meta replace_file_line read_file_lines flush_file_lines unflush_file_lines unix_user_input unix_group_input hlink user_chooser_button group_chooser_button foreign_check foreign_exists foreign_available foreign_require foreign_call foreign_config foreign_installed foreign_defined get_system_hostname get_webmin_version get_webmin_version_release get_webmin_full_version get_module_acl get_group_module_acl save_module_acl save_group_module_acl init_config load_language_auto load_language text_subs text encode_base64 decode_base64 encode_base32 decode_base32 get_module_info get_all_module_infos list_themes get_theme_info list_locales list_languages safe_language read_env_file write_env_file lock_file unlock_file test_lock unlock_all_files can_lock_file webmin_log additional_log var_dump webmin_debug_log system_logged backquote_logged backquote_with_timeout backquote_command kill_logged rename_logged rename_file symlink_logged symlink_file link_file make_dir make_dir_recursive set_ownership_permissions unlink_logged unlink_file copy_permissions_source_dest copy_source_dest move_source_dest remote_session_name verify_session_id remote_foreign_require remote_foreign_call remote_foreign_check remote_foreign_config remote_eval remote_write remote_read remote_finished remote_error_setup remote_rpc_call remote_multi_callback remote_multi_callback_error serialise_variable unserialise_variable other_groups date_chooser_button help_file read_help_file seed_random disk_usage_kb recursive_disk_usage help_search_link can_use_http_ssl make_http_connection validate_ssl_connection read_http_connection write_http_connection close_http_connection read_http_headers get_http_auth_reason clean_environment reset_environment clean_language progress_callback switch_to_remote_user switch_to_unix_user eval_as_unix_user create_user_config_dirs create_missing_homedir filter_javascript resolve_links simplify_path same_file flush_webmin_caches list_usermods available_usermods get_available_module_infos get_visible_module_infos get_visible_modules_categories is_under_directory parse_http_url check_clicks_function load_entities_map entities_to_ascii get_product_name get_charset get_display_hostname save_module_config save_user_module_config nice_size get_perl_path get_goto_module select_all_link select_invert_link select_rows_link check_pid_file get_mod_lib module_root_directory list_mime_types guess_mime_type open_tempfile close_tempfile print_tempfile is_selinux_enabled get_clear_file_attributes reset_file_attributes cleanup_tempnames open_lock_tempfile END month_to_number number_to_month supports_ipv6 execute_command execute_command_logged open_readfile open_execute_command translate_filename translate_command register_filename_callback register_command_callback capture_function_output capture_function_output_tempfile modules_chooser_button substitute_template substitute_pattern running_in_zone running_in_vserver running_in_xen running_in_openvz list_categories is_readonly_mode command_as_user list_osdn_mirrors convert_osdn_url get_current_dir supports_users supports_symlinks quote_path get_windows_root read_file_contents write_file_contents read_file_contents_limit unix_crypt split_quoted_string write_to_http_cache check_in_http_cache clear_http_cache supports_javascript get_module_name get_module_variable clear_time_locale reset_time_locale callers_package web_libs_package get_userdb_string connect_userdb disconnect_userdb split_userdb_string uniquelc list_combined_webmin_menu list_modules_webmin_menu module_to_menu_item list_combined_system_info shell_is_bash compare_version_numbers convert_to_json convert_from_json print_json get_referer_relative get_webmin_email_url get_webmin_browser_url trim ui_link ui_help ui_img ui_link_button ui_table_start ui_table_end ui_table_row ui_table_hr ui_table_span ui_columns_start ui_columns_row ui_columns_header ui_checked_columns_row ui_radio_columns_row ui_columns_end ui_columns_table ui_form_columns_table ui_form_elements_wrapper ui_form_start ui_form_end ui_form_end_side_by_side ui_form_grouped_buttons ui_textbox ui_filebox ui_bytesbox ui_upload ui_password ui_hidden ui_select ui_multi_select ui_multi_select_javascript ui_radio ui_yesno_radio ui_radio_row ui_checkbox ui_oneradio ui_textarea ui_user_textbox ui_users_textbox ui_group_textbox ui_groups_textbox ui_opt_textbox ui_submit ui_reset ui_button ui_date_input ui_buttons_start ui_buttons_end ui_buttons_row ui_buttons_hr ui_post_header ui_pre_footer ui_print_header ui_print_unbuffered_header ui_print_footer ui_config_link ui_print_endpage ui_subheading ui_links_row ui_hidden_javascript ui_hidden_start ui_hidden_end ui_hidden_table_row_start ui_hidden_table_row_end ui_hidden_table_start ui_hidden_table_end ui_tabs_start ui_tabs_end ui_tabs_start_tab ui_tabs_start_tabletab ui_tabs_end_tab ui_tabs_end_tabletab ui_max_text_width ui_radio_selector ui_radio_selector_javascript ui_switch_theme_javascript ui_grid_table ui_radio_table ui_up_down_arrows ui_hr ui_nav_link ui_confirmation_form ui_text_color ui_alert_box js_disable_inputs ui_page_flipper ui_form_field_state_javascript js_checkbox_disable js_redirect ui_webmin_link ui_line_break_double ui_page_refresh ui_details ui_div_row ui_space ui_newline ui_text_wrap ui_element_inline ui_paginations ui_hide_outside_of_viewport ui_read_file_contents_limit ui_note ui_brh ui_tag_start ui_tag_content ui_tag_end ui_tag ui_alert ui_button_icon ui_link_icon ui_icon ui_br ui_p ui_div ui_text_mask get_python_cmd get_buffer_size get_buffer_size_binary get_webprefix get_sub_ref_name setvar getvar delvar print_call_stack webmin_user_can_rpc webmin_user_login_mode webmin_user_is_admin webmin_user_is get_current_theme_info_cached miniserv_using_default_cert is_int float is_float parse_accepted_language get_default_system_locale get_http_redirect get_http_cookie create_wrapper get_lock_links_dir generate_miniserv_websocket_token allocate_miniserv_websocket get_miniserv_websocket_url remove_miniserv_websocket cleanup_miniserv_websockets get_miniserv_websockets_modules get_webmin_base_url encrypt_phrase decrypt_phrase is_encrypt_phrase ui_svg_icon ui_code ui_tip ui_page_assets ui_page_start ui_page_end ui_grid ui_stack ui_cluster ui_card ui_card_start ui_card_end ui_stat ui_stats ui_dl ui_badge ui_chip ui_list ui_feed ui_empty_state ui_progress ui_toggle ui_search ui_choice ui_radio_list ui_select_switch ui_multi_select_list); # Add global variables in web-lib.pl push(@EXPORT, qw(&can_read_file_under_global_acl &global_acl_file_unix_user diff --git a/lang/en b/lang/en index 5431b6b8c..5f5efbfb5 100644 --- a/lang/en +++ b/lang/en @@ -329,6 +329,11 @@ ui_etime=Invalid time ui_paging=Showing rows $1 to $2 of $3 ui_rowlabel=$2 in row $1 : ui_filterbox=Type to filter.. +ui_multi_noentries=No entries +ui_multi_filter=Filter content +ui_multi_filter_clear=Clear or close filter +ui_multi_nomatch=Nothing matches +ui_multi_selected=$1 selected ui_of=of ui_success=Success ui_info=Information diff --git a/t/ui-lib-widgets.t b/t/ui-lib-widgets.t index a1717036e..0835df664 100644 --- a/t/ui-lib-widgets.t +++ b/t/ui-lib-widgets.t @@ -20,6 +20,14 @@ require File::Spec->catfile($root, 'ui-lib.pl'); # Resolve the asset versions from this checkout, without init_config our $root_directory = $root; +# Load widget strings without init_config. +open(my $LANG, "<", File::Spec->catfile($root, 'lang', 'en')) or + die "lang/en: $!"; +while(my $line = <$LANG>) { + $main::text{$1} = $2 if ($line =~ /^([A-Za-z0-9_]+)=(.*)/); + } +close($LANG); + # Suppress the asset tags, whose legitimate ]*value="b\nzz"/, + 'hidden input carries the newline-joined selection'); + like($html, qr/name="doms_item" value="b"[^>]*checked/, + 'selected entry is checked'); + unlike($html, qr/name="doms_item" value="a"[^>]*checked/, + 'unselected entry is not checked'); + like($html, qr/value="zz"[^>]*checked/, + 'selected value missing from the options is added'); + like($html, qr/>Gone\.xPlan]*\bselect_all\b)(?=[^>]*data-ui-multi-action="all")/, + 'select all is the usual link'); + like($html, qr/]*\bselect_invert\b)(?=[^>]*data-ui-multi-action="invert")/, + 'invert selection is the usual link'); + like($html, qr/select_all[^>]*>[^<]*<\/a>\s*\|\s*\s*]*ui_search/, + 'without the line break that row ends with'); + unlike(main::ui_multi_select_list('x', [ ], [ [ 'a', 'A' ] ], { 'disabled' => 1 }), + qr/select_all/, 'a disabled widget has no links'); + like($html, qr/]*type="search")(?=[^>]*id="filter_search")(?=[^>]*aria-label="Filter content")/, + 'search input is labelled and targeted by its buttons'); + unlike(main::ui_multi_select_list('filter', [ ], + [ map { [ $_, $_ ] } 1..9 ], { 'search' => 0 }), + qr/data-ui-multi-search/, 'search can still be explicitly hidden'); + my $disabled = main::ui_multi_select_list('filter', [ ], [ [ 'a', 'A' ] ], + { 'search' => 1, 'disabled' => 1 }); + is(scalar(() = $disabled =~ /<(?:button|input) (?=[^>]*(?:data-ui-multi-action="filter(?:-clear)?"|data-ui-multi-search="1"))(?=[^>]*\bdisabled\b)/g), 3, + 'disabled pickers disable the input and both filter buttons'); +} + +# Mode radio labels are plain text. +assert_no_handler_injection( + main::ui_multi_select_list('d', [ ], [ [ 'a', 'A' ] ], + { 'modes' => { 'name' => 'mode', 'value' => 'some', 'radios' => 1, + 'options' => [ [ 'some', $xss ], [ 'all', 'All' ] ] } }), + 'ui_multi_select_list mode radios'); + +# Hidden-mode values must survive attribute encoding intact. +{ + my @hide = ( 'all servers', '', '"', "line\nbreak" ); + my $html = main::ui_multi_select_list('d', [ ], [ [ 'a', 'A' ] ], + { 'modes' => { 'name' => 'mode', 'value' => 'all servers', + 'options' => [ map { [ $_, $_ ] } @hide ], + 'hide' => \@hide } }); + my ($hide) = $html =~ /data-ui-multi-hide="([^"]*)"/; + is_deeply(main::convert_from_json(decode_attr($hide)), \@hide, + 'hidden-mode values survive attribute encoding without splitting'); +} + +# Empty pickers retain form values without visible controls. +{ + my $opts = { 'search' => 1, + 'modes' => { 'name' => 'all', 'value' => 2, + 'options' => [ [ 1, 'All' ], [ 2, 'Except' ] ] }, + 'children' => { 'name' => 'sub', 'checked' => 1, 'value' => 'yes' } }; + my $html = main::ui_multi_select_list('d', [ ], [ ], $opts); + like($html, qr/^No entries<\/span>/, + 'an empty picker is a plain span without empty-state styling'); + unlike($html, qr/<(?:select|button|label|a|div|link|script)\b|type=['"](?:checkbox|radio|search)['"]|\bui_multi_(?:tools|list)\b/, + 'no controls, wrapper or assets are emitted, even when search is requested'); + like($html, qr/type='hidden'[^>]*name="d"[^>]*value=""/, + 'an empty picker still submits an empty selection'); + like($html, qr/type='hidden'[^>]*name="all"[^>]*value="2"/, + 'the saved mode is retained without a visible selector'); + like($html, qr/type='hidden'[^>]*name="sub"[^>]*value="yes"/, + 'the checked children option retains its submitted value'); + unlike($html, qr/\bdata-ui-multi=/, + 'the static empty label needs no JavaScript initialization'); + my $disabled = main::ui_multi_select_list('d', [ ], [ ], + { %$opts, 'disabled' => 1 }); + unlike($disabled, qr/name="(?:all|sub)"/, + 'disabled mode and children controls remain excluded from submission'); + my $label = 'No virtual servers have been created yet'; + like(main::ui_multi_select_list('d', [ ], [ ], { 'empty_label' => $label }), + qr/^\Q$label\E<\/span>/, + 'caller can supply a plain empty label'); + like(main::ui_multi_select_list('d', [ ], [ ], { 'empty_label' => 'None & none' }), + qr/<b>None & none<\/b>/, + 'custom empty label is escaped as plain text'); + assert_no_handler_injection( + main::ui_multi_select_list('d', [ ], [ ], { 'empty_label' => $xss }), + 'custom empty label'); +} + +# Add missing saved values only once. +{ + my $html = main::ui_multi_select_list('d', [ 'gone', 'gone' ], [ ]); + is(scalar(() = $html =~ /\bclass="[^"]*\bui_multi_item\b/g), 1, + 'a missing selected entry is added once'); + unlike($html, qr/>No entries 'c1', 'label' => 'One', 'level' => 1 }, + { 'value' => 'c2', 'label' => 'Two', 'level' => 1 } ); + # $1 expands to the child count. + $main::text{'ui_multi_test_note'} = '+$1 kids'; + my $html = main::ui_multi_select_list('d', [ 'p' ], \@opts, + { 'children' => { 'name' => 'sub', 'checked' => 1, + 'label' => 'With children', + 'note' => 'ui_multi_test_note' } }); + like($html, qr/\bui_toggle\b/, 'the switch is a toggle'); + like($html, qr/]*\bname="sub")(?=[^>]*\bdata-ui-multi-action="children")(?=[^>]*\bchecked\b)/, + 'switch rendered on with its action'); + my @folded = $html =~ /(
]*\bui_multi_item\b)(?=[^>]*\bhidden\b)[^>]*>)/g; + is(scalar(@folded), 2, 'indented entries fold away while on'); + like($html, qr/]*\bui_multi_note\b)[^>]*>\+2 kids { 'name' => 'sub', 'label' => 'With children' } }), + qr/ui_multi_note/, 'no note without a key for it'); + unlike($html, qr/]*\bui_multi_note\b)(?=[^>]*\bhidden\b)/, + 'count shown while on'); + my $open = main::ui_multi_select_list('d', [ 'p' ], \@opts, + { 'children' => { 'name' => 'sub', 'label' => 'With children', + 'note' => 'ui_multi_test_note' } }); + my @shown = $open =~ /(
]*\bui_multi_item\b)(?=[^>]*\bhidden\b)[^>]*>)/g; + is(scalar(@shown), 0, 'entries shown while off'); + like($open, qr/]*\bui_multi_note\b)(?=[^>]*\bhidden\b)/, + 'count hidden while off'); + + # Initial counts must match JavaScript without losing folded selections. + my $selected = [ 'p', 'c1', 'c2' ]; + my $closed = main::ui_multi_select_list('d', $selected, \@opts, + { 'children' => { 'name' => 'sub', 'checked' => 1 } }); + like($closed, qr/]*\bui_multi_count\b)[^>]*>1 selected]*name="d"[^>]*value="p\nc1\nc2"/, + 'folded children retain their submitted selections'); + my $expanded = main::ui_multi_select_list('d', $selected, \@opts, + { 'children' => { 'name' => 'sub' } }); + like($expanded, qr/]*\bui_multi_count\b)[^>]*>3 selected { 'name' => 'sub', 'checked' => 1 } }); + like($only_children, qr/]*\bui_multi_count\b)(?=[^>]*\bhidden\b)[^>]*>0 selected]*name="d"[^>]*value="c1"/, + 'folding retains child selections even without a selected parent'); +} + done_testing(); diff --git a/ui-lib.pl b/ui-lib.pl index 4450abc86..48adcb3d2 100755 --- a/ui-lib.pl +++ b/ui-lib.pl @@ -1261,7 +1261,8 @@ return $rv; Returns HTML for selecting many of many from a list. By default, this is implemented using two