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
38 changes: 35 additions & 3 deletions src/components/Features/SearchContent.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<UtilitySection
title="Search Content"
description="Search through pages, blog posts, or collections for content matching your search term. Results will highlight exactly where matches were found."
:description="`Search through pages, blog posts, or collections for content ${negateSearch ? 'NOT' : ''} matching your search term. Results will highlight exactly where matches were ${negateSearch ? 'NOT' : ''} found.`"
>
<!-- Search Scopes Selection -->
<fieldset class="search-content__scopes-selection">
Expand Down Expand Up @@ -73,6 +73,20 @@
</div>
</fieldset>

<!-- Negation Option -->
<fieldset class="search-content__negation-section">
<legend class="search-content__negation-label">Search Mode</legend>
<label class="search-content__checkbox-option">
<input
type="checkbox"
v-model="negateSearch"
:disabled="hasResults"
aria-label="Negate search to find items NOT containing the search term"
/>
<span>Show items NOT containing search term</span>
</label>
</fieldset>

<!-- Search Term -->
<TextInput
id="search-content-search-term"
Expand Down Expand Up @@ -172,6 +186,7 @@ const includeBlog = computed({
},
})
const searchTerm = ref('')
const negateSearch = ref(false)
const showMissingSearchTermError = ref(false)
const isLoading = ref(false)
const statusMessage = ref('')
Expand Down Expand Up @@ -277,6 +292,7 @@ async function executeSearch(): Promise<void> {
store.selectedScopes.pageTypes,
store.selectedScopes.collectionKeys,
includeBlog.value,
negateSearch.value,
)

