mirror of
https://github.com/standardnotes/app
synced 2026-09-21 06:13:27 -04:00
feat: add generic component and controller
This commit is contained in:
+124
@@ -0,0 +1,124 @@
|
||||
import { createMockUniversalSearchProvider } from './providers/createMockUniversalSearchProvider'
|
||||
import {
|
||||
getNextUniversalSearchResultIndex,
|
||||
getPreviousUniversalSearchResultIndex,
|
||||
UniversalSearchController,
|
||||
} from './UniversalSearchController'
|
||||
import { UniversalSearchProvider } from './types'
|
||||
import { NoOpUniversalSearchProvider } from './providers/NoOpUniversalSearchProvider'
|
||||
|
||||
describe('UniversalSearchController', () => {
|
||||
async function flushSearch() {
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
it('calculates predictable wraparound indexes', () => {
|
||||
expect(getNextUniversalSearchResultIndex(-1, 0)).toBe(-1)
|
||||
expect(getNextUniversalSearchResultIndex(-1, 3)).toBe(0)
|
||||
expect(getNextUniversalSearchResultIndex(2, 3)).toBe(0)
|
||||
expect(getPreviousUniversalSearchResultIndex(-1, 0)).toBe(-1)
|
||||
expect(getPreviousUniversalSearchResultIndex(0, 3)).toBe(2)
|
||||
})
|
||||
|
||||
it('opens, searches, selects, navigates, and closes with reset state', async () => {
|
||||
const onSelectResult = jest.fn()
|
||||
const provider = createMockUniversalSearchProvider({
|
||||
documents: [{ id: 'one', text: 'hello hello' }],
|
||||
onSelectResult,
|
||||
})
|
||||
const controller = new UniversalSearchController(provider)
|
||||
|
||||
controller.open()
|
||||
controller.setQuery('hello')
|
||||
await flushSearch()
|
||||
|
||||
expect(controller.isOpen).toBe(true)
|
||||
expect(controller.status).toBe('ready')
|
||||
expect(controller.results).toHaveLength(2)
|
||||
expect(controller.currentResultIndex).toBe(0)
|
||||
|
||||
controller.goToNextResult()
|
||||
|
||||
expect(controller.currentResultIndex).toBe(1)
|
||||
expect(onSelectResult).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'one-6' }))
|
||||
|
||||
controller.goToNextResult()
|
||||
|
||||
expect(controller.currentResultIndex).toBe(0)
|
||||
|
||||
controller.close()
|
||||
|
||||
expect(controller.isOpen).toBe(false)
|
||||
expect(controller.query).toBe('')
|
||||
expect(controller.results).toEqual([])
|
||||
expect(controller.currentResultIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('handles provider errors', async () => {
|
||||
const provider: UniversalSearchProvider = {
|
||||
id: 'failing',
|
||||
capabilities: {
|
||||
supportsSearch: true,
|
||||
supportsReplace: false,
|
||||
supportsHighlightAll: false,
|
||||
},
|
||||
search: () => {
|
||||
throw new Error('Provider failed')
|
||||
},
|
||||
selectResult: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
}
|
||||
const controller = new UniversalSearchController(provider)
|
||||
|
||||
controller.open()
|
||||
controller.setQuery('hello')
|
||||
await flushSearch()
|
||||
|
||||
expect(controller.status).toBe('error')
|
||||
expect(controller.error).toBe('Provider failed')
|
||||
expect(controller.results).toEqual([])
|
||||
})
|
||||
|
||||
it('delegates replacement through provider contracts', async () => {
|
||||
const provider = createMockUniversalSearchProvider({
|
||||
documents: [{ id: 'one', text: 'hello hello' }],
|
||||
})
|
||||
const controller = new UniversalSearchController(provider)
|
||||
|
||||
controller.open()
|
||||
controller.setQuery('hello')
|
||||
controller.setReplaceQuery('goodbye')
|
||||
await flushSearch()
|
||||
|
||||
await controller.replaceCurrentResult()
|
||||
|
||||
expect(provider.getDocuments()[0].text).toBe('goodbye hello')
|
||||
expect(controller.results).toHaveLength(1)
|
||||
|
||||
await controller.replaceAllResults()
|
||||
|
||||
expect(provider.getDocuments()[0].text).toBe('goodbye goodbye')
|
||||
expect(controller.results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps unsupported providers stable and predictable', async () => {
|
||||
const controller = new UniversalSearchController(NoOpUniversalSearchProvider)
|
||||
|
||||
controller.open()
|
||||
controller.setQuery('hello')
|
||||
controller.goToNextResult()
|
||||
await flushSearch()
|
||||
|
||||
expect(controller.results).toEqual([])
|
||||
expect(controller.currentResultIndex).toBe(-1)
|
||||
await expect(controller.replaceCurrentResult()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not open when access is disabled', () => {
|
||||
const controller = new UniversalSearchController(NoOpUniversalSearchProvider, { isEnabled: false })
|
||||
|
||||
controller.open()
|
||||
|
||||
expect(controller.isOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
import { action, computed, makeObservable, observable, runInAction } from 'mobx'
|
||||
import {
|
||||
UniversalSearchProvider,
|
||||
UniversalSearchResult,
|
||||
UniversalSearchResultPayload,
|
||||
UniversalSearchStatus,
|
||||
} from './types'
|
||||
|
||||
export function getNextUniversalSearchResultIndex(currentResultIndex: number, resultCount: number): number {
|
||||
if (resultCount < 1) {
|
||||
return -1
|
||||
}
|
||||
|
||||
const next = currentResultIndex + 1
|
||||
return next >= resultCount ? 0 : next
|
||||
}
|
||||
|
||||
export function getPreviousUniversalSearchResultIndex(currentResultIndex: number, resultCount: number): number {
|
||||
if (resultCount < 1) {
|
||||
return -1
|
||||
}
|
||||
|
||||
const previous = currentResultIndex - 1
|
||||
return previous < 0 ? resultCount - 1 : previous
|
||||
}
|
||||
|
||||
function statusForEmptyQuery(provider: UniversalSearchProvider): UniversalSearchStatus {
|
||||
return provider.capabilities.supportsSearch ? 'idle' : 'ready'
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return 'Search failed'
|
||||
}
|
||||
|
||||
interface UniversalSearchControllerOptions<TPayload = UniversalSearchResultPayload> {
|
||||
provider: UniversalSearchProvider<TPayload>
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
export class UniversalSearchController<TPayload = UniversalSearchResultPayload> {
|
||||
isOpen = false
|
||||
query = ''
|
||||
replaceQuery = ''
|
||||
results: UniversalSearchResult<TPayload>[] = []
|
||||
currentResultIndex = -1
|
||||
status: UniversalSearchStatus = 'idle'
|
||||
error: string | undefined = undefined
|
||||
isCaseSensitive = false
|
||||
isReplaceMode = false
|
||||
shouldHighlightAll: boolean
|
||||
|
||||
private searchId = 0
|
||||
private isEnabled: boolean
|
||||
|
||||
constructor(
|
||||
public readonly provider: UniversalSearchProvider<TPayload>,
|
||||
options: Omit<UniversalSearchControllerOptions<TPayload>, 'provider'> = {},
|
||||
) {
|
||||
this.isEnabled = options.isEnabled ?? true
|
||||
this.shouldHighlightAll = provider.capabilities.supportsHighlightAll
|
||||
|
||||
makeObservable<this, 'setSearchResults' | 'setSearchError' | 'clearResults'>(this, {
|
||||
isOpen: observable,
|
||||
query: observable,
|
||||
replaceQuery: observable,
|
||||
results: observable,
|
||||
currentResultIndex: observable,
|
||||
status: observable,
|
||||
error: observable,
|
||||
isCaseSensitive: observable,
|
||||
isReplaceMode: observable,
|
||||
shouldHighlightAll: observable,
|
||||
|
||||
currentResult: computed,
|
||||
|
||||
open: action,
|
||||
close: action,
|
||||
setQuery: action,
|
||||
setReplaceQuery: action,
|
||||
toggleCaseSensitivity: action,
|
||||
toggleReplaceMode: action,
|
||||
setShouldHighlightAll: action,
|
||||
goToNextResult: action,
|
||||
goToPreviousResult: action,
|
||||
setSearchResults: action,
|
||||
setSearchError: action,
|
||||
clearResults: action,
|
||||
})
|
||||
}
|
||||
|
||||
get currentResult(): UniversalSearchResult<TPayload> | undefined {
|
||||
return this.results[this.currentResultIndex]
|
||||
}
|
||||
|
||||
deinit(): void {
|
||||
this.searchId++
|
||||
void this.provider.clear()
|
||||
this.results = []
|
||||
}
|
||||
|
||||
open = (): void => {
|
||||
if (!this.isEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isOpen = true
|
||||
void this.search()
|
||||
}
|
||||
|
||||
close = (): void => {
|
||||
void this.selectCurrentResult()
|
||||
this.searchId++
|
||||
void this.provider.clear()
|
||||
this.isOpen = false
|
||||
this.query = ''
|
||||
this.replaceQuery = ''
|
||||
this.results = []
|
||||
this.currentResultIndex = -1
|
||||
this.status = 'idle'
|
||||
this.error = undefined
|
||||
this.isCaseSensitive = false
|
||||
this.isReplaceMode = false
|
||||
this.shouldHighlightAll = this.provider.capabilities.supportsHighlightAll
|
||||
}
|
||||
|
||||
setQuery = (query: string): void => {
|
||||
this.query = query
|
||||
void this.search()
|
||||
}
|
||||
|
||||
setReplaceQuery = (replaceQuery: string): void => {
|
||||
this.replaceQuery = replaceQuery
|
||||
}
|
||||
|
||||
toggleCaseSensitivity = (): void => {
|
||||
this.isCaseSensitive = !this.isCaseSensitive
|
||||
void this.search()
|
||||
}
|
||||
|
||||
toggleReplaceMode = (): void => {
|
||||
this.isReplaceMode = !this.isReplaceMode
|
||||
}
|
||||
|
||||
setShouldHighlightAll = (shouldHighlightAll: boolean): void => {
|
||||
this.shouldHighlightAll = this.provider.capabilities.supportsHighlightAll && shouldHighlightAll
|
||||
}
|
||||
|
||||
goToNextResult = (): void => {
|
||||
this.currentResultIndex = getNextUniversalSearchResultIndex(this.currentResultIndex, this.results.length)
|
||||
void this.selectCurrentResult()
|
||||
}
|
||||
|
||||
goToPreviousResult = (): void => {
|
||||
this.currentResultIndex = getPreviousUniversalSearchResultIndex(this.currentResultIndex, this.results.length)
|
||||
void this.selectCurrentResult()
|
||||
}
|
||||
|
||||
selectCurrentResult = async (): Promise<void> => {
|
||||
const result = this.currentResult
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.provider.selectResult(result)
|
||||
}
|
||||
|
||||
replaceCurrentResult = async (): Promise<void> => {
|
||||
if (!this.provider.capabilities.supportsReplace || !this.provider.replaceCurrentResult) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = this.currentResult
|
||||
if (!result || !this.replaceQuery) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextResults = await this.provider.replaceCurrentResult(result, {
|
||||
query: this.query,
|
||||
isCaseSensitive: this.isCaseSensitive,
|
||||
replaceQuery: this.replaceQuery,
|
||||
})
|
||||
|
||||
await this.handleReplaceResult(nextResults)
|
||||
}
|
||||
|
||||
replaceAllResults = async (): Promise<void> => {
|
||||
if (!this.provider.capabilities.supportsReplace || !this.provider.replaceAllResults) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.results.length < 1 || !this.replaceQuery) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextResults = await this.provider.replaceAllResults(this.results, {
|
||||
query: this.query,
|
||||
isCaseSensitive: this.isCaseSensitive,
|
||||
replaceQuery: this.replaceQuery,
|
||||
})
|
||||
|
||||
await this.handleReplaceResult(nextResults)
|
||||
}
|
||||
|
||||
private search = async (): Promise<void> => {
|
||||
if (!this.isOpen || !this.isEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const searchId = this.searchId + 1
|
||||
this.searchId = searchId
|
||||
|
||||
if (!this.query || !this.provider.capabilities.supportsSearch) {
|
||||
void this.provider.clear()
|
||||
this.clearResults(statusForEmptyQuery(this.provider))
|
||||
return
|
||||
}
|
||||
|
||||
this.status = 'loading'
|
||||
this.error = undefined
|
||||
|
||||
try {
|
||||
const results = await this.provider.search({
|
||||
query: this.query,
|
||||
isCaseSensitive: this.isCaseSensitive,
|
||||
})
|
||||
|
||||
if (searchId !== this.searchId) {
|
||||
return
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.setSearchResults(results)
|
||||
})
|
||||
|
||||
await this.selectCurrentResult()
|
||||
} catch (error) {
|
||||
if (searchId !== this.searchId) {
|
||||
return
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.setSearchError(errorMessage(error))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleReplaceResult = async (nextResults: UniversalSearchResult<TPayload>[] | void): Promise<void> => {
|
||||
if (Array.isArray(nextResults)) {
|
||||
runInAction(() => {
|
||||
this.setSearchResults(nextResults)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const results = await this.provider.search({
|
||||
query: this.query,
|
||||
isCaseSensitive: this.isCaseSensitive,
|
||||
})
|
||||
|
||||
runInAction(() => {
|
||||
this.setSearchResults(results)
|
||||
})
|
||||
}
|
||||
|
||||
private setSearchResults(results: UniversalSearchResult<TPayload>[]): void {
|
||||
this.results = results
|
||||
this.currentResultIndex = results.length > 0 ? 0 : -1
|
||||
this.status = 'ready'
|
||||
this.error = undefined
|
||||
}
|
||||
|
||||
private setSearchError(error: string): void {
|
||||
this.results = []
|
||||
this.currentResultIndex = -1
|
||||
this.status = 'error'
|
||||
this.error = error
|
||||
}
|
||||
|
||||
private clearResults(status: UniversalSearchStatus): void {
|
||||
this.results = []
|
||||
this.currentResultIndex = -1
|
||||
this.status = status
|
||||
this.error = undefined
|
||||
}
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
import { ArrowDownIcon, ArrowRightIcon, ArrowUpIcon, CloseIcon } from '@standardnotes/icons'
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { KeyboardEvent, useCallback } from 'react'
|
||||
import Button from '../../Button/Button'
|
||||
import Icon from '../../Icon/Icon'
|
||||
import DecoratedInput from '../../Input/DecoratedInput'
|
||||
import StyledTooltip from '../../StyledTooltip/StyledTooltip'
|
||||
import { UniversalSearchController } from './UniversalSearchController'
|
||||
import { UniversalSearchResultPayload } from './types'
|
||||
|
||||
interface UniversalSearchShellProps<TPayload = UniversalSearchResultPayload> {
|
||||
controller: UniversalSearchController<TPayload>
|
||||
className?: string
|
||||
position?: 'absolute' | 'inline'
|
||||
topMarginClassName?: string
|
||||
closeShortcut?: string
|
||||
replaceShortcut?: string
|
||||
caseSensitivityShortcut?: string
|
||||
}
|
||||
|
||||
function statusLabel<TPayload = UniversalSearchResultPayload>(controller: UniversalSearchController<TPayload>): string {
|
||||
if (controller.status === 'loading') {
|
||||
return 'Loading'
|
||||
}
|
||||
|
||||
if (controller.status === 'error') {
|
||||
return controller.error || 'Error'
|
||||
}
|
||||
|
||||
if (!controller.query) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (controller.results.length < 1) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
return `${controller.currentResultIndex + 1} / ${controller.results.length}`
|
||||
}
|
||||
|
||||
export const UniversalSearchShell = observer(function UniversalSearchShell<TPayload = UniversalSearchResultPayload>({
|
||||
controller,
|
||||
className,
|
||||
position = 'absolute',
|
||||
topMarginClassName,
|
||||
closeShortcut,
|
||||
replaceShortcut,
|
||||
caseSensitivityShortcut,
|
||||
}: UniversalSearchShellProps<TPayload>) {
|
||||
const focusOnMount = useCallback((node: HTMLInputElement | null) => {
|
||||
if (node) {
|
||||
node.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const canReplace = controller.provider.capabilities.supportsReplace
|
||||
const canHighlightAll = controller.provider.capabilities.supportsHighlightAll
|
||||
const hasResults = controller.results.length > 0
|
||||
const hasReplaceQuery = controller.replaceQuery.length > 0
|
||||
|
||||
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Enter' || !hasResults) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
controller.goToPreviousResult()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
controller.goToNextResult()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleReplaceKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Enter' || !hasResults || !hasReplaceQuery || !canReplace) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.ctrlKey && event.altKey) {
|
||||
void controller.replaceAllResults()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
void controller.replaceCurrentResult()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
if (!controller.isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
const defaultTopMarginClassName = position === 'absolute' ? 'top-2 md:top-3' : 'mt-2 md:mt-3'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'z-10 flex select-none rounded border border-border bg-default font-sans',
|
||||
position === 'absolute' ? 'absolute left-2 right-6 md:left-auto' : 'relative mx-2 mb-2 w-fit',
|
||||
topMarginClassName ?? defaultTopMarginClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className="focus:ring-none border-r border-border px-1 hover:bg-contrast focus:shadow-inner focus:shadow-info disabled:cursor-not-allowed"
|
||||
onClick={controller.toggleReplaceMode}
|
||||
title={replaceShortcut ? `Toggle Replace Mode (${replaceShortcut})` : 'Toggle Replace Mode'}
|
||||
disabled={!canReplace}
|
||||
aria-label="Toggle replace mode"
|
||||
>
|
||||
{controller.isReplaceMode ? (
|
||||
<ArrowDownIcon className="h-4 w-4 fill-text" />
|
||||
) : (
|
||||
<ArrowRightIcon className="h-4 w-4 fill-text" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className="flex flex-col gap-2 px-2 py-2"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
controller.close()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<DecoratedInput
|
||||
placeholder="Search"
|
||||
className={{
|
||||
container: classNames('flex-grow !text-[length:inherit]', !controller.query.length && '!py-1'),
|
||||
right: '!py-1',
|
||||
}}
|
||||
value={controller.query}
|
||||
onChange={controller.setQuery}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
ref={focusOnMount}
|
||||
right={[
|
||||
<div
|
||||
className="min-w-[7ch] max-w-[7ch] flex-shrink-0 whitespace-nowrap text-right"
|
||||
aria-live="polite"
|
||||
aria-label="Search status"
|
||||
>
|
||||
{statusLabel(controller)}
|
||||
</div>,
|
||||
]}
|
||||
/>
|
||||
<label
|
||||
className={classNames(
|
||||
'relative flex items-center rounded border px-1.5 py-1 focus-within:ring-2 focus-within:ring-info focus-within:ring-offset-2 focus-within:ring-offset-default',
|
||||
controller.isCaseSensitive ? 'border-info bg-info text-info-contrast' : 'border-border hover:bg-contrast',
|
||||
)}
|
||||
title={caseSensitivityShortcut ? `Case sensitive (${caseSensitivityShortcut})` : 'Case sensitive'}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="absolute left-0 top-0 z-[1] m-0 h-full w-full cursor-pointer border border-transparent p-0 opacity-0 shadow-none outline-none"
|
||||
checked={controller.isCaseSensitive}
|
||||
onChange={controller.toggleCaseSensitivity}
|
||||
aria-label="Case sensitive"
|
||||
/>
|
||||
<span aria-hidden>Aa</span>
|
||||
</label>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
|
||||
onClick={controller.goToPreviousResult}
|
||||
disabled={!hasResults}
|
||||
title="Previous result (Shift + Enter)"
|
||||
aria-label="Previous result"
|
||||
>
|
||||
<ArrowUpIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
|
||||
onClick={controller.goToNextResult}
|
||||
disabled={!hasResults}
|
||||
title="Next result (Enter)"
|
||||
aria-label="Next result"
|
||||
>
|
||||
<ArrowDownIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast"
|
||||
onClick={controller.close}
|
||||
title={closeShortcut ? `Close (${closeShortcut})` : 'Close'}
|
||||
aria-label="Close search"
|
||||
>
|
||||
<CloseIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
</div>
|
||||
{controller.isReplaceMode && (
|
||||
<div className="flex flex-wrap items-center gap-2 md:flex-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Replace"
|
||||
value={controller.replaceQuery}
|
||||
onChange={(event) => {
|
||||
controller.setReplaceQuery(event.target.value)
|
||||
}}
|
||||
onKeyDown={handleReplaceKeyDown}
|
||||
className="rounded border border-border bg-default p-1 px-2"
|
||||
ref={focusOnMount}
|
||||
disabled={!canReplace}
|
||||
aria-label="Replace"
|
||||
/>
|
||||
<Button
|
||||
small
|
||||
onClick={() => void controller.replaceCurrentResult()}
|
||||
disabled={!canReplace || !hasResults || !hasReplaceQuery}
|
||||
title="Replace (Enter)"
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
small
|
||||
onClick={() => void controller.replaceAllResults()}
|
||||
disabled={!canReplace || !hasResults || !hasReplaceQuery}
|
||||
title="Replace all (Ctrl + Alt + Enter)"
|
||||
>
|
||||
Replace all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
className="h-4 w-4 rounded accent-info"
|
||||
type="checkbox"
|
||||
checked={controller.shouldHighlightAll}
|
||||
onChange={(event) => controller.setShouldHighlightAll(event.target.checked)}
|
||||
disabled={!canHighlightAll}
|
||||
aria-label="Highlight all results"
|
||||
/>
|
||||
<div>Highlight all results</div>
|
||||
</label>
|
||||
{!canHighlightAll && (
|
||||
<StyledTooltip
|
||||
label="This editor does not support search result highlighting yet."
|
||||
className="!z-modal"
|
||||
showOnMobile
|
||||
>
|
||||
<button className="cursor-default" aria-label="Highlight all unavailable">
|
||||
<Icon type="info" size="medium" />
|
||||
</button>
|
||||
</StyledTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './UniversalSearchShell'
|
||||
export * from './UniversalSearchController'
|
||||
export * from './types'
|
||||
export * from './providers/NoOpUniversalSearchProvider'
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { UniversalSearchProvider } from '../types'
|
||||
|
||||
export const NoOpUniversalSearchProvider: UniversalSearchProvider = {
|
||||
id: 'noop',
|
||||
capabilities: {
|
||||
supportsSearch: false,
|
||||
supportsReplace: false,
|
||||
supportsHighlightAll: false,
|
||||
},
|
||||
search: () => [],
|
||||
selectResult: () => {
|
||||
return
|
||||
},
|
||||
clear: () => {
|
||||
return
|
||||
},
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { NoOpUniversalSearchProvider } from './NoOpUniversalSearchProvider'
|
||||
import { createMockUniversalSearchProvider } from './createMockUniversalSearchProvider'
|
||||
|
||||
describe('UniversalSearchProvider', () => {
|
||||
it('returns no results for unsupported editors', async () => {
|
||||
expect(await NoOpUniversalSearchProvider.search({ query: 'hello', isCaseSensitive: false })).toEqual([])
|
||||
expect(NoOpUniversalSearchProvider.capabilities.supportsSearch).toBe(false)
|
||||
expect(NoOpUniversalSearchProvider.capabilities.supportsReplace).toBe(false)
|
||||
})
|
||||
|
||||
it('searches documents with a mock provider', async () => {
|
||||
const provider = createMockUniversalSearchProvider({
|
||||
documents: [
|
||||
{ id: 'one', text: 'Hello world' },
|
||||
{ id: 'two', text: 'hello again' },
|
||||
],
|
||||
})
|
||||
|
||||
const results = await provider.search({ query: 'hello', isCaseSensitive: false })
|
||||
|
||||
expect(results.map(({ id }) => id)).toEqual(['one-0', 'two-0'])
|
||||
})
|
||||
|
||||
it('honors case sensitivity in the mock provider', async () => {
|
||||
const provider = createMockUniversalSearchProvider({
|
||||
documents: [{ id: 'one', text: 'Hello hello' }],
|
||||
})
|
||||
|
||||
expect(await provider.search({ query: 'hello', isCaseSensitive: true })).toHaveLength(1)
|
||||
expect(await provider.search({ query: 'hello', isCaseSensitive: false })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('supports current and all replacements in the mock provider', async () => {
|
||||
const provider = createMockUniversalSearchProvider({
|
||||
documents: [{ id: 'one', text: 'hello hello' }],
|
||||
})
|
||||
const [firstResult] = await provider.search({ query: 'hello', isCaseSensitive: false })
|
||||
|
||||
await provider.replaceCurrentResult?.(firstResult, {
|
||||
query: 'hello',
|
||||
isCaseSensitive: false,
|
||||
replaceQuery: 'goodbye',
|
||||
})
|
||||
|
||||
expect(provider.getDocuments()[0].text).toBe('goodbye hello')
|
||||
|
||||
const results = await provider.search({ query: 'hello', isCaseSensitive: false })
|
||||
await provider.replaceAllResults?.(results, {
|
||||
query: 'hello',
|
||||
isCaseSensitive: false,
|
||||
replaceQuery: 'bye',
|
||||
})
|
||||
|
||||
expect(provider.getDocuments()[0].text).toBe('goodbye bye')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
export type UniversalSearchStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
export type UniversalSearchResultPayload = unknown
|
||||
|
||||
export interface UniversalSearchQuery {
|
||||
query: string
|
||||
isCaseSensitive: boolean
|
||||
}
|
||||
|
||||
export interface UniversalSearchReplaceQuery extends UniversalSearchQuery {
|
||||
replaceQuery: string
|
||||
}
|
||||
|
||||
export interface UniversalSearchResult<TPayload = UniversalSearchResultPayload> {
|
||||
id: string
|
||||
label?: string
|
||||
context?: string
|
||||
payload?: TPayload
|
||||
}
|
||||
|
||||
export interface UniversalSearchProviderCapabilities {
|
||||
supportsSearch: boolean
|
||||
supportsReplace: boolean
|
||||
supportsHighlightAll: boolean
|
||||
}
|
||||
|
||||
export interface UniversalSearchProvider<TPayload = UniversalSearchResultPayload> {
|
||||
id: string
|
||||
capabilities: UniversalSearchProviderCapabilities
|
||||
search(query: UniversalSearchQuery): Promise<UniversalSearchResult<TPayload>[]> | UniversalSearchResult<TPayload>[]
|
||||
selectResult(result: UniversalSearchResult<TPayload>): Promise<void> | void
|
||||
clear(): Promise<void> | void
|
||||
replaceCurrentResult?(
|
||||
result: UniversalSearchResult<TPayload>,
|
||||
query: UniversalSearchReplaceQuery,
|
||||
): Promise<UniversalSearchResult<TPayload>[]> | UniversalSearchResult<TPayload>[] | Promise<void> | void
|
||||
replaceAllResults?(
|
||||
results: UniversalSearchResult<TPayload>[],
|
||||
query: UniversalSearchReplaceQuery,
|
||||
): Promise<UniversalSearchResult<TPayload>[]> | UniversalSearchResult<TPayload>[] | Promise<void> | void
|
||||
}
|
||||
|
||||
export interface UniversalSearchControllerState<TPayload = UniversalSearchResultPayload> {
|
||||
isOpen: boolean
|
||||
query: string
|
||||
replaceQuery: string
|
||||
results: UniversalSearchResult<TPayload>[]
|
||||
currentResultIndex: number
|
||||
status: UniversalSearchStatus
|
||||
error?: string
|
||||
isCaseSensitive: boolean
|
||||
isReplaceMode: boolean
|
||||
shouldHighlightAll: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user