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