Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,80 @@ describe('Filters form controller - filter count badge', () => {
});
});
});

describe('Filters form controller - rows added without a value', () => {
let ctx:StimulusTestContext;
let FiltersFormController:typeof FiltersFormControllerType;

beforeAll(async () => {
({ default: FiltersFormController } = await import('./filters-form.controller'));
});

afterEach(() => {
ctx.dispose();
});

async function mountForm() {
ctx = await setupStimulusTest({
controllers: { 'filter--filters-form': FiltersFormController },
});

await ctx.mount(`
<div data-controller="filter--filters-form">
<select data-filter--filters-form-target="addFilterSelect">
<option value=""></option>
<option value="assignee">Assignee</option>
</select>
${ASSIGNEE_FILTER_ROW}
</div>
`);

return ctx.getController<FiltersFormControllerType>('filter--filters-form');
}

function row() {
return ctx.container.querySelector<HTMLElement>('[data-filter--filters-form-target="filter"]')!;
}

function addFilterOption() {
return ctx.container.querySelector<HTMLOptionElement>('option[value="assignee"]')!;
}

it('shows the row again after a re-render hid it', async () => {
const controller = await mountForm();
controller.addFilterByName('assignee');

// What the server sends back: the query does not hold the filter, so the row is hidden
// and its entry in the add-filter select is selectable again.
row().setAttribute('hidden', '');
addFilterOption().removeAttribute('disabled');

controller.restorePendingFilters();

expect(row().hasAttribute('hidden')).toBe(false);
expect(addFilterOption().hasAttribute('disabled')).toBe(true);
});

it('leaves a row the user removed hidden', async () => {
const controller = await mountForm();
controller.addFilterByName('assignee');
controller.removeFilter({ params: { filterName: 'assignee' } });

controller.restorePendingFilters();

expect(row().hasAttribute('hidden')).toBe(true);
});

it('stops tracking a row once the server renders it itself', async () => {
const controller = await mountForm();
controller.addFilterByName('assignee');

// A re-render that leaves the row visible means the query holds the filter now, so the
// row stops being this controller's business and a later re-render decides on its own.
controller.restorePendingFilters();
row().setAttribute('hidden', '');
controller.restorePendingFilters();

expect(row().hasAttribute('hidden')).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export default class FiltersFormController extends Controller {

declare readonly hasFilterFormToggleTarget:boolean;
declare readonly hasFiltersInputTarget:boolean;
declare readonly hasAddFilterSelectTarget:boolean;

static values = {
displayFilters: { type: Boolean, default: false },
Expand Down Expand Up @@ -117,6 +118,11 @@ export default class FiltersFormController extends Controller {
private boundClearListener:(event:MouseEvent) => void;
private sentFilters:string|null = null;

// Rows the user added that have no value yet. They are kept visible here instead of being
// sent: the server hides a row its query does not hold, and a blank value would make that
// query invalid, which empties the result set.
private readonly pendingFilters = new Set<string>();

initialize() {
// Initialize runs anytime an element with a controller connected to the DOM for the first time
this.boundListener = debounce(this.sendFormLive.bind(this), 300);
Expand Down Expand Up @@ -298,6 +304,7 @@ export default class FiltersFormController extends Controller {
const selectedFilter = this.findTargetByName(filterName, this.filterTargets);
if (selectedFilter) {
selectedFilter.removeAttribute('hidden');
this.pendingFilters.add(filterName);
}
this.addFilterSelectTarget.selectedOptions[0].disabled = true;
this.addFilterSelectTarget.selectedIndex = 0;
Expand All @@ -307,6 +314,39 @@ export default class FiltersFormController extends Controller {
this.sendFormLive();
}

// A re-render renders each row from what the query holds, so it hides the pending ones again.
// Run after every stream render, and for rows that come back as new nodes rather than morphed.
restorePendingFilters() {
this.pendingFilters.forEach((filterName) => {
const row = this.findTargetByName(filterName, this.filterTargets);

if (!row?.hasAttribute('hidden')) {
// The row is gone, or the query holds it now and the server renders it visible itself.
this.pendingFilters.delete(filterName);
return;
}

this.showPendingFilter(row, filterName);
});
}

filterTargetConnected(target:HTMLElement) {
const filterName = target.getAttribute('data-filter-name');

if (filterName && this.pendingFilters.has(filterName) && target.hasAttribute('hidden')) {
this.showPendingFilter(target, filterName);
}
}

private showPendingFilter(row:HTMLElement, filterName:string) {
row.removeAttribute('hidden');

if (!this.hasAddFilterSelectTarget) return;

const option = Array.from(this.addFilterSelectTarget.options).find((candidate) => candidate.value === filterName);
option?.setAttribute('disabled', 'disabled');
}

focusFilterValueIfPossible(element:undefined|HTMLElement) {
const filterName = element?.getAttribute('data-filter-name');
if (!filterName) return;
Expand Down Expand Up @@ -343,6 +383,7 @@ export default class FiltersFormController extends Controller {
removeFilter({ params: { filterName } }:{ params:{ filterName:string } }) {
const filterToRemove = this.findTargetByName(filterName, this.filterTargets);
filterToRemove?.setAttribute('hidden', '');
this.pendingFilters.delete(filterName);

const selectOptions = Array.from(this.addFilterSelectTarget.options);
const removedFilterOption = selectOptions.find((option) => option.value === filterName);
Expand Down Expand Up @@ -506,6 +547,7 @@ export default class FiltersFormController extends Controller {
.then((response:Response) => response.text())
.then((html:string) => {
renderStreamMessage(html);
this.restorePendingFilters();
if (this.sentFilters === newFilters) {
window.history.replaceState(window.history.state, '', browserUrl);
}
Expand Down
Loading