Skip to content
Merged
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
208 changes: 126 additions & 82 deletions Dist/WebflowOnly/CMSFilter.js
Original file line number Diff line number Diff line change
Expand Up @@ -559,88 +559,100 @@ class CMSFilter {
});
}

ApplyFilters() {
const filters = this.GetFilters();
this.currentPage = 1; // Reset pagination to first page
this.filteredItems = this.allItems.filter((item) => {
return Object.keys(filters).every((category) => {
// Fix 1: Safari-compatible array handling
const categoryFilters = filters[category] || [];
const values = Array.isArray(categoryFilters)
? categoryFilters.slice()
: [];
if (values.length === 0) return true;

// Use cached search data instead of live DOM queries
const searchCache = item._wtSearchCache;
if (!searchCache) {
console.warn(
"Search cache missing for item, falling back to live query",
);
// Fallback to original method if cache is missing
const categoryElement = item.querySelector(
`[wt-cmsfilter-category="${category}"]`,
);
let matchingText = "";
if (categoryElement && categoryElement.innerText) {
matchingText = categoryElement.innerText.toLowerCase();
}
matchingText = matchingText.replace(/(?: |\s)+/gi, " ");
/**
* Whether a single list item matches the given filter map (same rules as ApplyFilters).
*/
itemMatchesFilters(item, filters) {
return Object.keys(filters).every((category) => {
const categoryFilters = filters[category] || [];
const values = Array.isArray(categoryFilters)
? categoryFilters.slice()
: [];
if (values.length === 0) return true;

const searchCache = item._wtSearchCache;
if (!searchCache) {
console.warn(
"Search cache missing for item, falling back to live query",
);
const categoryElement = item.querySelector(
`[wt-cmsfilter-category="${category}"]`,
);
let matchingText = "";
if (categoryElement && categoryElement.innerText) {
matchingText = categoryElement.innerText.toLowerCase();
}
matchingText = matchingText.replace(/(?: |\s)+/gi, " ");
}

if (category === "*") {
// Global search using cached text
const globalText = searchCache ? searchCache.globalSearchText : "";
return (
values.some((value) => globalText.includes(value.toLowerCase())) ||
Object.values(item.dataset || {}).some((dataValue) =>
values.some((value) => {
if (dataValue && typeof dataValue.toLowerCase === "function") {
return dataValue.toLowerCase().includes(value.toLowerCase());
}
return false;
}),
)
);
} else {
return values.some((value) => {
if (typeof value === "object" && value !== null) {
// Range filtering - use normalized dataset key
const datasetCategory = this.GetDataSet(category);
const datasetValue =
item.dataset && item.dataset[datasetCategory]
? item.dataset[datasetCategory]
: "";
const itemValue = parseFloat(datasetValue);
if (isNaN(itemValue)) return false;
if (value.from !== null && value.to !== null) {
return itemValue >= value.from && itemValue <= value.to;
} else if (value.from !== null && value.to == null) {
return itemValue >= value.from;
} else if (value.from == null && value.to !== null) {
return itemValue <= value.to;
if (category === "*") {
const globalText = searchCache ? searchCache.globalSearchText : "";
return (
values.some((value) => globalText.includes(value.toLowerCase())) ||
Object.values(item.dataset || {}).some((dataValue) =>
values.some((value) => {
if (dataValue && typeof dataValue.toLowerCase === "function") {
return dataValue.toLowerCase().includes(value.toLowerCase());
}
return false;
} else {
// Text filtering using cached data
const datasetCategory = this.GetDataSet(category);
const cachedDatasetValue = searchCache
? searchCache.datasetValues.get(datasetCategory) || ""
: "";
const cachedCategoryText = searchCache
? searchCache.categoryTexts.get(category) || ""
: "";
const valueStr = value ? value.toString().toLowerCase() : "";

return (
cachedDatasetValue.includes(valueStr) ||
cachedCategoryText.includes(valueStr)
);
}
});
}),
)
);
}
return values.some((value) => {
if (typeof value === "object" && value !== null) {
const datasetCategory = this.GetDataSet(category);
const datasetValue =
item.dataset && item.dataset[datasetCategory]
? item.dataset[datasetCategory]
: "";
const itemValue = parseFloat(datasetValue);
if (isNaN(itemValue)) return false;
if (value.from !== null && value.to !== null) {
return itemValue >= value.from && itemValue <= value.to;
} else if (value.from !== null && value.to == null) {
return itemValue >= value.from;
} else if (value.from == null && value.to !== null) {
return itemValue <= value.to;
}
return false;
} else {
const datasetCategory = this.GetDataSet(category);
const cachedDatasetValue = searchCache
? searchCache.datasetValues.get(datasetCategory) || ""
: "";
const cachedCategoryText = searchCache
? searchCache.categoryTexts.get(category) || ""
: "";
const valueStr = value ? value.toString().toLowerCase() : "";

return (
cachedDatasetValue.includes(valueStr) ||
cachedCategoryText.includes(valueStr)
);
}
});
});
}

/**
* Items matching current filters but ignoring one or more categories (for hybrid availability).
*/
getFilteredItemsIgnoringCategories(excludedCategoryAttrs) {
const filters = this.GetFilters();
const f = { ...filters };
excludedCategoryAttrs.forEach((cat) => {
f[cat] = [];
});
return this.allItems.filter((item) => this.itemMatchesFilters(item, f));
}

ApplyFilters() {
const filters = this.GetFilters();
this.currentPage = 1; // Reset pagination to first page
this.filteredItems = this.allItems.filter((item) =>
this.itemMatchesFilters(item, filters),
);

this.activeFilters = filters;
this.SortItems();
Expand Down Expand Up @@ -765,19 +777,48 @@ class CMSFilter {
});
}

/**
* Categories whose checkbox availability ignores that facet’s own selections (hybrid mode).
* Set on the form: wt-cmsfilter-hybrid-categories="bodytype" or "bodytype,colour"
* Omit or leave empty for no self-exclude categories (hybrid availability matches advanced for every facet).
*/
getHybridSelfExcludeCategories() {
const raw = this.filterForm.getAttribute(
"wt-cmsfilter-hybrid-categories",
);
if (raw === null || raw.trim() === "") {
return [];
}
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}

UpdateAvailableFilters() {
if (this.filterForm.getAttribute("wt-cmsfilter-filtering") !== "advanced")
return;
const filteringMode = this.filterForm.getAttribute(
"wt-cmsfilter-filtering",
);
if (filteringMode !== "advanced" && filteringMode !== "hybrid") return;

this.availableFilters = {};

this.filterElements.forEach((element) => {
const category = this.GetDataSet(
element.getAttribute("wt-cmsfilter-category"),
);
const categoryAttr = element.getAttribute("wt-cmsfilter-category");
const category = this.GetDataSet(categoryAttr);

let sourceItems = this.filteredItems;
if (
filteringMode === "hybrid" &&
categoryAttr &&
this.getHybridSelfExcludeCategories().includes(categoryAttr)
) {
sourceItems = this.getFilteredItemsIgnoringCategories([categoryAttr]);
}

// Safari-compatible dataset access
const availableValues = new Set(
this.filteredItems
sourceItems
.map((item) =>
item.dataset && item.dataset[category]
? item.dataset[category]
Expand Down Expand Up @@ -919,7 +960,10 @@ class CMSFilter {
input.value = "";
}
} else if (input.type === "checkbox") {
if (advancedFiltering === "advanced") {
if (
advancedFiltering === "advanced" ||
advancedFiltering === "hybrid"
) {
input.checked = false;
} else {
if (categoryElement.innerText === value) {
Expand Down
125 changes: 125 additions & 0 deletions __tests__/CMSFilter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,37 @@ describe("CMSFilter", () => {
`;
}

/** make, bodytype, Category — hybrid (bodytype self-exclude) vs advanced */
function buildHybridScenarioDOM(filteringMode = "hybrid", hybridCategoriesAttr) {
const modeAttr = filteringMode
? `wt-cmsfilter-filtering="${filteringMode}"`
: "";
const hybridAttr =
filteringMode === "hybrid"
? ` wt-cmsfilter-hybrid-categories="${hybridCategoriesAttr ?? ""}"`
: "";
document.body.innerHTML = `
<form wt-cmsfilter-element="filter-form" ${modeAttr} ${hybridAttr} wt-cmsfilter-debounce="0">
<label wt-cmsfilter-category="make"><input type="checkbox"><span>Toyota</span></label>
<label wt-cmsfilter-category="make"><input type="checkbox"><span>Honda</span></label>
<label wt-cmsfilter-category="bodytype"><input type="checkbox"><span>SUV</span></label>
<label wt-cmsfilter-category="bodytype"><input type="checkbox"><span>Sedan</span></label>
<label wt-cmsfilter-category="Category"><input type="checkbox"><span>Red</span></label>
<label wt-cmsfilter-category="Category"><input type="checkbox"><span>Blue</span></label>
<label wt-cmsfilter-category="*"><input type="text" /></label>
<select wt-cmsfilter-element="sort-options"><option value="title-asc">A</option></select>
<div wt-cmsfilter-element="results-count"></div>
</form>
<div id="tags-wrapper"><div wt-cmsfilter-element="tag-template"><span wt-cmsfilter-element="tag-text"></span><a href="#" wt-cmsfilter-element="tag-remove">x</a></div></div>
<div wt-cmsfilter-element="list">
<div class="item" data-title="t1" data-make="Toyota" data-bodytype="SUV" data-category="Red">T1</div>
<div class="item" data-title="t2" data-make="Toyota" data-bodytype="Sedan" data-category="Red">T2</div>
<div class="item" data-title="h1" data-make="Honda" data-bodytype="SUV" data-category="Blue">H1</div>
</div>
<div wt-cmsfilter-element="empty" style="display:none;"></div>
`;
}

test("initializes and caches items, pushes instance", () => {
buildBasicDOM();
InitializeCMSFilter();
Expand Down Expand Up @@ -172,6 +203,100 @@ describe("CMSFilter", () => {
expect(ordered).toEqual(["gamma", "beta", "alpha"]);
});

test("hybrid mode narrows make like advanced but keeps all relevant body types visible for multi-select", () => {
buildHybridScenarioDOM("hybrid", "bodytype");
InitializeCMSFilter();
const form = document.querySelector('[wt-cmsfilter-element="filter-form"]');
const toyota = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="make"]'),
).find((l) => l.textContent.includes("Toyota"));
const suv = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'),
).find((l) => l.textContent.includes("SUV"));
toyota.querySelector("input").checked = true;
suv.querySelector("input").checked = true;
toyota
.querySelector("input")
.dispatchEvent(new Event("change", { bubbles: true }));
suv.querySelector("input").dispatchEvent(new Event("change", { bubbles: true }));
const instance = window.webtricks[0].CMSFilter;
instance.ApplyFilters();

expect(instance.filteredItems.length).toBe(1);
const makeLabels = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="make"]'),
);
const visibleMake = makeLabels.filter((l) => l.style.display !== "none");
expect(visibleMake.map((l) => l.textContent.trim())).toEqual(["Toyota"]);

const bodyLabels = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'),
);
const visibleBody = bodyLabels.filter((l) => l.style.display !== "none");
expect(visibleBody.map((l) => l.textContent.trim()).sort()).toEqual([
"SUV",
"Sedan",
]);
});

test("hybrid with empty wt-cmsfilter-hybrid-categories narrows all facets like advanced", () => {
buildHybridScenarioDOM("hybrid", "");
InitializeCMSFilter();
const form = document.querySelector('[wt-cmsfilter-element="filter-form"]');
const toyota = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="make"]'),
).find((l) => l.textContent.includes("Toyota"));
const suv = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'),
).find((l) => l.textContent.includes("SUV"));
toyota.querySelector("input").checked = true;
suv.querySelector("input").checked = true;
toyota
.querySelector("input")
.dispatchEvent(new Event("change", { bubbles: true }));
suv.querySelector("input").dispatchEvent(new Event("change", { bubbles: true }));
const instance = window.webtricks[0].CMSFilter;
instance.ApplyFilters();

const bodyLabels = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'),
);
const visibleBody = bodyLabels.filter((l) => l.style.display !== "none");
expect(visibleBody.map((l) => l.textContent.trim())).toEqual(["SUV"]);
});

test("advanced mode hides sibling make and hides body types not in result set", () => {
buildHybridScenarioDOM("advanced");
InitializeCMSFilter();
const form = document.querySelector('[wt-cmsfilter-element="filter-form"]');
const toyota = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="make"]'),
).find((l) => l.textContent.includes("Toyota"));
const suv = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'),
).find((l) => l.textContent.includes("SUV"));
toyota.querySelector("input").checked = true;
suv.querySelector("input").checked = true;
toyota
.querySelector("input")
.dispatchEvent(new Event("change", { bubbles: true }));
suv.querySelector("input").dispatchEvent(new Event("change", { bubbles: true }));
const instance = window.webtricks[0].CMSFilter;
instance.ApplyFilters();

const makeLabels = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="make"]'),
);
const visibleMake = makeLabels.filter((l) => l.style.display !== "none");
expect(visibleMake.map((l) => l.textContent.trim())).toEqual(["Toyota"]);

const bodyLabels = Array.from(
form.querySelectorAll('label[wt-cmsfilter-category="bodytype"]'),
);
const visibleBody = bodyLabels.filter((l) => l.style.display !== "none");
expect(visibleBody.map((l) => l.textContent.trim())).toEqual(["SUV"]);
});

test("advanced filtering hides unavailable checkboxes then restores after clearing", () => {
buildBasicDOM({ advanced: true });
InitializeCMSFilter();
Expand Down
Loading
Loading