Skip to content

Commit 0c01bc3

Browse files
authored
[996] Case Study Search Panel (#252)
feat(case-studies): add search with shared SearchService and safe regex - Add search bar UI and client handler (form submit, clear, placeholder) - Add SearchService for getSearchTerm and buildSearchRegexPattern (ReDoS-safe) - Use SearchService in index query and NavigationService.applySearchFilter - Preserve filters in search form; include search in clear-all and active state - Respect reduced motion for search bar; fix clear button a11y and template comment
1 parent 999eb54 commit 0c01bc3

8 files changed

Lines changed: 494 additions & 37 deletions

File tree

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

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,110 @@
7878
}
7979
}
8080

81+
// New search bar (separate from .cs_search-form to avoid conflicts)
82+
.cs_search-bar-wrapper {
83+
width: 100%;
84+
margin-bottom: 20px;
85+
@include breakpoint-medium {
86+
margin-bottom: 0;
87+
}
88+
}
89+
90+
.cs_search-bar-form {
91+
width: 100%;
92+
max-width: 1200px;
93+
margin: 0 auto;
94+
}
95+
96+
.cs_search-bar-input-wrapper {
97+
position: relative;
98+
display: flex;
99+
align-items: center;
100+
width: 100%;
101+
height: 56px;
102+
background: $white;
103+
border: 1px solid rgba($gray-200, 0.4);
104+
box-sizing: border-box;
105+
transition: border-color 0.2s ease;
106+
107+
&:focus-within {
108+
outline: 0;
109+
border: none;
110+
border-bottom: 1px solid rgba($gray-200, 0.4);
111+
112+
.cs_search-bar-icon {
113+
display: none;
114+
}
115+
}
116+
}
117+
118+
.cs_search-bar-icon {
119+
position: absolute;
120+
left: 1rem;
121+
width: 24px;
122+
height: 24px;
123+
pointer-events: none;
124+
z-index: 1;
125+
}
126+
127+
.cs_search-bar-input {
128+
flex: 1;
129+
width: 100%;
130+
height: 100%;
131+
padding: 0 1rem 0 3rem;
132+
border: none;
133+
background: transparent;
134+
font-size: 14px;
135+
font-weight: $font-weight-medium;
136+
color: $gray-500;
137+
outline: none;
138+
box-shadow: none;
139+
140+
&::placeholder {
141+
color: $gray-300;
142+
}
143+
144+
&:focus {
145+
border: none;
146+
outline: none;
147+
box-shadow: none;
148+
}
149+
}
150+
151+
.cs_search-bar-clear {
152+
display: none;
153+
position: absolute;
154+
right: 1rem;
155+
width: 16px;
156+
height: 16px;
157+
padding: 0;
158+
border: none;
159+
background: transparent;
160+
cursor: pointer;
161+
z-index: 2;
162+
align-items: center;
163+
justify-content: center;
164+
165+
img {
166+
width: 16px;
167+
height: 16px;
168+
}
169+
170+
&:hover {
171+
opacity: 0.7;
172+
}
173+
}
174+
175+
.cs_search-bar-input:not(:placeholder-shown) ~ .cs_search-bar-clear,
176+
.cs_search-bar-input:focus ~ .cs_search-bar-clear,
177+
.cs_search-bar-clear--visible {
178+
display: flex;
179+
}
180+
81181
// Tags styling
82182
.tags-filter {
83183
border: 1px solid $gray-border;
184+
flex-shrink: 0;
84185
font-style: $font-style-normal;
85186
max-width: 262px;
86187
width: 100%;
@@ -468,6 +569,7 @@
468569
align-items: flex-start;
469570
justify-content: flex-start;
470571
top: $desktop-header-height;
572+
margin-top: 20px;
471573
}
472574
}
473575

@@ -630,6 +732,7 @@
630732
grid-template-columns: 1fr;
631733
gap: 32px;
632734
margin: 0 auto;
735+
min-width: 0;
633736
opacity: 0;
634737
transform: translateY(30px);
635738
filter: blur(2px);
@@ -1190,6 +1293,11 @@
11901293
filter: none;
11911294
}
11921295

