feat: Adds new search filter options: search titles only and tag filters (#3039)

* feat: Adds search by note title

* feat: Adds search enhancement feature flag

* feat: Improve UX

* fix: Fixes checkboxes cursor

* feat: Adds tag filter

* feat: Use sheet layout for mobile

* feat: Adds tag filter condition to smart view builder

* chore: Clean up code

* feat: Implements or logic for tag filters

* fix: Includes title + body matches for search title only

* chore: add package_name to fastfile [skip ci]

* chore: remove internal feature flag
This commit is contained in:
Antonella Sgarlatta
2026-08-17 16:55:57 -03:00
committed by GitHub
parent 1ea8da6009
commit f7f947a855
22 changed files with 547 additions and 86 deletions
@@ -20,7 +20,7 @@ describe('item display options', () => {
return collection
}
it('string query title', () => {
it('matches notes whose title contains the search query', () => {
const query = 'foo'
const options: NotesAndFilesDisplayOptions = {
@@ -30,7 +30,7 @@ describe('item display options', () => {
expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
})
it('string query text', async function () {
it('matches notes whose body contains the search query', () => {
const query = 'foo'
const options: NotesAndFilesDisplayOptions = {
searchQuery: { query: query, includeProtectedNoteText: true },
@@ -42,7 +42,7 @@ describe('item display options', () => {
expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
})
it('string query title and text', async function () {
it('matches notes when the search query appears in either title or body', () => {
const query = 'foo'
const options: NotesAndFilesDisplayOptions = {
searchQuery: { query: query, includeProtectedNoteText: true },
@@ -50,4 +50,33 @@ describe('item display options', () => {
const collection = collectionWithNotes(['hello', 'foobar'], ['foo', 'fobar'])
expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
})
describe('title-only search', () => {
it('matches notes when the query appears only in the title', () => {
const query = 'foo'
const options: NotesAndFilesDisplayOptions = {
searchQuery: { query, includeProtectedNoteText: true, noteTitleOnly: true },
} as jest.Mocked<NotesAndFilesDisplayOptions>
const collection = collectionWithNotes(['foo', 'hello', 'foobar'], ['bar', 'baz', 'qux'])
expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
})
it('does not match notes when the query appears only in the body', () => {
const query = 'foo'
const options: NotesAndFilesDisplayOptions = {
searchQuery: { query, includeProtectedNoteText: true, noteTitleOnly: true },
} as jest.Mocked<NotesAndFilesDisplayOptions>
const collection = collectionWithNotes(['hello', 'world'], ['foo', 'foobar'])
expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(0)
})
it('matches notes with a matching title even when the query also appears in the body', () => {
const query = 'foo'
const options: NotesAndFilesDisplayOptions = {
searchQuery: { query, includeProtectedNoteText: true, noteTitleOnly: true },
} as jest.Mocked<NotesAndFilesDisplayOptions>
const collection = collectionWithNotes(['foo', 'hello', 'foobar'], ['bar', 'foo', 'foo'])
expect(notesAndFilesMatchingOptions(options, collection.all() as SNNote[], collection)).toHaveLength(2)
})
})
})
@@ -55,6 +55,16 @@ export function computeFiltersForDisplayOptions(
}
}
if (options.searchQuery) {
const query = options.searchQuery
filters.push((item) => itemMatchesQuery(item, query, collection))
if (query.tagFilters && query.tagFilters.length > 0) {
const tagFilters = query.tagFilters
filters.push((item) => tagFilters.some((tag) => tag.isReferencingItem(item)))
}
}
if (options.includePinned === false && !viewsPredicate?.keypathIncludesString('pinned')) {
filters.push((item) => !item.pinned)
}
@@ -71,11 +81,6 @@ export function computeFiltersForDisplayOptions(
filters.push((item) => !item.archived)
}
if (options.searchQuery) {
const query = options.searchQuery
filters.push((item) => itemMatchesQuery(item, query, collection))
}
if (
!viewsPredicate?.keypathIncludesString('conflict_of') &&
!options.views?.some((v) => v.uuid === SystemViewId.TrashedNotes)
@@ -38,18 +38,33 @@ export function itemMatchesQuery(
searchQuery: SearchQuery,
collection: ReferenceLookupCollection,
): boolean {
const shouldCheckForSomeTagMatches = searchQuery.shouldCheckForSomeTagMatches ?? true
const itemTags = collection.elementsReferencingElement(itemToMatch, ContentType.TYPES.Tag) as SNTag[]
const someTagsMatches =
shouldCheckForSomeTagMatches &&
itemTags.some((tag) => matchResultForStringQuery(tag, searchQuery.query) !== MatchResult.None)
const { query, includeProtectedNoteText, shouldCheckForSomeTagMatches = true, noteTitleOnly = false } = searchQuery
if (itemToMatch.protected && !searchQuery.includeProtectedNoteText) {
const match = matchResultForStringQuery(itemToMatch, searchQuery.query)
return match === MatchResult.Title || match === MatchResult.TitleAndText || someTagsMatches
if (query.length === 0) {
return true
}
return matchResultForStringQuery(itemToMatch, searchQuery.query) !== MatchResult.None || someTagsMatches
const itemMatch = matchResultForStringQuery(itemToMatch, query)
if (noteTitleOnly) {
return itemMatch === MatchResult.Title || itemMatch === MatchResult.TitleAndText
}
if (shouldCheckForSomeTagMatches) {
const itemTags = collection.elementsReferencingElement(itemToMatch, ContentType.TYPES.Tag) as SNTag[]
const tagMatches = itemTags.map((tag) => matchResultForStringQuery(tag, query))
const someTagsMatches = tagMatches.some((match) => match !== MatchResult.None)
if (someTagsMatches) {
return true
}
}
if (itemToMatch.protected && !includeProtectedNoteText) {
return itemMatch === MatchResult.Title || itemMatch === MatchResult.TitleAndText
}
return itemMatch !== MatchResult.None
}
function matchResultForStringQuery(item: SearchableItem, searchString: string): MatchResult {
@@ -1,11 +1,14 @@
import { ItemCollection } from './../../Collection/Item/ItemCollection'
import { DecryptedItemInterface } from '../../../Abstract/Item'
import { SNTag } from '../../../Syncable/Tag'
import { SearchableItem } from './SearchableItem'
export type SearchQuery = {
query: string
includeProtectedNoteText: boolean
shouldCheckForSomeTagMatches?: boolean
noteTitleOnly?: boolean
tagFilters?: SNTag[]
}
export interface ReferenceLookupCollection {
@@ -170,6 +170,17 @@ export class ItemManager extends Services.AbstractService implements Services.It
})
.filter((tag) => tag != undefined)
const mostRecentVersionOfSearchQuery = options.searchQuery
? {
...options.searchQuery,
tagFilters: options.searchQuery.tagFilters
?.map((tag) => {
return this.collection.find(tag.uuid) as Models.SNTag
})
.filter((tag) => tag != undefined),
}
: undefined
const mostRecentVersionOfViews = options.views
?.map((view) => {
if (Models.isSystemView(view)) {
@@ -184,6 +195,7 @@ export class ItemManager extends Services.AbstractService implements Services.It
...override,
...{
tags: mostRecentVersionOfTags,
searchQuery: mostRecentVersionOfSearchQuery,
views: mostRecentVersionOfViews,
hiddenContentTypes: [ContentType.TYPES.Tag],
},
@@ -1,3 +1,4 @@
import { classNames } from '@standardnotes/snjs'
import { ChangeEventHandler, FunctionComponent } from 'react'
type CheckboxProps = {
@@ -10,9 +11,15 @@ type CheckboxProps = {
const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onChange, disabled, label }) => {
return (
<label htmlFor={name} className="fit-content mb-2 flex items-center text-sm">
<label
htmlFor={name}
className={classNames(
'fit-content mb-2 flex items-center text-sm',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
)}
>
<input
className="mr-2"
className={classNames('mr-2', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}
type="checkbox"
name={name}
id={name}
@@ -342,6 +342,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
itemListController={itemListController}
searchOptionsController={searchOptionsController}
hideOptions={shouldUseTableView}
showSearchEnhancements={application.featuresController.isSearchEnhancementsEnabled()}
/>
)}
<NoAccountWarning
@@ -10,7 +10,7 @@ import {
} from '@ariakit/react'
import { classNames, DecryptedItem, naturalSort } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useDeferredValue, useEffect, useState } from 'react'
import { useDeferredValue, useEffect, useRef, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import LinkedItemMeta from '../LinkedItems/LinkedItemMeta'
@@ -18,6 +18,7 @@ type Props = {
contentTypes: string[]
placeholder: string
onSelection: (item: DecryptedItem) => void
excludeUuids?: string[]
className?: {
input?: string
popover?: string
@@ -25,28 +26,46 @@ type Props = {
comboboxProps?: ComboboxStoreProps
}
const ItemSelectionDropdown = ({ contentTypes, placeholder, onSelection, comboboxProps, className = {} }: Props) => {
const ItemSelectionDropdown = ({
contentTypes,
placeholder,
onSelection,
excludeUuids = [],
comboboxProps,
className = {},
}: Props) => {
const application = useApplication()
const combobox = useComboboxStore(comboboxProps)
const value = combobox.useState('value')
const open = combobox.useState('open')
const previousValueRef = useRef(value)
useEffect(() => {
if (value.length < 1 && open) {
const valueWasCleared = previousValueRef.current.length > 0 && value.length < 1
if (valueWasCleared && open) {
combobox.setOpen(false)
}
}, [combobox, open, value.length])
previousValueRef.current = value
}, [combobox, open, value])
const searchQuery = useDeferredValue(value)
const [items, setItems] = useState<DecryptedItem[]>([])
useEffect(() => {
const excludedUuids = new Set(excludeUuids)
const searchableItems = naturalSort(application.items.getItems(contentTypes), 'title')
const filteredItems = searchableItems.filter((item) => {
if (excludedUuids.has(item.uuid)) {
return false
}
return doesItemMatchSearchQuery(item, searchQuery, application)
})
setItems(filteredItems)
}, [searchQuery, application, contentTypes])
}, [searchQuery, application, contentTypes, excludeUuids])
return (
<div>
@@ -1,9 +1,11 @@
import { ItemListController } from '@/Controllers/ItemList/ItemListController'
import { c } from 'ttag'
import { KeyboardKey } from '@standardnotes/ui-services'
import { useCallback, KeyboardEventHandler, useRef } from 'react'
import { useCallback, KeyboardEventHandler, useRef, useState } from 'react'
import SearchOptions from '@/Components/SearchOptions/SearchOptions'
import SearchFilterSheet from '@/Components/SearchOptions/SearchFilterSheet'
import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import { observer } from 'mobx-react-lite'
@@ -15,13 +17,49 @@ type Props = {
itemListController: ItemListController
searchOptionsController: SearchOptionsController
hideOptions?: boolean
showSearchEnhancements?: boolean
}
const SearchBar = ({ itemListController, searchOptionsController, hideOptions = false }: Props) => {
type SearchFilterButtonProps = {
activeFilterCount: number
isOpen: boolean
onClick: () => void
}
const SearchFilterButton = ({ activeFilterCount, isOpen, onClick }: SearchFilterButtonProps) => (
<button
type="button"
className={classNames(
'relative flex flex-shrink-0 cursor-pointer border-0 bg-transparent p-0',
isOpen ? 'text-info' : 'text-passive-1 hover:text-info',
)}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
aria-label="Search filters"
aria-expanded={isOpen}
>
<Icon type="tune" className="h-4.5 w-4.5" />
{activeFilterCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-0.5 text-[10px] font-semibold leading-none text-info-contrast">
{activeFilterCount}
</span>
)}
</button>
)
const SearchBar = ({
itemListController,
searchOptionsController,
hideOptions = false,
showSearchEnhancements = false,
}: Props) => {
const searchBarRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false)
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const { noteFilterText, setNoteFilterText, clearFilterText, onFilterEnter } = itemListController
const { activeSearchFilterCount } = searchOptionsController
const onNoteFilterTextChange = useCallback(
(text: string) => {
@@ -44,6 +82,47 @@ const SearchBar = ({ itemListController, searchOptionsController, hideOptions =
searchInputRef.current?.focus()
}, [clearFilterText])
const toggleFilterPanel = useCallback(() => {
setIsFilterPanelOpen((current) => !current)
}, [])
const closeFilterPanel = useCallback(() => {
setIsFilterPanelOpen(false)
}, [])
const searchOptionsVisibilityClass = (() => {
if (hideOptions) {
return 'hidden'
}
if (showSearchEnhancements) {
if (isMobileScreen) {
return 'hidden'
}
return isFilterPanelOpen ? 'flex' : 'hidden'
}
if (!noteFilterText) {
return 'hidden group-focus-within:flex'
}
return undefined
})()
const rightDecorations = showSearchEnhancements
? [
<div key="search-actions" className="flex items-center gap-1">
{noteFilterText && <ClearInputButton onClick={onClearSearch} />}
<SearchFilterButton
activeFilterCount={activeSearchFilterCount}
isOpen={isFilterPanelOpen}
onClick={toggleFilterPanel}
/>
</div>,
]
: [noteFilterText && <ClearInputButton key="clear-search" onClick={onClearSearch} />].filter(Boolean)
return (
<div className="group pb-0.5 pt-3" role="search" ref={searchBarRef}>
<DecoratedInput
@@ -59,18 +138,21 @@ const SearchBar = ({ itemListController, searchOptionsController, hideOptions =
onChange={onNoteFilterTextChange}
onKeyUp={onNoteFilterKeyUp}
left={[<Icon type="search" className="mr-1 h-4.5 w-4.5 flex-shrink-0 text-passive-1" />]}
right={[noteFilterText && <ClearInputButton onClick={onClearSearch} />]}
right={rightDecorations}
roundedFull
/>
<div
className={classNames(
'animate-fade-from-top',
hideOptions ? 'hidden' : !noteFilterText && 'hidden group-focus-within:flex',
)}
>
<SearchOptions searchOptions={searchOptionsController} />
<div className={classNames('animate-fade-from-top w-full', searchOptionsVisibilityClass)}>
<SearchOptions searchOptions={searchOptionsController} showSearchEnhancements={showSearchEnhancements} />
</div>
{showSearchEnhancements && isMobileScreen && (
<SearchFilterSheet
open={isFilterPanelOpen && !hideOptions}
onClose={closeFilterPanel}
searchOptions={searchOptionsController}
/>
)}
</div>
)
}
@@ -0,0 +1,53 @@
import Checkbox from '@/Components/Checkbox/Checkbox'
import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { classNames } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import SearchBubbles from './SearchBubbles'
import SearchOptionsSection, { ClearFilterButton } from './SearchOptionsSection'
import SearchTagFilters from './SearchTagFilters'
type Props = {
searchOptions: SearchOptionsController
className?: string
}
const EnhancedSearchOptionsContent = ({ searchOptions, className }: Props) => {
const { noteTitleOnly, tagFilterList, activeSearchFilterCount } = searchOptions
return (
<div className={classNames('flex w-full flex-col gap-2', className)}>
<div className="flex flex-col gap-2" onMouseDown={(event) => event.preventDefault()}>
<div className="flex items-start justify-between gap-2">
<Checkbox
name="search-note-title-only"
label="Search titles only"
checked={noteTitleOnly}
onChange={(event) => searchOptions.setNoteTitleOnly(event.target.checked)}
/>
{activeSearchFilterCount > 0 && (
<ClearFilterButton onClick={searchOptions.clearAllFilters}>Clear all filters</ClearFilterButton>
)}
</div>
<SearchOptionsSection label="Include">
<div className="flex flex-wrap gap-2">
<SearchBubbles searchOptions={searchOptions} />
</div>
</SearchOptionsSection>
</div>
<SearchOptionsSection
label="Filter by tag"
action={
tagFilterList.length > 0 ? (
<ClearFilterButton onClick={searchOptions.clearTagFilters}>Clear tag filters</ClearFilterButton>
) : undefined
}
>
<SearchTagFilters searchOptions={searchOptions} />
</SearchOptionsSection>
</div>
)
}
export default observer(EnhancedSearchOptionsContent)
@@ -0,0 +1,30 @@
import Bubble from '@/Components/Bubble/Bubble'
import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { observer } from 'mobx-react-lite'
import { useCallback } from 'react'
type Props = {
searchOptions: SearchOptionsController
}
const SearchBubbles = ({ searchOptions }: Props) => {
const { includeProtectedContents, includeArchived, includeTrashed } = searchOptions
const toggleIncludeProtectedContents = useCallback(async () => {
await searchOptions.toggleIncludeProtectedContents()
}, [searchOptions])
return (
<>
<Bubble
label="Protected Contents"
selected={includeProtectedContents}
onSelect={toggleIncludeProtectedContents}
/>
<Bubble label="Archived" selected={includeArchived} onSelect={searchOptions.toggleIncludeArchived} />
<Bubble label="Trashed" selected={includeTrashed} onSelect={searchOptions.toggleIncludeTrashed} />
</>
)
}
export default observer(SearchBubbles)
@@ -0,0 +1,35 @@
import MobilePopoverContent from '@/Components/Popover/MobilePopoverContent'
import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import { observer } from 'mobx-react-lite'
import { useEffect } from 'react'
import EnhancedSearchOptionsContent from './EnhancedSearchOptionsContent'
type Props = {
open: boolean
onClose: () => void
searchOptions: SearchOptionsController
}
const SearchFilterSheet = ({ open, onClose, searchOptions }: Props) => {
const addAndroidBackHandler = useAndroidBackHandler()
useEffect(() => {
if (!open) {
return
}
return addAndroidBackHandler(() => {
onClose()
return true
})
}, [addAndroidBackHandler, onClose, open])
return (
<MobilePopoverContent open={open} requestClose={onClose} title="Search filters" id="search-filters" className="p-4">
<EnhancedSearchOptionsContent searchOptions={searchOptions} />
</MobilePopoverContent>
)
}
export default observer(SearchFilterSheet)
@@ -1,41 +1,23 @@
import { observer } from 'mobx-react-lite'
import { c } from 'ttag'
import Bubble from '@/Components/Bubble/Bubble'
import { useCallback } from 'react'
import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { observer } from 'mobx-react-lite'
import EnhancedSearchOptionsContent from './EnhancedSearchOptionsContent'
import SearchBubbles from './SearchBubbles'
type Props = {
searchOptions: SearchOptionsController
showSearchEnhancements?: boolean
}
const SearchOptions = ({ searchOptions }: Props) => {
const { includeProtectedContents, includeArchived, includeTrashed } = searchOptions
const SearchOptions = ({ searchOptions, showSearchEnhancements = false }: Props) => {
if (!showSearchEnhancements) {
return (
<div className="mt-3 flex flex-wrap gap-2" onMouseDown={(event) => event.preventDefault()}>
<SearchBubbles searchOptions={searchOptions} />
</div>
)
}
const toggleIncludeProtectedContents = useCallback(async () => {
await searchOptions.toggleIncludeProtectedContents()
}, [searchOptions])
return (
<div className="mt-3 flex flex-wrap gap-2" onMouseDown={(e) => e.preventDefault()}>
<Bubble
label={c('B2.NavSharedUI.Label').t`Protected Contents`}
selected={includeProtectedContents}
onSelect={toggleIncludeProtectedContents}
/>
<Bubble
label={c('B2.NavSharedUI.Label').t`Archived`}
selected={includeArchived}
onSelect={searchOptions.toggleIncludeArchived}
/>
<Bubble
label={c('B2.NavSharedUI.Label').t`Trashed`}
selected={includeTrashed}
onSelect={searchOptions.toggleIncludeTrashed}
/>
</div>
)
return <EnhancedSearchOptionsContent searchOptions={searchOptions} className="mt-2" />
}
export default observer(SearchOptions)
@@ -0,0 +1,35 @@
import { ReactNode } from 'react'
type ClearFilterButtonProps = {
onClick: () => void
children: ReactNode
}
export const ClearFilterButton = ({ onClick, children }: ClearFilterButtonProps) => (
<button
type="button"
className="mb-2 cursor-pointer border-0 bg-transparent p-0 text-xs text-info hover:underline"
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
)
type SearchOptionsSectionProps = {
label: string
children: ReactNode
action?: ReactNode
}
const SearchOptionsSection = ({ label, children, action }: SearchOptionsSectionProps) => (
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="text-xs text-passive-0">{label}</div>
{action}
</div>
{children}
</div>
)
export default SearchOptionsSection
@@ -0,0 +1,49 @@
import ItemSelectionDropdown from '@/Components/ItemSelectionDropdown/ItemSelectionDropdown'
import LinkedItemBubble from '@/Components/LinkedItems/LinkedItemBubble'
import { useApplication } from '@/Components/ApplicationProvider'
import { ElementIds } from '@/Constants/ElementIDs'
import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem'
import { ContentType, SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
type Props = {
searchOptions: SearchOptionsController
}
const SearchTagFilters = ({ searchOptions }: Props) => {
const application = useApplication()
const { tagFilterList } = searchOptions
const selected = application.navigationController.selected
const excludeUuids = tagFilterList.map((tag) => tag.uuid)
if (selected instanceof SNTag && !excludeUuids.includes(selected.uuid)) {
excludeUuids.push(selected.uuid)
}
return (
<div className="flex flex-wrap items-center gap-2">
{tagFilterList.map((tag) => (
<LinkedItemBubble
key={tag.uuid}
className="m-0"
link={createLinkFromItem(tag, 'linked')}
unlinkItem={async (item) => {
searchOptions.removeTagFilter(item as SNTag)
document.getElementById(ElementIds.SearchBar)?.focus()
}}
isBidirectional={false}
inlineFlex={true}
/>
))}
<ItemSelectionDropdown
onSelection={(item) => searchOptions.addTagFilter(item as SNTag)}
placeholder="Add tag..."
contentTypes={[ContentType.TYPES.Tag]}
excludeUuids={excludeUuids}
/>
</div>
)
}
export default observer(SearchTagFilters)
@@ -66,19 +66,23 @@ const CompoundPredicateBuilder = ({ controller }: Props) => {
</option>
))}
</select>
<select
className="rounded border border-border bg-default px-2 py-1.5 focus:outline focus:outline-1 focus:outline-info"
value={predicate.operator}
onChange={(event) => {
setPredicate(index, { operator: event.target.value as PredicateOperator })
}}
>
{AllNonCompoundPredicateOperators.map((operator) => (
<option key={operator} value={operator}>
{operator}
</option>
))}
</select>
{PredicateKeypathTypes[predicate.keypath as PredicateKeypath] === 'tag' ? (
<div className="shrink-0 px-2 py-1.5 text-sm">include</div>
) : (
<select
className="rounded border border-border bg-default px-2 py-1.5 focus:outline focus:outline-1 focus:outline-info"
value={predicate.operator}
onChange={(event) => {
setPredicate(index, { operator: event.target.value as PredicateOperator })
}}
>
{AllNonCompoundPredicateOperators.map((operator) => (
<option key={operator} value={operator}>
{operator}
</option>
))}
</select>
)}
{predicate.keypath && (
<PredicateValue
keypath={predicate.keypath as PredicateKeypath}
@@ -11,6 +11,16 @@ const getEmptyPredicate = (): PredicateJsonForm => {
}
}
const toTagsPredicate = (tagTitle: PredicateJsonForm['value']): PredicateJsonForm => ({
keypath: 'tags',
operator: 'includes',
value: {
keypath: 'title',
operator: '=',
value: tagTitle,
},
})
export class CompoundPredicateBuilderController {
operator: PredicateCompoundOperator = 'and'
predicates: PredicateJsonForm[] = [getEmptyPredicate()]
@@ -64,6 +74,9 @@ export class CompoundPredicateBuilderController {
case 'date':
this.setPredicate(index, { value: '1.days.ago' })
break
case 'tag':
this.setPredicate(index, { value: '' })
break
}
}
@@ -81,7 +94,12 @@ export class CompoundPredicateBuilderController {
toJson(): PredicateJsonForm {
return {
operator: this.operator,
value: this.predicates,
value: this.predicates.map((predicate) => {
if (predicate.keypath === 'tags') {
return toTagsPredicate(predicate.value)
}
return predicate
}),
}
}
@@ -21,6 +21,7 @@ export type PredicateKeypath =
| 'starred'
| 'hidePreview'
| 'spellcheck'
| 'tags'
export const PredicateKeypathLabels: { [k in PredicateKeypath]: string } = {
title: c('B4.Notes.TagsLinkedItems.Label').t`Title`,
@@ -42,10 +43,11 @@ export const PredicateKeypathLabels: { [k in PredicateKeypath]: string } = {
starred: c('B4.Notes.TagsLinkedItems.Label').t`Starred`,
hidePreview: c('B4.Notes.TagsLinkedItems.Label').t`Hide Preview`,
spellcheck: c('B4.Notes.TagsLinkedItems.Label').t`Spellcheck`,
tags: c('B4.Notes.TagsLinkedItems.Label').t`Tags`,
} as const
export const PredicateKeypathTypes: {
[k in PredicateKeypath]: 'string' | 'noteType' | 'editorIdentifier' | 'number' | 'boolean' | 'date'
[k in PredicateKeypath]: 'string' | 'noteType' | 'editorIdentifier' | 'number' | 'boolean' | 'date' | 'tag'
} = {
title: 'string',
'title.length': 'number',
@@ -66,4 +68,5 @@ export const PredicateKeypathTypes: {
starred: 'boolean',
hidePreview: 'boolean',
spellcheck: 'boolean',
tags: 'tag',
} as const
@@ -1,5 +1,5 @@
import { getDropdownItemsForAllEditors } from '@/Utils/DropdownItemsForEditors'
import { NoteType } from '@standardnotes/snjs'
import { ContentType, naturalSort, NoteType, SNTag } from '@standardnotes/snjs'
import { useApplication } from '../ApplicationProvider'
import { PredicateKeypath, PredicateKeypathTypes } from './PredicateKeypaths'
import { c } from 'ttag'
@@ -15,7 +15,22 @@ const PredicateValue = ({ keypath, value, setValue }: Props) => {
const type = PredicateKeypathTypes[keypath]
const editorItems = getDropdownItemsForAllEditors(application)
return type === 'noteType' ? (
return type === 'tag' ? (
<select
className="flex-grow truncate rounded border border-border bg-default px-2 py-1.5 focus:outline focus:outline-1 focus:outline-info"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
>
<option value="">Select a tag...</option>
{naturalSort(application.items.getItems<SNTag>(ContentType.TYPES.Tag), 'title').map((tag) => (
<option key={tag.uuid} value={tag.title}>
{application.items.getTagLongTitle(tag) || tag.title}
</option>
))}
</select>
) : type === 'noteType' ? (
<select
className="flex-grow rounded border border-border bg-default px-2 py-1.5 focus:outline focus:outline-1 focus:outline-info"
value={value}
@@ -157,6 +157,10 @@ export class FeaturesController extends AbstractViewController implements Intern
return featureTrunkVaultsEnabled() || isEntitledToFeature
}
isSearchEnhancementsEnabled(): boolean {
return true
}
isUniversalSearchEnabled(): boolean {
return true
}
@@ -230,6 +230,8 @@ export class ItemListController
this.searchOptionsController.includeProtectedContents,
this.searchOptionsController.includeArchived,
this.searchOptionsController.includeTrashed,
this.searchOptionsController.noteTitleOnly,
this.searchOptionsController.tagFilterList.map((tag) => tag.uuid).join(','),
],
() => {
this.reloadNotesDisplayOptions()
@@ -413,7 +415,7 @@ export class ItemListController
}
get isFiltering(): boolean {
return !!this.noteFilterText && this.noteFilterText.length > 0
return this.noteFilterText.length > 0 || this.searchOptionsController.tagFilterList.length > 0
}
reloadPanelTitle = () => {
@@ -627,7 +629,7 @@ export class ItemListController
const tag = this.navigationController.selected
const searchText = this.noteFilterText.toLowerCase()
const isSearching = searchText.length
const isSearching = searchText.length > 0 || this.searchOptionsController.tagFilterList.length > 0
let includeArchived: boolean
let includeTrashed: boolean
@@ -639,10 +641,15 @@ export class ItemListController
includeTrashed = this.displayOptions.includeTrashed ?? false
}
const tags: SNTag[] = []
if (tag instanceof SNTag) {
tags.push(tag)
}
const criteria: NotesAndFilesDisplayControllerOptions = {
sortBy: this.displayOptions.sortBy,
sortDirection: this.displayOptions.sortDirection,
tags: tag instanceof SNTag ? [tag] : [],
tags,
views: tag instanceof SmartView ? [tag] : [],
includeArchived,
includeTrashed,
@@ -651,6 +658,11 @@ export class ItemListController
searchQuery: {
query: searchText,
includeProtectedNoteText: this.searchOptionsController.includeProtectedContents,
noteTitleOnly: this.searchOptionsController.noteTitleOnly,
tagFilters:
this.searchOptionsController.tagFilterList.length > 0
? this.searchOptionsController.tagFilterList
: undefined,
},
}
@@ -4,14 +4,17 @@ import {
InternalEventBusInterface,
InternalEventHandlerInterface,
InternalEventInterface,
SNTag,
} from '@standardnotes/snjs'
import { makeObservable, observable, action, runInAction } from 'mobx'
import { makeObservable, observable, action, runInAction, computed } from 'mobx'
import { AbstractViewController } from './Abstract/AbstractViewController'
export class SearchOptionsController extends AbstractViewController implements InternalEventHandlerInterface {
includeProtectedContents = false
includeArchived = false
includeTrashed = false
noteTitleOnly = false
tagFilterList: SNTag[] = []
constructor(
private protections: ProtectionsClientInterface,
@@ -23,11 +26,20 @@ export class SearchOptionsController extends AbstractViewController implements I
includeProtectedContents: observable,
includeTrashed: observable,
includeArchived: observable,
noteTitleOnly: observable,
tagFilterList: observable,
toggleIncludeArchived: action,
toggleIncludeTrashed: action,
toggleIncludeProtectedContents: action,
refreshIncludeProtectedContents: action,
setNoteTitleOnly: action,
addTagFilter: action,
removeTagFilter: action,
clearTagFilters: action,
clearAllFilters: action,
activeSearchFilterCount: computed,
})
eventBus.addEventHandler(this, ApplicationEvent.UnprotectedSessionBegan)
@@ -64,4 +76,40 @@ export class SearchOptionsController extends AbstractViewController implements I
})
}
}
setNoteTitleOnly = (value: boolean): void => {
this.noteTitleOnly = value
}
addTagFilter = (tag: SNTag): void => {
if (!this.tagFilterList.some((existing) => existing.uuid === tag.uuid)) {
this.tagFilterList = [...this.tagFilterList, tag]
}
}
removeTagFilter = (tag: SNTag): void => {
this.tagFilterList = this.tagFilterList.filter((existing) => existing.uuid !== tag.uuid)
}
clearTagFilters = (): void => {
this.tagFilterList = []
}
clearAllFilters = (): void => {
this.noteTitleOnly = false
this.includeArchived = false
this.includeTrashed = false
this.includeProtectedContents = false
this.tagFilterList = []
}
get activeSearchFilterCount(): number {
return (
Number(this.noteTitleOnly) +
Number(this.includeProtectedContents) +
Number(this.includeArchived) +
Number(this.includeTrashed) +
this.tagFilterList.length
)
}
}