Merge pull request #23329 from opf/code-maintenance/75300-stimulus-test-helpers

[#75300] Stimulus: shared test helper infrastructure
This commit is contained in:
Alexander Brandon Coles
2026-05-27 10:57:14 +02:00
committed by GitHub
19 changed files with 1428 additions and 270 deletions
+1 -1
View File
@@ -142,7 +142,7 @@
"tsConfig": "tsconfig.spec.json",
"buildTarget": "OpenProject:build",
"providersFile": "src/test-providers.ts",
"setupFiles": ["src/test-setup.ts"],
"setupFiles": ["src/test-browser-polyfills.ts", "src/test-setup.ts"],
"browsers": ["chromium", "firefox", "webkit"],
"reporters": ["dot"]
}
+154
View File
@@ -139,6 +139,7 @@
"@html-eslint/parser": "^0.60.0",
"@stylistic/eslint-plugin": "^5.7.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/codemirror": "5.60.5",
"@types/dom-navigation": "^1.0.7",
@@ -184,6 +185,13 @@
"fsevents": "*"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@algolia/abtesting": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz",
@@ -9267,6 +9275,33 @@
"dequal": "^2.0.3"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
@@ -13481,6 +13516,13 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -16570,6 +16612,16 @@
"node": ">=0.8.19"
}
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -19317,6 +19369,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/mini-css-extract-plugin": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz",
@@ -21693,6 +21755,20 @@
"node": ">=8.10.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -23438,6 +23514,19 @@
"node": ">=4"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -25967,6 +26056,12 @@
}
},
"dependencies": {
"@adobe/css-tools": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
"dev": true
},
"@algolia/abtesting": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz",
@@ -31542,6 +31637,28 @@
}
}
},
"@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"requires": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"dependencies": {
"dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true
}
}
},
"@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
@@ -34437,6 +34554,12 @@
"integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==",
"dev": true
},
"css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true
},
"cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -36670,6 +36793,12 @@
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true
},
"indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true
},
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -38460,6 +38589,12 @@
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="
},
"min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true
},
"mini-css-extract-plugin": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz",
@@ -40091,6 +40226,16 @@
"picomatch": "^2.2.1"
}
},
"redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"requires": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
}
},
"reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -41344,6 +41489,15 @@
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
"dev": true
},
"strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"requires": {
"min-indent": "^1.0.0"
}
},
"strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@html-eslint/parser": "^0.60.0",
"@stylistic/eslint-plugin": "^5.7.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/codemirror": "5.60.5",
"@types/dom-navigation": "^1.0.7",
@@ -27,122 +27,105 @@
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
import { Application } from '@hotwired/stimulus';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
import CheckAllController from './check-all.controller';
import CheckableController from './checkable.controller';
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const checkAllTemplate = `
<div data-controller="check-all" data-check-all-checkable-outlet="#checkables">
<button id="check-all" data-action="check-all#checkAll">Check all</button>
<button id="uncheck-all" data-action="check-all#uncheckAll">Uncheck all</button>
</div>
`;
const checkableTemplate = `
<div id="checkables" data-controller="checkable">
<input type="checkbox" data-checkable-target="checkbox">
<input type="checkbox" data-checkable-target="checkbox">
<input type="checkbox" data-checkable-target="checkbox">
</div>
`;
describe('CheckAllController', () => {
let Stimulus:Application;
let fixturesElement:HTMLElement;
beforeEach(() => {
fixturesElement = document.createElement('div');
document.body.appendChild(fixturesElement);
});
let ctx:StimulusTestContext;
beforeEach(async () => {
Stimulus = Application.start();
// Stimulus.debug = true;
Stimulus.handleError = (error, message, detail) => {
console.error(error, message, detail);
};
Stimulus.register('checkable', CheckableController);
Stimulus.register('check-all', CheckAllController);
await nextFrame();
ctx = await setupStimulusTest({
controllers: {
'check-all': CheckAllController,
checkable: CheckableController,
},
});
});
const checkAllTemplate = `
<div data-controller="check-all" data-check-all-checkable-outlet="#checkables">
<button id="check-all" data-action="check-all#checkAll">Check all</button>
<button id="uncheck-all" data-action="check-all#uncheckAll">Uncheck all</button>
</div>
`;
const checkableTemplate = `
<div id="checkables" data-controller="checkable">
<input type="checkbox" data-checkable-target="checkbox">
<input type="checkbox" data-checkable-target="checkbox">
<input type="checkbox" data-checkable-target="checkbox">
</div>
`;
function appendTemplate(html:string) {
const template = document.createElement('template');
template.innerHTML = html.trim();
fixturesElement.appendChild(template.content.cloneNode(true));
}
afterEach(() => ctx.dispose());
describe('without checkable controller', () => {
beforeEach(async () => {
appendTemplate(checkAllTemplate);
await nextFrame();
ctx.appendHTML(checkAllTemplate);
await ctx.nextFrame();
});
it('does nothing and does not error', () => {
expect(() => {
document.getElementById('check-all')!.click();
document.getElementById('uncheck-all')!.click();
ctx.screen.getByRole('button', { name: 'Check all' }).click();
ctx.screen.getByRole('button', { name: 'Uncheck all' }).click();
}).not.toThrow();
});
});
describe('with checkable controller', () => {
beforeEach(async () => {
appendTemplate(checkableTemplate);
appendTemplate(checkAllTemplate);
await nextFrame();
ctx.appendHTML(checkableTemplate);
ctx.appendHTML(checkAllTemplate);
await ctx.nextFrame();
});
it('toggles checkboxes', async () => {
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'));
const inputs = ctx.screen.getAllByRole('checkbox');
expect(inputs).toHaveLength(3);
expect(inputs.every((i) => !i.checked)).toBe(true);
inputs.forEach((input) => {
expect(input).not.toBeChecked();
});
document.getElementById('check-all')!.click();
await nextFrame();
ctx.screen.getByRole('button', { name: 'Check all' }).click();
await ctx.nextFrame();
expect(inputs.every((i) => i.checked)).toBe(true);
inputs.forEach((input) => {
expect(input).toBeChecked();
});
document.getElementById('uncheck-all')!.click();
await nextFrame();
ctx.screen.getByRole('button', { name: 'Uncheck all' }).click();
await ctx.nextFrame();
expect(inputs.every((i) => !i.checked)).toBe(true);
inputs.forEach((input) => {
expect(input).not.toBeChecked();
});
});
it('applies aria-controls for connected outlet', () => {
const checkAllEl = document.querySelector('[data-controller="check-all"]')!;
const checkAllEl = ctx.container.querySelector('[data-controller="check-all"]')!;
expect(checkAllEl).toBeDefined();
expect(checkAllEl).toHaveAttribute('aria-controls');
const ariaControls = checkAllEl.getAttribute('aria-controls');
const ariaControls = checkAllEl.getAttribute('aria-controls')!;
expect(ariaControls).toBeTruthy();
expect(ariaControls!.split(/\s+/)).toContain('checkables');
expect(ariaControls.split(/\s+/)).toContain('checkables');
});
it('removes aria-controls entry when outlet disconnects', async () => {
const checkAllEl = document.querySelector('[data-controller="check-all"]')!;
const checkAllEl = ctx.container.querySelector('[data-controller="check-all"]')!;
const ariaBefore = checkAllEl.getAttribute('aria-controls') ?? '';
// Scenarios with connected checkable outlets
expect(ariaBefore.split(/\s+/)).toContain('checkables');
// Remove the outlet element to trigger outlet disconnect
document.getElementById('checkables')!.remove();
await nextFrame();
ctx.container.querySelector('#checkables')!.remove();
await ctx.nextFrame();
const ariaAfter = checkAllEl.getAttribute('aria-controls') ?? '';
expect(ariaAfter.split(/\s+/)).not.toContain('checkables');
});
});
afterEach(() => {
fixturesElement.remove();
Stimulus.stop();
});
});
@@ -31,14 +31,14 @@
import { ActionEvent } from '@hotwired/stimulus';
import CheckableController from './checkable.controller';
import { createControllerInstance } from 'core-stimulus/test-helpers';
describe('CheckableController', () => {
let controller:any;
let inputs:HTMLInputElement[];
beforeEach(() => {
// Create a plain object that uses the controller prototype so we can call methods
controller = Object.create(CheckableController.prototype);
controller = createControllerInstance(CheckableController);
inputs = [0, 1, 2].map(() => {
const input = document.createElement('input');
@@ -103,7 +103,6 @@ describe('CheckableController', () => {
});
describe('toggleSelection', () => {
// Helper to create an ActionEvent-like object with params
function createActionEvent(params:ActionEvent['params']):ActionEvent {
const event = new Event('click') as ActionEvent;
event.params = params;
@@ -129,7 +128,6 @@ describe('CheckableController', () => {
});
it('toggles only checkboxes matching the key/value pair', () => {
// Add data attributes to checkboxes
inputs[0].dataset.role = 'admin';
inputs[1].dataset.role = 'member';
inputs[2].dataset.role = 'admin';
@@ -138,7 +136,6 @@ describe('CheckableController', () => {
controller.toggleSelection(event);
// Only admin checkboxes should be checked
expect(inputs[0].checked).toBe(true);
expect(inputs[1].checked).toBe(false);
expect(inputs[2].checked).toBe(true);
@@ -156,7 +153,6 @@ describe('CheckableController', () => {
controller.toggleSelection(event);
// Only admin checkboxes should be unchecked
expect(inputs[0].checked).toBe(false);
expect(inputs[1].checked).toBe(true); // member stays checked
expect(inputs[2].checked).toBe(false);
@@ -0,0 +1,155 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
import OpDisableWhenCheckedController from './disable-when-checked.controller';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
describe('OpDisableWhenCheckedController', () => {
let ctx:StimulusTestContext;
beforeEach(async () => {
ctx = await setupStimulusTest({
controllers: { 'disable-when-checked': OpDisableWhenCheckedController },
});
});
afterEach(() => ctx.dispose());
describe('basic disable on check', () => {
beforeEach(async () => {
ctx.appendHTML(`
<div data-controller="disable-when-checked">
<label>
<input type="checkbox"
data-disable-when-checked-target="cause"
data-target-name="group1">
Toggle
</label>
<input type="text"
data-disable-when-checked-target="effect"
data-target-name="group1"
aria-label="Text field">
</div>
`);
await ctx.nextFrame();
});
it('disables effect targets when cause is checked', async () => {
const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' });
const textField = ctx.screen.getByRole('textbox', { name: 'Text field' });
expect(textField).toBeEnabled();
checkbox.click();
await ctx.nextFrame();
expect(textField).toBeDisabled();
});
it('re-enables effect targets when cause is unchecked', async () => {
const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' });
const textField = ctx.screen.getByRole('textbox', { name: 'Text field' });
checkbox.click();
await ctx.nextFrame();
expect(textField).toBeDisabled();
checkbox.click();
await ctx.nextFrame();
expect(textField).toBeEnabled();
});
});
describe('reversed mode', () => {
beforeEach(async () => {
ctx.appendHTML(`
<div data-controller="disable-when-checked"
data-disable-when-checked-reversed-value="true">
<label>
<input type="checkbox"
data-disable-when-checked-target="cause"
data-target-name="group1">
Toggle
</label>
<input type="text"
data-disable-when-checked-target="effect"
data-target-name="group1"
aria-label="Text field">
</div>
`);
await ctx.nextFrame();
});
it('enables effect targets when cause is checked', async () => {
const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' });
const textField = ctx.screen.getByRole('textbox', { name: 'Text field' });
checkbox.click();
await ctx.nextFrame();
expect(textField).toBeEnabled();
});
});
describe('select option handling', () => {
it('resets select value when selected option becomes disabled', async () => {
ctx.appendHTML(`
<div data-controller="disable-when-checked">
<label>
<input type="checkbox"
data-disable-when-checked-target="cause"
data-target-name="opts">
Toggle
</label>
<select aria-label="Options">
<option value="">-- Select --</option>
<option value="a"
data-disable-when-checked-target="effect"
data-target-name="opts"
selected>Option A</option>
<option value="b">Option B</option>
</select>
</div>
`);
await ctx.nextFrame();
const select = ctx.container.querySelector<HTMLSelectElement>('select[aria-label="Options"]')!;
expect(select.value).toBe('a');
ctx.screen.getByRole('checkbox', { name: 'Toggle' }).click();
await ctx.nextFrame();
expect(select.value).toBe('');
});
});
});
@@ -0,0 +1,79 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
import DisableWhenClickedController from './disable-when-clicked.controller';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
describe('DisableWhenClickedController', () => {
let ctx:StimulusTestContext;
beforeEach(async () => {
ctx = await setupStimulusTest({
controllers: { 'disable-when-clicked': DisableWhenClickedController },
});
});
afterEach(() => ctx.dispose());
it('disables button after click', async () => {
ctx.appendHTML(`
<button data-controller="disable-when-clicked">
Submit
</button>
`);
await ctx.nextFrame();
const button = ctx.screen.getByRole('button', { name: 'Submit' });
button.click();
// setTimeout(fn) defers by one task; nextFrame (rAF) fires after
await ctx.nextFrame();
expect(button).toBeDisabled();
});
it('replaces button text when text value is set', async () => {
ctx.appendHTML(`
<button data-controller="disable-when-clicked"
data-disable-when-clicked-text-value="Processing...">
Submit
</button>
`);
await ctx.nextFrame();
const button = ctx.screen.getByRole('button', { name: 'Submit' });
button.click();
await ctx.nextFrame();
expect(button).toHaveTextContent('Processing...');
expect(button).toBeDisabled();
});
});
@@ -29,12 +29,13 @@
*/
import GenericDragAndDropController from './generic-drag-and-drop.controller';
import { createControllerInstance } from 'core-stimulus/test-helpers';
describe('GenericDragAndDropController', () => {
let controller:GenericDragAndDropController;
beforeEach(() => {
controller = Object.create(GenericDragAndDropController.prototype) as GenericDragAndDropController;
controller = createControllerInstance(GenericDragAndDropController);
});
function setValue(name:'handleValue' | 'handleSelectorValue', value:boolean | string) {
@@ -0,0 +1,117 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import FlashController, { SUCCESS_AUTOHIDE_TIMEOUT } from './flash.controller';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
describe('FlashController', () => {
let ctx:StimulusTestContext;
beforeEach(async () => {
ctx = await setupStimulusTest({
controllers: { flash: FlashController },
});
});
afterEach(() => ctx.dispose());
describe('without autohide', () => {
it('keeps flash items visible', async () => {
ctx.appendHTML(`
<div data-controller="flash">
<div data-flash-target="item" data-autohide="true" role="alert">
Success message
</div>
</div>
`);
await ctx.nextFrame();
expect(ctx.screen.getByRole('alert')).toBeInTheDocument();
});
});
describe('with autohide', () => {
it('schedules removal of autohide items', async () => {
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
ctx.appendHTML(`
<div data-controller="flash" data-flash-autohide-value="true">
<div data-flash-target="item" data-autohide="true" role="alert">
Success message
</div>
</div>
`);
await ctx.nextFrame();
const autohideCall = timeoutSpy.mock.calls.find(([, delay]) => delay === SUCCESS_AUTOHIDE_TIMEOUT);
expect(autohideCall).toBeDefined();
timeoutSpy.mockRestore();
});
it('does not schedule removal for items without data-autohide', async () => {
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
ctx.appendHTML(`
<div data-controller="flash" data-flash-autohide-value="true">
<div data-flash-target="item" role="alert">
Error message
</div>
</div>
`);
await ctx.nextFrame();
const autohideCall = timeoutSpy.mock.calls.find(([, delay]) => delay === SUCCESS_AUTOHIDE_TIMEOUT);
expect(autohideCall).toBeUndefined();
timeoutSpy.mockRestore();
});
});
describe('flashTargetDisconnected', () => {
it('removes empty item containers when flash target is removed', async () => {
ctx.appendHTML(`
<div data-controller="flash">
<div data-flash-target="item" data-testid="item-container"></div>
<div data-flash-target="flash" data-testid="flash-content">Content</div>
</div>
`);
await ctx.nextFrame();
ctx.screen.getByTestId('flash-content').remove();
await ctx.nextFrame();
expect(ctx.screen.queryByTestId('item-container')).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,62 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import ScrollIntoViewController from './scroll-into-view.controller';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
describe('ScrollIntoViewController', () => {
let ctx:StimulusTestContext;
beforeEach(async () => {
ctx = await setupStimulusTest({
controllers: { 'scroll-into-view': ScrollIntoViewController },
});
});
afterEach(() => ctx.dispose());
it('calls scrollIntoView on connect', async () => {
const scrollSpy = vi.spyOn(Element.prototype, 'scrollIntoView');
ctx.appendHTML(`
<div data-controller="scroll-into-view">
Target element
</div>
`);
// Two frames: one for Stimulus connect, one for the setTimeout(fn, 0)
await ctx.nextFrame();
await ctx.nextFrame();
expect(scrollSpy).toHaveBeenCalledWith({ block: 'center' });
scrollSpy.mockRestore();
});
});
@@ -0,0 +1,99 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
import { waitFor } from '@testing-library/dom';
import SelectAutosizeController from './select-autosize.controller';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
describe('SelectAutosizeController', () => {
let ctx:StimulusTestContext;
beforeEach(async () => {
ctx = await setupStimulusTest({
controllers: { 'select-autosize': SelectAutosizeController },
});
});
afterEach(() => ctx.dispose());
// updateSize is debounced (100ms), so assertions use waitFor
it('sets size to option count on connect', async () => {
ctx.appendHTML(`
<select data-controller="select-autosize" aria-label="Items">
<option>A</option>
<option>B</option>
<option>C</option>
</select>
`);
await ctx.nextFrame();
const select = ctx.container.querySelector('select')!;
await waitFor(() => {
expect(select.size).toBe(3);
});
});
it('respects size limit', async () => {
const options = Array.from({ length: 15 }, (_, i) => `<option>Item ${i}</option>`).join('');
ctx.appendHTML(`
<select data-controller="select-autosize"
data-select-autosize-size-limit-value="5"
aria-label="Items">
${options}
</select>
`);
await ctx.nextFrame();
const select = ctx.container.querySelector('select')!;
await waitFor(() => {
expect(select.size).toBe(5);
});
});
it('defaults size limit to 10', async () => {
const options = Array.from({ length: 20 }, (_, i) => `<option>Item ${i}</option>`).join('');
ctx.appendHTML(`
<select data-controller="select-autosize" aria-label="Items">
${options}
</select>
`);
await ctx.nextFrame();
const select = ctx.container.querySelector('select')!;
await waitFor(() => {
expect(select.size).toBe(10);
});
});
});
@@ -0,0 +1,194 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
import OpShowWhenCheckedController from './show-when-checked.controller';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
describe('OpShowWhenCheckedController', () => {
let ctx:StimulusTestContext;
beforeEach(async () => {
ctx = await setupStimulusTest({
controllers: { 'show-when-checked': OpShowWhenCheckedController },
});
});
afterEach(() => ctx.dispose());
describe('show-when="checked"', () => {
beforeEach(async () => {
ctx.appendHTML(`
<div data-controller="show-when-checked">
<label>
<input type="checkbox"
data-show-when-checked-target="cause"
data-target-name="group1">
Toggle
</label>
<div data-show-when-checked-target="effect"
data-target-name="group1"
data-show-when="checked"
hidden
data-testid="conditional">
Conditional content
</div>
</div>
`);
await ctx.nextFrame();
});
it('shows element when checkbox is checked', async () => {
const el = ctx.screen.getByTestId('conditional');
expect(el).not.toBeVisible();
ctx.screen.getByRole('checkbox', { name: 'Toggle' }).click();
await ctx.nextFrame();
expect(el).toBeVisible();
});
it('hides element when checkbox is unchecked', async () => {
const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' });
const el = ctx.screen.getByTestId('conditional');
checkbox.click();
await ctx.nextFrame();
checkbox.click();
await ctx.nextFrame();
expect(el).not.toBeVisible();
});
});
describe('show-when="unchecked"', () => {
it('hides element when checkbox is checked, shows when unchecked', async () => {
ctx.appendHTML(`
<div data-controller="show-when-checked">
<label>
<input type="checkbox"
data-show-when-checked-target="cause"
data-target-name="group1">
Toggle
</label>
<div data-show-when-checked-target="effect"
data-target-name="group1"
data-show-when="unchecked"
hidden
data-testid="conditional">
Shown when unchecked
</div>
</div>
`);
await ctx.nextFrame();
const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' });
const el = ctx.screen.getByTestId('conditional');
expect(el).not.toBeVisible();
checkbox.click();
await ctx.nextFrame();
expect(el.hidden).toBe(true);
checkbox.click();
await ctx.nextFrame();
expect(el).toBeVisible();
});
});
describe('reversed mode', () => {
it('inverts the checked/unchecked logic', async () => {
ctx.appendHTML(`
<div data-controller="show-when-checked"
data-show-when-checked-reversed-value="true">
<label>
<input type="checkbox"
data-show-when-checked-target="cause"
data-target-name="group1">
Toggle
</label>
<div data-show-when-checked-target="effect"
data-target-name="group1"
data-show-when="checked"
data-testid="conditional">
Content
</div>
</div>
`);
await ctx.nextFrame();
const el = ctx.screen.getByTestId('conditional');
ctx.screen.getByRole('checkbox', { name: 'Toggle' }).click();
await ctx.nextFrame();
expect(el).not.toBeVisible();
});
});
describe('visibility toggle via data-set-visibility', () => {
it('uses CSS visibility instead of hidden attribute', async () => {
ctx.appendHTML(`
<div data-controller="show-when-checked">
<label>
<input type="checkbox"
data-show-when-checked-target="cause"
data-target-name="group1">
Toggle
</label>
<div data-show-when-checked-target="effect"
data-target-name="group1"
data-show-when="checked"
data-set-visibility="true"
style="visibility: hidden;"
data-testid="conditional">
Content
</div>
</div>
`);
await ctx.nextFrame();
const el = ctx.screen.getByTestId('conditional');
expect(el).not.toBeVisible();
expect(el.hidden).toBe(false);
ctx.screen.getByRole('checkbox', { name: 'Toggle' }).click();
await ctx.nextFrame();
expect(el).toBeVisible();
expect(el.hidden).toBe(false);
});
});
});
@@ -0,0 +1,162 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
import OpShowWhenValueSelectedController from './show-when-value-selected.controller';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
describe('OpShowWhenValueSelectedController', () => {
let ctx:StimulusTestContext;
beforeEach(async () => {
ctx = await setupStimulusTest({
controllers: { 'show-when-value-selected': OpShowWhenValueSelectedController },
});
});
afterEach(() => ctx.dispose());
describe('data-value matching', () => {
beforeEach(async () => {
ctx.appendHTML(`
<div data-controller="show-when-value-selected">
<select data-show-when-value-selected-target="cause"
data-target-name="type"
aria-label="Type">
<option value="">-- Select --</option>
<option value="a">Type A</option>
<option value="b">Type B</option>
</select>
<input type="text"
data-show-when-value-selected-target="effect"
data-target-name="type"
data-value="a"
hidden
disabled
aria-label="Effect A">
<input type="text"
data-show-when-value-selected-target="effect"
data-target-name="type"
data-value="b"
hidden
disabled
aria-label="Effect B">
</div>
`);
await ctx.nextFrame();
});
it('shows matching effect and hides non-matching', async () => {
const select = ctx.container.querySelector<HTMLSelectElement>('select[aria-label="Type"]')!;
const effectA = ctx.container.querySelector<HTMLInputElement>('input[aria-label="Effect A"]')!;
const effectB = ctx.container.querySelector<HTMLInputElement>('input[aria-label="Effect B"]')!;
expect(effectA.hidden).toBe(true);
expect(effectA).toBeDisabled();
expect(effectB.hidden).toBe(true);
expect(effectB).toBeDisabled();
select.value = 'a';
select.dispatchEvent(new Event('change', { bubbles: true }));
await ctx.nextFrame();
expect(effectA.hidden).toBe(false);
expect(effectA).toBeEnabled();
expect(effectB.hidden).toBe(true);
expect(effectB).toBeDisabled();
});
it('swaps visibility when selection changes', async () => {
const select = ctx.container.querySelector<HTMLSelectElement>('select[aria-label="Type"]')!;
const effectA = ctx.container.querySelector<HTMLInputElement>('input[aria-label="Effect A"]')!;
const effectB = ctx.container.querySelector<HTMLInputElement>('input[aria-label="Effect B"]')!;
expect(effectA.hidden).toBe(true);
expect(effectA).toBeDisabled();
expect(effectB.hidden).toBe(true);
expect(effectB).toBeDisabled();
select.value = 'a';
select.dispatchEvent(new Event('change', { bubbles: true }));
await ctx.nextFrame();
select.value = 'b';
select.dispatchEvent(new Event('change', { bubbles: true }));
await ctx.nextFrame();
expect(effectA.hidden).toBe(true);
expect(effectA).toBeDisabled();
expect(effectB.hidden).toBe(false);
expect(effectB).toBeEnabled();
});
});
describe('data-not-value matching', () => {
it('hides effect when select matches not-value', async () => {
ctx.appendHTML(`
<div data-controller="show-when-value-selected">
<select data-show-when-value-selected-target="cause"
data-target-name="mode"
aria-label="Mode">
<option value="simple">Simple</option>
<option value="advanced">Advanced</option>
</select>
<input type="text"
data-show-when-value-selected-target="effect"
data-target-name="mode"
data-not-value="simple"
hidden
disabled
aria-label="Advanced options">
</div>
`);
await ctx.nextFrame();
const select = ctx.container.querySelector<HTMLSelectElement>('select[aria-label="Mode"]')!;
const effect = ctx.container.querySelector<HTMLInputElement>('input[aria-label="Advanced options"]')!;
expect(effect.hidden).toBe(true);
expect(effect).toBeDisabled();
select.value = 'simple';
select.dispatchEvent(new Event('change', { bubbles: true }));
await ctx.nextFrame();
expect(effect.hidden).toBe(true);
expect(effect).toBeDisabled();
select.value = 'advanced';
select.dispatchEvent(new Event('change', { bubbles: true }));
await ctx.nextFrame();
expect(effect.hidden).toBe(false);
expect(effect).toBeEnabled();
});
});
});
@@ -0,0 +1,117 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
import TableHighlightingController from './table-highlighting.controller';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
const tableTemplate = `
<table data-controller="table-highlighting">
<colgroup>
<col>
<col>
<col data-highlight="false">
</colgroup>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Row 1</td>
<td>100</td>
<td>Note</td>
</tr>
</tbody>
</table>
`;
describe('TableHighlightingController', () => {
let ctx:StimulusTestContext;
beforeEach(async () => {
ctx = await setupStimulusTest({
controllers: { 'table-highlighting': TableHighlightingController },
});
ctx.appendHTML(tableTemplate);
await ctx.nextFrame();
});
afterEach(() => ctx.dispose());
it('adds hover class to col on header mouseenter', () => {
const th = ctx.screen.getByRole('columnheader', { name: 'Name' });
const col = ctx.container.querySelector('colgroup col:first-child')!;
th.dispatchEvent(new MouseEvent('mouseenter'));
expect(col).toHaveClass('hover');
});
it('removes hover class on header mouseleave', () => {
const th = ctx.screen.getByRole('columnheader', { name: 'Name' });
const col = ctx.container.querySelector('colgroup col:first-child')!;
th.dispatchEvent(new MouseEvent('mouseenter'));
expect(col).toHaveClass('hover');
th.dispatchEvent(new MouseEvent('mouseleave'));
expect(col).not.toHaveClass('hover');
});
it('skips columns with data-highlight="false"', () => {
const th = ctx.screen.getByRole('columnheader', { name: 'Notes' });
const col = ctx.container.querySelector('colgroup col:nth-child(3)')!;
th.dispatchEvent(new MouseEvent('mouseenter'));
expect(col).not.toHaveClass('hover');
});
it('does not error on tables without colgroup', async () => {
ctx.appendHTML(`
<table data-controller="table-highlighting">
<thead><tr><th>Col</th></tr></thead>
<tbody><tr><td>Val</td></tr></tbody>
</table>
`);
await ctx.nextFrame();
expect(() => {
ctx.screen.getAllByRole('columnheader').at(-1)!
.dispatchEvent(new MouseEvent('mouseenter'));
}).not.toThrow();
});
});
@@ -27,24 +27,29 @@
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
import { Application } from '@hotwired/stimulus';
import TruncationController from './truncation.controller';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const truncationTemplate = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 200px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
This is a very long text that should be truncated when it exceeds the container width
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
describe('TruncationController', () => {
let Stimulus:Application;
let fixturesElement:HTMLElement;
let ctx:StimulusTestContext;
let originalI18n:any;
beforeEach(() => {
fixturesElement = document.createElement('div');
document.body.appendChild(fixturesElement);
// Save original I18n and configure translations
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
originalI18n = (window as any).I18n;
if (originalI18n && typeof originalI18n.store === 'function') {
originalI18n.store({
@@ -59,278 +64,217 @@ describe('TruncationController', () => {
});
beforeEach(async () => {
Stimulus = Application.start();
Stimulus.handleError = (error, message, detail) => {
console.error(error, message, detail);
};
Stimulus.register('truncation', TruncationController);
await nextFrame();
ctx = await setupStimulusTest({
controllers: { truncation: TruncationController },
});
});
const truncationTemplate = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 200px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
This is a very long text that should be truncated when it exceeds the container width
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
function appendTemplate(html:string) {
const template = document.createElement('template');
template.innerHTML = html.trim();
fixturesElement.appendChild(template.content.cloneNode(true));
}
afterEach(() => {
try {
ctx.dispose();
} finally {
if (originalI18n) {
(window as any).I18n = originalI18n;
}
}
});
describe('initialization', () => {
beforeEach(async () => {
appendTemplate(truncationTemplate);
await nextFrame();
ctx.appendHTML(truncationTemplate);
await ctx.nextFrame();
});
it('connects successfully', () => {
const controller = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
const controller = ctx.getController('truncation');
expect(controller).toBeDefined();
});
it('sets initial aria attributes on expander button', () => {
const button = document.querySelector<HTMLButtonElement>('[data-truncation-target="expander"] button')!;
const button = ctx.screen.getByRole('button', { name: 'Expand text', hidden: true });
expect(button.getAttribute('aria-label')).toBe('Expand text');
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('adds Truncate--expanded class when expanded value is true', async () => {
const truncateEl = document.querySelector<HTMLElement>('[data-truncation-target="truncate"]')!;
const truncateEl = ctx.container.querySelector<HTMLElement>('[data-truncation-target="truncate"]')!;
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false);
expect(truncateEl).not.toHaveClass('Truncate--expanded');
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
const controller = ctx.getController<TruncationController>('truncation');
controller.expandedValue = true;
await nextFrame();
await ctx.nextFrame();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true);
expect(truncateEl).toHaveClass('Truncate--expanded');
});
});
describe('expander button click', () => {
beforeEach(async () => {
appendTemplate(truncationTemplate);
await nextFrame();
ctx.appendHTML(truncationTemplate);
await ctx.nextFrame();
});
it('toggles expanded state', async () => {
const button = document.querySelector<HTMLButtonElement>('[data-truncation-target="expander"] button')!;
const truncateEl = document.querySelector<HTMLElement>('[data-truncation-target="truncate"]')!;
const button = ctx.screen.getByRole('button', { name: 'Expand text', hidden: true });
const truncateEl = ctx.container.querySelector<HTMLElement>('[data-truncation-target="truncate"]')!;
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false);
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(truncateEl).not.toHaveClass('Truncate--expanded');
expect(button).toHaveAttribute('aria-expanded', 'false');
button.click();
await nextFrame();
await ctx.nextFrame();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true);
expect(button.getAttribute('aria-expanded')).toBe('true');
expect(button.getAttribute('aria-label')).toBe('Collapse text');
expect(truncateEl).toHaveClass('Truncate--expanded');
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(button).toHaveAttribute('aria-label', 'Collapse text');
button.click();
await nextFrame();
await ctx.nextFrame();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false);
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(button.getAttribute('aria-label')).toBe('Expand text');
expect(truncateEl).not.toHaveClass('Truncate--expanded');
expect(button).toHaveAttribute('aria-expanded', 'false');
expect(button).toHaveAttribute('aria-label', 'Expand text');
});
});
describe('expandedValue changes', () => {
beforeEach(async () => {
appendTemplate(truncationTemplate);
await nextFrame();
ctx.appendHTML(truncationTemplate);
await ctx.nextFrame();
});
it('updates aria-label when expanded', async () => {
const button = document.querySelector<HTMLButtonElement>('[data-truncation-target="expander"] button')!;
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
const button = ctx.screen.getByRole('button', { name: 'Expand text', hidden: true });
const controller = ctx.getController<TruncationController>('truncation');
expect(button.getAttribute('aria-label')).toBe('Expand text');
expect(button).toHaveAttribute('aria-label', 'Expand text');
controller.expandedValue = true;
await nextFrame();
await ctx.nextFrame();
expect(button.getAttribute('aria-label')).toBe('Collapse text');
expect(button).toHaveAttribute('aria-label', 'Collapse text');
});
it('updates aria-expanded attribute', async () => {
const button = document.querySelector<HTMLButtonElement>('[data-truncation-target="expander"] button')!;
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
const button = ctx.screen.getByRole('button', { name: 'Expand text', hidden: true });
const controller = ctx.getController<TruncationController>('truncation');
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(button).toHaveAttribute('aria-expanded', 'false');
controller.expandedValue = true;
await nextFrame();
await ctx.nextFrame();
expect(button.getAttribute('aria-expanded')).toBe('true');
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('toggles Truncate--expanded class', async () => {
const truncateEl = document.querySelector<HTMLElement>('[data-truncation-target="truncate"]')!;
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
const truncateEl = ctx.container.querySelector<HTMLElement>('[data-truncation-target="truncate"]')!;
const controller = ctx.getController<TruncationController>('truncation');
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false);
expect(truncateEl).not.toHaveClass('Truncate--expanded');
controller.expandedValue = true;
await nextFrame();
await ctx.nextFrame();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true);
expect(truncateEl).toHaveClass('Truncate--expanded');
controller.expandedValue = false;
await nextFrame();
await ctx.nextFrame();
expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false);
expect(truncateEl).not.toHaveClass('Truncate--expanded');
});
});
describe('expander visibility', () => {
// Helper to wait for ResizeObserver to process updates
// Wait multiple frames to ensure ResizeObserver has fired
const waitForResize = async () => {
// Wait multiple frames to ensure ResizeObserver has fired
await nextFrame();
await nextFrame();
await ctx.nextFrame();
await ctx.nextFrame();
};
it('hides expander when content is not truncated', async () => {
const shortTextTemplate = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 500px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Short text
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 500px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Short text
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
appendTemplate(shortTextTemplate);
ctx.appendHTML(shortTextTemplate);
await waitForResize();
const expander = document.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
const expander = ctx.container.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
// When content is not truncated, expander should be hidden
expect(expander.hidden).toBe(true);
});
it('shows expander when content is truncated', async () => {
const longTextTemplate = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 50px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap; width: 300px;">
This is a very long text that should definitely be truncated
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 50px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap; width: 300px;">
This is a very long text that should definitely be truncated
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
appendTemplate(longTextTemplate);
ctx.appendHTML(longTextTemplate);
const truncateText = document.querySelector<HTMLElement>('.Truncate-text')!;
const truncateText = ctx.container.querySelector<HTMLElement>('.Truncate-text')!;
Object.defineProperty(truncateText, 'scrollWidth', { value: 300, configurable: true });
Object.defineProperty(truncateText, 'clientWidth', { value: 50, configurable: true });
await waitForResize();
const expander = document.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
const expander = ctx.container.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
// When content is truncated, expander should be visible
expect(expander.hidden).toBe(false);
});
});
describe('resize() method', () => {
it('calls update() when resize is triggered', async () => {
const template = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 100px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Test text
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
appendTemplate(template);
await nextFrame();
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
// Spy on the private update method to verify resize() calls it
const updateSpy = vi.spyOn(controller, 'update');
controller.resize();
expect(updateSpy).toHaveBeenCalledWith();
});
it('updates expander visibility when content dimensions change', async () => {
const template = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 100px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Test
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
ctx.appendHTML(truncationTemplate);
await ctx.nextFrame();
appendTemplate(template);
await nextFrame();
const controller = ctx.getController<TruncationController>('truncation');
const expander = ctx.container.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
const truncateText = ctx.container.querySelector<HTMLElement>('.Truncate-text')!;
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
const expander = document.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
const truncateText = document.querySelector<HTMLElement>('.Truncate-text')!;
// Mock scrollWidth and clientWidth to simulate truncation state
const originalScrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth');
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
// Simulate not truncated: scrollWidth === clientWidth
Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 100 });
Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 100 });
controller.resize();
expect(expander.hidden).toBe(true);
// Simulate truncated: scrollWidth > clientWidth
Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 200 });
Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 100 });
controller.resize();
expect(expander.hidden).toBe(false);
// Simulate not truncated again
Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 50 });
Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 50 });
controller.resize();
expect(expander.hidden).toBe(true);
// Restore original descriptors
if (originalScrollWidth) {
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', originalScrollWidth);
}
@@ -340,46 +284,20 @@ describe('TruncationController', () => {
});
it('keeps expander visible when expanded even if not truncated', async () => {
const template = `
<div data-controller="truncation" data-truncation-expanded-value="false">
<div data-truncation-target="truncate" style="width: 200px; overflow: hidden;">
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
Short
</span>
</div>
<div data-truncation-target="expander">
<button type="button">Toggle</button>
</div>
</div>
`;
ctx.appendHTML(truncationTemplate);
await ctx.nextFrame();
appendTemplate(template);
await nextFrame();
const controller = ctx.getController<TruncationController>('truncation');
const expander = ctx.container.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation');
const expander = document.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
// Initially short text, expander should be hidden
controller.resize();
expect(expander.hidden).toBe(true);
// Expand the text
controller.expandedValue = true;
await nextFrame();
await ctx.nextFrame();
// When expanded, expander should remain visible even if not truncated
expect(expander.hidden).toBe(false);
});
});
afterEach(() => {
fixturesElement.remove();
Stimulus.stop();
// Restore original I18n
if (originalI18n) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
(window as any).I18n = originalI18n;
}
});
});
+110
View File
@@ -0,0 +1,110 @@
/*
* -- copyright
* OpenProject is an open source project management software.
* Copyright (C) the OpenProject GmbH
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 3.
*
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
* Copyright (C) 2006-2013 Jean-Philippe Lang
* Copyright (C) 2010-2013 the ChiliProject Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* See COPYRIGHT and LICENSE files for more details.
* ++
*/
import { Application, Controller } from '@hotwired/stimulus';
import { type ControllerConstructor } from '@hotwired/stimulus/dist/types/core/controller';
import { getQueriesForElement, queries, type BoundFunctions } from '@testing-library/dom';
export interface StimulusTestContext {
application:Application;
container:HTMLElement;
screen:BoundFunctions<typeof queries>;
appendHTML(html:string):void;
getController<T extends Controller>(identifier:string, element?:Element):T;
nextFrame():Promise<void>;
dispose():void;
}
export interface SetupOptions {
controllers:Record<string, ControllerConstructor>;
}
export async function setupStimulusTest(options:SetupOptions):Promise<StimulusTestContext> {
const container = document.createElement('div');
document.body.appendChild(container);
const application = Application.start(container);
const stimulusErrors:Error[] = [];
application.handleError = (error:Error, message:string, detail:Record<string, unknown>) => {
console.error(error, message, detail);
stimulusErrors.push(error);
};
for (const [identifier, ctor] of Object.entries(options.controllers)) {
application.register(identifier, ctor);
}
const screen = getQueriesForElement(container);
const ctx:StimulusTestContext = {
application,
container,
screen,
appendHTML(html:string) {
const template = document.createElement('template');
template.innerHTML = html.trim();
container.appendChild(template.content.cloneNode(true));
},
getController<T extends Controller>(identifier:string, element?:Element):T {
const el = element ?? container.querySelector(`[data-controller~="${identifier}"]`);
if (!el) {
throw new Error(`No element found matching [data-controller~="${identifier}"]`);
}
const controller = application.getControllerForElementAndIdentifier(el, identifier);
if (!controller) {
throw new Error(`Controller "${identifier}" not connected on element`);
}
return controller as T;
},
nextFrame() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
},
dispose() {
application.stop();
container.remove();
if (stimulusErrors.length > 0) {
throw stimulusErrors[0];
}
},
};
await ctx.nextFrame();
return ctx;
}
export function createControllerInstance<T extends Controller>(
ControllerClass:{ new (...args:unknown[]):T; prototype:T },
):T {
return Object.create(ControllerClass.prototype) as T;
}
+7
View File
@@ -0,0 +1,7 @@
// Browser test runners (Playwright) lack Node.js globals that some
// libraries read at import time (e.g. picocolors reads process.env).
const globalWithProcess = globalThis as unknown as { process?:{ env:Record<string, string|undefined> } };
if (typeof globalWithProcess.process === 'undefined') {
globalWithProcess.process = { env: {} };
}
+1
View File
@@ -1,5 +1,6 @@
import { I18n } from 'i18n-js';
import lodash from 'lodash';
import '@testing-library/jest-dom/vitest';
import { registerDialogStreamAction } from 'core-turbo/dialog-stream-action';
registerDialogStreamAction();
+3 -1
View File
@@ -3,10 +3,12 @@
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
"vitest/globals",
"@testing-library/jest-dom/vitest"
]
},
"files": [
"src/test-browser-polyfills.ts",
"src/test-setup.ts",
"src/test-providers.ts",
"src/polyfills.ts"