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
35 changes: 32 additions & 3 deletions packages/models/src/Domain/Runtime/Display/DisplayOptions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 },
Expand All @@ -42,12 +42,41 @@ 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 },
} as jest.Mocked<NotesAndFilesDisplayOptions>
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)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

if (itemToMatch.protected && !searchQuery.includeProtectedNoteText) {
const match = matchResultForStringQuery(itemToMatch, searchQuery.query)
return match === MatchResult.Title || match === MatchResult.TitleAndText || someTagsMatches
const { query, includeProtectedNoteText, shouldCheckForSomeTagMatches = true, noteTitleOnly = false } = searchQuery

if (query.length === 0) {
return true
}

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 matchResultForStringQuery(itemToMatch, searchQuery.query) !== MatchResult.None || someTagsMatches
return itemMatch !== MatchResult.None
}

function matchResultForStringQuery(item: SearchableItem, searchString: string): MatchResult {
Expand Down
3 changes: 3 additions & 0 deletions packages/models/src/Domain/Runtime/Display/Search/Types.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions packages/snjs/lib/Services/Items/ItemManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -184,6 +195,7 @@ export class ItemManager extends Services.AbstractService implements Services.It
...override,
...{
tags: mostRecentVersionOfTags,
searchQuery: mostRecentVersionOfSearchQuery,
views: mostRecentVersionOfViews,
hiddenContentTypes: [ContentType.TYPES.Tag],
},
Expand Down
11 changes: 9 additions & 2 deletions packages/web/src/javascripts/Components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { classNames } from '@standardnotes/snjs'
import { ChangeEventHandler, FunctionComponent } from 'react'

type CheckboxProps = {
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
itemListController={itemListController}
searchOptionsController={searchOptionsController}
hideOptions={shouldUseTableView}
showSearchEnhancements={application.featuresController.isSearchEnhancementsEnabled()}
/>
)}
<NoAccountWarning
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,43 +10,62 @@ 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'

type Props = {
contentTypes: string[]
placeholder: string
onSelection: (item: DecryptedItem) => void
excludeUuids?: string[]
className?: {
input?: string
popover?: string
}
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>
Expand Down
Loading
Loading