1296+
.cs_search-bar-input-wrapper,
1297+
.cs_search-bar-clear {
1298+
transition: none;
1299+
}
1300+
11931301
.tag-item {
11941302
transition: none;
11951303

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

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,60 @@
11
const mainWidgets = require('../../lib/mainWidgets');
2-
const TagCountService = require('./services/TagCountService');
32
const NavigationService = require('./services/NavigationService');
3+
const SearchService = require('./services/SearchService');
4+
const TagCountService = require('./services/TagCountService');
45
const UrlService = require('./services/UrlService');
56

7+
const buildIndexQuery = function (self, req) {
8+
const queryParams = { ...req.query };
9+
const searchTerm = SearchService.getSearchTerm(queryParams);
10+
delete queryParams.search;
11+
12+
const query = self.pieces
13+
.find(req, {})
14+
.applyBuildersSafely(queryParams)
15+
.perPage(self.perPage);
16+
self.filterByIndexPage(query, req.data.page);
17+
18+
const searchCondition = SearchService.buildSearchCondition(searchTerm);
19+
if (searchCondition) {
20+
query.and(searchCondition);
21+
}
22+
return query;
23+
};
24+
25+
const runSetupIndexData = async function (self, req) {
26+
try {
27+
const tagCounts = await TagCountService.calculateTagCounts(
28+
req,
29+
self.apos.modules,
30+
self.options,
31+
);
32+
UrlService.attachIndexData(req, tagCounts);
33+
} catch (error) {
34+
self.apos.util.error('Error calculating tag counts:', error);
35+
UrlService.attachIndexData(req, {
36+
industry: {},
37+
stack: {},
38+
caseStudyType: {},
39+
partner: {},
40+
});
41+
}
42+
};
43+
44+
const runSetupShowData = async function (self, req) {
45+
try {
46+
const navigation = await NavigationService.getNavigationDataForPage(
47+
req,
48+
self.apos,
49+
self,
50+
);
51+
UrlService.attachShowData(req, navigation);
52+
} catch (error) {
53+
self.apos.util.error('Error calculating navigation data:', error);
54+
UrlService.attachShowData(req, { prev: null, next: null });
55+
}
56+
};
57+
658
module.exports = {
759
extend: '@apostrophecms/piece-page-type',
860
options: {
@@ -35,48 +87,33 @@ module.exports = {
3587
},
3688

3789
init(self) {
90+
const superBeforeIndex = self.beforeIndex;
3891
self.beforeIndex = async (req) => {
92+
if (superBeforeIndex) {
93+
await superBeforeIndex(req);
94+
}
3995
await self.setupIndexData(req);
4096
};
4197

98+
const superBeforeShow = self.beforeShow;
4299
self.beforeShow = async (req) => {
100+
if (superBeforeShow) {
101+
await superBeforeShow(req);
102+
}
43103
await self.setupShowData(req);
44104
};
45105
},
46106

47107
methods(self) {
48108
return {
109+
indexQuery(req) {
110+
return buildIndexQuery(self, req);
111+
},
49112
async setupIndexData(req) {
50-
try {
51-
const tagCounts = await TagCountService.calculateTagCounts(
52-
req,
53-
self.apos.modules,
54-
self.options,
55-
);
56-
UrlService.attachIndexData(req, tagCounts);
57-
} catch (error) {
58-
self.apos.util.error('Error calculating tag counts:', error);
59-
UrlService.attachIndexData(req, {
60-
industry: {},
61-
stack: {},
62-
caseStudyType: {},
63-
partner: {},
64-
});
65-
}
113+
return await runSetupIndexData(self, req);
66114
},
67-
68115
async setupShowData(req) {
69-
try {
70-
const navigation = await NavigationService.getNavigationDataForPage(
71-
req,
72-
self.apos,
73-
self,
74-
);
75-
UrlService.attachShowData(req, navigation);
76-
} catch (error) {
77-
self.apos.util.error('Error calculating navigation data:', error);
78-
UrlService.attachShowData(req, { prev: null, next: null });
79-
}
116+
return await runSetupShowData(self, req);
80117
},
81118
};
82119
},

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const SearchService = require('./SearchService');
2+
13
/**
24
* NavigationService - Single Responsibility: Case study navigation
35
*
@@ -68,6 +70,22 @@ class NavigationService {
6870
);
6971
}
7072

73+
/**
74+
* Applies search filter to query when search param is present
75+
* Uses SearchService for safe regex (ReDoS prevention) and array handling
76+
* @param {Object} filteredQuery - Query object
77+
* @param {Object} req - Request object
78+
* @returns {Object} Modified query
79+
*/
80+
static applySearchFilter(filteredQuery, req) {
81+
const searchTerm = SearchService.getSearchTerm(req.query || {});
82+
const searchCondition = SearchService.buildSearchCondition(searchTerm);
83+
if (!searchCondition) {
84+
return filteredQuery;
85+
}
86+
return filteredQuery.and(searchCondition);
87+
}
88+
7189
/**
7290
* Applies filters to a query based on request parameters
7391
* @param {Object} query - ApostropheCMS query object
@@ -115,7 +133,7 @@ class NavigationService {
115133
});
116134
}
117135
}
118-
return filteredQuery;
136+
return NavigationService.applySearchFilter(filteredQuery, req);
119137
}
120138

121139
/**
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* SearchService - Shared search term normalization and safe regex building
3+
*
4+
* Used by case-studies-page index query and NavigationService so search
5+
* behavior and escaping stay consistent and safe (ReDoS prevention).
6+
*/
7+
8+
const REGEX_ESCAPE = /[$()*+.?[\\\]^{|}]/gu;
9+
10+
const MAX_SEARCH_TERM_LENGTH = 200;
11+
12+
/**
13+
* Normalizes search param from query (handles missing, array, non-string)
14+
* @param {Object} queryParams - Request query object (e.g. req.query)
15+
* @returns {string} Trimmed search string, or empty string
16+
*/
17+
const getSearchTerm = function (queryParams) {
18+
if (!queryParams || !queryParams.search) {
19+
return '';
20+
}
21+
const raw = queryParams.search;
22+
let value = raw;
23+
if (Array.isArray(raw)) {
24+
[value] = raw;
25+
}
26+
if (typeof value !== 'string') {
27+
return '';
28+
}
29+
return value.trim();
30+
};
31+
32+
/**
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
35+
* @param {string} searchTerm - User search string
36+
* @returns {string|null} Pattern for $regex, or null if no search
37+
*/
38+
const buildSearchRegexPattern = function (searchTerm) {
39+
if (!searchTerm || !searchTerm.trim()) {
40+
return null;
41+
}
42+
const trimmed = searchTerm.trim();
43+
if (!trimmed) {
44+
return null;
45+
}
46+
let capped = trimmed;
47+
if (trimmed.length > MAX_SEARCH_TERM_LENGTH) {
48+
capped = trimmed.slice(0, MAX_SEARCH_TERM_LENGTH);
49+
}
50+
const escaped = capped.replace(REGEX_ESCAPE, '\\$&');
51+
return escaped.split(/\s+/u).join('.*');
52+
};
53+
54+
/**
55+
* Builds MongoDB $or condition for case study search (single source of truth for searchable fields)
56+
* @param {string} searchTerm - User search string
57+
* @returns {Object|null} Condition to pass to query.and(), or null if no search
58+
*/
59+
const buildSearchCondition = function (searchTerm) {
60+
const regexPattern = buildSearchRegexPattern(searchTerm);
61+
if (!regexPattern) {
62+
return null;
63+
}
64+
const regexOpts = { $regex: regexPattern, $options: 'i' };
65+
return {
66+
$or: [{ title: regexOpts }, { portfolioTitle: regexOpts }],
67+
};
68+
};
69+
70+
module.exports = {
71+
buildSearchCondition,
72+
buildSearchRegexPattern,
73+
getSearchTerm,
74+
};

0 commit comments

Comments
 (0)