Skip to content

Commit e24f0e0

Browse files
cliftonmcintoshclaudecompwron
authored
New Case Contacts Table: row actions menu (#6834)
* Add per-row permission flags and action metadata to CaseContactDatatable Co-Authored-By: claude-sonnet-4-6 <noreply@anthropic.com> * Add ellipsis action menu to new case contacts table Each row in the new case contacts datatable now has a Bootstrap dropdown menu with Edit, Delete, and Set/Resolve Reminder actions. Items are shown or hidden based on per-row Pundit permissions already returned by the datatable JSON. Delete and Resolve Reminder use AJAX so the table reloads in place. Set Reminder opens a SweetAlert2 dialog for an optional note. Backend controllers now respond to JSON for destroy and followup resolve so jQuery does not follow the HTML redirect. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Require confirmation before deleting a case contact The delete action in the new case contacts table now shows a confirmation dialog before sending the DELETE request. Cancelling the dialog aborts the request. Tests updated to reflect async flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Disable unauthorized menu items instead of hiding them Edit and Delete are now rendered as disabled dropdown items when the current user lacks permission, rather than being omitted from the menu. Disabled items retain aria-disabled="true" for screen reader accessibility. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add system specs for action menu visibility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add system spec for Edit action Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add system specs for Delete action Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add system specs for Set Reminder and Resolve Reminder actions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add system specs for permission states on action menu items Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * use single quotes * Extract fireSwalFollowupAlert from case_contact.js into dashboard.js Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Extract clickActionButton helper in dashboard click handler tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix flaky permission state specs with unique occurred_at dates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add missing comma and simplify has_followup in CaseContactDatatable The missing comma after `has_followup` caused a Ruby syntax error, preventing the file from loading entirely. Also simplified `has_followup` to reuse the already-computed `requested_followup` value instead of scanning the collection a second time. Added a regression test asserting all action metadata keys are present in a single row. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove dataType 'json' from Delete and Resolve Reminder AJAX calls jQuery parses the response body when dataType is 'json', causing a parse error on the 204 No Content response and skipping the success callback. Removing dataType lets jQuery accept the empty response and reload the table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: include CSRF token in Set Reminder AJAX request $.post does not set the X-CSRF-Token header, which can cause Rails to reject the request with an invalid authenticity token error. Replaced $.post with $.ajax to match the pattern used by Delete and Resolve Reminder. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: preserve pagination position when reloading DataTable after row actions table.ajax.reload() resets to page 1 by default. Passing (null, false) keeps the user on the current page after Delete, Set Reminder, and Resolve Reminder actions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: send Accept: application/json header and add respond_to to followups#create Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: preload casa_org associations to eliminate N+1 queries in CaseContactDatatable Per-row policy checks call same_org? which loads casa_org through casa_case for every record. Added preload for :casa_org and :creator_casa_org since includes cannot resolve has_one :through when left_joins is already in the chain. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: replace Capybara.reset_sessions! with shared_context for admin sign-in Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: claude-sonnet-4-6 <noreply@anthropic.com> Co-authored-by: compwron <compiledwrong@gmail.com>
1 parent caaff39 commit e24f0e0

11 files changed

Lines changed: 747 additions & 28 deletions

File tree

app/controllers/case_contacts/case_contacts_new_design_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def index
1010
def datatable
1111
authorize CaseContact
1212
case_contacts = policy_scope(current_organization.case_contacts)
13-
datatable = CaseContactDatatable.new case_contacts, params
13+
datatable = CaseContactDatatable.new(case_contacts, params, current_user)
1414

1515
render json: datatable
1616
end

app/controllers/case_contacts/followups_controller.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ def create
77
note = simple_followup_params[:note]
88
FollowupService.create_followup(case_contact, current_user, note)
99

10-
redirect_to casa_case_path(case_contact.casa_case)
10+
respond_to do |format|
11+
format.html { redirect_to casa_case_path(case_contact.casa_case) }
12+
format.json { head :no_content }
13+
end
1114
end
1215

1316
def resolve
@@ -17,7 +20,10 @@ def resolve
1720
@followup.resolved!
1821
create_notification
1922

20-
redirect_to casa_case_path(@followup.case_contact.casa_case)
23+
respond_to do |format|
24+
format.html { redirect_to casa_case_path(@followup.case_contact.casa_case) }
25+
format.json { head :no_content }
26+
end
2127
end
2228

2329
private

app/controllers/case_contacts_controller.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,14 @@ def destroy
4444
authorize @case_contact
4545

4646
@case_contact.destroy
47-
flash[:notice] = "Contact is successfully deleted."
48-
redirect_to request.referer
47+
48+
respond_to do |format|
49+
format.html do
50+
flash[:notice] = "Contact is successfully deleted."
51+
redirect_to request.referer
52+
end
53+
format.json { head :no_content }
54+
end
4955
end
5056

5157
def restore

app/datatables/case_contact_datatable.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,20 @@ class CaseContactDatatable < ApplicationDatatable
88
duration_minutes
99
].freeze
1010

