Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add BulkController support for shift+click behaviour #10861

Merged
merged 1 commit into from
Oct 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Changelog
* Support extending Wagtail client-side with Stimulus (LB (Ben) Johnston)
* Update all `FieldPanel('title')` examples to use the recommended `TitleFieldPanel('title')` panel (Chinedu Ihedioha)
* The `purge_revisions` management command now respects revisions that have a `on_delete=PROTECT` foreign key relation and won't delete them (Neeraj P Yetheendran, Meghana Reddy, Sage Abdullah, Storm Heg)
* Add support for Shift + Click behaviour in form submissions and simple tranlations submissions (LB (Ben) Johnston)
* Fix: Ensure that StreamField's `FieldBlock`s correctly set the `required` and `aria-describedby` attributes (Storm Heg)
* Fix: Avoid an error when the moderation panel (admin dashboard) contains both snippets and private pages (Matt Westcott)
* Fix: When deleting collections, ensure the collection name is correctly shown in the success message (LB (Ben) Johnston)
Expand Down
173 changes: 164 additions & 9 deletions client/src/controllers/BulkController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { Application } from '@hotwired/stimulus';
import { BulkController } from './BulkController';

describe('BulkController', () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="bulk-container" data-controller="w-bulk">
const setup = async (
html = `
<div id="bulk-container" data-controller="w-bulk" data-action="custom:event@document->w-bulk#toggleAll">
<input id="select-all" type="checkbox" data-w-bulk-target="all" data-action="w-bulk#toggleAll">
<div id="checkboxes">
<input type="checkbox" data-w-bulk-target="item" disabled data-action="w-bulk#toggle">
Expand All @@ -13,12 +13,17 @@ describe('BulkController', () => {
</div>
<button id="clear" data-action="w-bulk#toggleAll" data-w-bulk-force-param="false">Clear all</button>
<button id="set" data-action="w-bulk#toggleAll" data-w-bulk-force-param="true">Select all</button>
</div>`;
</div>`,
) => {
document.body.innerHTML = `<main>${html}</main>`;

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();
Expand All @@ -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);

Expand All @@ -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);
Expand All @@ -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');

Expand All @@ -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
Expand Down Expand Up @@ -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(`
<div id="bulk-container-multi" data-controller="w-bulk">
<input id="select-all-multi" type="checkbox" data-w-bulk-target="all" data-action="w-bulk#toggleAll">
<div id="checkboxes">
<input id="c0" type="checkbox" data-w-bulk-target="item" data-action="w-bulk#toggle">
<input id="c1" type="checkbox" data-w-bulk-target="item" data-action="w-bulk#toggle">
<input id="cx" type="checkbox" data-w-bulk-target="item" data-action="w-bulk#toggle" disabled>
<input id="c2" type="checkbox" data-w-bulk-target="item" data-action="w-bulk#toggle">
<input id="c3" type="checkbox" data-w-bulk-target="item" data-action="w-bulk#toggle">
<input id="c4" type="checkbox" data-w-bulk-target="item" data-action="w-bulk#toggle">
<input id="c5" type="checkbox" data-w-bulk-target="item" data-action="w-bulk#toggle">
</div>
</div>`);

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);

// shift click should select all checkboxes between the first and last clicked
await shiftClick(document.getElementById('c2'));
expect(getClickedIds()).toEqual(['c0', 'c1', 'c2']);

await shiftClick(document.getElementById('c5'));
expect(getClickedIds()).toEqual([
'select-all-multi',
'c0',
'c1',
'c2',
'c3',
'c4',
'c5',
]);

// it should allow reverse clicks
document.getElementById('c4').click(); // un-click
expect(getClickedIds()).toEqual(['c0', 'c1', 'c2', 'c3', 'c5']);

// now shift click in reverse, un-clicking those between the last (4) and the new click (1)
await shiftClick(document.getElementById('c1'));
expect(getClickedIds()).toEqual(['c0', 'c5']);

// 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('c4'));
expect(getClickedIds()).toEqual(['c4']);

// finally, do a shift click to the first checkbox, check the select all works after a final click
await shiftClick(document.getElementById('c0'));
expect(getClickedIds()).toEqual(['c0', 'c1', 'c2', 'c3', 'c4']);

document.getElementById('c5').click();

expect(getClickedIds()).toEqual([
'select-all-multi',
'c0',
'c1',
'c2',
'c3',
'c4',
'c5',
]);

// now ensure that it still works if some element gets changed (not disabled)
document.getElementById('cx').removeAttribute('disabled');
document.getElementById('select-all-multi').click();
expect(getClickedIds()).toHaveLength(0);

await Promise.resolve();

document.getElementById('c3').click(); // click

await shiftClick(document.getElementById('c1'));

// it should include the previously disabled element, tracking against the DOM, not indexes
expect(getClickedIds()).toEqual(['c1', 'cx', 'c2', 'c3']);
});
});
Loading
Loading