mirror of
https://github.com/standardnotes/app
synced 2026-10-16 00:13:46 -04:00
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:
@@ -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
|
||||
|
||||
+24
-5
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+53
@@ -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)
|
||||
+17
-13
@@ -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}
|
||||
|
||||
+19
-1
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user