Skip to content

Commit 4cfa55e

Browse files
committed
[AGILE-176] Explicit apply/clear for filters
Replaces submit-on-close with explicit submission. Apply navigates the backlogs frame with the current selection; Clear deselects everything and navigates with an empty filter; the native close (X, Escape, click-away) reverts the selection to the applied filter without submitting, so the panel always mirrors what is applied. Apply and Clear are disabled when they would have no effect (selection unchanged, or nothing selected) and update live on itemActivated. The applied filter is read from the URL as the single source of truth for both the change check and the revert target. Covered by a Vitest spec. https://community.openproject.org/wp/AGILE-176
1 parent d5662fe commit 4cfa55e

6 files changed

Lines changed: 561 additions & 73 deletions

File tree

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
//-- copyright
2+
// OpenProject is an open source project management software.
3+
// Copyright (C) the OpenProject GmbH
4+
//
5+
// This program is free software; you can redistribute it and/or
6+
// modify it under the terms of the GNU General Public License version 3.
7+
//
8+
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
9+
// Copyright (C) 2006-2013 Jean-Philippe Lang
10+
// Copyright (C) 2010-2013 the ChiliProject Team
11+
//
12+
// This program is free software; you can redistribute it and/or
13+
// modify it under the terms of the GNU General Public License
14+
// as published by the Free Software Foundation; either version 2
15+
// of the License, or (at your option) any later version.
16+
//
17+
// This program is distributed in the hope that it will be useful,
18+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
// GNU General Public License for more details.
21+
//
22+
// You should have received a copy of the GNU General Public License
23+
// along with this program; if not, write to the Free Software
24+
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
25+
//
26+
// See COPYRIGHT and LICENSE files for more details.
27+
//++
28+
29+
import { Application } from '@hotwired/stimulus';
30+
import type { MockInstance } from 'vitest';
31+
32+
import type BacklogFilterSelectPanelControllerType from './backlog-filter-select-panel.controller';
33+
34+
interface Navigable {
35+
visit(this:void, url:string):void;
36+
}
37+
38+
interface PanelApi {
39+
selectedItems:{ value:string|null }[];
40+
items:HTMLElement[];
41+
checkItem(item:HTMLElement):void;
42+
uncheckItem(item:HTMLElement):void;
43+
hide():void;
44+
}
45+
46+
type PanelStub = HTMLElement & PanelApi;
47+
48+
const IDENTIFIER = 'backlogs--backlog-filter-select-panel';
49+
50+
describe('Backlogs filter select panel controller', () => {
51+
let application:Application;
52+
let fixture:HTMLElement;
53+
let panel:PanelStub;
54+
let panelHideCalls:number;
55+
let Controller:typeof BacklogFilterSelectPanelControllerType;
56+
let prototype:Navigable;
57+
58+
beforeAll(async () => {
59+
({ default: Controller } = await import('./backlog-filter-select-panel.controller'));
60+
});
61+
62+
beforeEach(() => {
63+
// Stub the navigation seam so submitting neither hits Turbo nor leaves the
64+
// test page; the spy doubles as the submit assertion.
65+
prototype = Controller.prototype as unknown as Navigable;
66+
vi.spyOn(prototype, 'visit').mockReturnValue(undefined);
67+
68+
panelHideCalls = 0;
69+
70+
fixture = document.createElement('div');
71+
document.body.appendChild(fixture);
72+
73+
application = Application.start();
74+
application.register(IDENTIFIER, Controller);
75+
});
76+
77+
afterEach(() => {
78+
application?.stop();
79+
fixture.remove();
80+
vi.restoreAllMocks();
81+
});
82+
83+
// Renders the panel + footer buttons and waits (on real timers) for Stimulus
84+
// to connect the controller, which binds asynchronously via a MutationObserver.
85+
async function mount({
86+
url = '/projects/demo/backlogs',
87+
items = [] as string[],
88+
checked = [] as string[],
89+
} = {}) {
90+
window.history.replaceState({}, '', url);
91+
92+
fixture.innerHTML = `
93+
<div
94+
data-controller="${IDENTIFIER}"
95+
data-${IDENTIFIER}-filter-key-value="bucket_ids"
96+
data-action="itemActivated->${IDENTIFIER}#refreshButtons panelClosed->${IDENTIFIER}#revertOnClose"
97+
>
98+
<select-panel></select-panel>
99+
<button type="button" data-${IDENTIFIER}-target="clearButton"
100+
data-action="click->${IDENTIFIER}#clear" disabled>Clear</button>
101+
<button type="button" data-${IDENTIFIER}-target="applyButton"
102+
data-action="click->${IDENTIFIER}#apply" disabled>Apply</button>
103+
</div>
104+
`;
105+
106+
const root = fixture.querySelector<HTMLElement>(`[data-controller="${IDENTIFIER}"]`)!;
107+
setupPanel(root, items, checked);
108+
109+
await new Promise((resolve) => { setTimeout(resolve, 0); });
110+
return root;
111+
}
112+
113+
// Wires a minimal fake of Primer's SelectPanelElement onto <select-panel>:
114+
// items carry their value on a `.ActionListContent[data-value]`, and
115+
// check/uncheck mutate the selection and fire `itemActivated`, as Primer does.
116+
function setupPanel(root:HTMLElement, items:string[], checked:string[]) {
117+
const element = root.querySelector('select-panel') as unknown as PanelStub;
118+
const selected = new Set(checked);
119+
120+
element.innerHTML = items.map((value) => `
121+
<li><span class="ActionListContent" data-value="${value}"
122+
aria-selected="${selected.has(value)}"></span></li>`).join('');
123+
124+
const setItem = (item:HTMLElement, on:boolean) => {
125+
const content = item.querySelector('.ActionListContent')!;
126+
const value = content.getAttribute('data-value')!;
127+
if (on === selected.has(value)) return;
128+
129+
if (on) { selected.add(value); } else { selected.delete(value); }
130+
content.setAttribute('aria-selected', String(on));
131+
element.dispatchEvent(new CustomEvent('itemActivated', { bubbles: true }));
132+
};
133+
134+
Object.defineProperty(element, 'items', {
135+
configurable: true,
136+
get: () => Array.from(element.querySelectorAll('li')),
137+
});
138+
Object.defineProperty(element, 'selectedItems', {
139+
configurable: true,
140+
get: () => [...selected].map((value) => ({ value })),
141+
});
142+
element.checkItem = (item:HTMLElement) => setItem(item, true);
143+
element.uncheckItem = (item:HTMLElement) => setItem(item, false);
144+
element.hide = () => { panelHideCalls += 1; };
145+
146+
panel = element;
147+
}
148+
149+
// Simulates a user toggling an item in the open panel (fires itemActivated).
150+
function toggle(value:string, on:boolean) {
151+
const item = panel.items.find(
152+
(li) => li.querySelector('.ActionListContent')?.getAttribute('data-value') === value,
153+
)!;
154+
if (on) { panel.checkItem(item); } else { panel.uncheckItem(item); }
155+
}
156+
157+
function controllerFor(root:HTMLElement):BacklogFilterSelectPanelControllerType {
158+
return application.getControllerForElementAndIdentifier(
159+
root, IDENTIFIER,
160+
) as unknown as BacklogFilterSelectPanelControllerType;
161+
}
162+
163+
const applyButton = () => fixture.querySelector<HTMLButtonElement>(`[data-${IDENTIFIER}-target="applyButton"]`)!;
164+
const clearButton = () => fixture.querySelector<HTMLButtonElement>(`[data-${IDENTIFIER}-target="clearButton"]`)!;
165+
166+
function lastVisitedUrl():URL {
167+
const calls = (prototype.visit as unknown as MockInstance<(url:string) => void>).mock.calls;
168+
const [url] = calls.at(-1)!;
169+
return new URL(url, window.location.origin);
170+
}
171+
172+
function selectedValues():string[] {
173+
return panel.selectedItems.map((item) => item.value).filter((v):v is string => v != null);
174+
}
175+
176+
describe('button enablement', () => {
177+
it('enables Apply when the selection differs from the applied filter and disables it when reverted', async () => {
178+
await mount({ url: '/projects/demo/backlogs?bucket_ids=1', items: ['1', '2', '3'], checked: ['1'] });
179+
180+
expect(applyButton()).toBeDisabled();
181+
182+
toggle('2', true);
183+
expect(applyButton()).toBeEnabled();
184+
185+
toggle('2', false);
186+
expect(applyButton()).toBeDisabled();
187+
});
188+
189+
it('treats whitespace around comma-delimited ids as already applied (Apply stays disabled)', async () => {
190+
// The server tolerates padded, comma-delimited params (e.g. "1, 2 ,3"),
191+
// so a URL carrying them must read back as the same applied selection.
192+
await mount({ url: '/projects/demo/backlogs?bucket_ids=1,%202%20,3', items: ['1', '2', '3'], checked: ['1', '2', '3'] });
193+
194+
// Round-trip a toggle to re-run the change check without altering the
195+
// selection: it must still equal the (padded) applied filter.
196+
toggle('2', false);
197+
toggle('2', true);
198+
199+
expect(applyButton()).toBeDisabled();
200+
});
201+
202+
it('enables Clear only while something is selected', async () => {
203+
await mount({ url: '/projects/demo/backlogs', items: ['1', '2'], checked: [] });
204+
205+
expect(clearButton()).toBeDisabled();
206+
207+
toggle('1', true);
208+
expect(clearButton()).toBeEnabled();
209+
210+
toggle('1', false);
211+
expect(clearButton()).toBeDisabled();
212+
});
213+
});
214+
215+
describe('apply', () => {
216+
it('navigates the backlogs frame with the current selection, preserving other params', async () => {
217+
const root = await mount({
218+
url: '/projects/demo/backlogs?bucket_ids=1&sprint_ids=9',
219+
items: ['1', '2'],
220+
checked: ['1'],
221+
});
222+
toggle('2', true);
223+
224+
controllerFor(root).apply();
225+
226+
expect(prototype.visit).toHaveBeenCalledTimes(1);
227+
const params = lastVisitedUrl().searchParams;
228+
expect(params.get('bucket_ids')).toBe('1,2');
229+
expect(params.get('sprint_ids')).toBe('9');
230+
});
231+
});
232+
233+
describe('clear', () => {
234+
it('navigates with an empty selection and removes the filter param', async () => {
235+
const root = await mount({
236+
url: '/projects/demo/backlogs?bucket_ids=1',
237+
items: ['1', '2'],
238+
checked: ['1'],
239+
});
240+
241+
controllerFor(root).clear();
242+
243+
expect(prototype.visit).toHaveBeenCalledTimes(1);
244+
expect(lastVisitedUrl().searchParams.has('bucket_ids')).toBe(false);
245+
expect(selectedValues()).toEqual([]);
246+
});
247+
248+
it('deselects and closes without navigating when the filter is already empty', async () => {
249+
const root = await mount({ url: '/projects/demo/backlogs', items: ['1', '2'], checked: [] });
250+
toggle('1', true);
251+
toggle('2', true);
252+
253+
controllerFor(root).clear();
254+
255+
expect(prototype.visit).not.toHaveBeenCalled();
256+
expect(panelHideCalls).toBe(1);
257+
expect(selectedValues()).toEqual([]);
258+
});
259+
});
260+
261+
describe('close (native dismiss)', () => {
262+
it('reverts the selection to the applied filter without submitting', async () => {
263+
const root = await mount({
264+
url: '/projects/demo/backlogs?bucket_ids=1',
265+
items: ['1', '2'],
266+
checked: ['1'],
267+
});
268+
toggle('2', true);
269+
toggle('1', false);
270+
expect(selectedValues()).toEqual(['2']);
271+
272+
controllerFor(root).revertOnClose();
273+
274+
expect(prototype.visit).not.toHaveBeenCalled();
275+
expect(selectedValues()).toEqual(['1']);
276+
});
277+
278+
it('does not revert once a submit is in flight', async () => {
279+
const root = await mount({
280+
url: '/projects/demo/backlogs?bucket_ids=1',
281+
items: ['1', '2'],
282+
checked: ['1'],
283+
});
284+
toggle('2', true);
285+
const controller = controllerFor(root);
286+
287+
controller.apply();
288+
controller.revertOnClose();
289+
290+
expect(prototype.visit).toHaveBeenCalledTimes(1);
291+
expect(selectedValues()).toEqual(['1', '2']);
292+
});
293+
});
294+
});

0 commit comments

Comments
 (0)