Repository: wagtail/wagtail

Issue:
🎛️ Migrate simple_translation select all checkbox to a Stimulus Controller `w-bulk`
> ℹ️ **Part of the [Stimulus 🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

**Note:** This is marked as a good first issue, but only should be picked up if you have read the Stimulus documentation and have a basic understanding of Stimulus, Jest (tests) & TypeScript.

### Is your proposal related to a problem?

There is a small ad-hoc JS inline script for the contrib simple translation library that allows someone to easily select all (via a single checkbox) against a list of multiple checkboxes. This is very similar to the more complex bulk actions behaviour and a great candidate for a small step in adopting Stimulus for bulk actions.

<img width="1267" alt="Screenshot 2023-03-20 at 7 43 26 am" src="https://user-images.githubusercontent.com/1396140/226211507-92eefef4-3688-4f35-b3cc-3ae3f4f78c60.png">

### Describe the solution you'd like

#### Current implementation

* [`wagtail/contrib/simple_translation/templates/simple_translation/admin/submit_translation.html`](https://github.com/wagtail/wagtail/blob/main/wagtail/contrib/simple_translation/templates/simple_translation/admin/submit_translation.html)
* [`wagtail/contrib/simple_translation/forms.py`](https://github.com/wagtail/wagtail/blob/main/wagtail/contrib/simple_translation/forms.py)

#### Proposed implementation

**Important**: This is quite prescriptive, this approach may not work exactly as intended , please ensure that the expected behaviour in the check-list below is implemented.

* Create a new controller `BulkController` -> `client/src/controllers/BulkController.ts` with the identifier `w-bulk`, this will be a precursor to the more complex behaviour in bulk actions.
* This controller should have two targets; `all` (for any select all checkboxes) and `item` (for the item checkbox)
* This controller should have two methods; `toggle` and `toggleAll`
* The controller should keep the select all checkbox in sync (slight enhancement) if other checkboxes are manually all checked and visa versa.

#### Completed check-list

* [ ] When any checkbox is disabled, clicking select all will NOT activate the disabled items
* [ ] When the select all checkbox is checked it will check all other non-disabled items
* [ ] When all non-disabled checkboxes are manually checked, the select all checkbox will also be checked automatically
* [ ] When all non-disabled checkboxes are checked and then one gets un-checked, the select all checkbox will also uncheck
* [ ] Unit tests are added for the behaviour in a new file `client/src/controllers/BulkController.test.js`
* [ ] Ad-hoc `script` is removed from `wagtail/contrib/simple_translation/templates/simple_translation/admin/submit_translation.html`
* [ ] JSDoc added to the controller (see other controllers for inspiration)

### Describe alternatives you've considered

- This code is not compliant for CSP, it should be removed and it also makes a great starting point for the future migration of Bulk Actions.

### Additional context

- This is part of a larger goal to migrate to Stimulus, see RFC 78 linked above
- Be sure to understand how to manage translations & have the simple translations set up in bakerydemo (click Settings > Locales, add some locales. Then attempt to translate a page). See https://docs.wagtail.org/en/latest/reference/contrib/simple_translation.html
- See Stimulus documentation https://stimulus.hotwired.dev/handbook/introduction
- See documentation from the project https://docs.wagtail.org/en/latest/contributing/ui_guidelines.html#stimulus
- This change will also support a more secure implementation of the admin by removing an inline script, see #7053 & #1288 

#### POC implementation

This is a rough starting point, does not include some JSDOC and needs to be reviewed, tested etc.

**submit_translation.html**

```html
{% extends "wagtailadmin/base.html" %}
{% load i18n wagtailadmin_tags %}
{% block titletag %}{{ view.get_title }} {{ view.get_subtitle }}{% endblock %}

{% block content %}
    {% include "wagtailadmin/shared/header.html" with title=view.get_title subtitle=view.get_subtitle icon="doc-empty-inverse" %}

    <div class="nice-padding"  data-controller="w-bulk">
    ... Rest of the template...
```

**.../simple_translation/forms.py**

```python
# imports...

class CheckboxSelectMultipleWithDisabledOptions(forms.CheckboxSelectMultiple):
    # ... other stuff

    def create_option(self, *args, **kwargs):
        option = super().create_option(*args, **kwargs)
        if option["value"] in self.disabled_values:
            option["attrs"]["disabled"] = True
        else:
            # Note: only adding the target/action to the non-disabled ones so that disabled ones get ignored
            option["attrs"]["data-action"] = "w-bulk#toggle"
            option["attrs"]["data-w-bulk-target"] = "item"
        return option

class SubmitTranslationForm(forms.Form):
    """
    Note: We don't actually use select_all in Python, it is just the
    easiest way to add the widget to the form. It uses the Stimulus
    controller w-bulk / BulkController.
    """
    # note: updated the class docs above to use """ syntax and revised to reference the controller identifier (so that it's easier to find later)

    select_all = forms.BooleanField(
        label=gettext_lazy("Select all"),
        required=False,
        widget=forms.CheckboxInput(
            # note: here we add the select all checkbox data attributes, we could do this in __init__ instead but seems to make sense here.
            attrs={
                "data-action": "w-bulk#toggleAll",
                "data-w-bulk-target": "all",
            },
        ),
    )
    locales = forms.ModelMultipleChoiceField(
        label=gettext_lazy("Locales"),
        queryset=Locale.objects.none(),
        widget=CheckboxSelectMultipleWithDisabledOptions,
    )
    include_subtree = forms.BooleanField(
        required=False,
        help_text=gettext_lazy("All child pages will be created."),
    )

    def __init__(self, instance, *args, **kwargs):
        # ... rest of the stuff
```

**BulkController.ts**
```ts
import { Controller } from '@hotwired/stimulus';
import { ChangeEvent } from 'react';

/**
 * ...
 */
export class BulkController extends Controller<HTMLElement> {
  static targets = ['all', 'item'];

  declare readonly allTargets: HTMLInputElement[];
  declare readonly itemTargets: HTMLInputElement[];

  toggle() {
    const isAllChecked = !this.itemTargets.find(
      (item) => item.checked === false,
    );

    this.allTargets.forEach((target) => {
      // Important: check that this does not error out if the select all checkbox is disabled (can happen if only one translation exists or only one non-disabled one exists, see Django form __init__ for this behaviour)
      // eslint-disable-next-line no-param-reassign
      target.checked = isAllChecked;
    });
  }

  toggleAll(event: ChangeEvent<HTMLInputElement>) {
    const isChecked = event.target.checked;

    this.itemTargets.forEach((target) => {
      // eslint-disable-next-line no-param-reassign
      target.checked = isChecked;
    });
  }
}
```



Discussion:
Hi,

I have reviewed the issue and using the `BulkController.ts` provided, I modified it to this (code given below). I hope the JSdoc in the file is valid. Kindly review it and let me know if I can add this

```ts
import { ChangeEvent } from 'react'; 
import { Controller } from '@hotwired/stimulus';

/**
 * Controller that adds the ability to toggle some or all the elements, ignoring the disabled ones
 * Additionaly, it checks if all non-disabled items are selected or not. If they are, then select all
 *
 * @example
 * <div data-controller="w-bulk">
 *   <input type="checkbox" data-w-bulk-target="all">
 *
 *   <div>
 *     <input type="checkbox" data-w-bulk-target="item" disabled>
 *     <input type="checkbox" data-w-bulk-target="item">
 *     <input type="checkbox" data-w-bulk-target="item">
 *   </div>
 *
 *   <button data-action="w-bulk#clear">Clear</button>
 * </div>
 */
export class BulkController extends Controller {
  /** The list of targets for this controller. */
  static targets = ['all', 'item'];

  /** The list of all select-all checkbox targets for this controller. */
  declare readonly allTargets: HTMLInputElement[];

  /** The list of all item checkbox targets for this controller. */
  declare readonly itemTargets: HTMLInputElement[];

  /**
   * Toggles the select-all checkbox based on item checkboxes.
   */
  toggle(): void {
    // Check if all non-disabled item checkboxes are checked
    const nonDisabledItemTargets = this.itemTargets.filter((item) => !item.disabled); 
    const isAllChecked = !nonDisabledItemTargets.some((item) => !item.checked);

    // Update all select all checkboxes (except disabled ones)
    this.allTargets.forEach((target) => {
      if (!target.disabled) {
        target.checked = isAllChecked;
      }
    });
  }

  /**
   * Toggles all item checkboxes based on select-all checkbox.
   *
   * @param {ChangeEvent<HTMLInputElement>} event - The `ChangeEvent` object.
   */
  toggleAll(event: ChangeEvent<HTMLInputElement>): void {
    const isChecked = event.target.checked;

    // Update all non-disabled item checkboxes
    const nonDisabledItemTargets = this.itemTargets.filter((item) => !item.disabled);
    nonDisabledItemTargets.forEach((target) => {
      target.checked = isChecked;
    });

    this.allTargets.forEach((target) => {
      if (!target.disabled) {
        target.checked = isChecked;
      }
    });
  }
}
```

For the BulkController.test.js I took reference of other test files in controller directory and drafted rough testing file. Check it and see if it needs changes (I hope not)

Its running the 4 checks mentioned in the issue

```
import { BulkController } from './BulkController';
import { Application } from '@hotwired/stimulus';

describe('BulkController', () => {
  beforeEach(() => {
    document.body.innerHTML = `
    <div data-controller="w-bulk">
      <input type="checkbox" data-w-bulk-target="all">
      <div>
        <input type="checkbox" data-w-bulk-target="item" disabled>
        <input type="checkbox" data-w-bulk-target="item">
        <input type="checkbox" data-w-bulk-target="item">
      </div>
    </div>
    `;
    Application.start().register('w-bulk', BulkController);
  });

  it('disables select all checkbox if an item checkbox is disabled', () => {
    const allCheckbox = document.querySelector('[data-w-bulk-target="all"]');
    const itemCheckbox = document.querySelector('[data-w-bulk-target="item"][disabled]');

    expect(allCheckbox.disabled).toBe(true);

    itemCheckbox.disabled = false;
    expect(allCheckbox.disabled).toBe(false);
  });

  it('checks all non-disabled item checkboxes when select all is checked', () => {
    const allCheckbox = document.querySelector('[data-w-bulk-target="all"]');
    const itemCheckboxes = document.querySelectorAll('[data-w-bulk-target="item"]');
    const disabledItemCheckbox = document.querySelector('[data-w-bulk-target="item"][disabled]');

    disabledItemCheckbox.disabled = true;

    allCheckbox.click();

    itemCheckboxes.forEach((item) => {
      if (!item.disabled) {
        expect(item.checked).toBe(true);
      }
    });
  });

  it('checks select all checkbox when all non-disabled item checkboxes are checked', () => {
    const allCheckbox = document.querySelector('[data-w-bulk-target="all"]');
    const itemCheckboxes = document.querySelectorAll('[data-w-bulk-target="item"]');
    const disabledItemCheckbox = document.querySelector('[data-w-bulk-target="item"][disabled]');

    disabledItemCheckbox.disabled = true;

    itemCheckboxes.forEach((item) => {
      if (!item.disabled) {
        item.click();
      }
    });

    expect(allCheckbox.checked).toBe(true);
  });

  it('unchecks select all checkbox when any non-disabled item checkbox is unchecked', () => {
    const allCheckbox = document.querySelector('[data-w-bulk-target="all"]');
    const itemCheckboxes = document.querySelectorAll('[data-w-bulk-target="item"]');
    const disabledItemCheckbox = document.querySelector('[data-w-bulk-target="item"][disabled]');

    disabledItemCheckbox.disabled = true;

    itemCheckboxes.forEach((item) => {
      if (!item.disabled) {
        item.click();
      }
    });

    itemCheckboxes[0].click();

    expect(allCheckbox.checked).toBe(false);
  });
});

```
This looks great @Hanoon02 !
Only point of note is that you don't need JSDoc `@param` as this is handled by typescript. 

Go for a PR when you are ready and we can work through specific feedback there. 

Resolving patch:
diff --git a/wagtail/contrib/simple_translation/forms.py b/wagtail/contrib/simple_translation/forms.py
--- a/wagtail/contrib/simple_translation/forms.py
+++ b/wagtail/contrib/simple_translation/forms.py
@@ -14,13 +14,27 @@ def create_option(self, *args, **kwargs):
         option = super().create_option(*args, **kwargs)
         if option["value"] in self.disabled_values:
             option["attrs"]["disabled"] = True
+        else:
+            # Only set target/action if not disabled to ignore change on disabled items
+            option["attrs"]["data-action"] = "w-bulk#toggle"
+            option["attrs"]["data-w-bulk-target"] = "item"
         return option
 
 
 class SubmitTranslationForm(forms.Form):
     # Note: We don't actually use select_all in Python, it is just the
     # easiest way to add the widget to the form. It's controlled in JS.
-    select_all = forms.BooleanField(label=gettext_lazy("Select all"), required=False)
+    select_all = forms.BooleanField(
+        label=gettext_lazy("Select all"),
+        required=False,
+        widget=forms.CheckboxInput(
+            attrs={
+                "data-action": "w-bulk#toggleAll",
+                "data-w-bulk-target": "all",
+            },
+        ),
+    )
+
     locales = forms.ModelMultipleChoiceField(
         label=gettext_lazy("Locales"),
         queryset=Locale.objects.none(),


Test patch:
diff --git a/client/src/controllers/BulkController.test.js b/client/src/controllers/BulkController.test.js
new file mode 100644
--- /dev/null
+++ b/client/src/controllers/BulkController.test.js
@@ -0,0 +1,118 @@
+import { Application } from '@hotwired/stimulus';
+import { BulkController } from './BulkController';
+
+describe('BulkController', () => {
+  beforeEach(() => {
+    document.body.innerHTML = `
+    <div data-controller="w-bulk">
+      <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">
+        <input type="checkbox" data-w-bulk-target="item" data-action="w-bulk#toggle">
+        <input type="checkbox" data-w-bulk-target="item" data-action="w-bulk#toggle">
+      </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>
+    `;
+    const application = Application.start();
+    application.register('w-bulk', BulkController);
+  });
+
+  it('selects all checkboxes when the select all checkbox is clicked', () => {
+    const allCheckbox = document.getElementById('select-all');
+
+    allCheckbox.click();
+
+    expect(
+      document
+        .getElementById('checkboxes')
+        .querySelectorAll(':checked:not(:disabled)').length,
+    ).toEqual(2);
+
+    allCheckbox.click();
+
+    expect(
+      document
+        .getElementById('checkboxes')
+        .querySelectorAll(':checked:not(:disabled)').length,
+    ).toEqual(0);
+  });
+
+  it('should keep the select all checkbox in sync when individual checkboxes are all ticked', () => {
+    const allCheckbox = document.getElementById('select-all');
+    expect(allCheckbox.checked).toBe(false);
+
+    [
+      ...document.querySelectorAll(
+        '[data-w-bulk-target="item"]:not([disabled])',
+      ),
+    ].forEach((itemCheckbox) => {
+      itemCheckbox.click();
+    });
+
+    expect(allCheckbox.checked).toBe(true);
+
+    [
+      ...document.querySelectorAll(
+        '[data-w-bulk-target="item"]:not([disabled])',
+      ),
+    ].forEach((itemCheckbox) => {
+      itemCheckbox.click();
+    });
+
+    expect(allCheckbox.checked).toBe(false);
+  });
+
+  it('executes the correct action when the Clear all button is clicked', () => {
+    const allCheckbox = document.getElementById('select-all');
+    const clearAllButton = document.getElementById('clear');
+    expect(allCheckbox.checked).toBe(false);
+
+    [
+      ...document.querySelectorAll(
+        '[data-w-bulk-target="item"]:not([disabled])',
+      ),
+    ].forEach((itemCheckbox) => {
+      itemCheckbox.click();
+    });
+
+    expect(allCheckbox.checked).toBe(true);
+
+    clearAllButton.click();
+
+    expect(
+      document
+        .getElementById('checkboxes')
+        .querySelectorAll(':checked:not(:disabled)').length,
+    ).toEqual(0);
+    expect(allCheckbox.checked).toBe(false);
+  });
+
+  it('executes the correct action when the Set all button is clicked', () => {
+    const allCheckbox = document.getElementById('select-all');
+    const setAllButton = document.getElementById('set');
+
+    expect(allCheckbox.checked).toBe(false);
+
+    const checkboxes = document.querySelectorAll(
+      '[data-w-bulk-target="item"]:not([disabled])',
+    );
+
+    expect(checkboxes.length).toEqual(2);
+
+    setAllButton.click();
+
+    expect(
+      document
+        .getElementById('checkboxes')
+        .querySelectorAll(':checked:not(:disabled)').length,
+    ).toEqual(2);
+
+    expect(allCheckbox.checked).toBe(true);
+
+    checkboxes.forEach((itemCheckbox) => {
+      expect(itemCheckbox.checked).toBe(true);
+    });
+  });
+});