Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions website/modules/asset/ui/src/scss/_cases.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
119 changes: 114 additions & 5 deletions website/modules/case-studies-page/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -92,6 +193,8 @@ module.exports = {
if (superBeforeIndex) {
await superBeforeIndex(req);
}
await self.resolveSearchRelationships(req);
await self.applyEnhancedSearchResults(req);
await self.setupIndexData(req);
};

Expand All @@ -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);
},
};
},
Expand Down
19 changes: 13 additions & 6 deletions website/modules/case-studies-page/services/NavigationService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>} 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;
}
Expand Down Expand Up @@ -133,7 +140,7 @@ class NavigationService {
});
}
}
return NavigationService.applySearchFilter(filteredQuery, req);
return await NavigationService.applySearchFilter(filteredQuery, req, apos);
}

/**
Expand Down
104 changes: 90 additions & 14 deletions website/modules/case-studies-page/services/SearchService.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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()) {
Expand All @@ -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<Object>} 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,
};
Loading
Loading