From 6768f86163022a9c5bdc27f12cdb7764f6e11a1b Mon Sep 17 00:00:00 2001 From: Ihor Masechko Date: Thu, 19 Feb 2026 16:09:38 +0200 Subject: [PATCH 1/3] feat: add Partner filter to case studies page - Add Partner filter option in sidebar after Industry, Case Type, and Technology - Implement backend filtering logic for business-partner relationships - Update NavigationService, UrlService, and TagCountService to support partner filtering - Add partner filter UI matching existing filter patterns - Support multi-select and combination with other filters --- website/modules/case-studies-page/index.js | 2 + .../services/NavigationService.js | 34 ++++++++++++++++ .../services/TagCountService.js | 40 ++++++++++++++++--- .../case-studies-page/services/UrlService.js | 8 ++++ .../case-studies-page/views/index.html | 32 +++++++++++++-- website/modules/case-studies/index.js | 3 ++ 6 files changed, 110 insertions(+), 9 deletions(-) diff --git a/website/modules/case-studies-page/index.js b/website/modules/case-studies-page/index.js index f0f0241e..29daa7f8 100644 --- a/website/modules/case-studies-page/index.js +++ b/website/modules/case-studies-page/index.js @@ -13,6 +13,7 @@ module.exports = { { name: 'industry' }, { name: 'stack' }, { name: 'caseStudyType' }, + { name: 'partner' }, ], pieces: 'case-studies', piecesFiltersUrl: '/case-studies', @@ -59,6 +60,7 @@ module.exports = { industry: {}, stack: {}, caseStudyType: {}, + partner: {}, }); } }, diff --git a/website/modules/case-studies-page/services/NavigationService.js b/website/modules/case-studies-page/services/NavigationService.js index d0871f37..7b7034d2 100644 --- a/website/modules/case-studies-page/services/NavigationService.js +++ b/website/modules/case-studies-page/services/NavigationService.js @@ -29,6 +29,27 @@ class NavigationService { return tags.filter((tag) => tag).map((tag) => tag.aposDocId); } + /** + * Converts business partner slugs to IDs + * @param {Object} apos - ApostropheCMS instance + * @param {Object} req - Request object + * @param {Array} slugs - Array of partner slugs + * @returns {Promise} Promise resolving to array of partner IDs + */ + static async convertPartnerSlugsToIds(apos, req, slugs) { + const partnerPromises = slugs.map(async (slug) => { + const results = await apos.modules['business-partner'] + .find(req, { slug }) + .toArray(); + if (results.length > 0) { + return results[0]; + } + return null; + }); + const partners = await Promise.all(partnerPromises); + return partners.filter((partner) => partner).map((partner) => partner.aposDocId); + } + /** * Applies filters to a query based on request parameters * @param {Object} query - ApostropheCMS query object @@ -76,6 +97,19 @@ class NavigationService { } } + if (req.query.partner && req.query.partner.length > 0) { + const partnerIds = await NavigationService.convertPartnerSlugsToIds( + apos, + req, + req.query.partner, + ); + if (partnerIds.length > 0) { + filteredQuery = filteredQuery.and({ + partnerIds: { $in: partnerIds }, + }); + } + } + return filteredQuery; } diff --git a/website/modules/case-studies-page/services/TagCountService.js b/website/modules/case-studies-page/services/TagCountService.js index 9494f3d4..803983df 100644 --- a/website/modules/case-studies-page/services/TagCountService.js +++ b/website/modules/case-studies-page/services/TagCountService.js @@ -21,6 +21,19 @@ class TagCountService { return tagMap; } + /** + * Creates a mapping from partner IDs to partner slugs + * @param {Array} businessPartners - Array of partner objects + * @returns {Object} Map of partner ID to slug + */ + static createPartnerMap(businessPartners) { + const partnerMap = {}; + businessPartners.forEach((partner) => { + partnerMap[partner.aposDocId] = partner.slug; + }); + return partnerMap; + } + /** * Counts tags of a specific type and updates the counts object * @param {Array} tagIds - Array of tag IDs to count @@ -47,21 +60,25 @@ class TagCountService { * @param {Object} req - ApostropheCMS request object * @param {Object} aposModules - ApostropheCMS modules * @param {Object} options - Module options - * @returns {Promise} Promise resolving to [caseStudies, casesTags] arrays + * @returns {Promise} Promise resolving to [caseStudies, casesTags, businessPartners] arrays */ static async fetchCaseStudiesAndTags(req, aposModules, options) { const caseStudies = await aposModules[options.pieces].find(req).toArray(); const casesTags = await aposModules['cases-tags'].find(req).toArray(); - return [caseStudies, casesTags]; + const businessPartners = await aposModules['business-partner'] + .find(req) + .toArray(); + return [caseStudies, casesTags, businessPartners]; } /** * Processes case studies to count tags by type * @param {Array} caseStudies - Array of case study objects * @param {Object} tagMap - Map of tag ID to slug + * @param {Object} partnerMap - Map of partner ID to slug * @param {Object} tagCounts - Object to store all tag counts */ - static processCaseStudies(caseStudies, tagMap, tagCounts) { + static processCaseStudies(caseStudies, tagMap, partnerMap, tagCounts) { caseStudies.forEach((study) => { TagCountService.countTagsOfType( study.industryIds, @@ -76,6 +93,12 @@ class TagCountService { tagMap, tagCounts.caseStudyType, ); + + TagCountService.countTagsOfType( + study.partnerIds, + partnerMap, + tagCounts.partner, + ); }); } @@ -91,14 +114,21 @@ class TagCountService { industry: {}, stack: {}, caseStudyType: {}, + partner: {}, }; - const [caseStudies, casesTags] = + const [caseStudies, casesTags, businessPartners] = await TagCountService.fetchCaseStudiesAndTags(req, aposModules, options); const tagMap = TagCountService.createTagMap(casesTags); + const partnerMap = TagCountService.createPartnerMap(businessPartners); - TagCountService.processCaseStudies(caseStudies, tagMap, tagCounts); + TagCountService.processCaseStudies( + caseStudies, + tagMap, + partnerMap, + tagCounts, + ); return tagCounts; } diff --git a/website/modules/case-studies-page/services/UrlService.js b/website/modules/case-studies-page/services/UrlService.js index 9c0b17c4..4376be3e 100644 --- a/website/modules/case-studies-page/services/UrlService.js +++ b/website/modules/case-studies-page/services/UrlService.js @@ -41,6 +41,14 @@ class UrlService { }); } + // Add partner parameters + if (query.partner) { + const partners = UrlService.ensureArray(query.partner); + partners.forEach((partner, index) => { + params.append(`partner[${index}]`, partner); + }); + } + // Add search parameter if (query.search) { params.append('search', query.search); diff --git a/website/modules/case-studies-page/views/index.html b/website/modules/case-studies-page/views/index.html index 5eb103bf..23738a1d 100644 --- a/website/modules/case-studies-page/views/index.html +++ b/website/modules/case-studies-page/views/index.html @@ -19,7 +19,7 @@
Filter Case Studies
{% set hasActiveFilters = data.query.industry or data.query.stack or - data.query.caseStudyType %} {% if hasActiveFilters %} + data.query.caseStudyType or data.query.partner %} {% if hasActiveFilters %}

