From 29fd9e026e62bcb0bee7974d9ded0b4e303379ea Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Fri, 8 May 2026 15:04:15 -0300 Subject: [PATCH] feat: add generic component and controller --- .../UniversalSearchController.spec.ts | 124 ++++++++ .../UniversalSearchController.ts | 289 ++++++++++++++++++ .../UniversalSearch/UniversalSearchShell.tsx | 252 +++++++++++++++ .../NoteView/UniversalSearch/index.ts | 4 + .../providers/NoOpUniversalSearchProvider.ts | 17 ++ .../providers/UniversalSearchProvider.spec.ts | 60 ++++ .../NoteView/UniversalSearch/types.ts | 54 ++++ 7 files changed, 800 insertions(+) create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchShell.tsx create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/index.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/NoOpUniversalSearchProvider.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/UniversalSearchProvider.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/types.ts diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.spec.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.spec.ts new file mode 100644 index 000000000..9b57a9621 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.spec.ts @@ -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) + }) +}) diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.ts new file mode 100644 index 000000000..00468de69 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.ts @@ -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 { + provider: UniversalSearchProvider + isEnabled?: boolean +} + +export class UniversalSearchController { + isOpen = false + query = '' + replaceQuery = '' + results: UniversalSearchResult[] = [] + 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, + options: Omit, 'provider'> = {}, + ) { + this.isEnabled = options.isEnabled ?? true + this.shouldHighlightAll = provider.capabilities.supportsHighlightAll + + makeObservable(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 | 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 => { + const result = this.currentResult + if (!result) { + return + } + + await this.provider.selectResult(result) + } + + replaceCurrentResult = async (): Promise => { + 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 => { + 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 => { + 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[] | void): Promise => { + 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[]): 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 + } +} diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchShell.tsx b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchShell.tsx new file mode 100644 index 000000000..4c35e4cb4 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchShell.tsx @@ -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 { + controller: UniversalSearchController + className?: string + position?: 'absolute' | 'inline' + topMarginClassName?: string + closeShortcut?: string + replaceShortcut?: string + caseSensitivityShortcut?: string +} + +function statusLabel(controller: UniversalSearchController): 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({ + controller, + className, + position = 'absolute', + topMarginClassName, + closeShortcut, + replaceShortcut, + caseSensitivityShortcut, +}: UniversalSearchShellProps) { + 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) => { + if (event.key !== 'Enter' || !hasResults) { + return + } + + if (event.shiftKey) { + controller.goToPreviousResult() + event.preventDefault() + return + } + + controller.goToNextResult() + event.preventDefault() + } + + const handleReplaceKeyDown = (event: KeyboardEvent) => { + 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 ( +
+ +
{ + if (event.key === 'Escape') { + controller.close() + } + }} + > +
+ + {statusLabel(controller)} +
, + ]} + /> + + + + +
+ {controller.isReplaceMode && ( +
+ { + 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" + /> + + +
+ )} +
+ + {!canHighlightAll && ( + + + + )} +
+
+ + ) +}) diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/index.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/index.ts new file mode 100644 index 000000000..1a06cd117 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/index.ts @@ -0,0 +1,4 @@ +export * from './UniversalSearchShell' +export * from './UniversalSearchController' +export * from './types' +export * from './providers/NoOpUniversalSearchProvider' diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/NoOpUniversalSearchProvider.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/NoOpUniversalSearchProvider.ts new file mode 100644 index 000000000..834544113 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/NoOpUniversalSearchProvider.ts @@ -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 + }, +} diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/UniversalSearchProvider.spec.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/UniversalSearchProvider.spec.ts new file mode 100644 index 000000000..1f9dcb646 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/UniversalSearchProvider.spec.ts @@ -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') + }) +}) diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/types.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/types.ts new file mode 100644 index 000000000..c0564319e --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/types.ts @@ -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 { + id: string + label?: string + context?: string + payload?: TPayload +} + +export interface UniversalSearchProviderCapabilities { + supportsSearch: boolean + supportsReplace: boolean + supportsHighlightAll: boolean +} + +export interface UniversalSearchProvider { + id: string + capabilities: UniversalSearchProviderCapabilities + search(query: UniversalSearchQuery): Promise[]> | UniversalSearchResult[] + selectResult(result: UniversalSearchResult): Promise | void + clear(): Promise | void + replaceCurrentResult?( + result: UniversalSearchResult, + query: UniversalSearchReplaceQuery, + ): Promise[]> | UniversalSearchResult[] | Promise | void + replaceAllResults?( + results: UniversalSearchResult[], + query: UniversalSearchReplaceQuery, + ): Promise[]> | UniversalSearchResult[] | Promise | void +} + +export interface UniversalSearchControllerState { + isOpen: boolean + query: string + replaceQuery: string + results: UniversalSearchResult[] + currentResultIndex: number + status: UniversalSearchStatus + error?: string + isCaseSensitive: boolean + isReplaceMode: boolean + shouldHighlightAll: boolean +}