Skip to content

Commit d60867c

Browse files
committed
Add Created Date search filter
Add the Created Date range filter on top of the generic full-search filter controls. Expose createdFrom and createdTo URL parameters, normalize invalid or reversed year ranges, and expand committed ranges into inclusive Pagefind created_year OR filters. Keep same-year selections encoded as a single starting year. Render the Created Date control as a Year selector plus a disabled To selector that unlocks after Year is selected. Display a single range chip for the committed years and keep clear/remove actions in draft state until Search or Enter commits. Add stylesheet and validation coverage for the Created Date control, loading shell, URL normalization, and Pagefind year filter expansion.
1 parent c4e4ff3 commit d60867c

7 files changed

Lines changed: 350 additions & 4 deletions

File tree

sass/assets/css/search.scss

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
}
189189

190190
.search-filter-input,
191+
.search-created-date-select,
191192
.search-filter-toggle,
192193
.search-filter-clear,
193194
.search-filter-panel-clear,
@@ -216,6 +217,36 @@
216217
opacity: 0.75;
217218
}
218219

220+
.search-created-date-controls {
221+
display: grid;
222+
gap: 0.5rem;
223+
grid-template-columns: 1fr 1fr;
224+
}
225+
226+
.search-created-date-control {
227+
display: grid;
228+
gap: 0.3rem;
229+
}
230+
231+
.search-created-date-control-label {
232+
color: var(--bs-secondary-color, #666);
233+
font-size: 0.9rem;
234+
}
235+
236+
.search-created-date-select {
237+
background: var(--bs-body-bg, #fff);
238+
cursor: pointer;
239+
min-width: 0;
240+
width: 100%;
241+
}
242+
243+
.search-created-date-select:disabled {
244+
background: var(--bs-secondary-bg, #f8f9fa);
245+
color: var(--bs-secondary-color, #666);
246+
cursor: not-allowed;
247+
opacity: 0.75;
248+
}
249+
219250
.search-filter-toggle {
220251
align-items: center;
221252
background: var(--bs-secondary-bg, #f8f9fa);

scripts/check-pagefind-search-ui

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,24 +391,38 @@ assert_not_contains "$search_constants" 'key: "prefix"' "prefix must not remain
391391
assert_not_contains "$search_constants" 'key: "type"' "type must not remain a visible filter field"
392392
assert_not_contains "$search_constants" 'key: "category"' "category must not remain a visible filter field"
393393
assert_contains "$search_constants" 'key: "author"' "filter constants must define the author field"
394+
assert_contains "$search_constants" 'CREATED_YEAR_FIELD = "created_year"' "filter constants must define the created year Pagefind field"
395+
assert_contains "$search_constants" 'CREATED_FROM_PARAM = "createdFrom"' "filter constants must define the createdFrom URL parameter"
396+
assert_contains "$search_constants" 'CREATED_TO_PARAM = "createdTo"' "filter constants must define the createdTo URL parameter"
394397
assert_contains "$search_state" "AUTHOR_MODE_PARAM" "state module must own author mode URL state"
395398
assert_contains "$search_state" "pagefindFilters.author = values" "state module must encode author AND filters as arrays"
396399
assert_contains "$search_state" "filterUrlParam(field)" "state module must use field-specific URL parameter names"
397400
assert_contains "$search_state" "filterPagefindField(field)" "state module must use field-specific Pagefind filter names"
398401
assert_contains "$search_state" "pagefindFilters[filterPagefindField(field)]" "state module must encode Proposal Category using the Pagefind field name"
402+
assert_contains "$search_state" "CREATED_FROM_PARAM" "state module must own createdFrom URL state"
403+
assert_contains "$search_state" "CREATED_TO_PARAM" "state module must own createdTo URL state"
404+
assert_contains "$search_state" "pagefindFilters[CREATED_YEAR_FIELD] = { any: createdYears }" "state module must encode created year filters as Pagefind OR filters"
405+
assert_contains "$search_state" "for (let year = from; year <= to; year += 1)" "state module must expand created year ranges inclusively"
399406
assert_contains "$search_state" "pushSearchStateIfNeeded" "state module must avoid duplicate clean-route history entries"
400407
assert_contains "$search_filters" 'role: "combobox"' "filter module must render custom combobox controls"
401408
assert_contains "$search_template" "search-filter-grid-loading" "search template must render loading filter shells before JS starts"
402409
assert_contains "$search_template" "search-filter-field-loading" "search template loading shells must use a distinct field hook"
403410
assert_contains "$search_template" "data-search-filter-field=\"proposalCategory\"" "search template must render the Proposal Category loading shell"
404411
assert_contains "$search_template" "data-search-filter-field=\"status\"" "search template must render the Status loading shell"
405412
assert_contains "$search_template" "data-search-filter-field=\"author\"" "search template must render the Author loading shell"
413+
assert_contains "$search_template" "data-search-filter-field=\"created_year\"" "search template must render the Created Date loading shell"
406414
assert_contains "$search_template" "Loading values..." "search template loading shells must not render fake counts"
407415
assert_contains "$search_template" "disabled" "search template loading shells must be disabled"
408416
assert_not_contains "$search_filters" "renderLoadingFilterControls" "filter module must not duplicate the template loading render path"
409417
assert_contains "$search_filters" "filterPagefindField(field)" "filter module must look up Pagefind filters by field config"
410418
assert_contains "$search_filters" 'Unexpected ${filterPagefindField(field)} filter values' "filter module must reject unexpected configured filter values"
419+
assert_contains "$search_filters" "CREATED_DATE_LABEL" "filter module must render the dedicated Created Date control"
420+
assert_contains "$search_filters" '"Year"' "created date filter must label the starting year control"
421+
assert_contains "$search_filters" '"To"' "created date filter must label the ending year control"
422+
assert_contains "$search_filters" "appendYearOptions(toSelect, false)" "Created Date To control must not render a visible Any option"
423+
assert_contains "$search_filters" "createdDate.to || createdDate.from" "Created Date To control must default to the selected Year"
411424
assert_contains "$search_filters" 'aria-label": `Remove ${filterLabel(field.key).toLowerCase()} ${value}`' "filter chips must have specific accessible remove labels"
425+
assert_contains "$search_filters" 'aria-label": `Remove created date ${selectedLabel}`' "created date chip must have a specific accessible remove label"
412426
assert_contains "$search_filters" 'class: "search-filter-toggle-icon"' "filter toggles must use the chevron icon hook"
413427
assert_contains "$search_filters" '"Choose operator"' "author filter must label the operator control"
414428
assert_contains "$search_filters" '"And"' "author filter must expose the AND operator label"
@@ -418,12 +432,15 @@ assert_contains "$search_filters" "isOutsideClickExempt" "filter outside-click h
418432
assert_not_contains "$search_filters" "Any author" "author operator labels must stay concise"
419433
assert_not_contains "$search_filters" "All authors" "author operator labels must stay concise"
420434
assert_contains "$search_css" "max-height: min(50vh, 28rem)" "filter dropdowns must be capped around 50vh"
435+
assert_contains "$search_css" ".search-created-date-controls" "search stylesheet must style the Created Date control"
436+
assert_contains "$search_css" ".search-created-date-select:disabled" "Created Date To control must have a disabled visual state"
421437
assert_contains "$search_css" ".search-filter-input:disabled" "loading filter inputs must share disabled styling"
422438
assert_contains "$search_css" ".search-filter-toggle:disabled" "loading filter toggles must share disabled styling"
423439
assert_contains "$search_css" ".search-page-controls select:disabled" "loading sort control must share disabled styling"
424440
assert_contains "$search_css" ".search-page-controls select" "sort dropdown must have explicit select styling"
425441
assert_contains "$search_css" ".search-filter-input" "generic filter dropdown inputs must have explicit input styling"
426-
assert_contains "$search_css" "background: var(--bs-body-bg, #fff)" "generic filter dropdown inputs must have body background"
442+
assert_contains "$search_css" "background: var(--bs-body-bg, #fff)" "generic filter dropdown inputs must match Created Date select background"
443+
assert_contains "$search_css" "cursor: pointer;" "Created Date selects must use a pointer cursor when enabled"
427444
assert_contains "$search_css" "top: 4.6rem" "filter dropdowns must keep a stable overlay position"
428445
assert_contains "$search_css" '.search-filter-toggle[aria-expanded="true"] .search-filter-toggle-icon::before' "filter chevrons must flip when open"
429446
assert_contains "$search_css" "[data-search-page-submit]" "Search button must keep the highlighted action styling"
@@ -482,6 +499,7 @@ assert_contains "$search_html" 'search-filter-grid-loading' "enabled search page
482499
assert_contains "$search_html" 'data-search-filter-field="proposalCategory"' "enabled search page must render Proposal Category loading shell"
483500
assert_contains "$search_html" 'data-search-filter-field="status"' "enabled search page must render Status loading shell"
484501
assert_contains "$search_html" 'data-search-filter-field="author"' "enabled search page must render Author loading shell"
502+
assert_contains "$search_html" 'data-search-filter-field="created_year"' "enabled search page must render Created Date loading shell"
485503
assert_contains "$search_html" 'Loading values...' "enabled search page loading shells must avoid fake counts"
486504
assert_not_contains "$search_html" "data-$pending_token" "enabled search page must not render pending-change status mount"
487505
assert_contains "$search_html" 'value="number-asc"' "enabled search page must expose number ascending sort"
@@ -504,6 +522,7 @@ assert_contains "$proposal_html" 'data-pagefind-filter="proposal_category:Core"'
504522
assert_not_contains "$proposal_html" 'data-pagefind-filter="prefix:' "proposal page must not expose prefix as a filter"
505523
assert_not_contains "$proposal_html" 'data-pagefind-filter="type:' "proposal page must not expose type as a filter"
506524
assert_not_contains "$proposal_html" 'data-pagefind-filter="category:' "proposal page must not expose category as a filter"
525+
assert_contains "$proposal_html" 'data-pagefind-filter="created_year:2021"' "proposal page must expose the created year filter"
507526
assert_not_contains "$home_html" 'data-pagefind-body' "home must remain unindexed"
508527
assert_not_contains "$search_html" 'data-pagefind-body' "search route must remain unindexed"
509528
if grep -R 'data-pagefind-filter="\(prefix\|type\|category\):' "$enabled_output" >/dev/null; then
@@ -581,6 +600,11 @@ assert(!("Other" in filters.proposal_category), "proposal_category must not expo
581600
assert(filters.status.Draft === 10, "status Draft count should come from Pagefind filters");
582601
assert(filters.status.Review === 10, "status Review count should come from Pagefind filters");
583602
assert(filters.status.Final === 10, "status Final count should come from Pagefind filters");
603+
assert(filters.created_year["2021"] === 6, "created year 2021 count should come from Pagefind filters");
604+
assert(filters.created_year["2022"] === 6, "created year 2022 count should come from Pagefind filters");
605+
assert(filters.created_year["2023"] === 6, "created year 2023 count should come from Pagefind filters");
606+
assert(filters.created_year["2024"] === 6, "created year 2024 count should come from Pagefind filters");
607+
assert(filters.created_year["2025"] === 6, "created year 2025 count should come from Pagefind filters");
584608
assert(filters.author.Alice === 15, "author Alice count should come from Pagefind filters");
585609
assert(filters.author.Bob === 15, "author Bob count should come from Pagefind filters");
586610
assert(filters.author.Carol === 10, "author Carol count should come from Pagefind filters");
@@ -629,6 +653,16 @@ assert(proposalCategorySorted.results.length === 5, "proposal category filter sh
629653
const [highestCore] = await Promise.all(proposalCategorySorted.results.slice(0, 1).map((result) => result.data()));
630654
assert(highestCore.meta.proposal === "EIP-25", "sorted proposal category search should preserve sort order");
631655
656+
const createdSingle = await pagefind.search(null, {
657+
filters: { created_year: { any: ["2021"] } },
658+
});
659+
assert(createdSingle.results.length === 6, "created year single filter should find matching proposals");
660+
661+
const createdRange = await pagefind.search(null, {
662+
filters: { created_year: { any: ["2021", "2022", "2023"] } },
663+
});
664+
assert(createdRange.results.length === 18, "created year range filter should expand to inclusive OR years");
665+
632666
for (const [term, label] of [
633667
["UniqueSearchMarker", "/search/"],
634668
["UniqueHomeMarker", "home"],
@@ -646,6 +680,8 @@ console.log(JSON.stringify({
646680
authorAllAliceCarol: authorAll.results.length,
647681
authorAnyAliceCarol: authorAny.results.length,
648682
proposalCategoryCore: proposalCategorySorted.results.length,
683+
createdYear2021: createdSingle.results.length,
684+
createdYear2021To2023: createdRange.results.length,
649685
},
650686
}));
651687
NODE

static/assets/scripts/search/constants.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export const SORT_OPTIONS = {
1313
export const FILTER_MODE_ANY = "any";
1414
export const FILTER_MODE_ALL = "all";
1515
export const AUTHOR_MODE_PARAM = "authorMode";
16+
export const CREATED_YEAR_FIELD = "created_year";
17+
export const CREATED_FROM_PARAM = "createdFrom";
18+
export const CREATED_TO_PARAM = "createdTo";
19+
export const CREATED_DATE_LABEL = "Created Date";
1620
export const PROPOSAL_CATEGORY_KEY = "proposalCategory";
1721
export const PROPOSAL_CATEGORY_FIELD = "proposal_category";
1822
export const PROPOSAL_CATEGORY_VALUES = [

static/assets/scripts/search/filters.mjs

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
CREATED_DATE_LABEL,
3+
CREATED_YEAR_FIELD,
24
FILTER_FIELDS,
35
FILTER_MODE_ALL,
46
FILTER_MODE_ANY,
@@ -7,6 +9,8 @@ import {
79
import { createElement, clearElement } from "./dom.mjs";
810
import {
911
cloneSearchState,
12+
createdDateSelectionsEqual,
13+
createdYearOptions,
1014
filterLabel,
1115
} from "./state.mjs";
1216

@@ -22,7 +26,8 @@ const fieldSelectionsEqual = (left, right) =>
2226

2327
const searchFilterStateEqual = (left, right) =>
2428
fieldSelectionsEqual(left.filters || {}, right.filters || {}) &&
25-
left.modes?.author === right.modes?.author;
29+
left.modes?.author === right.modes?.author &&
30+
createdDateSelectionsEqual(left.createdDate, right.createdDate);
2631

2732
const fieldOptions = (availableFilters, field) => {
2833
const entries = Object.entries(availableFilters[filterPagefindField(field)] || {});
@@ -45,6 +50,9 @@ const fieldOptions = (availableFilters, field) => {
4550
.sort((left, right) => left.value.localeCompare(right.value));
4651
};
4752

53+
const createdDateChipLabel = (createdDate) =>
54+
createdDate?.to ? `${createdDate.from}-${createdDate.to}` : createdDate?.from;
55+
4856
export const createFilterControls = (container, availableFilters, onDraftChange, options = {}) => {
4957
let currentState = null;
5058
let controls = new Map();
@@ -84,6 +92,131 @@ export const createFilterControls = (container, availableFilters, onDraftChange,
8492
onDraftChange(normalizedState);
8593
};
8694

95+
const buildCreatedDateControl = () => {
96+
const years = createdYearOptions(availableFilters);
97+
if (years.length === 0) {
98+
return null;
99+
}
100+
101+
const root = createElement("section", {
102+
class: "search-filter-field search-created-date-field",
103+
"data-search-filter-field": CREATED_YEAR_FIELD,
104+
});
105+
const yearId = "search-filter-created-from";
106+
const toId = "search-filter-created-to";
107+
108+
const heading = createElement("div", { class: "search-filter-label" }, CREATED_DATE_LABEL);
109+
const controls = createElement("div", { class: "search-created-date-controls" });
110+
const yearControl = createElement("div", { class: "search-created-date-control" });
111+
const yearLabel = createElement(
112+
"label",
113+
{ class: "search-created-date-control-label", for: yearId },
114+
"Year",
115+
);
116+
const yearSelect = createElement("select", {
117+
id: yearId,
118+
class: "search-created-date-select",
119+
});
120+
const toControl = createElement("div", { class: "search-created-date-control" });
121+
const toLabel = createElement("label", { class: "search-created-date-control-label", for: toId }, "To");
122+
const toSelect = createElement("select", {
123+
id: toId,
124+
class: "search-created-date-select",
125+
disabled: true,
126+
});
127+
const summary = createElement("div", {
128+
class: "search-filter-summary",
129+
"aria-live": "polite",
130+
});
131+
const chips = createElement("div", { class: "search-filter-chips" });
132+
const clearButton = createElement(
133+
"button",
134+
{
135+
class: "search-filter-clear",
136+
type: "button",
137+
},
138+
"Clear created date",
139+
);
140+
141+
const appendYearOptions = (select, includeAny = true) => {
142+
if (includeAny) {
143+
select.appendChild(createElement("option", { value: "" }, "Any"));
144+
} else {
145+
select.appendChild(createElement("option", { value: "", disabled: true, hidden: true }, ""));
146+
}
147+
for (const year of years) {
148+
select.appendChild(createElement("option", { value: year }, year));
149+
}
150+
};
151+
152+
appendYearOptions(yearSelect);
153+
appendYearOptions(toSelect, false);
154+
yearControl.append(yearLabel, yearSelect);
155+
toControl.append(toLabel, toSelect);
156+
controls.append(yearControl, toControl);
157+
root.append(heading, controls, summary, chips, clearButton);
158+
159+
const updateCreatedDate = (createdDate) => {
160+
const nextState = cloneSearchState(currentState);
161+
nextState.createdDate = createdDate;
162+
nextState.page = 1;
163+
applyDraftState(nextState);
164+
};
165+
166+
const clearCreatedDate = () => updateCreatedDate({ from: null, to: null });
167+
168+
const renderSelections = () => {
169+
const createdDate = currentState.createdDate || {};
170+
const selectedLabel = createdDateChipLabel(createdDate);
171+
yearSelect.value = createdDate.from || "";
172+
toSelect.disabled = !createdDate.from;
173+
toSelect.setAttribute("aria-disabled", createdDate.from ? "false" : "true");
174+
toSelect.value = createdDate.from ? createdDate.to || createdDate.from : "";
175+
summary.textContent = selectedLabel ? selectedSummary(1) : "No filters selected";
176+
clearButton.hidden = !selectedLabel;
177+
clearElement(chips);
178+
if (!selectedLabel) {
179+
return;
180+
}
181+
182+
const chip = createElement("span", { class: "search-filter-chip" });
183+
chip.appendChild(createElement("span", { class: "search-filter-chip-label" }, selectedLabel));
184+
const remove = createElement(
185+
"button",
186+
{
187+
class: "search-filter-chip-remove",
188+
type: "button",
189+
"aria-label": `Remove created date ${selectedLabel}`,
190+
},
191+
"x",
192+
);
193+
remove.addEventListener("click", clearCreatedDate);
194+
chip.appendChild(remove);
195+
chips.appendChild(chip);
196+
};
197+
198+
yearSelect.addEventListener("change", () => {
199+
if (!yearSelect.value) {
200+
clearCreatedDate();
201+
return;
202+
}
203+
updateCreatedDate({ from: yearSelect.value, to: toSelect.value || yearSelect.value });
204+
});
205+
toSelect.addEventListener("change", () => {
206+
if (!yearSelect.value) {
207+
clearCreatedDate();
208+
return;
209+
}
210+
updateCreatedDate({ from: yearSelect.value, to: toSelect.value || null });
211+
});
212+
clearButton.addEventListener("click", clearCreatedDate);
213+
214+
return {
215+
root,
216+
render: renderSelections,
217+
};
218+
};
219+
87220
const buildControl = (field) => {
88221
const options = fieldOptions(availableFilters, field);
89222
if (options.length === 0) {
@@ -423,7 +556,8 @@ export const createFilterControls = (container, availableFilters, onDraftChange,
423556
const visibleFields = FILTER_FIELDS.filter(
424557
(field) => fieldOptions(availableFilters, field).length > 0,
425558
);
426-
if (visibleFields.length === 0) {
559+
const createdDateControl = buildCreatedDateControl();
560+
if (visibleFields.length === 0 && !createdDateControl) {
427561
container.hidden = true;
428562
return;
429563
}
@@ -438,6 +572,10 @@ export const createFilterControls = (container, availableFilters, onDraftChange,
438572
controls.set(field.key, control);
439573
grid.appendChild(control.root);
440574
}
575+
if (createdDateControl) {
576+
createdDateControl.render();
577+
grid.appendChild(createdDateControl.root);
578+
}
441579
container.appendChild(grid);
442580
}
443581

0 commit comments

Comments
 (0)