From bf5f1857b0558e0ae02f225aaf5e41e3605af097 Mon Sep 17 00:00:00 2001 From: Alex Ost Date: Mon, 6 Jul 2026 15:41:20 +0200 Subject: [PATCH 1/2] moved case studies filtering to frontend --- website/modules/asset/ui/src/scss/_cases.scss | 4 + website/modules/case-studies-page/index.js | 210 ++--------- .../case-studies-page/views/index.html | 119 +++--- .../case-studies-page/search-handler.js | 343 ++++++++++++++++-- 4 files changed, 400 insertions(+), 276 deletions(-) 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..8f6279c7 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,141 +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 = @@ -214,13 +77,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 +96,41 @@ 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.pieces = pieces; + req.data.totalPieces = pieces.length; + req.data.totalPages = 1; + req.data.casesTags = casesTags; + req.data.businessPartners = 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...
-
+ -
+
    {% for filterType in ['industry', 'stack', 'caseStudyType', 'partner'] %} {% if data.query[filterType] %} {% for tag in data.piecesFilters[filterType] @@ -115,44 +111,20 @@ (single value) #} {% set isTagSelected = tag.value == data.query[filterType] %} {% endif %} {% if isTagSelected %}
  • - {{ tag.label }} {% if filterType == 'industry' %} + {{ tag.label }} - Close Icon - - {% elif filterType == 'stack' %} - - Close Icon - - {% elif filterType == 'caseStudyType' %} - - Close Icon - - {% elif filterType == 'partner' %} - Close Icon - {% endif %}
  • {% endif %} {% endfor %} {% endif %} {% endfor %}
- {% endif %}
- {% 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} + + Close Icon + + `; + 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 From fd8ae950f9db2e7a37aa4a8cfb9bccdf33471eea Mon Sep 17 00:00:00 2001 From: Alex Ost Date: Mon, 6 Jul 2026 15:52:59 +0200 Subject: [PATCH 2/2] fixed lint issues --- website/modules/case-studies-page/index.js | 49 ++++++---------------- 1 file changed, 13 insertions(+), 36 deletions(-) diff --git a/website/modules/case-studies-page/index.js b/website/modules/case-studies-page/index.js index 8f6279c7..45cd52c8 100644 --- a/website/modules/case-studies-page/index.js +++ b/website/modules/case-studies-page/index.js @@ -28,36 +28,6 @@ const collectFilterOptions = function (pieces, fieldName, docMap) { return options; }; -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( @@ -105,11 +75,14 @@ module.exports = { self.apos.modules['cases-tags'].find(req).toArray(), self.apos.modules['business-partner'].find(req).toArray(), ]); - req.data.pieces = pieces; - req.data.totalPieces = pieces.length; - req.data.totalPages = 1; - req.data.casesTags = casesTags; - req.data.businessPartners = businessPartners; + req.data = { + ...req.data, + pieces, + totalPieces: pieces.length, + totalPages: 1, + casesTags, + businessPartners, + }; // Build filter options from all tags const tagMap = createDocMapById(casesTags); @@ -117,7 +90,11 @@ module.exports = { req.data.piecesFilters = { industry: collectFilterOptions(pieces, 'industryIds', tagMap), stack: collectFilterOptions(pieces, 'stackIds', tagMap), - caseStudyType: collectFilterOptions(pieces, 'caseStudyTypeIds', tagMap), + caseStudyType: collectFilterOptions( + pieces, + 'caseStudyTypeIds', + tagMap, + ), partner: collectFilterOptions(pieces, 'partnerIds', partnerMap), };