if (!searchResponse.success) {
Expand All @@ -288,15 +304,17 @@ async function executeSearch(): Promise<void> {

totalItems.value = searchResponse.totalItems!

const modeText = negateSearch.value ? 'NOT containing' : 'containing'

if (searchResponse.results.length === 0) {
setStatus(
`No matches found for "${searchTermValue}" in ${searchResponse.totalItems} selected items`,
`No items ${modeText} "${searchTermValue}" found in ${searchResponse.totalItems} selected items`,
'info',
false,
)
} else {
setStatus(
`Found ${searchResponse.results.length} items with matches out of ${searchResponse.totalItems} total selected items`,
`Found ${searchResponse.results.length} items ${modeText} "${searchTermValue}" out of ${searchResponse.totalItems} total selected items`,
'success',
false,
)
Expand Down Expand Up @@ -339,6 +357,20 @@ function getResultMatchCount(
font-size: 0.9375rem;
}

&__negation-section {
margin: 1.5rem 0;
padding: 0;
border: 0;
}

&__negation-label {
display: block;
font-weight: 500;
margin-bottom: 0.75rem;
color: var(--text-primary);
font-size: 0.9375rem;
}

&__checkbox-option {
display: flex;
align-items: center;
Expand Down
12 changes: 10 additions & 2 deletions src/components/WhatsNew.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,22 @@ interface Feature {
const showModal = ref<boolean>(false)

const features: Feature[] = [
{
id: 'search-content-negate',
type: 'improvement',
title: 'Ability to negate search in Search Content to find items NOT containing search term',
description:
'You can now negate your search in the Search Content utility to find items that do NOT contain your search term. This is useful for finding items that may be missing a term you expected to be there, or for excluding common terms to find more specific results. Simply check the "Show items NOT containing search term" checkbox.',
utcDatetimeAdded: new Date('2026-02-08T20:30:00Z'),
},
{
id: 'search-content-multi-scope',
type: 'improvement',
title:
'Define and store your known page types & collections keys, and search across them simultaneously',
description:
'You can now use the API Configuration UI to store your known page types and collection keys. These will be remembered on your device. Use these to use the new checkbox-UI in the Search Content utility to search across all your content simultaneously, instead of having to search each page type and collection separately.',
utcDatetimeAdded: new Date('2026-02-08T20:30:00Z'),
utcDatetimeAdded: new Date('2026-02-08T20:15:00Z'),
},
{
id: 'search-content-alt-text',
Expand Down Expand Up @@ -79,7 +87,7 @@ const features: Feature[] = [
title: 'Normalise &nbsp; and spaces',
description:
'Searching for a "<code> </code>" (space) now also returns results with non-breaking spaces (<code>&amp;nbsp;</code>), and vice versa.',
utcDatetimeAdded: new Date('2026-01-14T19:30:00Z'),
utcDatetimeAdded: new Date('2026-01-24T19:30:00Z'),
},
{
id: 'search-content',
Expand Down
188 changes: 188 additions & 0 deletions src/features/searchContent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,192 @@ describe('searchContent', () => {
expect(result.results).toHaveLength(1)
})
})

describe('Negation mode', () => {
it('should return items NOT containing search term when negate is true', async () => {
mockGetAllPages.mockImplementation(async () => [
{
id: '1',
slug: 'about',
name: 'About Us',
page_type: 'landing_page',
published: '2023-01-01',
},
{
id: '2',
slug: 'contact',
name: 'Contact Page',
page_type: 'landing_page',
published: '2023-01-02',
},
{
id: '3',
slug: 'services',
name: 'TestServices',
page_type: 'landing_page',
published: '2023-01-03',
},
])
mockGetAllPosts.mockImplementation(async () => [])
mockGetAllCollections.mockImplementation(async () => [])

const result = await searchContent(
'Test',
'test-token',
false,
['landing_page'],
[],
false,
true,
)

expect(result.success).toBe(true)
expect(result.results).toHaveLength(2)
expect(result.results.map((r) => r.slug)).toEqual(['about', 'contact'])
})

it('should return empty matches array for negated results', async () => {
mockGetAllPages.mockImplementation(async () => [
{
id: '1',
slug: 'about',
name: 'About Us',
page_type: 'landing_page',
published: '2023-01-01',
},
])
mockGetAllPosts.mockImplementation(async () => [])
mockGetAllCollections.mockImplementation(async () => [])

const result = await searchContent(
'Test',
'test-token',
false,
['landing_page'],
[],
false,
true,
)

expect(result.success).toBe(true)
expect(result.results).toHaveLength(1)
expect(result.results[0]!.matches).toHaveLength(0)
})

it('should work with multiple scopes in negate mode', async () => {
mockGetAllPages.mockImplementation(async () => [
{
id: '1',
slug: 'test-page',
name: 'TestPage',
page_type: 'landing_page',
published: '2023-01-01',
},
{
id: '2',
slug: 'about',
name: 'About',
page_type: 'landing_page',
published: '2023-01-02',
},
])
mockGetAllPosts.mockImplementation(async () => [
{
id: '1',
slug: 'post-1',
title: 'Regular Post',
published: '2023-01-01',
},
{
id: '2',
slug: 'test-blog',
title: 'Test Blog Post',
published: '2023-01-02',
},
])
mockGetAllCollections.mockImplementation(async () => [])

const result = await searchContent(
'Test',
'test-token',
false,
['landing_page'],
[],
true,
true,
)

expect(result.success).toBe(true)
expect(result.results).toHaveLength(2)
expect(result.results.map((r) => r.slug).sort()).toEqual(['about', 'post-1'])
})

it('should return all items when negating and no items contain search term', async () => {
mockGetAllPages.mockImplementation(async () => [
{
id: '1',
slug: 'page-1',
name: 'Page One',
page_type: 'landing_page',
published: '2023-01-01',
},
{
id: '2',
slug: 'page-2',
name: 'Page Two',
page_type: 'landing_page',
published: '2023-01-02',
},
])
mockGetAllPosts.mockImplementation(async () => [])
mockGetAllCollections.mockImplementation(async () => [])

const result = await searchContent(
'NonExistent',
'test-token',
false,
['landing_page'],
[],
false,
true,
)

expect(result.success).toBe(true)
expect(result.results).toHaveLength(2)
})

it('should return no items when negating and all items contain search term', async () => {
mockGetAllPages.mockImplementation(async () => [
{
id: '1',
slug: 'test-1',
name: 'Test Page One',
page_type: 'landing_page',
published: '2023-01-01',
},
{
id: '2',
slug: 'test-2',
name: 'Test Page Two',
page_type: 'landing_page',
published: '2023-01-02',
},
])
mockGetAllPosts.mockImplementation(async () => [])
mockGetAllCollections.mockImplementation(async () => [])

const result = await searchContent(
'Test',
'test-token',
false,
['landing_page'],
[],
false,
true,
)

expect(result.success).toBe(true)
expect(result.results).toHaveLength(0)
})
})
})
Loading