feat: add thread list action injectors
These injectors replace the functions which add an element or a generic
button to the bulk actions toolbar in thread lists.
In the future, all features which inject an action there (batch lock,
workflows, and the soon to be developed bulk move feature) will use
this.
Bug: twpowertools:51
Change-Id: I6a6a636cfaf72b1536399edc8ccf64156cb53197
diff --git a/src/contentScripts/communityConsole/utils/common.js b/src/contentScripts/communityConsole/utils/common.js
index e56a9ed..598dd4c 100644
--- a/src/contentScripts/communityConsole/utils/common.js
+++ b/src/contentScripts/communityConsole/utils/common.js
@@ -39,6 +39,8 @@
* Adds a button to the thread list actions bar next to the button given by
* |originalBtn|. The button will have icon |icon|, when hovered it will display
* |tooltip|, and will have a debugid attribute with value |debugId|.
+ *
+ * @deprecated Use CCThreadListGenericActionButtonInjectorAdapter
*/
export function addButtonToThreadListActions(
originalBtn, icon, debugId, tooltip) {
@@ -64,6 +66,8 @@
* Returns true if |node| is the "mark as read/unread" button, the parent of the
* parent of |node| is the actions bar of the thread list, and the button with
* debugid |debugid| is NOT part of the actions bar.
+ *
+ * @deprecated Use CCThreadListActionInjectorAdapter
*/
export function shouldAddBtnToActionBar(debugid, node) {
return node?.tagName == 'MATERIAL-BUTTON' &&
diff --git a/src/infrastructure/ui/injectors/communityConsole/threadListAction.injector.adapter.test.ts b/src/infrastructure/ui/injectors/communityConsole/threadListAction.injector.adapter.test.ts
new file mode 100644
index 0000000..454930c
--- /dev/null
+++ b/src/infrastructure/ui/injectors/communityConsole/threadListAction.injector.adapter.test.ts
@@ -0,0 +1,119 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { CCThreadListActionInjectorAdapter } from './threadListAction.injector.adapter';
+
+const DUMMY_KEY = 'dummy-key';
+
+function setUpDOMWithActions(actionsInnerHTML: string) {
+ document.body.innerHTML = `
+ <ec-bulk-actions>
+ <div class="selection"></div>
+ <div class="actions">
+ ${actionsInnerHTML}
+ </div>
+ </ec-bulk-actions>
+ `;
+}
+
+function createTestElement() {
+ return document.createElement('div');
+}
+
+let sut: CCThreadListActionInjectorAdapter;
+beforeEach(() => {
+ document.body.innerHTML = '';
+ sut = new CCThreadListActionInjectorAdapter();
+});
+
+describe("if the element hasn't been injected yet", () => {
+ describe('when the mark duplicate button is present', () => {
+ const ACTIONS_HTML = `
+ <div debugid="mark-read-button"></div>
+ <div debugid="mark-duplicate-button"></div>
+ <div debugid="unrelated-button"></div>
+ `;
+
+ it('should inject the element after the mark duplicate button if present', () => {
+ setUpDOMWithActions(ACTIONS_HTML);
+
+ const testElement = createTestElement();
+ sut.execute({ element: testElement, key: DUMMY_KEY });
+
+ const duplicateBtn = document.querySelector(
+ '[debugid="mark-duplicate-button"]',
+ );
+ expect(duplicateBtn.nextElementSibling).toBe(testElement);
+ });
+
+ it('should return true', () => {
+ setUpDOMWithActions(ACTIONS_HTML);
+
+ const testElement = createTestElement();
+ const result = sut.execute({ element: testElement, key: DUMMY_KEY });
+
+ expect(result).toBe(true);
+ });
+ });
+
+ it('should inject the element after the mark read button if the mark duplicate button is not present', () => {
+ setUpDOMWithActions(`
+ <div debugid="mark-read-button"></div>
+ <div debugid="unrelated-button"></div>
+ `);
+
+ const testElement = createTestElement();
+ sut.execute({ element: testElement, key: DUMMY_KEY });
+
+ const markReadButton = document.querySelector(
+ '[debugid="mark-read-button"]',
+ );
+ expect(markReadButton.nextElementSibling).toBe(testElement);
+ });
+
+ it('should inject the element after the mark unread button if neither the mark read and the mark duplicate button are present', () => {
+ setUpDOMWithActions(`
+ <div debugid="mark-unread-button"></div>
+ <div debugid="unrelated-button"></div>
+ `);
+
+ const testElement = createTestElement();
+ sut.execute({ element: testElement, key: DUMMY_KEY });
+
+ const markReadButton = document.querySelector(
+ '[debugid="mark-unread-button"]',
+ );
+ expect(markReadButton.nextElementSibling).toBe(testElement);
+ });
+
+ it('should throw an error if none of the reference buttons are present', () => {
+ setUpDOMWithActions(`
+ <div debugid="unrelated-button"></div>
+ `);
+
+ const testElement = createTestElement();
+ expect(() =>
+ sut.execute({ element: testElement, key: DUMMY_KEY }),
+ ).toThrow();
+ });
+
+ it('should throw an error if the bulk actions toolbar is not present', () => {
+ const testElement = createTestElement();
+ expect(() =>
+ sut.execute({ element: testElement, key: DUMMY_KEY }),
+ ).toThrow();
+ });
+});
+
+describe('if the element has been already injected', () => {
+ it('should return false', () => {
+ setUpDOMWithActions(`
+ <div debugid="mark-read-button"></div>
+ <div debugid="${DUMMY_KEY}"></div>
+ <div debugid="unrelated-button"></div>
+ `);
+
+ const testElement = createTestElement();
+ const result = sut.execute({ element: testElement, key: DUMMY_KEY });
+
+ expect(result).toBe(false);
+ });
+});
diff --git a/src/infrastructure/ui/injectors/communityConsole/threadListAction.injector.adapter.ts b/src/infrastructure/ui/injectors/communityConsole/threadListAction.injector.adapter.ts
new file mode 100644
index 0000000..acf760f
--- /dev/null
+++ b/src/infrastructure/ui/injectors/communityConsole/threadListAction.injector.adapter.ts
@@ -0,0 +1,40 @@
+import {
+ ThreadListActionInjectorOptions,
+ ThreadListActionInjectorPort,
+} from '../../../../ui/injectors/threadListAction.injector.port';
+
+export class CCThreadListActionInjectorAdapter
+ implements ThreadListActionInjectorPort
+{
+ execute({ element, key }: ThreadListActionInjectorOptions): boolean {
+ const actionsContainer = document.querySelector('ec-bulk-actions .actions');
+ if (actionsContainer === null) {
+ throw new Error('Could not find thread list actions container.');
+ }
+
+ if (!this.shouldAddNodeToActionBar(key, actionsContainer)) {
+ return false;
+ }
+
+ // We provide a fallback just in case the duplicate button disappears for
+ // some reason.
+ const referenceBtn =
+ actionsContainer.querySelector('[debugid="mark-duplicate-button"]') ??
+ actionsContainer.querySelector(
+ '[debugid="mark-read-button"], [debugid="mark-unread-button"]',
+ );
+ if (referenceBtn === null) {
+ throw new Error(
+ 'Could not find a reference button in the bulk actions toolbar.',
+ );
+ }
+
+ element.setAttribute('debugid', key);
+ referenceBtn.after(element);
+ return true;
+ }
+
+ private shouldAddNodeToActionBar(key: string, actionsContainer: Element) {
+ return !actionsContainer.querySelector(`[debugid="${key}"]`);
+ }
+}
diff --git a/src/infrastructure/ui/injectors/communityConsole/threadListGenericActionButton.injector.adapter.test.ts b/src/infrastructure/ui/injectors/communityConsole/threadListGenericActionButton.injector.adapter.test.ts
new file mode 100644
index 0000000..5e37d6b
--- /dev/null
+++ b/src/infrastructure/ui/injectors/communityConsole/threadListGenericActionButton.injector.adapter.test.ts
@@ -0,0 +1,134 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { CCThreadListGenericActionButtonInjectorAdapter } from './threadListGenericActionButton.injector.adapter';
+import { CCThreadListActionInjectorAdapter } from './threadListAction.injector.adapter';
+
+const DUMMY_ICON = 'dummy-icon';
+const DUMMY_KEY = 'dummy-key';
+const DUMMY_TOOLTIP = 'dummy-tooltip';
+
+function setUpDOMWithActions(actionsInnerHTML: string) {
+ document.body.innerHTML = `
+ <ec-bulk-actions>
+ <div class="selection"></div>
+ <div class="actions">
+ ${actionsInnerHTML}
+ </div>
+ </ec-bulk-actions>
+ `;
+}
+
+const chromeI18nGetMessageMock: typeof chrome.i18n.getMessage = vi.fn();
+global.chrome = {
+ i18n: {
+ getMessage: chromeI18nGetMessageMock,
+ } as typeof chrome.i18n,
+} as typeof chrome;
+
+const injector = new CCThreadListActionInjectorAdapter();
+const injectorExecuteSpy = vi.spyOn(injector, 'execute');
+
+let sut: CCThreadListGenericActionButtonInjectorAdapter;
+beforeEach(() => {
+ vi.resetAllMocks();
+ document.body.innerHTML = '';
+ sut = new CCThreadListGenericActionButtonInjectorAdapter(injector);
+});
+
+describe('when a reference button exists', () => {
+ // This is a copy of the vanilla HTML rendered by the Community Console.
+ const ACTIONS_HTML = `
+ <material-button
+ animated="true"
+ debugid="mark-duplicate-button"
+ icon=""
+ class="_ngcontent-lit-37 _nghost-lit-2"
+ aria-label="Mark as duplicate"
+ tabindex="0"
+ role="button"
+ aria-disabled="false"
+ elevation="1"
+ >
+ <div class="content _ngcontent-lit-2">
+ <material-icon
+ icon="content_copy"
+ class="_ngcontent-lit-37
+ _nghost-lit-3"
+ >
+ <i
+ class="material-icon-i material-icons-extended _ngcontent-lit-3"
+ role="img"
+ aria-hidden="true"
+ >
+ content_copy
+ </i>
+ </material-icon>
+ </div>
+ <material-ripple aria-hidden="true" class="_ngcontent-lit-2">
+ </material-ripple>
+ <div class="touch-target _ngcontent-lit-2"></div>
+ <div class="focus-ring _ngcontent-lit-2"></div>
+ </material-button>
+ `;
+
+ beforeEach(() => {
+ setUpDOMWithActions(ACTIONS_HTML);
+ });
+
+ it('should call the thread list action injector', () => {
+ sut.execute({ icon: DUMMY_ICON, key: DUMMY_KEY });
+
+ expect(injectorExecuteSpy).toHaveBeenCalledOnce();
+ });
+
+ it('should call the thread list action injector with the supplied key', () => {
+ sut.execute({ icon: DUMMY_ICON, key: DUMMY_KEY });
+
+ expect(injectorExecuteSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ key: DUMMY_KEY }),
+ );
+ });
+
+ describe('regarding the element passed to the thread list action injector', () => {
+ function getInjectedElement() {
+ return injectorExecuteSpy.mock.lastCall?.[0].element;
+ }
+
+ describe('when not passed a tooltip', () => {
+ let injectedElement: Element;
+ beforeEach(() => {
+ sut.execute({ icon: DUMMY_ICON, key: DUMMY_KEY });
+
+ injectedElement = getInjectedElement();
+ });
+
+ it('should be a clone of the reference button', () => {
+ expect(injectedElement).toBeDefined();
+ expect(injectedElement.tagName).toBe('MATERIAL-BUTTON');
+ expect(injectedElement.className).toContain('_ngcontent-lit-37 ');
+ expect(injectedElement.className).toContain('_nghost-lit-2');
+ });
+
+ it('should include the supplied icon', () => {
+ const injectedElementIcon =
+ injectedElement.querySelector('material-icon');
+ expect(injectedElementIcon.textContent.trim()).toBe(DUMMY_ICON);
+ });
+
+ it('should have the key in the debugid attribute', () => {
+ expect(injectedElement.getAttribute('debugid')).toBe(DUMMY_KEY);
+ });
+ });
+
+ it('should inject a tooltip when passed', () => {
+ sut.execute({ icon: DUMMY_ICON, key: DUMMY_KEY, tooltip: DUMMY_TOOLTIP });
+
+ expect(document.body.innerHTML).toContain(DUMMY_TOOLTIP);
+ });
+ });
+});
+
+it('should throw an error when a reference button is not found', () => {
+ setUpDOMWithActions('');
+
+ expect(() => sut.execute({ icon: DUMMY_ICON, key: DUMMY_KEY })).toThrow();
+});
diff --git a/src/infrastructure/ui/injectors/communityConsole/threadListGenericActionButton.injector.adapter.ts b/src/infrastructure/ui/injectors/communityConsole/threadListGenericActionButton.injector.adapter.ts
new file mode 100644
index 0000000..9fdfe49
--- /dev/null
+++ b/src/infrastructure/ui/injectors/communityConsole/threadListGenericActionButton.injector.adapter.ts
@@ -0,0 +1,60 @@
+import { MDCTooltip } from "@material/tooltip";
+import { createExtBadge } from "../../../../contentScripts/communityConsole/utils/common";
+import { ThreadListActionInjectorPort } from "../../../../ui/injectors/threadListAction.injector.port";
+import { ThreadListGenericActionButtonInjectorPort, ThreadListGenericActionButtonOptions } from "../../../../ui/injectors/threadListGenericActionButton.injector.port";
+import { createPlainTooltip } from "../../../../common/tooltip";
+
+export class CCThreadListGenericActionButtonInjectorAdapter implements ThreadListGenericActionButtonInjectorPort {
+ constructor(private threadListActionInjector: ThreadListActionInjectorPort) {}
+
+ execute(options: ThreadListGenericActionButtonOptions): Element {
+ const button = this.createGenericButton();
+ button.setAttribute('debugid', options.key);
+ button.classList.add('TWPT-btn--with-badge');
+ button.querySelector('material-icon').setAttribute('icon', options.icon);
+ button.querySelector('i.material-icon-i').textContent = options.icon;
+
+ // TODO(https://iavm.xyz/b/twpowertools/230): fix these types.
+ const [badge, badgeTooltip] = createExtBadge() as [HTMLDivElement, any];
+ button.append(badge);
+
+ this.threadListActionInjector.execute({
+ element: button,
+ key: options.key,
+ });
+
+ if (options.tooltip !== undefined) {
+ createPlainTooltip(button, options.tooltip);
+ }
+ new MDCTooltip(badgeTooltip);
+
+ return button;
+ }
+
+ private createGenericButton() {
+ const referenceBtn = this.getReferenceBtn();
+ const clone = referenceBtn.cloneNode(true);
+ if (!(clone instanceof HTMLElement)) {
+ throw new Error('The cloned reference button is not an HTMLElement.');
+ }
+ return clone;
+ }
+
+ private getReferenceBtn() {
+ const actionsContainer = document.querySelector('ec-bulk-actions .actions');
+ if (actionsContainer === null) {
+ throw new Error('Could not find thread list actions container.');
+ }
+
+ const referenceBtn = actionsContainer.querySelector('[debugid="mark-duplicate-button"]') ??
+ actionsContainer.querySelector(
+ '[debugid="mark-read-button"], [debugid="mark-unread-button"]'
+ );
+ if (referenceBtn === null) {
+ throw new Error(
+ 'Could not find a reference button in the bulk actions toolbar.'
+ );
+ }
+ return referenceBtn;
+ }
+}
diff --git a/src/ui/injectors/threadListAction.injector.port.ts b/src/ui/injectors/threadListAction.injector.port.ts
new file mode 100644
index 0000000..48cd3c3
--- /dev/null
+++ b/src/ui/injectors/threadListAction.injector.port.ts
@@ -0,0 +1,19 @@
+export interface ThreadListActionInjectorOptions {
+ /* Element to insert into the bulk actions toolbar. */
+ element: Element;
+
+ /**
+ * Unique key to identify the action being added. This prevents inserting it
+ * multiple times in the toolbar.
+ */
+ key: string;
+};
+
+export interface ThreadListActionInjectorPort {
+ /**
+ * Inject a bulk action element into the thread list bulk actions toolbar.
+ *
+ * @returns True if the element was injected, or false if it already exists.
+ */
+ execute(options: ThreadListActionInjectorOptions): boolean;
+}
diff --git a/src/ui/injectors/threadListGenericActionButton.injector.port.ts b/src/ui/injectors/threadListGenericActionButton.injector.port.ts
new file mode 100644
index 0000000..e1c96f8
--- /dev/null
+++ b/src/ui/injectors/threadListGenericActionButton.injector.port.ts
@@ -0,0 +1,20 @@
+export interface ThreadListGenericActionButtonOptions {
+ /* Material design icon codename for the button. */
+ icon: string,
+
+ /* Key which univocally identifies the action. */
+ key: string,
+
+ /* Optional text to show in a tooltip. */
+ tooltip?: string,
+}
+
+export interface ThreadListGenericActionButtonInjectorPort {
+ /**
+ * Inject a generic button into the thread list bulk actions toolbar.
+ *
+ * @returns The button element if it could be injected, or false if it already
+ * exists.
+ */
+ execute(options: ThreadListGenericActionButtonOptions): Element | null;
+}