11+
def initialize(base_relation, params, current_user)
12+
super(base_relation, params)
13+
@current_user = current_user
14+
end
15+
1116
private
1217

18+
attr_reader :current_user
19+
1320
def data
1421
records.map do |case_contact|
22+
policy = CaseContactPolicy.new(current_user, case_contact)
23+
requested_followup = case_contact.followups.find(&:requested?)
24+
1525
{
1626
id: case_contact.id,
1727
occurred_at: I18n.l(case_contact.occurred_at, format: :full, default: nil),
@@ -35,7 +45,11 @@ def data
3545
.map { |a| {question: a.contact_topic&.question, value: a.value} },
3646
notes: case_contact.notes.presence,
3747
is_draft: !case_contact.active?,
38-
has_followup: case_contact.followups.any?(&:requested?)
48+
has_followup: requested_followup.present?,
49+
can_edit: policy.update?,
50+
can_destroy: policy.destroy?,
51+
edit_path: Rails.application.routes.url_helpers.edit_case_contact_path(case_contact),
52+
followup_id: requested_followup&.id
3953
}
4054
end
4155
end
@@ -49,6 +63,7 @@ def raw_records
4963
.joins("INNER JOIN users creators ON creators.id = case_contacts.creator_id")
5064
.left_joins(:casa_case)
5165
.includes(:casa_case, :contact_types, :contact_topics, :followups, :creator, contact_topic_answers: :contact_topic)
66+
.preload(:casa_org, :creator_casa_org)
5267
.order(order_clause)
5368
.order(:id)
5469
end

app/javascript/__tests__/dashboard.test.js

Lines changed: 262 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,17 @@
33
* @jest-environment jsdom
44
*/
55

6+
import Swal from 'sweetalert2'
67
import { defineCaseContactsTable } from '../src/dashboard'
8+
import { fireSwalFollowupAlert } from '../src/case_contact'
9+
jest.mock('sweetalert2', () => ({
10+
__esModule: true,
11+
default: { fire: jest.fn() }
12+
}))
13+
14+
jest.mock('../src/case_contact', () => ({
15+
fireSwalFollowupAlert: jest.fn()
16+
}))
717

818
// Mock DataTable
919
const mockDataTable = jest.fn()
@@ -382,10 +392,259 @@ describe('defineCaseContactsTable', () => {
382392
expect(columns[10].searchable).toBe(false)
383393
})
384394

