mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge pull request #23329 from opf/code-maintenance/75300-stimulus-test-helpers
[#75300] Stimulus: shared test helper infrastructure
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
|
||||
Generated
+154
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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,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"
|
||||
|
||||
Reference in New Issue
Block a user