{{ data.totalPieces }} Ite{% if data.totalPieces == 1 %}m{% else %}ms{% endif %} Found @@ -32,7 +32,7 @@

@@ -46,7 +46,7 @@
    - {% for filterType in ['industry', 'stack', 'caseStudyType'] %} {% if + {% for filterType in ['industry', 'stack', 'caseStudyType', 'partner'] %} {% if data.query[filterType] %} {% for tag in data.piecesFilters[filterType] %} {# Check if tag is selected (handle both array and string) #} {% set isTagSelected = false %} {% if data.query[filterType].length is @@ -79,6 +79,14 @@ > Close Icon + {% elif filterType == 'partner' %} + + Close Icon + {% endif %} {% endif %} {% endfor %} {% endif %} {% endfor %} @@ -91,7 +99,7 @@
    {# Display all filter types #} {% for filterType, filterLabel in [ ['industry', 'Industry'], ['caseStudyType', 'Case Type'], ['stack', - 'Technology'] ] %} {% set tags = data.piecesFilters[filterType] %} {% if + 'Technology'], ['partner', 'Partner'] ] %} {% set tags = data.piecesFilters[filterType] %} {% if tags and tags.length %}
    @@ -175,6 +183,14 @@ {{ tag.label }} [ {{ count }} ] + {% elif filterType == 'partner' %} + + {{ tag.label }} + [ {{ count }} ] + {% endif %} {% else %} @@ -206,6 +222,14 @@ {{ tag.label }} [ {{ count }} ] + {% elif filterType == 'partner' %} + + {{ tag.label }} + [ {{ count }} ] + {% endif %} {% endif %} {% endfor %} diff --git a/website/modules/case-studies/index.js b/website/modules/case-studies/index.js index ed319c96..6b94cbac 100644 --- a/website/modules/case-studies/index.js +++ b/website/modules/case-studies/index.js @@ -199,6 +199,9 @@ module.exports = { _stack: { label: 'Stack', }, + _partner: { + label: 'Partner', + }, }, }, helpers() { From 42bd92c26ecfc453c2a31c605939b11ffc7292ee Mon Sep 17 00:00:00 2001 From: Ihor Masechko Date: Sat, 21 Feb 2026 10:01:06 +0200 Subject: [PATCH 2/3] refactor(case-studies-page): generic slug/id helpers, parallel queries, lint fixes in NavigationService and TagCountService --- .../services/NavigationService.js | 157 ++++++++++-------- .../services/TagCountService.js | 43 ++--- 2 files changed, 105 insertions(+), 95 deletions(-) diff --git a/website/modules/case-studies-page/services/NavigationService.js b/website/modules/case-studies-page/services/NavigationService.js index 7b7034d2..a47d4cd2 100644 --- a/website/modules/case-studies-page/services/NavigationService.js +++ b/website/modules/case-studies-page/services/NavigationService.js @@ -9,15 +9,16 @@ */ class NavigationService { /** - * Converts tag slugs to IDs + * Converts slugs to document IDs for a given piece module * @param {Object} apos - ApostropheCMS instance * @param {Object} req - Request object - * @param {Array} slugs - Array of tag slugs - * @returns {Promise} Promise resolving to array of tag IDs + * @param {Array} slugs - Array of slugs + * @param {string} moduleKey - Key of the piece module in apos.modules (e.g. 'cases-tags', 'business-partner') + * @returns {Promise} Promise resolving to array of document IDs */ - static async convertSlugsToIds(apos, req, slugs) { - const tagPromises = slugs.map(async (slug) => { - const results = await apos.modules['cases-tags'] + static async convertSlugsToIdsForModule(apos, req, slugs, moduleKey) { + const docPromises = slugs.map(async (slug) => { + const results = await apos.modules[moduleKey] .find(req, { slug }) .toArray(); if (results.length > 0) { @@ -25,8 +26,24 @@ class NavigationService { } return null; }); - const tags = await Promise.all(tagPromises); - return tags.filter((tag) => tag).map((tag) => tag.aposDocId); + const docs = await Promise.all(docPromises); + return docs.filter((doc) => doc).map((doc) => doc.aposDocId); + } + + /** + * Converts tag slugs to IDs (cases-tags module) + * @param {Object} apos - ApostropheCMS instance + * @param {Object} req - Request object + * @param {Array} slugs - Array of tag slugs + * @returns {Promise} Promise resolving to array of tag IDs + */ + static convertSlugsToIds(apos, req, slugs) { + return NavigationService.convertSlugsToIdsForModule( + apos, + req, + slugs, + 'cases-tags', + ); } /** @@ -36,18 +53,37 @@ class NavigationService { * @param {Array} slugs - Array of partner slugs * @returns {Promise} Promise resolving to array of partner IDs */ - static async convertPartnerSlugsToIds(apos, req, slugs) { - const partnerPromises = slugs.map(async (slug) => { - const results = await apos.modules['business-partner'] - .find(req, { slug }) - .toArray(); - if (results.length > 0) { - return results[0]; - } - return null; - }); - const partners = await Promise.all(partnerPromises); - return partners.filter((partner) => partner).map((partner) => partner.aposDocId); + static convertPartnerSlugsToIds(apos, req, slugs) { + return NavigationService.convertSlugsToIdsForModule( + apos, + req, + slugs, + 'business-partner', + ); + } + + /** + * Applies a single slug-based filter to the query + * @param {Object} filteredQuery - Current query object + * @param {Object} req - Request object + * @param {Object} apos - ApostropheCMS instance + * @param {Object} options - Options object + * @param {string} options.paramName - Query parameter name + * @param {Function} options.convertFn - Async function to convert slugs to IDs + * @param {string} options.fieldName - Document field name for the filter + * @returns {Promise} Promise resolving to modified query object + */ + static async applySlugFilter(filteredQuery, req, apos, options) { + const { paramName, convertFn, fieldName } = options; + const values = req.query[paramName]; + if (!values || values.length === 0) { + return filteredQuery; + } + const ids = await convertFn(apos, req, values); + if (ids.length === 0) { + return filteredQuery; + } + return filteredQuery.and({ [fieldName]: { $in: ids } }); } /** @@ -58,58 +94,45 @@ class NavigationService { * @returns {Promise} Promise resolving to modified query object */ static async applyFiltersToQuery(query, req, apos) { + const filterConfigs = [ + { + param: 'industry', + convert: NavigationService.convertSlugsToIds, + field: 'industryIds', + }, + { + param: 'stack', + convert: NavigationService.convertSlugsToIds, + field: 'stackIds', + }, + { + param: 'caseStudyType', + convert: NavigationService.convertSlugsToIds, + field: 'caseStudyTypeIds', + }, + { + param: 'partner', + convert: NavigationService.convertPartnerSlugsToIds, + field: 'partnerIds', + }, + ]; + const idArrays = await Promise.all( + filterConfigs.map((config) => { + if (req.query[config.param]?.length) { + return config.convert(apos, req, req.query[config.param]); + } + return Promise.resolve([]); + }), + ); let filteredQuery = query; - - if (req.query.industry && req.query.industry.length > 0) { - const industryIds = await NavigationService.convertSlugsToIds( - apos, - req, - req.query.industry, - ); - if (industryIds.length > 0) { - filteredQuery = filteredQuery.and({ - industryIds: { $in: industryIds }, - }); - } - } - - if (req.query.stack && req.query.stack.length > 0) { - const stackIds = await NavigationService.convertSlugsToIds( - apos, - req, - req.query.stack, - ); - if (stackIds.length > 0) { - filteredQuery = filteredQuery.and({ stackIds: { $in: stackIds } }); - } - } - - if (req.query.caseStudyType && req.query.caseStudyType.length > 0) { - const caseStudyTypeIds = await NavigationService.convertSlugsToIds( - apos, - req, - req.query.caseStudyType, - ); - if (caseStudyTypeIds.length > 0) { + for (let index = 0; index < filterConfigs.length; index += 1) { + const ids = idArrays[index]; + if (ids.length > 0) { filteredQuery = filteredQuery.and({ - caseStudyTypeIds: { $in: caseStudyTypeIds }, + [filterConfigs[index].field]: { $in: ids }, }); } } - - if (req.query.partner && req.query.partner.length > 0) { - const partnerIds = await NavigationService.convertPartnerSlugsToIds( - apos, - req, - req.query.partner, - ); - if (partnerIds.length > 0) { - filteredQuery = filteredQuery.and({ - partnerIds: { $in: partnerIds }, - }); - } - } - return filteredQuery; } diff --git a/website/modules/case-studies-page/services/TagCountService.js b/website/modules/case-studies-page/services/TagCountService.js index 803983df..0637f474 100644 --- a/website/modules/case-studies-page/services/TagCountService.js +++ b/website/modules/case-studies-page/services/TagCountService.js @@ -9,29 +9,16 @@ */ class TagCountService { /** - * Creates a mapping from tag IDs to tag slugs - * @param {Array} casesTags - Array of tag objects - * @returns {Object} Map of tag ID to slug + * Creates a mapping from document ID to slug for any array of docs with aposDocId and slug + * @param {Array} docs - Array of document objects with aposDocId and slug + * @returns {Object} Map of document ID to slug */ - static createTagMap(casesTags) { - const tagMap = {}; - casesTags.forEach((tag) => { - tagMap[tag.aposDocId] = tag.slug; + static createIdToSlugMap(docs) { + const map = {}; + docs.forEach((doc) => { + map[doc.aposDocId] = doc.slug; }); - return tagMap; - } - - /** - * Creates a mapping from partner IDs to partner slugs - * @param {Array} businessPartners - Array of partner objects - * @returns {Object} Map of partner ID to slug - */ - static createPartnerMap(businessPartners) { - const partnerMap = {}; - businessPartners.forEach((partner) => { - partnerMap[partner.aposDocId] = partner.slug; - }); - return partnerMap; + return map; } /** @@ -63,11 +50,11 @@ class TagCountService { * @returns {Promise} Promise resolving to [caseStudies, casesTags, businessPartners] arrays */ static async fetchCaseStudiesAndTags(req, aposModules, options) { - const caseStudies = await aposModules[options.pieces].find(req).toArray(); - const casesTags = await aposModules['cases-tags'].find(req).toArray(); - const businessPartners = await aposModules['business-partner'] - .find(req) - .toArray(); + const [caseStudies, casesTags, businessPartners] = await Promise.all([ + aposModules[options.pieces].find(req).toArray(), + aposModules['cases-tags'].find(req).toArray(), + aposModules['business-partner'].find(req).toArray(), + ]); return [caseStudies, casesTags, businessPartners]; } @@ -120,8 +107,8 @@ class TagCountService { const [caseStudies, casesTags, businessPartners] = await TagCountService.fetchCaseStudiesAndTags(req, aposModules, options); - const tagMap = TagCountService.createTagMap(casesTags); - const partnerMap = TagCountService.createPartnerMap(businessPartners); + const tagMap = TagCountService.createIdToSlugMap(casesTags); + const partnerMap = TagCountService.createIdToSlugMap(businessPartners); TagCountService.processCaseStudies( caseStudies, From 04effdf0863e3a76e813612f275a23a26d18221a Mon Sep 17 00:00:00 2001 From: Ihor Masechko Date: Sat, 21 Feb 2026 10:14:55 +0200 Subject: [PATCH 3/3] fix(NavigationService): normalize single query param to array, remove dead applySlugFilter --- .../services/NavigationService.js | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/website/modules/case-studies-page/services/NavigationService.js b/website/modules/case-studies-page/services/NavigationService.js index a47d4cd2..28e2f26e 100644 --- a/website/modules/case-studies-page/services/NavigationService.js +++ b/website/modules/case-studies-page/services/NavigationService.js @@ -12,12 +12,18 @@ class NavigationService { * Converts slugs to document IDs for a given piece module * @param {Object} apos - ApostropheCMS instance * @param {Object} req - Request object - * @param {Array} slugs - Array of slugs + * @param {Array|string} slugs - Array of slugs or single slug (Express may pass string for one query param) * @param {string} moduleKey - Key of the piece module in apos.modules (e.g. 'cases-tags', 'business-partner') * @returns {Promise} Promise resolving to array of document IDs */ static async convertSlugsToIdsForModule(apos, req, slugs, moduleKey) { - const docPromises = slugs.map(async (slug) => { + let slugList = []; + if (Array.isArray(slugs)) { + slugList = slugs; + } else if (slugs) { + slugList = [slugs]; + } + const docPromises = slugList.map(async (slug) => { const results = await apos.modules[moduleKey] .find(req, { slug }) .toArray(); @@ -62,30 +68,6 @@ class NavigationService { ); } - /** - * Applies a single slug-based filter to the query - * @param {Object} filteredQuery - Current query object - * @param {Object} req - Request object - * @param {Object} apos - ApostropheCMS instance - * @param {Object} options - Options object - * @param {string} options.paramName - Query parameter name - * @param {Function} options.convertFn - Async function to convert slugs to IDs - * @param {string} options.fieldName - Document field name for the filter - * @returns {Promise} Promise resolving to modified query object - */ - static async applySlugFilter(filteredQuery, req, apos, options) { - const { paramName, convertFn, fieldName } = options; - const values = req.query[paramName]; - if (!values || values.length === 0) { - return filteredQuery; - } - const ids = await convertFn(apos, req, values); - if (ids.length === 0) { - return filteredQuery; - } - return filteredQuery.and({ [fieldName]: { $in: ids } }); - } - /** * Applies filters to a query based on request parameters * @param {Object} query - ApostropheCMS query object