diff --git a/client/src/controllers/BulkController.test.js b/client/src/controllers/BulkController.test.js
index d5e3a7eb1d7e..5531c9cc0b61 100644
--- a/client/src/controllers/BulkController.test.js
+++ b/client/src/controllers/BulkController.test.js
@@ -2,9 +2,9 @@ import { Application } from '@hotwired/stimulus';
import { BulkController } from './BulkController';
describe('BulkController', () => {
- beforeEach(() => {
- document.body.innerHTML = `
-
+ const setup = async (
+ html = `
+
`;
+
`,
+ ) => {
+ document.body.innerHTML = `${html}`;
+
const application = Application.start();
application.register('w-bulk', BulkController);
- });
+ };
+
+ it('selects all checkboxes when the select all checkbox is clicked', async () => {
+ await setup();
- it('selects all checkboxes when the select all checkbox is clicked', () => {
const allCheckbox = document.getElementById('select-all');
allCheckbox.click();
@@ -38,7 +43,9 @@ describe('BulkController', () => {
).toEqual(0);
});
- it('should keep the select all checkbox in sync when individual checkboxes are all ticked', () => {
+ it('should keep the select all checkbox in sync when individual checkboxes are all ticked', async () => {
+ await setup();
+
const allCheckbox = document.getElementById('select-all');
expect(allCheckbox.checked).toBe(false);
@@ -63,7 +70,9 @@ describe('BulkController', () => {
expect(allCheckbox.checked).toBe(false);
});
- it('executes the correct action when the Clear all button is clicked', () => {
+ it('executes the correct action when the Clear all button is clicked', async () => {
+ await setup();
+
const allCheckbox = document.getElementById('select-all');
const clearAllButton = document.getElementById('clear');
expect(allCheckbox.checked).toBe(false);
@@ -88,7 +97,9 @@ describe('BulkController', () => {
expect(allCheckbox.checked).toBe(false);
});
- it('executes the correct action when the Set all button is clicked', () => {
+ it('executes the correct action when the Set all button is clicked', async () => {
+ await setup();
+
const allCheckbox = document.getElementById('select-all');
const setAllButton = document.getElementById('set');
@@ -115,7 +126,50 @@ describe('BulkController', () => {
});
});
+ it('should support using another method (e.g. CustomEvent) to toggle all', async () => {
+ await setup();
+
+ const allCheckbox = document.getElementById('select-all');
+ expect(allCheckbox.checked).toBe(false);
+
+ document.dispatchEvent(new CustomEvent('custom:event'));
+
+ expect(allCheckbox.checked).toBe(true);
+ expect(document.querySelectorAll(':checked')).toHaveLength(3);
+
+ // calling again, should switch the toggles back
+
+ document.dispatchEvent(new CustomEvent('custom:event'));
+
+ expect(allCheckbox.checked).toBe(false);
+ expect(document.querySelectorAll(':checked')).toHaveLength(0);
+ });
+
+ it('should support a force value in a CustomEvent to override the select all checkbox', async () => {
+ await setup();
+
+ const allCheckbox = document.getElementById('select-all');
+ expect(allCheckbox.checked).toBe(false);
+
+ document.dispatchEvent(
+ new CustomEvent('custom:event', { detail: { force: true } }),
+ );
+
+ expect(allCheckbox.checked).toBe(true);
+ expect(document.querySelectorAll(':checked')).toHaveLength(3);
+
+ // calling again, should not change the state of the checkboxes
+ document.dispatchEvent(
+ new CustomEvent('custom:event', { detail: { force: true } }),
+ );
+
+ expect(allCheckbox.checked).toBe(true);
+ expect(document.querySelectorAll(':checked')).toHaveLength(3);
+ });
+
it('should allow for action targets to have classes toggled when any checkboxes are clicked', async () => {
+ await setup();
+
const container = document.getElementById('bulk-container');
// create innerActions container that will be conditionally hidden with test classes
@@ -151,4 +205,105 @@ describe('BulkController', () => {
expect(innerActionsElement.className).toEqual('keep-me hidden w-invisible');
});
+
+ it('should support shift+click to select a range of checkboxes', async () => {
+ await setup(`
+ `);
+
+ 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('0'));
+ expect(getClickedIds()).toHaveLength(1);
+
+ // shift click should select all checkboxes between the first and last clicked
+ await shiftClick(document.getElementById('2'));
+ expect(getClickedIds()).toEqual(['0', '1', '2']);
+
+ await shiftClick(document.getElementById('5'));
+ expect(getClickedIds()).toEqual([
+ 'select-all-multi',
+ '0',
+ '1',
+ '2',
+ '3',
+ '4',
+ '5',
+ ]);
+
+ // it should allow reverse clicks
+ document.getElementById('4').click(); // un-click
+ expect(getClickedIds()).toEqual(['0', '1', '2', '3', '5']);
+
+ // now shift click in reverse, un-clicking those between the last (4) and the new click (1)
+ await shiftClick(document.getElementById('1'));
+ expect(getClickedIds()).toEqual(['0', '5']);
+
+ // reset the clicks, then using shift click should do nothing
+ document.getElementById('select-all-multi').click();
+ document.getElementById('select-all-multi').click();
+ expect(getClickedIds()).toHaveLength(0);
+
+ await shiftClick(document.getElementById('4'));
+ expect(getClickedIds()).toEqual(['4']);
+
+ // finally, do a shift click to the first checkbox, check the select all works after a final click
+ await shiftClick(document.getElementById('0'));
+ expect(getClickedIds()).toEqual(['0', '1', '2', '3', '4']);
+
+ document.getElementById('5').click();
+
+ expect(getClickedIds()).toEqual([
+ 'select-all-multi',
+ '0',
+ '1',
+ '2',
+ '3',
+ '4',
+ '5',
+ ]);
+
+ // now ensure that it still works if some element gets changed (not disabled)
+ document.getElementById('x').removeAttribute('disabled');
+ document.getElementById('select-all-multi').click();
+ expect(getClickedIds()).toHaveLength(0);
+
+ await Promise.resolve();
+
+ document.getElementById('3').click(); // click
+
+ await shiftClick(document.getElementById('1'));
+
+ // it should include the previously disabled element, tracking against the DOM, not indexes
+ expect(getClickedIds()).toEqual(['1', 'x', '2', '3']);
+ });
});
diff --git a/client/src/controllers/BulkController.ts b/client/src/controllers/BulkController.ts
index 398f79a6ca8c..c37fdca4ba34 100644
--- a/client/src/controllers/BulkController.ts
+++ b/client/src/controllers/BulkController.ts
@@ -1,5 +1,10 @@
import { Controller } from '@hotwired/stimulus';
+type ToggleAllOptions = {
+ /** Override check all behaviour to either force check or uncheck all */
+ force?: boolean;
+};
+
/**
* Adds the ability to collectively toggle a set of (non-disabled) checkboxes.
*
@@ -27,7 +32,7 @@ import { Controller } from '@hotwired/stimulus';
*
*
*
-
+ *
*/
export class BulkController extends Controller {
static classes = ['actionInactive'];
@@ -45,28 +50,91 @@ export class BulkController extends Controller {
/** Classes to remove on the actions target if any actions are checked */
declare readonly actionInactiveClasses: string[];
- get activeItems() {
- return this.itemTargets.filter(({ disabled }) => !disabled);
- }
+ /** Internal tracking of last clicked for shift+click behaviour */
+ lastChanged?: HTMLElement | null;
+
+ /** Internal tracking of whether the shift key is active for multiple selection */
+ shiftActive?: boolean;
/**
* On creation, ensure that the select all checkboxes are in sync.
+ * Set up the event listeners for shift+click behaviour.
*/
connect() {
this.toggle();
+ this.handleShiftKey = this.handleShiftKey.bind(this);
+ document.addEventListener('keydown', this.handleShiftKey);
+ document.addEventListener('keyup', this.handleShiftKey);
+ }
+
+ /**
+ * Returns all valid targets (i.e. not disabled).
+ */
+ getValidTargets(
+ targets: HTMLInputElement[] = this.itemTargets,
+ ): HTMLInputElement[] {
+ return targets.filter(({ disabled }) => !disabled);
+ }
+
+ /**
+ * Event handler to determine if shift key is pressed.
+ */
+ handleShiftKey(event: KeyboardEvent) {
+ if (!event) return;
+
+ const { shiftKey, type } = event;
+
+ if (type === 'keydown' && shiftKey) {
+ this.shiftActive = true;
+ }
+
+ if (type === 'keyup' && this.shiftActive) {
+ this.shiftActive = false;
+ }
}
/**
- * When something is toggled, ensure the select all targets are kept in sync.
+ * When an item is toggled, ensure the select all targets are kept in sync.
* Update the classes on the action targets to reflect the current state.
+ * If the shift key is pressed, toggle all the items between the last clicked
+ * item and the current item.
*/
- toggle() {
- const activeItems = this.activeItems;
+ toggle(event?: Event) {
+ const activeItems = this.getValidTargets();
+ const lastChanged = this.lastChanged;
+
+ if (this.shiftActive && lastChanged instanceof HTMLElement) {
+ this.shiftActive = false;
+
+ const lastClickedIndex = activeItems.findIndex(
+ (item) => item === lastChanged,
+ );
+ const currentIndex = activeItems.findIndex(
+ (item) => item === event?.target,
+ );
+
+ const [start, end] = [lastClickedIndex, currentIndex].sort(
+ // eslint-disable-next-line id-length
+ (a, b) => a - b,
+ );
+
+ activeItems.forEach((target, index) => {
+ if (index >= start && index <= end) {
+ // eslint-disable-next-line no-param-reassign
+ target.checked = !!activeItems[lastClickedIndex].checked;
+ this.dispatch('change', { target, bubbles: true });
+ }
+ });
+ }
+
+ this.lastChanged =
+ activeItems.find((item) => item.contains(event?.target as Node)) ?? null;
+
const totalCheckedItems = activeItems.filter((item) => item.checked).length;
const isAnyChecked = totalCheckedItems > 0;
const isAllChecked = totalCheckedItems === activeItems.length;
- this.allTargets.forEach((target) => {
+ this.getValidTargets(this.allTargets).forEach((target) => {
// eslint-disable-next-line no-param-reassign
target.checked = isAllChecked;
});
@@ -82,14 +150,34 @@ export class BulkController extends Controller {
}
/**
- * Toggles all item checkboxes based on select-all checkbox.
+ * Toggles all item checkboxes, can be used to force check or uncheck all.
+ * If the event used to trigger this method does not have a suitable target,
+ * the first allTarget will be used to determine the current checked value.
*/
- toggleAll(event: Event & { params?: { force?: boolean } }): void {
- const force = event?.params?.force;
- const checkbox = event.target as HTMLInputElement;
- const isChecked = typeof force === 'boolean' ? force : checkbox.checked;
+ toggleAll(
+ event: CustomEvent & { params?: ToggleAllOptions },
+ ) {
+ const { force = null } = {
+ ...event?.detail,
+ ...event?.params,
+ };
+
+ this.lastChanged = null;
+
+ let isChecked = false;
- this.activeItems.forEach((target) => {
+ if (typeof force === 'boolean') {
+ isChecked = force;
+ } else if (event.target instanceof HTMLInputElement) {
+ isChecked = event.target.checked;
+ } else {
+ const checkbox = this.allTargets[0];
+ // use the opposite of the current state
+ // as this is being triggered by an external call
+ isChecked = !checkbox?.checked;
+ }
+
+ this.getValidTargets().forEach((target) => {
if (target.checked !== isChecked) {
// eslint-disable-next-line no-param-reassign
target.checked = isChecked;
@@ -99,4 +187,9 @@ export class BulkController extends Controller {
this.toggle();
}
+
+ disconnect() {
+ document?.removeEventListener('keydown', this.handleShiftKey);
+ document?.removeEventListener('keyup', this.handleShiftKey);
+ }
}