diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 09c323cac4..a9b71cd838 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,6 +10,7 @@ 5. **If requirements are not clear, ask for clarification before proceeding** 6. **Never commit directly to `up-develop` or `develop` branches** - always create feature branches and pull requests 7. **Focus on configuration over code** - most features should be achievable through admin panel settings rather than new Ruby code +8. **Create new files and edit directly in the editor**; avoid using command line file operations unless absolutely necessary ### Critical Rules for Running Terminal Commands diff --git a/app/assets/javascripts/app/_fpa.js b/app/assets/javascripts/app/_fpa.js index 558f84cd4f..a20bf98639 100644 --- a/app/assets/javascripts/app/_fpa.js +++ b/app/assets/javascripts/app/_fpa.js @@ -1316,8 +1316,8 @@ _fpa = { }) if (modal_index) { - // Hide a previously shown modal back - $('.modal.in').removeClass('in').addClass('was-in'); + // Hide a previously shown modal back, but not the one we're currently showing + $('.modal.in').not(pm).removeClass('in').addClass('was-in'); pm.on('click.dismiss.bs.modal', `[data-dismiss="modal${modal_index}"]`, function () { _fpa.hide_modal(modal_index); diff --git a/app/assets/javascripts/app/_fpa_ajax_processors_reports.js b/app/assets/javascripts/app/_fpa_ajax_processors_reports.js index 2af2853de4..bc17c9ffac 100644 --- a/app/assets/javascripts/app/_fpa_ajax_processors_reports.js +++ b/app/assets/javascripts/app/_fpa_ajax_processors_reports.js @@ -36,35 +36,74 @@ _fpa.postprocessors_reports = { $('#modal_results_block').prop('id', 'modal_results_block_1') }, + /** + * Postprocessor for embedded dynamic block links in reports. + * Opens a modal dialog displaying a dynamic model or activity log record. + * + * Supports two modes: + * - Show mode: Displays the record in read-only view + * - Edit mode: Opens the edit form directly (GitHub #325) + * When data-edit-mode="true", clicks the edit button and closes modal on save + */ report_embed_dynamic_block: function (block, data) { - var us_name = block.attr('data-model-name') - var hyph_name = us_name.hyphenate() - var id = block.attr('data-id') - var master_id = block.attr('data-master-id') - var target_block = "report-result-embedded-block" - var html = $(`
`) - if ($(block).contents().length == 0) { + var modelName = block.attr('data-model-name'); + var hyphenatedName = modelName.hyphenate(); + var recordId = block.attr('data-id'); + var masterId = block.attr('data-master-id'); + var isEditMode = block.attr('data-edit-mode') === 'true'; + var targetBlockId = 'report-result-embedded-block'; + + // Build the modal content container + var modalContent = $(`
`); + + // If no content was loaded via AJAX, hide the modal and return + if ($(block).contents().length === 0) { _fpa.hide_modal(1); return; } - block.removeClass('sv-added-setup-links') - _fpa.show_modal(html, null, true, 'embedded-dynamic-block', 1) + + block.removeClass('sv-added-setup-links'); + _fpa.show_modal(modalContent, null, true, 'embedded-dynamic-block', 1); + + // Move content into modal and set up handlers window.setTimeout(function () { - $(block).contents().appendTo(`#${target_block}`) + $(block).contents().appendTo(`#${targetBlockId}`); $(block).html(''); + window.setTimeout(function () { - const $target_block = $(`#${target_block}`); - _fpa.form_utils.resize_labels($target_block, null, true) + const $targetBlock = $(`#${targetBlockId}`); + _fpa.form_utils.resize_labels($targetBlock, null, true); - // Ensure that the viewer is set up with the user's capabilities to view and download - var sv_opt = { allow_actions: null }; - sv_opt.allow_actions = _fpa.state.user_can; + // Set up secure view links with user capabilities + var secureViewOptions = { allow_actions: _fpa.state.user_can }; + _fpa.secure_view.setup_links($targetBlock, 'a.redcap-file-use-secure-view', secureViewOptions); - _fpa.secure_view.setup_links($target_block, 'a.redcap-file-use-secure-view', sv_opt); + // Handle edit mode: click edit button and set up save handler + if (isEditMode) { + _fpa.postprocessors_reports.setup_edit_mode($targetBlock); + } }, 500); }, 500); }, + /** + * Sets up edit mode behavior for an embedded block. + * Clicks the edit button to open the form and closes modal on successful save. + */ + setup_edit_mode: function ($targetBlock) { + var editButton = $targetBlock.find('.edit-entity.glyphicon-pencil').first(); + if (editButton.length) { + editButton.click(); + } + + // Close modal after successful form save + $targetBlock.on('ajax:success', 'form', function () { + window.setTimeout(function () { + _fpa.hide_modal(1); + }, 300); + }); + }, + reports_form: function (block, data) { _fpa.report_criteria.reports_form(block, data); }, diff --git a/app/helpers/report_results/reports_common_result_cell.rb b/app/helpers/report_results/reports_common_result_cell.rb index acd12c63de..097d9a54c9 100644 --- a/app/helpers/report_results/reports_common_result_cell.rb +++ b/app/helpers/report_results/reports_common_result_cell.rb @@ -177,36 +177,81 @@ def cell_content_for_url html.html_safe end + # + # Generate HTML for opening a dynamic model or activity log record in a modal dialog. + # Supports both show and edit modes: + # - Show mode: URL like /masters/123/dynamic_model/table_name/456 + # - Edit mode: URL ending with /edit (GitHub #325) + # + # The content can be either a plain URL or a markdown format link [label](url) def cell_content_for_embedded_block return cell_content unless cell_content.present? - url = cell_content + parsed = parse_embedded_block_url(cell_content) + build_embedded_block_html(parsed) + end + + private - if url.start_with? '[' - # This is a markdown format link - col_url_parts = url&.scan(/^\[(.+)\]\((.+)\)$/) - a_text = html_escape col_url_parts&.first&.first - url = html_escape col_url_parts&.first&.last + # + # Parse the URL from embedded_block content. + # Returns a hash with parsed components including edit_mode flag. + def parse_embedded_block_url(content) + url = content + link_text = nil + icon_class = nil + + if content.start_with?('[') + # Markdown format link: [Label](/url/path) + url_parts = content.scan(/^\[(.+)\]\((.+)\)$/) + link_text = html_escape(url_parts&.first&.first) + url = html_escape(url_parts&.first&.last) else - # This is a plain URL - icon = 'glyphicon glyphicon-tasks' + # Plain URL - show icon + icon_class = 'glyphicon glyphicon-tasks' end - split_url = url.split('/') + edit_mode = url.end_with?('/edit') + url_segments = url.split('/').reject(&:blank?) + + # Remove 'edit' suffix before extracting record id + url_segments.pop if edit_mode + + record_id = url_segments.last + master_id = url_segments[1] if url_segments.first == 'masters' + # Join the last two path segments to form model name (e.g., dynamic_model__table_name) + # rubocop:disable Style/SafeNavigationChainLength + model_name_hyphenated = url_segments[-3..-2]&.join('__')&.hyphenate&.singularize || '' + # rubocop:enable Style/SafeNavigationChainLength + + { + url:, + link_text:, + icon_class:, + edit_mode:, + record_id:, + master_id:, + model_name_hyphenated: + } + end - split_url = split_url.reject(&:blank?) - id = split_url.last - master_id = split_url[1] if split_url.first == 'masters' - hyph_name = split_url[-3..-2]&.join('__')&.hyphenate&.singularize || '' + # + # Build the HTML for an embedded_block link and target div + def build_embedded_block_html(parsed) + hyph_name = parsed[:model_name_hyphenated] + record_id = parsed[:record_id] + edit_mode_attr = parsed[:edit_mode] ? ' data-edit-mode="true"' : '' html = <<~END_HTML - #{a_text} -
+ #{parsed[:link_text]} +
END_HTML html.html_safe end + public + def cell_content_for_embedded_report return cell_content unless cell_content.present? diff --git a/spec/helpers/report_results/reports_common_result_cell_spec.rb b/spec/helpers/report_results/reports_common_result_cell_spec.rb new file mode 100644 index 0000000000..82593dd466 --- /dev/null +++ b/spec/helpers/report_results/reports_common_result_cell_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# ReportsCommonResultCell Spec +# +# Tests the cell content rendering for report table cells, specifically focusing on +# the embedded_block feature that displays dynamic model and activity log records +# in a modal dialog when clicked. +# +# Test Coverage: +# - #cell_content_for_embedded_block: Generates HTML for opening records in a modal +# - Handles plain URLs like /dynamic_model/table_name/123 +# - Handles markdown format links like [Label](/dynamic_model/table_name/123) +# - Handles activity log URLs like /activity_log/log_type/456 +# - Handles edit mode URLs ending with /edit (GitHub #325) + +require 'rails_helper' + +RSpec.describe ReportResults::ReportsCommonResultCell do + let(:table_name) { 'dynamic_model__test_items' } + let(:col_name) { 'edit_link' } + let(:col_tag) { nil } + let(:col_show_as) { 'embedded_block' } + let(:selection_options) { double('selection_options') } + + def build_cell(content) + described_class.new(table_name, content, col_name, col_tag, col_show_as, selection_options) + end + + describe '#cell_content_for_embedded_block' do + describe 'edge cases' do + it 'returns blank content unchanged' do + expect(build_cell('').cell_content_for_embedded_block).to eq('') + end + + it 'returns nil content as nil' do + expect(build_cell(nil).cell_content_for_embedded_block).to be_nil + end + end + + describe 'URL parsing' do + shared_examples 'parses URL correctly' do |url, expected| + it "extracts correct attributes from #{url}" do + html = build_cell(url).cell_content_for_embedded_block + + expect(html).to include("data-id=\"#{expected[:id]}\"") + expect(html).not_to include('data-id="edit"') + expect(html).to include("data-model-name=\"#{expected[:model_name]}\"") + + expect(html).to include("data-master-id=\"#{expected[:master_id]}\"") if expected[:master_id] + + if expected[:edit_mode] + expect(html).to include('data-edit-mode="true"') + else + expect(html).not_to include('data-edit-mode') + end + end + end + + context 'with dynamic model URLs' do + include_examples 'parses URL correctly', + '/dynamic_model/datadic_variables/123', + { id: '123', model_name: 'dynamic_model__datadic_variable', edit_mode: false } + + include_examples 'parses URL correctly', + '/masters/789/dynamic_model/test_items/456', + { id: '456', model_name: 'dynamic_model__test_item', master_id: '789', edit_mode: false } + + include_examples 'parses URL correctly', + '/dynamic_model/datadic_variables/123/edit', + { id: '123', model_name: 'dynamic_model__datadic_variable', edit_mode: true } + + include_examples 'parses URL correctly', + '/masters/999/dynamic_model/test_items/123/edit', + { id: '123', model_name: 'dynamic_model__test_item', master_id: '999', edit_mode: true } + end + + context 'with activity log URLs' do + include_examples 'parses URL correctly', + '/activity_log/test_processes/456', + { id: '456', model_name: 'activity_log__test_process', edit_mode: false } + + include_examples 'parses URL correctly', + '/activity_log/test_processes/456/edit', + { id: '456', model_name: 'activity_log__test_process', edit_mode: true } + end + end + + describe 'HTML generation' do + it 'generates link with correct attributes for plain URL' do + html = build_cell('/dynamic_model/datadic_variables/123').cell_content_for_embedded_block + + expect(html).to include('href="/dynamic_model/datadic_variables/123"') + expect(html).to include('data-remote="true"') + expect(html).to include('class="report-embedded-block-link glyphicon glyphicon-tasks"') + expect(html).to include('data-preprocessor="report_embed_dynamic_block"') + end + + it 'generates link with label for markdown format' do + html = build_cell('[Edit Item](/dynamic_model/datadic_variables/456)').cell_content_for_embedded_block + + expect(html).to include('>Edit Item') + expect(html).to include('href="/dynamic_model/datadic_variables/456"') + end + + it 'generates target div with correct id' do + html = build_cell('/dynamic_model/test/789').cell_content_for_embedded_block + + expect(html).to include('id="report-result-embedded-block--789"') + expect(html).to include('class="report-temp-embedded-block"') + end + + it 'preserves full URL including /edit for edit mode' do + html = build_cell('/dynamic_model/test/123/edit').cell_content_for_embedded_block + + expect(html).to include('href="/dynamic_model/test/123/edit"') + end + end + end +end diff --git a/spec/system/report_embedded_block_spec.rb b/spec/system/report_embedded_block_spec.rb new file mode 100644 index 0000000000..48b63a56d3 --- /dev/null +++ b/spec/system/report_embedded_block_spec.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +# Report Embedded Block System Spec +# +# Tests the embedded_block feature in reports that displays dynamic model and activity log +# records in a modal dialog when clicked. This is a UI test that verifies the full interaction. +# +# Test Coverage: +# - Dynamic Models: +# - Show URL opens modal in show mode (uses JSON + client-side Handlebars templates) +# - Edit URL (ending in /edit) opens modal in edit mode (GitHub #325) +# - When edit mode modal is saved, the modal closes automatically +# - Activity Logs: +# - Edit URL (ending in /edit) opens modal in edit mode (GitHub #325) +# - Note: Show mode not tested for activity logs as Handlebars templates aren't loaded on report pages + +require 'rails_helper' + +describe 'report embedded_block', js: true, driver: $browser_driver do + include ModelSupport + include MasterDataSupport + include FeatureSupport + include TestFieldsDmSupport + + before(:all) do + change_setting('TwoFactorAuthDisabledForUser', true) + change_setting('TwoFactorAuthDisabledForAdmin', true) + SetupHelper.feature_setup + + @admin, = create_admin + seed_database + create_data_set_outside_tx + + setup_test_user + setup_dynamic_model + setup_test_data + setup_reports + end + + # + # Setup helpers + # + def setup_test_user + @user, @good_password = create_user + @good_email = @user.email + + grant_report_access + grant_model_access + end + + def grant_report_access + Admin::UserAccessControl.create!( + app_type_id: @user.app_type_id, + access: :read, + resource_type: :general, + resource_name: :view_reports, + current_admin: @admin, + user: @user + ) + end + + def grant_model_access + setup_access :dynamic_model__test_with_id_recs, user: @user + setup_access :activity_log__player_contact_phones, user: @user + setup_access :activity_log__player_contact_phone__primary, resource_type: :activity_log_type, user: @user + setup_access :activity_log__player_contact_phone__blank_log, resource_type: :activity_log_type, user: @user + end + + def setup_dynamic_model + setup_fields_dm + DynamicModel.routes_reload + Rails.application.routes_reloader.reload! + end + + def setup_test_data + @master = Master.create!(current_user: @user) + + @test_record = DynamicModel::TestWithIdRec.create!( + master: @master, + name: 'Embedded Block Test Record', + value: 'Test Value 123', + current_user: @user + ) + + @player_contact = PlayerContact.create!( + master: @master, + data: '(617)555-1234', + rec_type: 'phone', + rank: 10, + current_user: @user + ) + + @activity_log = ActivityLog::PlayerContactPhone.create!( + master: @master, + player_contact: @player_contact, + select_call_direction: 'from player', + select_who: 'user', + extra_log_type: 'primary', + current_user: @user + ) + end + + def setup_reports + @report_dm_show = create_embedded_block_report( + name: 'DM Show Mode', + sql: dynamic_model_show_sql, + link_column: 'show_link' + ) + + @report_dm_edit = create_embedded_block_report( + name: 'DM Edit Mode', + sql: dynamic_model_edit_sql, + link_column: 'edit_link' + ) + + @report_al_edit = create_embedded_block_report( + name: 'AL Edit Mode', + sql: activity_log_edit_sql, + link_column: 'al_edit_link' + ) + end + + def create_embedded_block_report(name:, sql:, link_column:) + report = Report.create!( + current_admin: @admin, + name: "#{name} #{SecureRandom.hex(4)}", + description: "Test #{name}", + sql: sql, + options: "column_options:\n show_as:\n #{link_column}: embedded_block", + disabled: false, + report_type: 'regular_report', + auto: false, + searchable: false + ) + + Admin::UserAccessControl.create!( + app_type: @user.app_type, + access: :read, + resource_type: :report, + resource_name: report.alt_resource_name, + current_admin: @admin + ) + + report + end + + # + # SQL generators for reports + # + def dynamic_model_show_sql + <<~SQL + SELECT '/masters/' || master_id || '/dynamic_model/test_with_id_recs/' || id AS show_link, name + FROM dynamic_test.test_with_id_recs ORDER BY id DESC LIMIT 1 + SQL + end + + def dynamic_model_edit_sql + <<~SQL + SELECT '/masters/' || master_id || '/dynamic_model/test_with_id_recs/' || id || '/edit' AS edit_link, name + FROM dynamic_test.test_with_id_recs ORDER BY id DESC LIMIT 1 + SQL + end + + def activity_log_edit_sql + <<~SQL + SELECT '/masters/' || master_id || '/activity_log/player_contact_phones/' || id || '/edit' AS al_edit_link, extra_log_type + FROM activity_log_player_contact_phones ORDER BY id DESC LIMIT 1 + SQL + end + + # + # Navigation and interaction helpers + # + before(:each) do + login + visit '/reports' + finish_page_loading + end + + def navigate_to_report(report) + visit '/reports' + finish_page_loading + + expect(page).to have_css(".data-results table.tablesorter tr[data-report-id='#{report.id}']") + + within ".data-results table.tablesorter tr[data-report-id='#{report.id}']" do + click_link report.name + end + + expect(page).to have_css('.report-criteria') + finish_page_loading + end + + def run_report_and_click_embedded_link + within('#report_query_form') { click_button 'table' } + expect(page).to have_css('.search-status-done') + expect(page).to have_css('.report-results-block table') + finish_page_loading + + dismiss_all_alerts + link = find('.report-embedded-block-link') + scroll_into_view(link) + sleep 0.5 + link.click + finish_page_loading + end + + def wait_for_modal_to_appear + modal_appeared = false + 5.times do + sleep 2 + if page.has_css?('#primary-modal1.fade.in', visible: true) + modal_appeared = true + break + end + end + + unless modal_appeared + puts_debug 'Modal did not appear - checking for errors' + puts_debug "Alert: #{find('.alert-danger').text}" if page.has_css?('.alert-danger') + save_html_snapshot('/tmp/modal_debug.html') + end + + expect(modal_appeared).to be(true), 'Modal did not appear after multiple retries' + finish_page_loading + end + + # + # Shared examples for common test patterns + # + shared_examples 'opens modal with embedded content' do + it 'displays the modal when clicking embedded_block link' do + navigate_to_report(report) + run_report_and_click_embedded_link + wait_for_modal_to_appear + expect(page).to have_css('#primary-modal1.fade.in') + end + end + + shared_examples 'opens in edit mode' do + it 'shows the edit form directly' do + navigate_to_report(report) + run_report_and_click_embedded_link + wait_for_modal_to_appear + + within '#primary-modal1.fade.in' do + expect(page).to have_css(edit_form_selector, wait: 10) + expect(page).to have_button('Save') + end + end + end + + # + # Test contexts + # + context 'with dynamic model show URL (existing behavior)' do + let(:report) { @report_dm_show } + + include_examples 'opens modal with embedded content' + + it 'displays record in read-only mode without edit form' do + navigate_to_report(report) + run_report_and_click_embedded_link + wait_for_modal_to_appear + + within '#primary-modal1.fade.in' do + expect(page).to have_content(/embedded block test record/i) + expect(page).not_to have_css('form.edit_dynamic_model_test_with_id_rec') + end + end + end + + context 'with dynamic model edit URL (GitHub #325)' do + let(:report) { @report_dm_edit } + let(:edit_form_selector) { 'form.edit_dynamic_model_test_with_id_rec' } + + include_examples 'opens modal with embedded content' + include_examples 'opens in edit mode' + + it 'allows editing and closes modal on save' do + navigate_to_report(report) + run_report_and_click_embedded_link + wait_for_modal_to_appear + + within '#primary-modal1.fade.in' do + expect(page).to have_css(edit_form_selector, wait: 10) + fill_in 'dynamic_model_test_with_id_rec[name]', with: 'Updated Record Name' + click_button 'Save' + end + + expect(page).not_to have_css('#primary-modal1.fade.in', wait: 10) + + @test_record.reload + expect(@test_record.name).to eq('updated record name') + end + end + + context 'with activity log edit URL (GitHub #325)' do + let(:report) { @report_al_edit } + let(:edit_form_selector) { 'form.edit_activity_log_player_contact_phone' } + + include_examples 'opens modal with embedded content' + include_examples 'opens in edit mode' + + it 'allows editing and closes modal on save' do + navigate_to_report(report) + run_report_and_click_embedded_link + wait_for_modal_to_appear + + within '#primary-modal1.fade.in' do + expect(page).to have_css(edit_form_selector, wait: 10) + # Form is already valid from setup, just save it + click_button 'Save' + end + + expect(page).not_to have_css('#primary-modal1.fade.in', wait: 10) + end + end +end