385-
it('renders ellipsis icon', () => {
386-
const rendered = columns[10].render(null, 'display', {})
395+
it('renders a button toggle with aria-label containing the contact date', () => {
396+
const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' }
397+
const rendered = columns[10].render(null, 'display', row)
398+
399+
expect(rendered).toContain('class="fas fa-ellipsis-v"')
400+
expect(rendered).toContain('aria-label="Actions for case contact on July 01, 2024"')
401+
expect(rendered).toContain('type="button"')
402+
})
403+
404+
it('renders the ellipsis icon as aria-hidden', () => {
405+
const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' }
406+
const rendered = columns[10].render(null, 'display', row)
407+
408+
expect(rendered).toContain('aria-hidden="true"')
409+
})
410+
411+
it('renders Edit item when can_edit is "true"', () => {
412+
const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' }
413+
const rendered = columns[10].render(null, 'display', row)
414+
415+
expect(rendered).toContain('href="/case_contacts/1/edit"')
416+
expect(rendered).toContain('Edit')
417+
})
418+
419+
it('renders Edit as disabled when can_edit is "false"', () => {
420+
const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' }
421+
const rendered = columns[10].render(null, 'display', row)
422+
423+
expect(rendered).toContain('Edit')
424+
expect(rendered).toContain('disabled')
425+
expect(rendered).toContain('aria-disabled="true"')
426+
expect(rendered).not.toContain('href="/case_contacts/1/edit"')
427+
})
428+
429+
it('renders Delete item when can_destroy is "true"', () => {
430+
const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' }
431+
const rendered = columns[10].render(null, 'display', row)
432+
433+
expect(rendered).toContain('cc-delete-action')
434+
expect(rendered).toContain('data-id="1"')
435+
expect(rendered).toContain('Delete')
436+
})
437+
438+
it('renders Delete as disabled when can_destroy is "false"', () => {
439+
const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' }
440+
const rendered = columns[10].render(null, 'display', row)
441+
442+
expect(rendered).toContain('Delete')
443+
expect(rendered).toContain('disabled')
444+
expect(rendered).toContain('aria-disabled="true"')
445+
expect(rendered).not.toContain('cc-delete-action')
446+
})
447+
448+
it('renders Set Reminder when followup_id is empty', () => {
449+
const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' }
450+
const rendered = columns[10].render(null, 'display', row)
451+
452+
expect(rendered).toContain('cc-set-reminder-action')
453+
expect(rendered).toContain('Set Reminder')
454+
expect(rendered).not.toContain('Resolve Reminder')
455+
})
456+
457+
it('renders Resolve Reminder when followup_id is present', () => {
458+
const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '42' }
459+
const rendered = columns[10].render(null, 'display', row)
460+
461+
expect(rendered).toContain('cc-resolve-reminder-action')
462+
expect(rendered).toContain('data-followup-id="42"')
463+
expect(rendered).toContain('Resolve Reminder')
464+
expect(rendered).not.toContain('Set Reminder')
465+
})
466+
467+
it('always renders the reminder menu item', () => {
468+
const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' }
469+
const rendered = columns[10].render(null, 'display', row)
470+
471+
expect(rendered).toMatch(/Set Reminder|Resolve Reminder/)
472+
})
473+
})
474+
})
475+
476+
describe('click handlers', () => {
477+
let mockAjaxReload
478+
let mockTableInstance
479+
480+
const clickActionButton = (action, attrs = {}) => {
481+
const dataAttrs = Object.entries(attrs).map(([k, v]) => `data-${k}="${v}"`).join(' ')
482+
$('table#case_contacts tbody').append(
483+
`<tr><td><button class="cc-${action}-action" ${dataAttrs}>${action}</button></td></tr>`
484+
)
485+
$(`.cc-${action}-action`).trigger('click')
486+
}
487+
488+
beforeEach(() => {
489+
mockAjaxReload = jest.fn()
490+
mockTableInstance = { ajax: { reload: mockAjaxReload } }
491+
mockDataTable.mockReturnValue(mockTableInstance)
492+
493+
// Add CSRF meta tag
494+
document.head.innerHTML = '<meta name="csrf-token" content="test-csrf-token">'
495+
496+
defineCaseContactsTable()
497+
})
498+
499+
afterEach(() => {
500+
Swal.fire.mockReset()
501+
fireSwalFollowupAlert.mockReset()
502+
})
503+
504+
describe('Delete action', () => {
505+
it('shows a SweetAlert confirmation dialog when cc-delete-action is clicked', () => {
506+
Swal.fire.mockResolvedValue({ isConfirmed: false })
507+
508+
clickActionButton('delete', { id: '42' })
509+
510+
expect(Swal.fire).toHaveBeenCalled()
511+
})
512+
513+
it('sends DELETE request when confirmed', async () => {
514+
Swal.fire.mockResolvedValue({ isConfirmed: true })
515+
const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
516+
517+
clickActionButton('delete', { id: '42' })
518+
519+
await Promise.resolve()
520+
521+
expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({
522+
url: '/case_contacts/42',
523+
type: 'DELETE',
524+
headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' }
525+
}))
526+
expect(ajaxSpy.mock.calls[0][0]).not.toHaveProperty('dataType')
527+
528+
ajaxSpy.mockRestore()
529+
})
530+
531+
it('does not send DELETE request when cancelled', async () => {
532+
Swal.fire.mockResolvedValue({ isConfirmed: false })
533+
const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation()
534+
535+
clickActionButton('delete', { id: '42' })
536+
537+
await Promise.resolve()
538+
539+
expect(ajaxSpy).not.toHaveBeenCalled()
540+
541+
ajaxSpy.mockRestore()
542+
})
543+
544+
it('reloads the DataTable without resetting pagination after successful delete', async () => {
545+
Swal.fire.mockResolvedValue({ isConfirmed: true })
546+
jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
547+
548+
clickActionButton('delete', { id: '42' })
549+
550+
await Promise.resolve()
551+
552+
expect(mockAjaxReload).toHaveBeenCalledWith(null, false)
553+
})
554+
})
555+
556+
describe('Set Reminder action', () => {
557+
it('calls fireSwalFollowupAlert when cc-set-reminder-action is clicked', () => {
558+
fireSwalFollowupAlert.mockResolvedValue({ isConfirmed: false })
559+
560+
clickActionButton('set-reminder', { id: '5' })
561+
562+
expect(fireSwalFollowupAlert).toHaveBeenCalled()
563+
})
564+
565+
it('posts to the followups endpoint with CSRF header when confirmed without a note', async () => {
566+
fireSwalFollowupAlert.mockResolvedValue({ value: '', isConfirmed: true })
567+
const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
568+
569+
clickActionButton('set-reminder', { id: '5' })
570+
571+
await Promise.resolve()
572+
573+
expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({
574+
url: '/case_contacts/5/followups',
575+
type: 'POST',
576+
data: {},
577+
headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' }
578+
}))
579+
580+
ajaxSpy.mockRestore()
581+
})
582+
583+
it('posts with note when confirmed with a note', async () => {
584+
fireSwalFollowupAlert.mockResolvedValue({ value: 'My note', isConfirmed: true })
585+
const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
586+
587+
clickActionButton('set-reminder', { id: '5' })
588+
589+
await Promise.resolve()
590+
591+
expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({
592+
url: '/case_contacts/5/followups',
593+
type: 'POST',
594+
data: { note: 'My note' },
595+
headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' }
596+
}))
597+
598+
ajaxSpy.mockRestore()
599+
})
600+
601+
it('does not post when cancelled', async () => {
602+
fireSwalFollowupAlert.mockResolvedValue({ isConfirmed: false })
603+
const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation()
604+
605+
clickActionButton('set-reminder', { id: '5' })
606+
607+
await Promise.resolve()
608+
609+
expect(ajaxSpy).not.toHaveBeenCalled()
610+
611+
ajaxSpy.mockRestore()
612+
})
613+
614+
it('reloads the DataTable without resetting pagination after creating a reminder', async () => {
615+
fireSwalFollowupAlert.mockResolvedValue({ value: '', isConfirmed: true })
616+
jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
617+
618+
clickActionButton('set-reminder', { id: '5' })
619+
620+
await Promise.resolve()
621+
622+
expect(mockAjaxReload).toHaveBeenCalledWith(null, false)
623+
})
624+
})
625+
626+
describe('Resolve Reminder action', () => {
627+
it('sends PATCH request when cc-resolve-reminder-action is clicked', () => {
628+
const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
629+
630+
clickActionButton('resolve-reminder', { id: '5', 'followup-id': '42' })
631+
632+
expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({
633+
url: '/followups/42/resolve',
634+
type: 'PATCH',
635+
headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' }
636+
}))
637+
expect(ajaxSpy.mock.calls[0][0]).not.toHaveProperty('dataType')
638+
639+
ajaxSpy.mockRestore()
640+
})
641+
642+
it('reloads the DataTable without resetting pagination after resolving a reminder', () => {
643+
jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
644+
645+
clickActionButton('resolve-reminder', { id: '5', 'followup-id': '42' })
387646

388-
expect(rendered).toBe('<i class="fas fa-ellipsis-v"></i>')
647+
expect(mockAjaxReload).toHaveBeenCalledWith(null, false)
389648
})
390649
})
391650
})

app/javascript/src/case_contact.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,6 @@ $(() => { // JQuery's callback for the DOM loading
5454
})
5555

5656
export {
57-
convertDateToSystemTimeZone
57+
convertDateToSystemTimeZone,
58+
fireSwalFollowupAlert
5859
}

0 commit comments

Comments
 (0)