Skip to content

Commit ebb62e0

Browse files
authored
[1025] Extend Search on Case Studies Page to All Fields (#263)
Enhance case studies search for text and taxonomy fields
1 parent 310f0d9 commit ebb62e0

8 files changed

Lines changed: 362 additions & 36 deletions

File tree

‎website/modules/asset/ui/src/scss/_cases.scss‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,60 @@
756756
}
757757
}
758758

759+
.cs_empty-state {
760+
width: 100%;
761+
min-height: 220px;
762+
display: flex;
763+
flex-direction: column;
764+
justify-content: center;
765+
align-items: center;
766+
gap: 8px;
767+
padding: 24px 16px;
768+
text-align: center;
769+
background-color: $white;
770+
771+
@include breakpoint-medium {
772+
min-height: 280px;
773+
padding: 40px 24px;
774+
}
775+
}
776+
777+
.cs_list--empty {
778+
justify-content: center;
779+
align-items: center;
780+
min-height: calc(100vh - 240px);
781+
782+
@include breakpoint-medium {
783+
min-height: calc(100vh - 290px);
784+
}
785+
786+
.tags-filter {
787+
display: none;
788+
}
789+
}
790+
791+
.cs_empty-state--standalone {
792+
width: min(960px, 100%);
793+
margin: 0 auto;
794+
}
795+
796+
.cs_empty-state-title {
797+
margin: 0;
798+
color: $gray-500;
799+
@include responsive-font(20px, 28px);
800+
@include responsive-line-height(120%, 120%);
801+
802+
font-weight: $font-weight-extra-bold;
803+
}
804+
805+
.cs_empty-state-text {
806+
margin: 0;
807+
max-width: 520px;
808+
color: $gray-300;
809+
@include responsive-font(12px, 14px);
810+
@include responsive-line-height(150%, 150%);
811+
}
812+
759813
.cs_card {
760814
background-color: $white;
761815
border: 1px solid $whisper;

‎website/modules/case-studies-page/index.js‎

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,47 @@ const SearchService = require('./services/SearchService');
44
const TagCountService = require('./services/TagCountService');
55
const UrlService = require('./services/UrlService');
66

7+
const createDocMapById = function (docs) {
8+
const map = {};
9+
docs.forEach((doc) => {
10+
map[doc.aposDocId] = {
11+
label: doc.title,
12+
value: doc.slug,
13+
};
14+
});
15+
return map;
16+
};
17+
18+
const collectFilterOptions = function (pieces, fieldName, docMap) {
19+
const values = {};
20+
pieces.forEach((piece) => {
21+
const ids = piece[fieldName] || [];
22+
ids.forEach((id) => {
23+
if (docMap[id]) {
24+
values[id] = docMap[id];
25+
}
26+
});
27+
});
28+
const options = Object.values(values);
29+
options.sort((first, second) => first.label.localeCompare(second.label));
30+
return options;
31+
};
32+
33+
const buildPiecesFiltersFromResults = async function (self, req, pieces) {
34+
const [tags, partners] = await Promise.all([
35+
self.apos.modules['cases-tags'].find(req).toArray(),
36+
self.apos.modules['business-partner'].find(req).toArray(),
37+
]);
38+
const tagMap = createDocMapById(tags);
39+
const partnerMap = createDocMapById(partners);
40+
return {
41+
industry: collectFilterOptions(pieces, 'industryIds', tagMap),
42+
stack: collectFilterOptions(pieces, 'stackIds', tagMap),
43+
caseStudyType: collectFilterOptions(pieces, 'caseStudyTypeIds', tagMap),
44+
partner: collectFilterOptions(pieces, 'partnerIds', partnerMap),
45+
};
46+
};
47+
748
const buildIndexQuery = function (self, req) {
849
const queryParams = { ...req.query };
950
const searchTerm = SearchService.getSearchTerm(queryParams);
@@ -15,13 +56,73 @@ const buildIndexQuery = function (self, req) {
1556
.perPage(self.perPage);
1657
self.filterByIndexPage(query, req.data.page);
1758

18-
const searchCondition = SearchService.buildSearchCondition(searchTerm);
59+
const resolved = req.data.searchRelationships || {};
60+
const searchCondition = SearchService.buildSearchCondition(
61+
searchTerm,
62+
resolved,
63+
);
1964
if (searchCondition) {
2065
query.and(searchCondition);
2166
}
2267
return query;
2368
};
2469

70+
const runResolveSearchRelationships = async function (self, req) {
71+
req.data ||= {};
72+
const reqData = req.data;
73+
const searchTerm = SearchService.getSearchTerm(req.query || {});
74+
if (!searchTerm) {
75+
reqData.searchRelationships = {};
76+
return;
77+
}
78+
let resolvedRelationships = {};
79+
try {
80+
resolvedRelationships = await SearchService.resolveSearchRelationships(
81+
searchTerm,
82+
self.apos,
83+
req,
84+
);
85+
} catch (error) {
86+
self.apos.util.error('Error resolving search relationships:', error);
87+
}
88+
reqData.searchRelationships = resolvedRelationships;
89+
};
90+
91+
const runApplyEnhancedSearchResults = async function (self, req) {
92+
const reqData = req.data;
93+
const searchTerm = SearchService.getSearchTerm(req.query || {});
94+
if (!searchTerm) {
95+
return;
96+
}
97+
const queryParams = { ...req.query };
98+
delete queryParams.search;
99+
const resolved = reqData.searchRelationships || {};
100+
const hasRelationshipMatches = Object.keys(resolved).length > 0;
101+
if (!hasRelationshipMatches) {
102+
return;
103+
}
104+
const searchCondition = SearchService.buildSearchCondition(
105+
searchTerm,
106+
resolved,
107+
);
108+
if (!searchCondition) {
109+
return;
110+
}
111+
112+
const piecesQuery = self.pieces
113+
.find(req, {})
114+
.applyBuildersSafely(queryParams);
115+
piecesQuery.and(searchCondition);
116+
117+
const pieces = await piecesQuery.toArray();
118+
const totalPieces = pieces.length;
119+
const piecesFilters = await buildPiecesFiltersFromResults(self, req, pieces);
120+
reqData.pieces = pieces;
121+
reqData.totalPieces = totalPieces;
122+
reqData.totalPages = 1;
123+
reqData.piecesFilters = piecesFilters;
124+
};
125+
25126
const runSetupIndexData = async function (self, req) {
26127
try {
27128
const tagCounts = await TagCountService.calculateTagCounts(
@@ -92,6 +193,8 @@ module.exports = {
92193
if (superBeforeIndex) {
93194
await superBeforeIndex(req);
94195
}
196+
await self.resolveSearchRelationships(req);
197+
await self.applyEnhancedSearchResults(req);
95198
await self.setupIndexData(req);
96199
};
97200

@@ -109,11 +212,17 @@ module.exports = {
109212
indexQuery(req) {
110213
return buildIndexQuery(self, req);
111214
},
112-
async setupIndexData(req) {
113-
return await runSetupIndexData(self, req);
215+
resolveSearchRelationships(req) {
216+
return runResolveSearchRelationships(self, req);
217+
},
218+
applyEnhancedSearchResults(req) {
219+
return runApplyEnhancedSearchResults(self, req);
220+
},
221+
setupIndexData(req) {
222+
return runSetupIndexData(self, req);
114223
},
115-
async setupShowData(req) {
116-
return await runSetupShowData(self, req);
224+
setupShowData(req) {
225+
return runSetupShowData(self, req);
117226
},
118227
};
119228
},

‎website/modules/case-studies-page/services/NavigationService.js‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,22 @@ class NavigationService {
7171
}
7272

7373
/**
74-
* Applies search filter to query when search param is present
75-
* Uses SearchService for safe regex (ReDoS prevention) and array handling
74+
* Applies search filter to query when search param is present.
75+
* Resolves relationship matches so search covers taxonomy and
76+
* partner fields in addition to text fields.
7677
* @param {Object} filteredQuery - Query object
7778
* @param {Object} req - Request object
78-
* @returns {Object} Modified query
79+
* @param {Object} apos - ApostropheCMS instance
80+
* @returns {Promise<Object>} Modified query
7981
*/
80-
static applySearchFilter(filteredQuery, req) {
82+
static async applySearchFilter(filteredQuery, req, apos) {
8183
const searchTerm = SearchService.getSearchTerm(req.query || {});
82-
const searchCondition = SearchService.buildSearchCondition(searchTerm);
84+
const resolvedRelationships =
85+
await SearchService.resolveSearchRelationships(searchTerm, apos, req);
86+
const searchCondition = SearchService.buildSearchCondition(
87+
searchTerm,
88+
resolvedRelationships,
89+
);
8390
if (!searchCondition) {
8491
return filteredQuery;
8592
}
@@ -133,7 +140,7 @@ class NavigationService {
133140
});
134141
}
135142
}
136-
return NavigationService.applySearchFilter(filteredQuery, req);
143+
return await NavigationService.applySearchFilter(filteredQuery, req, apos);
137144
}
138145

139146
/**

‎website/modules/case-studies-page/services/SearchService.js‎

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,39 @@
11
/**
2-
* SearchService - Shared search term normalization and safe regex building
2+
* SearchService - Search term normalization, safe regex building,
3+
* and relationship resolution for case-study search.
34
*
4-
* Used by case-studies-page index query and NavigationService so search
5-
* behavior and escaping stay consistent and safe (ReDoS prevention).
5+
* Used by case-studies-page index query and NavigationService so
6+
* search behavior stays consistent (ReDoS prevention, escaping).
67
*/
78

89
const REGEX_ESCAPE = /[$()*+.?[\\\]^{|}]/gu;
910

1011
const MAX_SEARCH_TERM_LENGTH = 200;
1112

13+
const TEXT_FIELDS = [
14+
'title',
15+
'portfolioTitle',
16+
'descriptor',
17+
'objective',
18+
'challenge',
19+
'solution',
20+
'results',
21+
];
22+
23+
const RELATIONSHIP_CONFIGS = [
24+
{
25+
module: 'cases-tags',
26+
caseStudyFields: ['stackIds', 'industryIds', 'caseStudyTypeIds'],
27+
},
28+
{
29+
module: 'business-partner',
30+
caseStudyFields: ['partnerIds'],
31+
},
32+
];
33+
1234
/**
13-
* Normalizes search param from query (handles missing, array, non-string)
14-
* @param {Object} queryParams - Request query object (e.g. req.query)
35+
* Normalizes search param from query
36+
* @param {Object} queryParams - Request query object
1537
* @returns {string} Trimmed search string, or empty string
1638
*/
1739
const getSearchTerm = function (queryParams) {
@@ -30,10 +52,10 @@ const getSearchTerm = function (queryParams) {
3052
};
3153

3254
/**
33-
* Builds a safe MongoDB regex pattern from search term (escape + word match)
34-
* Search term is capped at MAX_SEARCH_TERM_LENGTH to avoid pathologically long patterns
55+
* Builds a safe MongoDB regex pattern from search term.
56+
* Capped at MAX_SEARCH_TERM_LENGTH to avoid long patterns.
3557
* @param {string} searchTerm - User search string
36-
* @returns {string|null} Pattern for $regex, or null if no search
58+
* @returns {string|null} Pattern for $regex, or null
3759
*/
3860
const buildSearchRegexPattern = function (searchTerm) {
3961
if (!searchTerm || !searchTerm.trim()) {
@@ -52,23 +74,77 @@ const buildSearchRegexPattern = function (searchTerm) {
5274
};
5375

5476
/**
55-
* Builds MongoDB $or condition for case study search (single source of truth for searchable fields)
77+
* Resolves relationship document IDs whose title or slug
78+
* matches the search term. Returns a map of case-study
79+
* ID-array field names to arrays of matching aposDocId values.
5680
* @param {string} searchTerm - User search string
57-
* @returns {Object|null} Condition to pass to query.and(), or null if no search
81+
* @param {Object} apos - ApostropheCMS instance
82+
* @param {Object} req - Request object
83+
* @returns {Promise<Object>} Field-name-to-IDs map
5884
*/
59-
const buildSearchCondition = function (searchTerm) {
85+
const resolveSearchRelationships = async function (searchTerm, apos, req) {
86+
const regexPattern = buildSearchRegexPattern(searchTerm);
87+
if (!regexPattern) {
88+
return {};
89+
}
90+
91+
const regexOpts = { $regex: regexPattern, $options: 'i' };
92+
const result = {};
93+
94+
const lookups = RELATIONSHIP_CONFIGS.map(async (config) => {
95+
const docs = await apos.modules[config.module]
96+
.find(req, {})
97+
.and({
98+
$or: [{ title: regexOpts }, { slug: regexOpts }],
99+
})
100+
.toArray();
101+
102+
const ids = docs.map((doc) => doc.aposDocId);
103+
if (ids.length > 0) {
104+
config.caseStudyFields.forEach((field) => {
105+
result[field] = ids;
106+
});
107+
}
108+
});
109+
110+
await Promise.all(lookups);
111+
return result;
112+
};
113+
114+
/**
115+
* Builds MongoDB $or condition for case study search across
116+
* text fields and pre-resolved relationship ID fields.
117+
* @param {string} searchTerm - User search string
118+
* @param {Object} [resolvedRelationships] - Pre-resolved IDs
119+
* @returns {Object|null} Condition for query.and(), or null
120+
*/
121+
const buildSearchCondition = function (searchTerm, resolvedRelationships) {
60122
const regexPattern = buildSearchRegexPattern(searchTerm);
61123
if (!regexPattern) {
62124
return null;
63125
}
126+
64127
const regexOpts = { $regex: regexPattern, $options: 'i' };
65-
return {
66-
$or: [{ title: regexOpts }, { portfolioTitle: regexOpts }],
67-
};
128+
const orBranches = TEXT_FIELDS.map((field) => ({ [field]: regexOpts }));
129+
130+
const relationships = resolvedRelationships || {};
131+
Object.keys(relationships).forEach((field) => {
132+
const ids = relationships[field];
133+
if (ids && ids.length > 0) {
134+
orBranches.push({ [field]: { $in: ids } });
135+
}
136+
});
137+
138+
if (orBranches.length === 0) {
139+
return null;
140+
}
141+
142+
return { $or: orBranches };
68143
};
69144

70145
module.exports = {
71146
buildSearchCondition,
72147
buildSearchRegexPattern,
73148
getSearchTerm,
149+
resolveSearchRelationships,
74150
};

0 commit comments

Comments
 (0)