diff --git a/website/modules/asset/ui/src/scss/_cases.scss b/website/modules/asset/ui/src/scss/_cases.scss
index d0216600..6c8a4358 100644
--- a/website/modules/asset/ui/src/scss/_cases.scss
+++ b/website/modules/asset/ui/src/scss/_cases.scss
@@ -573,6 +573,10 @@
}
}
+.is-hidden {
+ display: none !important;
+}
+
.items-count {
font-weight: $font-weight-extra-bold;
color: $gray-500;
diff --git a/website/modules/case-studies-page/index.js b/website/modules/case-studies-page/index.js
index fca7e6d7..45cd52c8 100644
--- a/website/modules/case-studies-page/index.js
+++ b/website/modules/case-studies-page/index.js
@@ -1,7 +1,5 @@
const mainWidgets = require('../../lib/mainWidgets');
const NavigationService = require('./services/NavigationService');
-const SearchService = require('./services/SearchService');
-const TagCountService = require('./services/TagCountService');
const UrlService = require('./services/UrlService');
const createDocMapById = function (docs) {
@@ -30,171 +28,6 @@ const collectFilterOptions = function (pieces, fieldName, docMap) {
return options;
};
-const buildPiecesFiltersFromResults = async function (self, req, pieces) {
- const [tags, partners] = await Promise.all([
- self.apos.modules['cases-tags'].find(req).toArray(),
- self.apos.modules['business-partner'].find(req).toArray(),
- ]);
- const tagMap = createDocMapById(tags);
- const partnerMap = createDocMapById(partners);
- return {
- industry: collectFilterOptions(pieces, 'industryIds', tagMap),
- stack: collectFilterOptions(pieces, 'stackIds', tagMap),
- caseStudyType: collectFilterOptions(pieces, 'caseStudyTypeIds', tagMap),
- partner: collectFilterOptions(pieces, 'partnerIds', partnerMap),
- };
-};
-
-const buildIndexQuery = function (self, req) {
- const queryParams = { ...req.query };
- const searchTerm = SearchService.getSearchTerm(queryParams);
- delete queryParams.search;
-
- const query = self.pieces
- .find(req, {})
- .applyBuildersSafely(queryParams)
- .perPage(self.perPage);
- self.filterByIndexPage(query, req.data.page);
-
- const resolved = req.data.searchRelationships || {};
- const searchCondition = SearchService.buildSearchCondition(
- searchTerm,
- resolved,
- );
- if (searchCondition) {
- query.and(searchCondition);
- }
- return query;
-};
-
-const buildTagCountQuery = function (self, req) {
- const queryParams = { ...req.query };
- const searchTerm = SearchService.getSearchTerm(queryParams);
- delete queryParams.search;
- delete queryParams.page;
-
- const query = self.pieces.find(req, {}).applyBuildersSafely(queryParams);
- self.filterByIndexPage(query, req.data.page);
-
- const resolved = req.data.searchRelationships || {};
- const searchCondition = SearchService.buildSearchCondition(
- searchTerm,
- resolved,
- );
- if (searchCondition) {
- query.and(searchCondition);
- }
- return query;
-};
-
-const runResolveSearchRelationships = async function (self, req) {
- req.data ||= {};
- const reqData = req.data;
- const searchTerm = SearchService.getSearchTerm(req.query || {});
- if (!searchTerm) {
- reqData.searchRelationships = {};
- return;
- }
- let resolvedRelationships = {};
- try {
- resolvedRelationships = await SearchService.resolveSearchRelationships(
- searchTerm,
- self.apos,
- req,
- );
- } catch (error) {
- self.apos.util.error('Error resolving search relationships:', error);
- }
- reqData.searchRelationships = resolvedRelationships;
-};
-
-const runApplyEnhancedSearchResults = async function (self, req) {
- const reqData = req.data;
- const searchTerm = SearchService.getSearchTerm(req.query || {});
- if (!searchTerm) {
- return;
- }
- const queryParams = { ...req.query };
- delete queryParams.search;
- const resolved = reqData.searchRelationships || {};
- const hasRelationshipMatches = Object.keys(resolved).length > 0;
- if (!hasRelationshipMatches) {
- return;
- }
- const searchCondition = SearchService.buildSearchCondition(
- searchTerm,
- resolved,
- );
- if (!searchCondition) {
- return;
- }
-
- const piecesQuery = self.pieces
- .find(req, {})
- .applyBuildersSafely(queryParams);
- piecesQuery.and(searchCondition);
-
- const pieces = await piecesQuery.toArray();
- const totalPieces = pieces.length;
- const piecesFilters = await buildPiecesFiltersFromResults(self, req, pieces);
- reqData.pieces = pieces;
- reqData.totalPieces = totalPieces;
- reqData.totalPages = 1;
- reqData.piecesFilters = piecesFilters;
-};
-
-const runSetupIndexData = async function (self, req) {
- try {
- const countQuery = buildTagCountQuery(self, req);
- const caseStudiesForCounts = await countQuery.toArray();
- const tagCounts = await TagCountService.calculateTagCounts(
- req,
- self.apos.modules,
- self.options,
- caseStudiesForCounts,
- );
- UrlService.attachIndexData(req, tagCounts);
- } catch (error) {
- self.apos.util.error('Error calculating tag counts:', error);
- UrlService.attachIndexData(req, {
- industry: {},
- stack: {},
- caseStudyType: {},
- partner: {},
- });
- }
-};
-
-const buildIndexSeoData = function (req) {
- const query = req.query || {};
- const hasFilterParams =
- Boolean(query.search) ||
- Boolean(query.industry) ||
- Boolean(query.stack) ||
- Boolean(query.caseStudyType) ||
- Boolean(query.partner);
- const pageNumber = Number(query.page || 1);
- const hasPaginationParam = Number.isFinite(pageNumber) && pageNumber > 1;
- const shouldNoindex = hasFilterParams || hasPaginationParam;
- let pageUrl = '/cases';
- if (req.data && req.data.page && req.data.page.slug) {
- pageUrl = req.data.page.slug;
- }
- let robots = 'index,follow';
- if (shouldNoindex) {
- robots = 'noindex,nofollow';
- }
- return {
- canonicalUrl: pageUrl,
- robots,
- };
-};
-
-const runSetupIndexSeoData = function (req) {
- req.data ||= {};
- req.data.caseListingSeo = buildIndexSeoData(req);
-};
-
const runSetupShowData = async function (self, req) {
try {
const navigation = await NavigationService.getNavigationDataForPage(
@@ -214,13 +47,6 @@ module.exports = {
options: {
label: 'Case Studies Page',
pluralLabel: 'Case Studies Pages',
- perPage: 6,
- piecesFilters: [
- { name: 'industry' },
- { name: 'stack' },
- { name: 'caseStudyType' },
- { name: 'partner' },
- ],
pieces: 'case-studies',
piecesFiltersUrl: '/case-studies',
},
@@ -240,43 +66,48 @@ module.exports = {
},
},
- init(self) {
- const superBeforeIndex = self.beforeIndex;
- self.beforeIndex = async (req) => {
- if (superBeforeIndex) {
- await superBeforeIndex(req);
- }
- await self.resolveSearchRelationships(req);
- await self.applyEnhancedSearchResults(req);
- await self.setupIndexData(req);
- self.setupIndexSeoData(req);
- };
-
- const superBeforeShow = self.beforeShow;
- self.beforeShow = async (req) => {
- if (superBeforeShow) {
- await superBeforeShow(req);
- }
- await self.setupShowData(req);
- };
- },
-
methods(self) {
return {
- indexQuery(req) {
- return buildIndexQuery(self, req);
- },
- resolveSearchRelationships(req) {
- return runResolveSearchRelationships(self, req);
- },
- applyEnhancedSearchResults(req) {
- return runApplyEnhancedSearchResults(self, req);
- },
- setupIndexData(req) {
- return runSetupIndexData(self, req);
+ async beforeIndex(req) {
+ // Load all case studies and tags for frontend filtering
+ const [pieces, casesTags, businessPartners] = await Promise.all([
+ self.pieces.find(req).toArray(),
+ self.apos.modules['cases-tags'].find(req).toArray(),
+ self.apos.modules['business-partner'].find(req).toArray(),
+ ]);
+ req.data = {
+ ...req.data,
+ pieces,
+ totalPieces: pieces.length,
+ totalPages: 1,
+ casesTags,
+ businessPartners,
+ };
+
+ // Build filter options from all tags
+ const tagMap = createDocMapById(casesTags);
+ const partnerMap = createDocMapById(businessPartners);
+ req.data.piecesFilters = {
+ industry: collectFilterOptions(pieces, 'industryIds', tagMap),
+ stack: collectFilterOptions(pieces, 'stackIds', tagMap),
+ caseStudyType: collectFilterOptions(
+ pieces,
+ 'caseStudyTypeIds',
+ tagMap,
+ ),
+ partner: collectFilterOptions(pieces, 'partnerIds', partnerMap),
+ };
+
+ // Attach URL helpers for template
+ UrlService.attachIndexData(req, {
+ industry: {},
+ stack: {},
+ caseStudyType: {},
+ partner: {},
+ });
},
- setupIndexSeoData(req) {
- return runSetupIndexSeoData(req);
+ async beforeShow(req) {
+ await self.setupShowData(req);
},
setupShowData(req) {
return runSetupShowData(self, req);
diff --git a/website/modules/case-studies-page/views/index.html b/website/modules/case-studies-page/views/index.html
index 663a9098..8998cf53 100644
--- a/website/modules/case-studies-page/views/index.html
+++ b/website/modules/case-studies-page/views/index.html
@@ -1,6 +1,5 @@
{# modules/case-studies-page/views/index.html #}
{% extends "layout.html" %}
-{% import '@apostrophecms/pager:macros.html' as pager with context %}
{% block extraHead %}
{{ super() }}
@@ -11,7 +10,6 @@
class="cs_container"
id="case-studies-page-data"
data-default-visible-tags="{{ data.defaultVisibleTagsCount }}"
- data-total-pages="{{ data.totalPages }}"
>
Filter Case Studies
{% set hasActiveFilters = data.query.industry or data.query.stack or
- data.query.caseStudyType or data.query.partner or data.query.search %} {% if hasActiveFilters %}
-
- {{ data.totalPieces }} Ite{% if data.totalPieces == 1 %}m{% else %}ms{%
- endif %} Found
+ data.query.caseStudyType or data.query.partner or data.query.search %}
+
+ Loading...
-
- {{ data.totalPieces }} Ite{% if data.totalPieces == 1 %}m{% else %}ms{%
- endif %} Found
+
+ Loading...
-
+
-
@@ -218,11 +190,15 @@
{% if filterType == 'industry' %}
{{ tag.label }}
[ {{ count }} ]
@@ -230,7 +206,7 @@
{% elif filterType == 'caseStudyType' %}
{{ tag.label }}
[ {{ count }} ]
@@ -238,7 +214,7 @@
{% elif filterType == 'stack' %}
{{ tag.label }}
[ {{ count }} ]
@@ -246,7 +222,7 @@
{% elif filterType == 'partner' %}
{{ tag.label }}
[ {{ count }} ]
@@ -257,11 +233,15 @@
{% if filterType == 'industry' %}
{{ tag.label }}
[ {{ count }} ]
@@ -269,7 +249,7 @@
{% elif filterType == 'caseStudyType' %}
{{ tag.label }}
[ {{ count }} ]
@@ -277,7 +257,7 @@
{% elif filterType == 'stack' %}
{{ tag.label }}
[ {{ count }} ]
@@ -285,7 +265,7 @@
{% elif filterType == 'partner' %}
{{ tag.label }}
[ {{ count }} ]
@@ -312,10 +292,25 @@
{% endif %} {% endfor %}
- {% if data.pieces and data.pieces.length %}
-
+
- {% else %}
-
+
No Results Found
Try a different keyword or clear filters to see more case studies.
- {% endif %}
@@ -390,20 +387,9 @@
No Results Found
- {% if data.totalPages > 1 %}
-
- {% endif %}
-
-
-
{% endblock main %}
diff --git a/website/public/js/modules/case-studies-page/search-handler.js b/website/public/js/modules/case-studies-page/search-handler.js
index 9f8acaf6..d6cb8d29 100644
--- a/website/public/js/modules/case-studies-page/search-handler.js
+++ b/website/public/js/modules/case-studies-page/search-handler.js
@@ -1,38 +1,291 @@
/**
* Case Studies Search Handler
- * Triggers search on Enter (form submit) and clear button click
+ * Performs live, frontend-only keyword search and tag filtering (no page reload,
+ * no URL query params). Search is AND-combined with active tag filters; tag
+ * filters use AND across filter types and OR within a single filter type.
*/
(function () {
'use strict';
- // Build URL with search and existing filters
- function buildSearchUrl(searchValue) {
- const url = new URL(window.location.href);
- const params = new URLSearchParams(url.search);
+ // In-memory search term (disconnected from URL query parameters)
+ let searchTerm = '';
- // Update or remove search parameter
- if (searchValue && searchValue.trim()) {
- params.set('search', searchValue.trim());
- } else {
- params.delete('search');
+ // In-memory filter state (disconnected from URL query parameters)
+ const filterState = {
+ industry: new Set(),
+ stack: new Set(),
+ caseStudyType: new Set(),
+ partner: new Set()
+ };
+
+ // Populate filterState from server-rendered active tag items on page load
+ function initFilterState() {
+ Object.keys(filterState).forEach(function (filterType) {
+ filterState[filterType].clear();
+ });
+ document.querySelectorAll('.tag-item.active').forEach(function (item) {
+ const filterType = item.dataset.filterType;
+ const tagValue = item.dataset.tagValue;
+ if (filterType && filterState[filterType]) {
+ filterState[filterType].add(tagValue);
+ }
+ });
+ }
+
+ // Update in-memory filter state (no URL/history changes)
+ function updateFilterState(filterType, tagValue, action) {
+ if (!filterState[filterType]) {
+ return;
+ }
+ if (action === 'add') {
+ filterState[filterType].add(tagValue);
+ } else if (action === 'remove') {
+ filterState[filterType].delete(tagValue);
+ }
+ }
+
+ // Get a card's tag slugs for a given filter type (from data-* attributes)
+ function getCardSlugs(card, filterType) {
+ const raw = card.dataset[filterType] || '';
+ return raw
+ .split(',')
+ .map(function (value) { return value.trim(); })
+ .filter(Boolean);
+ }
+
+ // AND across filter types, OR within a single filter type
+ // (matches historical server-side behavior via Apostrophe's applyBuildersSafely)
+ function cardMatchesTags(card, state) {
+ return Object.keys(state).every(function (filterType) {
+ const activeSet = state[filterType];
+ if (!activeSet || activeSet.size === 0) {
+ return true;
+ }
+ const slugs = getCardSlugs(card, filterType);
+ return slugs.some(function (slug) { return activeSet.has(slug); });
+ });
+ }
+
+ // Keyword search against the card's precomputed data-search text
+ function cardMatchesSearch(card, term) {
+ const normalizedTerm = (term || '').trim().toLowerCase();
+ if (!normalizedTerm) {
+ return true;
+ }
+ const searchText = (card.dataset.search || '').toLowerCase();
+ return searchText.indexOf(normalizedTerm) !== -1;
+ }
+
+ // Combined match: tag filters (AND across types / OR within a type) AND search keyword
+ function cardMatchesFilters(card, state, term) {
+ return cardMatchesTags(card, state) && cardMatchesSearch(card, term);
+ }
+
+ // Apply the current filterState and searchTerm to the case study cards, updating
+ // visibility, items-count text, and the empty-state block
+ function applyCardFiltering() {
+ const cards = document.querySelectorAll('.cs_card');
+ let visibleCount = 0;
+
+ cards.forEach(function (card) {
+ const matches = cardMatchesFilters(card, filterState, searchTerm);
+ card.classList.toggle('is-hidden', !matches);
+ if (matches) {
+ visibleCount += 1;
+ }
+ });
+
+ const itemsCount = document.querySelector('.items-count');
+ const itemsCountMobile = document.querySelector('.items-count__mobile');
+ const itemLabel = visibleCount === 1 ? 'Item' : 'Items';
+ const itemsText = `${visibleCount} ${itemLabel} Found`;
+ if (itemsCount) {
+ itemsCount.textContent = itemsText;
+ }
+ if (itemsCountMobile) {
+ itemsCountMobile.textContent = itemsText;
+ }
+
+ const emptyState = document.querySelector('.cs_empty-state');
+ const csList = document.querySelector('.cs_list');
+ const hasNoResults = visibleCount === 0;
+ if (emptyState) {
+ emptyState.classList.toggle('is-hidden', !hasNoResults);
}
+ if (csList) {
+ csList.classList.toggle('cs_list--empty', hasNoResults);
+ }
+ }
+
+ // Recalculate each tag's visible-match count (standard faceted-search recount):
+ // for tag V in type T, count cards that would match if V were added to T's
+ // active set (OR-combined with other active tags in T), still applying AND
+ // against all other filter types' current selections.
+ function recalculateTagCounts() {
+ const cards = document.querySelectorAll('.cs_card');
+
+ document.querySelectorAll('.tag-item').forEach(function (tagItem) {
+ const filterType = tagItem.dataset.filterType;
+ const tagValue = tagItem.dataset.tagValue;
+ if (!filterType || !tagValue || !filterState[filterType]) {
+ return;
+ }
- // Remove page parameter when searching (reset to page 1)
- params.delete('page');
+ const tempState = {};
+ Object.keys(filterState).forEach(function (type) {
+ tempState[type] = new Set(filterState[type]);
+ });
+ tempState[filterType].add(tagValue);
- // Build new URL
- const newSearch = params.toString();
- const newUrl =
- url.pathname + (newSearch ? '?' + newSearch : '') + url.hash;
+ let count = 0;
+ cards.forEach(function (card) {
+ if (cardMatchesFilters(card, tempState, searchTerm)) {
+ count += 1;
+ }
+ });
- return newUrl;
+ const countSpan = tagItem.querySelector('.tag-count');
+ if (countSpan) {
+ countSpan.textContent = `[ ${count} ]`;
+ }
+ });
}
- // Navigate to URL with updated search (single history entry; Back works as expected)
- function performSearch(searchValue) {
- const newUrl = buildSearchUrl(searchValue);
- window.location.assign(newUrl);
+ // Update active class on tag items
+ function updateTagActiveState(filterType, tagValue, isActive) {
+ const tagItems = document.querySelectorAll(`.tag-item[data-filter-type="${filterType}"][data-tag-value="${tagValue}"]`);
+ tagItems.forEach(item => {
+ if (isActive) {
+ item.classList.add('active');
+ item.dataset.action = 'remove';
+ } else {
+ item.classList.remove('active');
+ item.dataset.action = 'add';
+ }
+ });
+ }
+
+ // Update selected tags list
+ function updateSelectedTagsList() {
+ const selectedTagsList = document.querySelector('.selected-tags-list');
+ const selectedTagsContainer = document.querySelector('.selected-tags');
+
+ if (!selectedTagsList || !selectedTagsContainer) return;
+
+ // Clear current selected tags
+ selectedTagsList.innerHTML = '';
+
+ // Get all active filter types
+ const filterTypes = ['industry', 'stack', 'caseStudyType', 'partner'];
+ let hasActiveFilters = false;
+
+ filterTypes.forEach(filterType => {
+ const values = Array.from(filterState[filterType]);
+ if (values.length > 0) {
+ hasActiveFilters = true;
+ values.forEach(value => {
+ // Find the corresponding tag item to get the label
+ const tagItem = document.querySelector(`.tag-item[data-filter-type="${filterType}"][data-tag-value="${value}"]`);
+ const label = tagItem ? tagItem.dataset.tagLabel : value;
+
+ const li = document.createElement('li');
+ li.className = 'selected-tag';
+ li.innerHTML = `
+ ${label}
+
+
+
+ `;
+ selectedTagsList.appendChild(li);
+ });
+ }
+ });
+
+ // Show/hide selected tags container and related elements based on active filters
+ const itemsCount = document.querySelector('.items-count');
+ const itemsCountMobile = document.querySelector('.items-count__mobile');
+ const clearAll = document.querySelector('.clear-all');
+
+ [selectedTagsContainer, itemsCount, itemsCountMobile, clearAll].forEach((el) => {
+ if (!el) return;
+ el.classList.toggle('is-hidden', !hasActiveFilters);
+ });
+ }
+
+ // Handle clear all click
+ function handleClearAllClick(event) {
+ const clearAllLink = event.target.closest('.clear-all-link');
+ if (!clearAllLink) return;
+
+ event.preventDefault();
+
+ Object.keys(filterState).forEach((filterType) => {
+ filterState[filterType].clear();
+ document
+ .querySelectorAll(`.tag-item[data-filter-type="${filterType}"].active`)
+ .forEach((item) => {
+ item.classList.remove('active');
+ item.dataset.action = 'add';
+ });
+ });
+
+ updateSelectedTagsList();
+ applyCardFiltering();
+ recalculateTagCounts();
+ }
+
+ // Handle tag click
+ function handleTagClick(event) {
+ const tagLink = event.target.closest('.tag-link');
+ if (!tagLink) return;
+
+ const tagItem = tagLink.closest('.tag-item');
+ if (!tagItem) return;
+
+ event.preventDefault();
+
+ const filterType = tagItem.dataset.filterType;
+ const tagValue = tagItem.dataset.tagValue;
+ const action = tagItem.dataset.action;
+
+ if (!filterType || !tagValue || !action) return;
+
+ // Update in-memory filter state
+ updateFilterState(filterType, tagValue, action);
+
+ // Update active state
+ const newIsActive = action === 'add';
+ updateTagActiveState(filterType, tagValue, newIsActive);
+
+ // Update selected tags list
+ updateSelectedTagsList();
+ applyCardFiltering();
+ recalculateTagCounts();
+ }
+
+ // Handle remove tag click from selected tags
+ function handleRemoveTagClick(event) {
+ const removeLink = event.target.closest('.remove-tag');
+ if (!removeLink) return;
+
+ event.preventDefault();
+
+ const filterType = removeLink.dataset.filterType;
+ const tagValue = removeLink.dataset.tagValue;
+
+ if (!filterType || !tagValue) return;
+
+ // Update in-memory filter state
+ updateFilterState(filterType, tagValue, 'remove');
+
+ // Update active state
+ updateTagActiveState(filterType, tagValue, false);
+
+ // Update selected tags list
+ updateSelectedTagsList();
+ applyCardFiltering();
+ recalculateTagCounts();
}
const VISIBLE_CLASS = 'cs_search-bar-clear--visible';
@@ -60,21 +313,18 @@
if (!searchInput) {
return;
}
- const hadValue = searchInput.value && searchInput.value.trim();
searchInput.value = '';
+ searchTerm = '';
updateClearButtonVisibility(searchInput, clearButton);
- if (hadValue) {
- performSearch('');
- }
+ updateSelectedTagsList();
+ applyCardFiltering();
+ recalculateTagCounts();
}
- // Handle form submission (Enter key) – triggers search
+ // Handle form submission (Enter key) – filtering already happens live on input,
+ // so just prevent the default page reload
function handleFormSubmit(event) {
event.preventDefault();
- const searchInput = event.target.querySelector('.cs_search-bar-input');
- if (searchInput) {
- performSearch(searchInput.value);
- }
}
function handleSearchFocus(event) {
@@ -102,9 +352,13 @@
function handleSearchInput(event) {
updateClearButtonVisibility(event.target, clearButton);
+ searchTerm = event.target.value;
+ updateSelectedTagsList();
+ applyCardFiltering();
+ recalculateTagCounts();
}
- // Add input event listener (handler closes over clearButton; no live query per keystroke)
+ // Add input event listener for live, frontend-only search filtering
searchInput.addEventListener('input', handleSearchInput);
// Add form submit handler
@@ -123,6 +377,33 @@
if (clearButton) {
updateClearButtonVisibility(searchInput, clearButton);
}
+
+ // Add tag click handlers
+ const tagsFilter = document.querySelector('.tags-filter');
+ if (tagsFilter) {
+ tagsFilter.addEventListener('click', handleTagClick);
+ }
+
+ // Add remove tag click handlers
+ const selectedTagsList = document.querySelector('.selected-tags-list');
+ if (selectedTagsList) {
+ selectedTagsList.addEventListener('click', handleRemoveTagClick);
+ }
+
+ // Add clear all click handler
+ const clearAll = document.querySelector('.clear-all');
+ if (clearAll) {
+ clearAll.addEventListener('click', handleClearAllClick);
+ }
+
+ // Initialize in-memory filter state and search term from server-rendered values
+ initFilterState();
+ searchTerm = searchInput.value;
+
+ // Initialize selected tags list, card filtering, and tag counts on page load
+ updateSelectedTagsList();
+ applyCardFiltering();
+ recalculateTagCounts();
}
// Initialize when DOM is ready