diff --git a/client/src/controllers/BulkController.test.js b/client/src/controllers/BulkController.test.js index 06324852db..1bef481c6d 100644 --- a/client/src/controllers/BulkController.test.js +++ b/client/src/controllers/BulkController.test.js @@ -2,6 +2,23 @@ import { Application } from '@hotwired/stimulus'; import { BulkController } from './BulkController'; describe('BulkController', () => { + const shiftClick = async (element) => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Shift', + shiftKey: true, + }), + ); + element.click(); + document.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'Shift', + shiftKey: true, + }), + ); + await Promise.resolve(); + }; + const setup = async ( html = `
@@ -224,23 +241,6 @@ describe('BulkController', () => { const getClickedIds = () => Array.from(document.querySelectorAll(':checked')).map(({ id }) => id); - const shiftClick = async (element) => { - document.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Shift', - shiftKey: true, - }), - ); - element.click(); - document.dispatchEvent( - new KeyboardEvent('keyup', { - key: 'Shift', - shiftKey: true, - }), - ); - await Promise.resolve(); - }; - // initial shift usage should have no impact await shiftClick(document.getElementById('c0')); expect(getClickedIds()).toHaveLength(1); @@ -306,4 +306,200 @@ describe('BulkController', () => { // it should include the previously disabled element, tracking against the DOM, not indexes expect(getClickedIds()).toEqual(['c1', 'cx', 'c2', 'c3']); }); + + describe('support for groups of checkboxes being used', () => { + const html = ` + + + + + + + ${[...Array(5).keys()] + .map( + (i) => ` + + + + + + `, + ) + .join('\n')} + + + + + + + +
+ Misc items + + + + +
NameAddChangeDelete
Item ${i + 1}
+ Check all (Add & Change) + + + + Check all (Add) + + + Check all (Change) + + + Check all (Delete) + +
+ `; + + it('should allow for the toggleAll method to be used to select all, irrespective of groupings', async () => { + const totalCheckboxes = 24; + + await setup(html); + + const allCheckbox = document.getElementById('select-all'); + expect(allCheckbox.checked).toBe(false); + expect(document.querySelectorAll('[type="checkbox"')).toHaveLength( + totalCheckboxes, + ); + expect(document.querySelectorAll(':checked')).toHaveLength(0); + + allCheckbox.click(); + + expect(allCheckbox.checked).toBe(true); + expect(document.querySelectorAll(':checked')).toHaveLength( + totalCheckboxes, + ); + + allCheckbox.click(); + expect(document.querySelectorAll(':checked')).toHaveLength(0); + }); + + it('should allow for the toggleAll method to be used for single group toggling', async () => { + await setup(html); + + expect(document.querySelectorAll(':checked')).toHaveLength(0); + + document.getElementById('select-all-delete').click(); + + const checked = document.querySelectorAll(':checked'); + + expect(checked).toHaveLength(9); + + expect(checked).toEqual( + document.querySelectorAll('[data-w-bulk-group-param~="delete"]'), + ); + + const otherCheckbox = document.getElementById('row-3-add'); + + otherCheckbox.click(); + + expect(document.querySelectorAll(':checked')).toHaveLength(10); + + document.getElementById('select-all-delete').click(); + + expect(document.querySelectorAll(':checked')).toHaveLength(1); + expect(otherCheckbox.checked).toEqual(true); + }); + + it('should allow for the toggleAll method to be used for multi group toggling', async () => { + await setup(html); + + expect(document.querySelectorAll(':checked')).toHaveLength(0); + + document.getElementById('select-all-add-change').click(); + + const checked = document.querySelectorAll(':checked'); + expect(checked).toHaveLength(17); + expect([...checked].map(({ id }) => id)).toEqual( + expect.arrayContaining([ + 'misc-any', + 'misc-add-change', + 'misc-change-delete', + 'misc-add-change-delete', + 'row-0-add', + 'row-0-change', + // ... others not needing explicit call out + ]), + ); + + // specific group select all checkboxes should now be checked automatically + expect(document.getElementById('select-all-add').checked).toEqual(true); + expect(document.getElementById('select-all-change').checked).toEqual( + true, + ); + }); + + it('should support shift+click within the groups', async () => { + await setup(html); + + expect(document.querySelectorAll(':checked')).toHaveLength(0); + + document.getElementById('row-0-change').click(); + + await shiftClick(document.getElementById('row-2-change')); + + // only checkboxes in + expect(document.getElementById('row-1-change').checked).toEqual(true); + expect(document.querySelectorAll(':checked')).toHaveLength(3); + + // now shift again to the last checkbox + await shiftClick(document.getElementById('row-4-change')); + expect(document.getElementById('row-3-change').checked).toEqual(true); + expect(document.querySelectorAll(':checked')).toHaveLength(5); + }); + + it('should support the group being passed in via a CustomEvent', async () => { + await setup(html); + + const table = document.getElementById('table'); + expect(document.querySelectorAll(':checked')).toHaveLength(0); + + table.dispatchEvent( + new CustomEvent('custom:event', { detail: { group: 'delete' } }), + ); + + await Promise.resolve(); + + const checked = document.querySelectorAll(':checked'); + + expect(checked).toHaveLength(9); + expect([...checked].map(({ id }) => id)).toEqual([ + 'misc-any', + 'misc-change-delete', + 'misc-add-change-delete', + 'row-0-delete', + 'row-1-delete', + 'row-2-delete', + 'row-3-delete', + 'row-4-delete', + 'select-all-delete', + ]); + + // now check one of the non-delete checkboxes + document.getElementById('row-0-add').click(); + expect(document.querySelectorAll(':checked')).toHaveLength(10); + + // use force to toggle only the delete checkboxes off + table.dispatchEvent( + new CustomEvent('custom:event', { + detail: { group: 'delete', force: false }, + }), + ); + + expect(document.querySelectorAll(':checked')).toHaveLength(1); + + // run a second time to confirm there should be no difference due to force + table.dispatchEvent( + new CustomEvent('custom:event', { + detail: { group: 'delete', force: false }, + }), + ); + + expect(document.querySelectorAll(':checked')).toHaveLength(1); + }); + }); }); diff --git a/client/src/controllers/BulkController.ts b/client/src/controllers/BulkController.ts index bf8615545a..2a9fe616b5 100644 --- a/client/src/controllers/BulkController.ts +++ b/client/src/controllers/BulkController.ts @@ -1,6 +1,11 @@ import { Controller } from '@hotwired/stimulus'; -type ToggleAllOptions = { +type ToggleOptions = { + /** Only toggle those within the provided group(s), a space separated set of strings. */ + group?: string; +}; + +type ToggleAllOptions = ToggleOptions & { /** Override check all behaviour to either force check or uncheck all */ force?: boolean; }; @@ -33,6 +38,43 @@ type ToggleAllOptions = { *
* * + * @example - Using groups to allow toggles to be controlled separately or together + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
NameAddChange
Item 1
Item 2
+ * Check all (Add & Change) + * + * + * Check all (Add) + * + * + * Check all (Change) + * + *
+ * */ export class BulkController extends Controller { static classes = ['actionInactive']; @@ -71,9 +113,21 @@ export class BulkController extends Controller { * Returns all valid targets (i.e. not disabled). */ getValidTargets( + group: string | null = null, targets: HTMLInputElement[] = this.itemTargets, + paramAttr = `data-${this.identifier}-group-param`, ): HTMLInputElement[] { - return targets.filter(({ disabled }) => !disabled); + const activeTargets = targets.filter(({ disabled }) => !disabled); + + if (!group) return activeTargets; + + const groups = group.split(' '); + return activeTargets.filter((target) => { + const targetGroups = new Set( + (target.getAttribute(paramAttr) || '').split(' '), + ); + return groups.some(targetGroups.has.bind(targetGroups)); + }); } /** @@ -99,8 +153,9 @@ export class BulkController extends Controller { * If the shift key is pressed, toggle all the items between the last clicked * item and the current item. */ - toggle(event?: Event) { - const activeItems = this.getValidTargets(); + toggle(event?: CustomEvent & { params?: ToggleOptions }) { + const { group = null } = { ...event?.detail, ...event?.params }; + const activeItems = this.getValidTargets(group); const lastChanged = this.lastChanged; if (this.shiftActive && lastChanged instanceof HTMLElement) { @@ -134,7 +189,7 @@ export class BulkController extends Controller { const isAnyChecked = totalCheckedItems > 0; const isAllChecked = totalCheckedItems === activeItems.length; - this.getValidTargets(this.allTargets).forEach((target) => { + this.getValidTargets(group, this.allTargets).forEach((target) => { // eslint-disable-next-line no-param-reassign target.checked = isAllChecked; }); @@ -157,7 +212,7 @@ export class BulkController extends Controller { toggleAll( event: CustomEvent & { params?: ToggleAllOptions }, ) { - const { force = null } = { + const { force = null, group = null } = { ...event.detail, ...event.params, }; @@ -177,7 +232,7 @@ export class BulkController extends Controller { isChecked = !checkbox?.checked; } - this.getValidTargets().forEach((target) => { + this.getValidTargets(group).forEach((target) => { if (target.checked !== isChecked) { // eslint-disable-next-line no-param-reassign target.checked = isChecked; @@ -185,7 +240,7 @@ export class BulkController extends Controller { } }); - this.toggle(); + this.toggle(event); } disconnect() {