diff --git a/website/modules/asset/ui/src/scss/_cases.scss b/website/modules/asset/ui/src/scss/_cases.scss index 3f593f3d..d0216600 100644 --- a/website/modules/asset/ui/src/scss/_cases.scss +++ b/website/modules/asset/ui/src/scss/_cases.scss @@ -756,6 +756,60 @@ } } +.cs_empty-state { + width: 100%; + min-height: 220px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + padding: 24px 16px; + text-align: center; + background-color: $white; + + @include breakpoint-medium { + min-height: 280px; + padding: 40px 24px; + } +} + +.cs_list--empty { + justify-content: center; + align-items: center; + min-height: calc(100vh - 240px); + + @include breakpoint-medium { + min-height: calc(100vh - 290px); + } + + .tags-filter { + display: none; + } +} + +.cs_empty-state--standalone { + width: min(960px, 100%); + margin: 0 auto; +} + +.cs_empty-state-title { + margin: 0; + color: $gray-500; + @include responsive-font(20px, 28px); + @include responsive-line-height(120%, 120%); + + font-weight: $font-weight-extra-bold; +} + +.cs_empty-state-text { + margin: 0; + max-width: 520px; + color: $gray-300; + @include responsive-font(12px, 14px); + @include responsive-line-height(150%, 150%); +} + .cs_card { background-color: $white; border: 1px solid $whisper; diff --git a/website/modules/case-studies-page/index.js b/website/modules/case-studies-page/index.js index d3a070fe..420b2781 100644 --- a/website/modules/case-studies-page/index.js +++ b/website/modules/case-studies-page/index.js @@ -4,6 +4,47 @@ const SearchService = require('./services/SearchService'); const TagCountService = require('./services/TagCountService'); const UrlService = require('./services/UrlService'); +const createDocMapById = function (docs) { + const map = {}; + docs.forEach((doc) => { + map[doc.aposDocId] = { + label: doc.title, + value: doc.slug, + }; + }); + return map; +}; + +const collectFilterOptions = function (pieces, fieldName, docMap) { + const values = {}; + pieces.forEach((piece) => { + const ids = piece[fieldName] || []; + ids.forEach((id) => { + if (docMap[id]) { + values[id] = docMap[id]; + } + }); + }); + const options = Object.values(values); + options.sort((first, second) => first.label.localeCompare(second.label)); + 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); @@ -15,13 +56,73 @@ const buildIndexQuery = function (self, req) { .perPage(self.perPage); self.filterByIndexPage(query, req.data.page); - const searchCondition = SearchService.buildSearchCondition(searchTerm); + 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 tagCounts = await TagCountService.calculateTagCounts( @@ -92,6 +193,8 @@ module.exports = { if (superBeforeIndex) { await superBeforeIndex(req); } + await self.resolveSearchRelationships(req); + await self.applyEnhancedSearchResults(req); await self.setupIndexData(req); }; @@ -109,11 +212,17 @@ module.exports = { indexQuery(req) { return buildIndexQuery(self, req); }, - async setupIndexData(req) { - return await runSetupIndexData(self, req); + resolveSearchRelationships(req) { + return runResolveSearchRelationships(self, req); + }, + applyEnhancedSearchResults(req) { + return runApplyEnhancedSearchResults(self, req); + }, + setupIndexData(req) { + return runSetupIndexData(self, req); }, - async setupShowData(req) { - return await runSetupShowData(self, req); + setupShowData(req) { + return runSetupShowData(self, req); }, }; }, diff --git a/website/modules/case-studies-page/services/NavigationService.js b/website/modules/case-studies-page/services/NavigationService.js index 74af551c..89944465 100644 --- a/website/modules/case-studies-page/services/NavigationService.js +++ b/website/modules/case-studies-page/services/NavigationService.js @@ -71,15 +71,22 @@ class NavigationService { } /** - * Applies search filter to query when search param is present - * Uses SearchService for safe regex (ReDoS prevention) and array handling + * Applies search filter to query when search param is present. + * Resolves relationship matches so search covers taxonomy and + * partner fields in addition to text fields. * @param {Object} filteredQuery - Query object * @param {Object} req - Request object - * @returns {Object} Modified query + * @param {Object} apos - ApostropheCMS instance + * @returns {Promise} Modified query */ - static applySearchFilter(filteredQuery, req) { + static async applySearchFilter(filteredQuery, req, apos) { const searchTerm = SearchService.getSearchTerm(req.query || {}); - const searchCondition = SearchService.buildSearchCondition(searchTerm); + const resolvedRelationships = + await SearchService.resolveSearchRelationships(searchTerm, apos, req); + const searchCondition = SearchService.buildSearchCondition( + searchTerm, + resolvedRelationships, + ); if (!searchCondition) { return filteredQuery; } @@ -133,7 +140,7 @@ class NavigationService { }); } } - return NavigationService.applySearchFilter(filteredQuery, req); + return await NavigationService.applySearchFilter(filteredQuery, req, apos); } /** diff --git a/website/modules/case-studies-page/services/SearchService.js b/website/modules/case-studies-page/services/SearchService.js index 3062c090..183536e7 100644 --- a/website/modules/case-studies-page/services/SearchService.js +++ b/website/modules/case-studies-page/services/SearchService.js @@ -1,17 +1,39 @@ /** - * SearchService - Shared search term normalization and safe regex building + * SearchService - Search term normalization, safe regex building, + * and relationship resolution for case-study search. * - * Used by case-studies-page index query and NavigationService so search - * behavior and escaping stay consistent and safe (ReDoS prevention). + * Used by case-studies-page index query and NavigationService so + * search behavior stays consistent (ReDoS prevention, escaping). */ const REGEX_ESCAPE = /[$()*+.?[\\\]^{|}]/gu; const MAX_SEARCH_TERM_LENGTH = 200; +const TEXT_FIELDS = [ + 'title', + 'portfolioTitle', + 'descriptor', + 'objective', + 'challenge', + 'solution', + 'results', +]; + +const RELATIONSHIP_CONFIGS = [ + { + module: 'cases-tags', + caseStudyFields: ['stackIds', 'industryIds', 'caseStudyTypeIds'], + }, + { + module: 'business-partner', + caseStudyFields: ['partnerIds'], + }, +]; + /** - * Normalizes search param from query (handles missing, array, non-string) - * @param {Object} queryParams - Request query object (e.g. req.query) + * Normalizes search param from query + * @param {Object} queryParams - Request query object * @returns {string} Trimmed search string, or empty string */ const getSearchTerm = function (queryParams) { @@ -30,10 +52,10 @@ const getSearchTerm = function (queryParams) { }; /** - * Builds a safe MongoDB regex pattern from search term (escape + word match) - * Search term is capped at MAX_SEARCH_TERM_LENGTH to avoid pathologically long patterns + * Builds a safe MongoDB regex pattern from search term. + * Capped at MAX_SEARCH_TERM_LENGTH to avoid long patterns. * @param {string} searchTerm - User search string - * @returns {string|null} Pattern for $regex, or null if no search + * @returns {string|null} Pattern for $regex, or null */ const buildSearchRegexPattern = function (searchTerm) { if (!searchTerm || !searchTerm.trim()) { @@ -52,23 +74,77 @@ const buildSearchRegexPattern = function (searchTerm) { }; /** - * Builds MongoDB $or condition for case study search (single source of truth for searchable fields) + * Resolves relationship document IDs whose title or slug + * matches the search term. Returns a map of case-study + * ID-array field names to arrays of matching aposDocId values. * @param {string} searchTerm - User search string - * @returns {Object|null} Condition to pass to query.and(), or null if no search + * @param {Object} apos - ApostropheCMS instance + * @param {Object} req - Request object + * @returns {Promise} Field-name-to-IDs map */ -const buildSearchCondition = function (searchTerm) { +const resolveSearchRelationships = async function (searchTerm, apos, req) { + const regexPattern = buildSearchRegexPattern(searchTerm); + if (!regexPattern) { + return {}; + } + + const regexOpts = { $regex: regexPattern, $options: 'i' }; + const result = {}; + + const lookups = RELATIONSHIP_CONFIGS.map(async (config) => { + const docs = await apos.modules[config.module] + .find(req, {}) + .and({ + $or: [{ title: regexOpts }, { slug: regexOpts }], + }) + .toArray(); + + const ids = docs.map((doc) => doc.aposDocId); + if (ids.length > 0) { + config.caseStudyFields.forEach((field) => { + result[field] = ids; + }); + } + }); + + await Promise.all(lookups); + return result; +}; + +/** + * Builds MongoDB $or condition for case study search across + * text fields and pre-resolved relationship ID fields. + * @param {string} searchTerm - User search string + * @param {Object} [resolvedRelationships] - Pre-resolved IDs + * @returns {Object|null} Condition for query.and(), or null + */ +const buildSearchCondition = function (searchTerm, resolvedRelationships) { const regexPattern = buildSearchRegexPattern(searchTerm); if (!regexPattern) { return null; } + const regexOpts = { $regex: regexPattern, $options: 'i' }; - return { - $or: [{ title: regexOpts }, { portfolioTitle: regexOpts }], - }; + const orBranches = TEXT_FIELDS.map((field) => ({ [field]: regexOpts })); + + const relationships = resolvedRelationships || {}; + Object.keys(relationships).forEach((field) => { + const ids = relationships[field]; + if (ids && ids.length > 0) { + orBranches.push({ [field]: { $in: ids } }); + } + }); + + if (orBranches.length === 0) { + return null; + } + + return { $or: orBranches }; }; module.exports = { buildSearchCondition, buildSearchRegexPattern, getSearchTerm, + resolveSearchRelationships, }; diff --git a/website/modules/case-studies-page/views/index.html b/website/modules/case-studies-page/views/index.html index dcf98a91..9680b986 100644 --- a/website/modules/case-studies-page/views/index.html +++ b/website/modules/case-studies-page/views/index.html @@ -36,7 +36,7 @@ placeholder="Search case studies" value="{{ data.query.search or '' }}" autocomplete="off" - aria-label="Search case studies by title" + aria-label="Search case studies" />