feat: general spotlight improvements and introducing user searcher
This commit is contained in:
@@ -583,6 +583,11 @@
|
|||||||
"log": "Log",
|
"log": "Log",
|
||||||
"url": "URL"
|
"url": "URL"
|
||||||
},
|
},
|
||||||
|
"spotlight": {
|
||||||
|
"section": {
|
||||||
|
"user": "User"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sse": {
|
"sse": {
|
||||||
"event_type": "Event type",
|
"event_type": "Event type",
|
||||||
"log": "Log",
|
"log": "Log",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ declare module '@vue/runtime-core' {
|
|||||||
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
|
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
|
||||||
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
|
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
|
||||||
AppFooter: typeof import('./components/app/Footer.vue')['default']
|
AppFooter: typeof import('./components/app/Footer.vue')['default']
|
||||||
AppFuse: typeof import('./components/app/Fuse.vue')['default']
|
|
||||||
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
|
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
|
||||||
AppHeader: typeof import('./components/app/Header.vue')['default']
|
AppHeader: typeof import('./components/app/Header.vue')['default']
|
||||||
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
|
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
|
||||||
@@ -132,6 +131,8 @@ declare module '@vue/runtime-core' {
|
|||||||
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||||
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
||||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||||
|
IconLucideRefreshCcw: typeof import('~icons/lucide/refresh-ccw')['default']
|
||||||
|
IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default']
|
||||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||||
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
||||||
|
|||||||
@@ -7,16 +7,23 @@
|
|||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col border-b transition border-dividerLight">
|
<div class="flex flex-col border-b transition border-dividerLight">
|
||||||
<input
|
<div class="flex items-center p-6 space-x-2">
|
||||||
id="command"
|
<input
|
||||||
v-model="search"
|
id="command"
|
||||||
v-focus
|
v-model="search"
|
||||||
type="text"
|
v-focus
|
||||||
autocomplete="off"
|
type="text"
|
||||||
name="command"
|
autocomplete="off"
|
||||||
:placeholder="`${t('app.type_a_command_search')}`"
|
name="command"
|
||||||
class="flex flex-shrink-0 p-6 text-base bg-transparent text-secondaryDark"
|
:placeholder="`${t('app.type_a_command_search')}`"
|
||||||
/>
|
class="flex flex-1 text-base bg-transparent text-secondaryDark"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<icon-lucide-refresh-cw
|
||||||
|
v-if="searchSession?.loading"
|
||||||
|
class="animate-spin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex flex-shrink-0 text-tiny text-secondaryLight px-4 pb-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
|
class="flex flex-shrink-0 text-tiny text-secondaryLight px-4 pb-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
|
||||||
>
|
>
|
||||||
@@ -76,6 +83,7 @@ import {
|
|||||||
} from "~/services/spotlight"
|
} from "~/services/spotlight"
|
||||||
import { isEqual } from "lodash-es"
|
import { isEqual } from "lodash-es"
|
||||||
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
|
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
|
||||||
|
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
@@ -90,6 +98,7 @@ const emit = defineEmits<{
|
|||||||
const spotlightService = useService(SpotlightService)
|
const spotlightService = useService(SpotlightService)
|
||||||
|
|
||||||
useService(HistorySpotlightSearcherService)
|
useService(HistorySpotlightSearcherService)
|
||||||
|
useService(UserSpotlightSearcherService)
|
||||||
|
|
||||||
const search = ref("")
|
const search = ref("")
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { Ref, computed, nextTick, ref, watch } from "vue"
|
|||||||
import { TestContainer } from "dioc/testing"
|
import { TestContainer } from "dioc/testing"
|
||||||
|
|
||||||
const echoSearcher: SpotlightSearcher = {
|
const echoSearcher: SpotlightSearcher = {
|
||||||
id: "echo-searcher",
|
searcherID: "echo-searcher",
|
||||||
sectionTitle: "Echo Searcher",
|
searcherSectionTitle: "Echo Searcher",
|
||||||
createSearchSession: (query: Readonly<Ref<string>>) => {
|
createSearchSession: (query: Readonly<Ref<string>>) => {
|
||||||
// A basic searcher that returns the query string as the sole result
|
// A basic searcher that returns the query string as the sole result
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -57,8 +57,8 @@ const echoSearcher: SpotlightSearcher = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const emptySearcher: SpotlightSearcher = {
|
const emptySearcher: SpotlightSearcher = {
|
||||||
id: "empty-searcher",
|
searcherID: "empty-searcher",
|
||||||
sectionTitle: "Empty Searcher",
|
searcherSectionTitle: "Empty Searcher",
|
||||||
createSearchSession: () => {
|
createSearchSession: () => {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
@@ -93,8 +93,8 @@ describe("SpotlightService", () => {
|
|||||||
|
|
||||||
it("if 2 searchers are registered with the same ID, the last one overwrites the first one", () => {
|
it("if 2 searchers are registered with the same ID, the last one overwrites the first one", () => {
|
||||||
const echoSearcherFake: SpotlightSearcher = {
|
const echoSearcherFake: SpotlightSearcher = {
|
||||||
id: "echo-searcher",
|
searcherID: "echo-searcher",
|
||||||
sectionTitle: "Echo Searcher",
|
searcherSectionTitle: "Echo Searcher",
|
||||||
createSearchSession: () => {
|
createSearchSession: () => {
|
||||||
throw new Error("not implemented")
|
throw new Error("not implemented")
|
||||||
},
|
},
|
||||||
@@ -123,8 +123,8 @@ describe("SpotlightService", () => {
|
|||||||
const notifiedFn = vi.fn()
|
const notifiedFn = vi.fn()
|
||||||
|
|
||||||
const sampleSearcher: SpotlightSearcher = {
|
const sampleSearcher: SpotlightSearcher = {
|
||||||
id: "searcher",
|
searcherID: "searcher",
|
||||||
sectionTitle: "Searcher",
|
searcherSectionTitle: "Searcher",
|
||||||
createSearchSession: (query) => {
|
createSearchSession: (query) => {
|
||||||
const stop = watch(query, notifiedFn, { immediate: true })
|
const stop = watch(query, notifiedFn, { immediate: true })
|
||||||
|
|
||||||
@@ -207,8 +207,8 @@ describe("SpotlightService", () => {
|
|||||||
const container = new TestContainer()
|
const container = new TestContainer()
|
||||||
|
|
||||||
const loadingSearcher: SpotlightSearcher = {
|
const loadingSearcher: SpotlightSearcher = {
|
||||||
id: "loading-searcher",
|
searcherID: "loading-searcher",
|
||||||
sectionTitle: "Loading Searcher",
|
searcherSectionTitle: "Loading Searcher",
|
||||||
createSearchSession: () => {
|
createSearchSession: () => {
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const results = ref<SpotlightSearcherResult[]>([])
|
const results = ref<SpotlightSearcherResult[]>([])
|
||||||
@@ -261,8 +261,8 @@ describe("SpotlightService", () => {
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
const loadingSearcher: SpotlightSearcher = {
|
const loadingSearcher: SpotlightSearcher = {
|
||||||
id: "loading-searcher",
|
searcherID: "loading-searcher",
|
||||||
sectionTitle: "Loading Searcher",
|
searcherSectionTitle: "Loading Searcher",
|
||||||
createSearchSession: () => {
|
createSearchSession: () => {
|
||||||
return [
|
return [
|
||||||
computed<SpotlightSearcherSessionState>(() => ({
|
computed<SpotlightSearcherSessionState>(() => ({
|
||||||
@@ -351,8 +351,8 @@ describe("SpotlightService", () => {
|
|||||||
const disposeFn = vi.fn()
|
const disposeFn = vi.fn()
|
||||||
|
|
||||||
const testSearcher: SpotlightSearcher = {
|
const testSearcher: SpotlightSearcher = {
|
||||||
id: "test-searcher",
|
searcherID: "test-searcher",
|
||||||
sectionTitle: "Test Searcher",
|
searcherSectionTitle: "Test Searcher",
|
||||||
createSearchSession: () => {
|
createSearchSession: () => {
|
||||||
return [
|
return [
|
||||||
computed<SpotlightSearcherSessionState>(() => ({
|
computed<SpotlightSearcherSessionState>(() => ({
|
||||||
@@ -384,8 +384,8 @@ describe("SpotlightService", () => {
|
|||||||
const notifiedFn = vi.fn()
|
const notifiedFn = vi.fn()
|
||||||
|
|
||||||
const testSearcher: SpotlightSearcher = {
|
const testSearcher: SpotlightSearcher = {
|
||||||
id: "test-searcher",
|
searcherID: "test-searcher",
|
||||||
sectionTitle: "Test Searcher",
|
searcherSectionTitle: "Test Searcher",
|
||||||
createSearchSession: (query) => {
|
createSearchSession: (query) => {
|
||||||
watch(query, notifiedFn, { immediate: true })
|
watch(query, notifiedFn, { immediate: true })
|
||||||
|
|
||||||
@@ -427,8 +427,8 @@ describe("SpotlightService", () => {
|
|||||||
const onResultSelectFn = vi.fn()
|
const onResultSelectFn = vi.fn()
|
||||||
|
|
||||||
const testSearcher: SpotlightSearcher = {
|
const testSearcher: SpotlightSearcher = {
|
||||||
id: "test-searcher",
|
searcherID: "test-searcher",
|
||||||
sectionTitle: "Test Searcher",
|
searcherSectionTitle: "Test Searcher",
|
||||||
createSearchSession: () => {
|
createSearchSession: () => {
|
||||||
return [
|
return [
|
||||||
computed<SpotlightSearcherSessionState>(() => ({
|
computed<SpotlightSearcherSessionState>(() => ({
|
||||||
|
|||||||
@@ -1,32 +1,83 @@
|
|||||||
import { Service } from "dioc"
|
import { Service } from "dioc"
|
||||||
import { watch, type Ref, ref, reactive, effectScope, Component } from "vue"
|
import { watch, type Ref, ref, reactive, effectScope, Component } from "vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how to render the entry text in a Spotlight Search Result
|
||||||
|
*/
|
||||||
export type SpotlightResultTextType<T extends object | Component = never> =
|
export type SpotlightResultTextType<T extends object | Component = never> =
|
||||||
| { type: "text"; text: string[] | string }
|
| {
|
||||||
|
type: "text"
|
||||||
|
/**
|
||||||
|
* The text to render. Passing an array of strings will render each string separated by a chevron
|
||||||
|
*/
|
||||||
|
text: string[] | string
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "custom"
|
type: "custom"
|
||||||
|
/**
|
||||||
|
* The component to render in place of the text
|
||||||
|
*/
|
||||||
component: T
|
component: T
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The props to pass to the component
|
||||||
|
*/
|
||||||
componentProps: T extends Component<infer Props> ? Props : never
|
componentProps: T extends Component<infer Props> ? Props : never
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines info about a spotlight light so the UI can render it
|
||||||
|
*/
|
||||||
export type SpotlightSearcherResult = {
|
export type SpotlightSearcherResult = {
|
||||||
|
/**
|
||||||
|
* The unique ID of the result
|
||||||
|
*/
|
||||||
id: string
|
id: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text to render in the result
|
||||||
|
*/
|
||||||
text: SpotlightResultTextType<any>
|
text: SpotlightResultTextType<any>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The icon to render as the signifier of the result
|
||||||
|
*/
|
||||||
icon: object | Component
|
icon: object | Component
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The score of the result, the UI should sort the results by this
|
||||||
|
*/
|
||||||
score: number
|
score: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional metadata about the result
|
||||||
|
*/
|
||||||
meta?: {
|
meta?: {
|
||||||
|
/**
|
||||||
|
* The keyboard shortcut to trigger the result
|
||||||
|
*/
|
||||||
keyboardShortcut?: string[]
|
keyboardShortcut?: string[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the state of a searcher during a spotlight search session
|
||||||
|
*/
|
||||||
export type SpotlightSearcherSessionState = {
|
export type SpotlightSearcherSessionState = {
|
||||||
|
/**
|
||||||
|
* Whether the searcher is currently loading results
|
||||||
|
*/
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The results presented by the corresponding searcher in a session
|
||||||
|
*/
|
||||||
results: SpotlightSearcherResult[]
|
results: SpotlightSearcherResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpotlightSearcher {
|
export interface SpotlightSearcher {
|
||||||
id: string
|
searcherID: string
|
||||||
sectionTitle: string
|
searcherSectionTitle: string
|
||||||
|
|
||||||
createSearchSession(
|
createSearchSession(
|
||||||
query: Readonly<Ref<string>>
|
query: Readonly<Ref<string>>
|
||||||
@@ -35,16 +86,29 @@ export interface SpotlightSearcher {
|
|||||||
onResultSelect(result: SpotlightSearcherResult): void
|
onResultSelect(result: SpotlightSearcherResult): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the state of a searcher during a search session that
|
||||||
|
* is exposed to through the spotlight service
|
||||||
|
*/
|
||||||
|
export type SpotlightSearchSearcherState = {
|
||||||
|
title: string
|
||||||
|
avgScore: number
|
||||||
|
results: SpotlightSearcherResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the state of a spotlight search session
|
||||||
|
*/
|
||||||
export type SpotlightSearchState = {
|
export type SpotlightSearchState = {
|
||||||
|
/**
|
||||||
|
* Whether any of the searchers are currently loading results
|
||||||
|
*/
|
||||||
loading: boolean
|
loading: boolean
|
||||||
results: Record<
|
|
||||||
string,
|
/**
|
||||||
{
|
* The results presented by the corresponding searcher in a session
|
||||||
title: string
|
*/
|
||||||
avgScore: number
|
results: Record<string, SpotlightSearchSearcherState>
|
||||||
results: SpotlightSearcherResult[]
|
|
||||||
}
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SpotlightService extends Service {
|
export class SpotlightService extends Service {
|
||||||
@@ -52,14 +116,26 @@ export class SpotlightService extends Service {
|
|||||||
|
|
||||||
private searchers: Map<string, SpotlightSearcher> = new Map()
|
private searchers: Map<string, SpotlightSearcher> = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a searcher with the spotlight service
|
||||||
|
* @param searcher The searcher instance to register
|
||||||
|
*/
|
||||||
public registerSearcher(searcher: SpotlightSearcher) {
|
public registerSearcher(searcher: SpotlightSearcher) {
|
||||||
this.searchers.set(searcher.id, searcher)
|
this.searchers.set(searcher.searcherID, searcher)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an iterator over all registered searchers and their IDs
|
||||||
|
*/
|
||||||
public getAllSearchers(): IterableIterator<[string, SpotlightSearcher]> {
|
public getAllSearchers(): IterableIterator<[string, SpotlightSearcher]> {
|
||||||
return this.searchers.entries()
|
return this.searchers.entries()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new search session
|
||||||
|
* @param query A ref to the query to search for, updating this ref will notify the searchers about the change
|
||||||
|
* @returns A ref to the state of the search session and a function to end the session
|
||||||
|
*/
|
||||||
public createSearchSession(
|
public createSearchSession(
|
||||||
query: Ref<string>
|
query: Ref<string>
|
||||||
): [Ref<SpotlightSearchState>, () => void] {
|
): [Ref<SpotlightSearchState>, () => void] {
|
||||||
@@ -83,16 +159,16 @@ export class SpotlightService extends Service {
|
|||||||
state,
|
state,
|
||||||
(newState) => {
|
(newState) => {
|
||||||
if (newState.loading) {
|
if (newState.loading) {
|
||||||
loadingSearchers.add(searcher.id)
|
loadingSearchers.add(searcher.searcherID)
|
||||||
} else {
|
} else {
|
||||||
loadingSearchers.delete(searcher.id)
|
loadingSearchers.delete(searcher.searcherID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newState.results.length === 0) {
|
if (newState.results.length === 0) {
|
||||||
delete resultObj.value.results[searcher.id]
|
delete resultObj.value.results[searcher.searcherID]
|
||||||
} else {
|
} else {
|
||||||
resultObj.value.results[searcher.id] = {
|
resultObj.value.results[searcher.searcherID] = {
|
||||||
title: searcher.sectionTitle,
|
title: searcher.searcherSectionTitle,
|
||||||
avgScore:
|
avgScore:
|
||||||
newState.results.reduce((acc, x) => acc + x.score, 0) /
|
newState.results.reduce((acc, x) => acc + x.score, 0) /
|
||||||
newState.results.length,
|
newState.results.length,
|
||||||
@@ -126,6 +202,11 @@ export class SpotlightService extends Service {
|
|||||||
return [resultObj, onSearchEnd]
|
return [resultObj, onSearchEnd]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects a search result. To be called when the user selects a result
|
||||||
|
* @param searcherID The ID of the searcher that the result belongs to
|
||||||
|
* @param result The resuklt to look at
|
||||||
|
*/
|
||||||
public selectSearchResult(
|
public selectSearchResult(
|
||||||
searcherID: string,
|
searcherID: string,
|
||||||
result: SpotlightSearcherResult
|
result: SpotlightSearcherResult
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { beforeEach, describe, it, expect, vi } from "vitest"
|
||||||
|
import { TestContainer } from "dioc/testing"
|
||||||
|
import { UserSpotlightSearcherService } from "../user.searcher"
|
||||||
|
import { nextTick, ref } from "vue"
|
||||||
|
|
||||||
|
async function flushPromises() {
|
||||||
|
return await new Promise((r) => setTimeout(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("~/modules/i18n", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
getI18n: () => (x: string) => x,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const actionsMock = vi.hoisted(() => ({
|
||||||
|
value: ["user.login"],
|
||||||
|
invokeAction: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("~/helpers/actions", async () => {
|
||||||
|
const { BehaviorSubject }: any = await vi.importActual("rxjs")
|
||||||
|
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
activeActions$: new BehaviorSubject(actionsMock.value),
|
||||||
|
invokeAction: actionsMock.invokeAction,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("UserSearcher", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
let x = actionsMock.value.pop()
|
||||||
|
while (x) {
|
||||||
|
x = actionsMock.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsMock.invokeAction.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("if login action is available, the search result should have the login result", async () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
actionsMock.value.push("user.login")
|
||||||
|
const user = container.bind(UserSpotlightSearcherService)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("log")
|
||||||
|
const result = user.createSearchSession(query)[0]
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(result.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "login",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("if login action is not available, the search result should not have the login result", async () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const user = container.bind(UserSpotlightSearcherService)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("log")
|
||||||
|
const result = user.createSearchSession(query)[0]
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(result.value.results).not.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "login",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("if logout action is available, the search result should have the logout result", async () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
actionsMock.value.push("user.logout")
|
||||||
|
const user = container.bind(UserSpotlightSearcherService)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("log")
|
||||||
|
const result = user.createSearchSession(query)[0]
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(result.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "logout",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("if logout action is not available, the search result should not have the logout result", async () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const user = container.bind(UserSpotlightSearcherService)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("log")
|
||||||
|
const result = user.createSearchSession(query)[0]
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(result.value.results).not.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "logout",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("if login action and logout action are available, the search result should have both results", async () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
actionsMock.value.push("user.login", "user.logout")
|
||||||
|
const user = container.bind(UserSpotlightSearcherService)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("log")
|
||||||
|
const result = user.createSearchSession(query)[0]
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(result.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "logout",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "login",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selecting the login event should invoke the login action", () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
actionsMock.value.push("user.login")
|
||||||
|
const user = container.bind(UserSpotlightSearcherService)
|
||||||
|
const query = ref("log")
|
||||||
|
|
||||||
|
user.createSearchSession(query)[0]
|
||||||
|
|
||||||
|
user.onDocSelected("login")
|
||||||
|
|
||||||
|
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
|
||||||
|
expect(actionsMock.invokeAction).toHaveBeenCalledWith("user.login")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selecting the logout event should invoke the logout action", () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
actionsMock.value.push("user.logout")
|
||||||
|
const user = container.bind(UserSpotlightSearcherService)
|
||||||
|
const query = ref("log")
|
||||||
|
|
||||||
|
user.createSearchSession(query)[0]
|
||||||
|
|
||||||
|
user.onDocSelected("logout")
|
||||||
|
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
|
||||||
|
expect(actionsMock.invokeAction).toHaveBeenCalledWith("user.logout")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selecting an invalid event should not invoke any action", () => {
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
actionsMock.value.push("user.logout")
|
||||||
|
const user = container.bind(UserSpotlightSearcherService)
|
||||||
|
const query = ref("log")
|
||||||
|
|
||||||
|
user.createSearchSession(query)[0]
|
||||||
|
|
||||||
|
user.onDocSelected("bla")
|
||||||
|
expect(actionsMock.invokeAction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest"
|
||||||
|
import {
|
||||||
|
SearchResult,
|
||||||
|
StaticSpotlightSearcherService,
|
||||||
|
} from "../static.searcher"
|
||||||
|
import { nextTick, reactive, ref } from "vue"
|
||||||
|
import { SpotlightSearcherResult } from "../../.."
|
||||||
|
import { TestContainer } from "dioc/testing"
|
||||||
|
|
||||||
|
async function flushPromises() {
|
||||||
|
return await new Promise((r) => setTimeout(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StaticSpotlightSearcherService", () => {
|
||||||
|
it("returns docs that have excludeFromSearch set to false", async () => {
|
||||||
|
class TestSearcherService extends StaticSpotlightSearcherService<
|
||||||
|
Record<string, any>
|
||||||
|
> {
|
||||||
|
public static readonly ID = "TEST_SEARCHER_SERVICE"
|
||||||
|
|
||||||
|
public readonly searcherID = "test"
|
||||||
|
public searcherSectionTitle = "test"
|
||||||
|
|
||||||
|
private documents: Record<string, any> = reactive({
|
||||||
|
login: {
|
||||||
|
text: "Login",
|
||||||
|
excludeFromSearch: false,
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
text: "Logout",
|
||||||
|
excludeFromSearch: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
searchFields: ["text"],
|
||||||
|
fieldWeights: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setDocuments(this.documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSearcherResultForSearchResult(
|
||||||
|
result: SearchResult<Record<string, any>>
|
||||||
|
): SpotlightSearcherResult {
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
icon: {},
|
||||||
|
text: { type: "text", text: result.doc.text },
|
||||||
|
score: result.score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDocSelected(): void {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const service = container.bind(TestSearcherService)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("log")
|
||||||
|
|
||||||
|
const [results] = service.createSearchSession(query)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(results.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "login",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("doesn't return docs that have excludeFromSearch set to true", async () => {
|
||||||
|
class TestSearcherServiceB extends StaticSpotlightSearcherService<
|
||||||
|
Record<string, any>
|
||||||
|
> {
|
||||||
|
public static readonly ID = "TEST_SEARCHER_SERVICE_B"
|
||||||
|
|
||||||
|
public readonly searcherID = "test"
|
||||||
|
public searcherSectionTitle = "test"
|
||||||
|
|
||||||
|
private documents: Record<string, any> = reactive({
|
||||||
|
login: {
|
||||||
|
text: "Login",
|
||||||
|
excludeFromSearch: true,
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
text: "Logout",
|
||||||
|
excludeFromSearch: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
searchFields: ["text"],
|
||||||
|
fieldWeights: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setDocuments(this.documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSearcherResultForSearchResult(
|
||||||
|
result: SearchResult<Record<string, any>>
|
||||||
|
): SpotlightSearcherResult {
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
icon: {},
|
||||||
|
text: { type: "text", text: result.doc.text },
|
||||||
|
score: result.score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDocSelected(): void {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const service = container.bind(TestSearcherServiceB)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("log")
|
||||||
|
const [results] = service.createSearchSession(query)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(results.value.results).not.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "login",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(results.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "logout",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns docs that have excludeFromSearch set to undefined", async () => {
|
||||||
|
class TestSearcherServiceC extends StaticSpotlightSearcherService<
|
||||||
|
Record<string, any>
|
||||||
|
> {
|
||||||
|
public static readonly ID = "TEST_SEARCHER_SERVICE_C"
|
||||||
|
|
||||||
|
public readonly searcherID = "test"
|
||||||
|
public searcherSectionTitle = "test"
|
||||||
|
|
||||||
|
private documents: Record<string, any> = reactive({
|
||||||
|
login: {
|
||||||
|
text: "Login",
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
text: "Logout",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
searchFields: ["text"],
|
||||||
|
fieldWeights: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setDocuments(this.documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSearcherResultForSearchResult(
|
||||||
|
result: SearchResult<Record<string, any>>
|
||||||
|
): SpotlightSearcherResult {
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
icon: {},
|
||||||
|
text: { type: "text", text: result.doc.text },
|
||||||
|
score: result.score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDocSelected(): void {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const service = container.bind(TestSearcherServiceC)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("log")
|
||||||
|
const [results] = service.createSearchSession(query)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(results.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "login",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(results.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "logout",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("onDocSelected is called with a valid doc id and doc when onResultSelect is called", async () => {
|
||||||
|
class TestSearcherServiceD extends StaticSpotlightSearcherService<
|
||||||
|
Record<string, any>
|
||||||
|
> {
|
||||||
|
public static readonly ID = "TEST_SEARCHER_SERVICE_D"
|
||||||
|
|
||||||
|
public readonly searcherID = "test"
|
||||||
|
public searcherSectionTitle = "test"
|
||||||
|
|
||||||
|
public documents: Record<string, any> = reactive({
|
||||||
|
login: {
|
||||||
|
text: "Login",
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
text: "Logout",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
searchFields: ["text"],
|
||||||
|
fieldWeights: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setDocuments(this.documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSearcherResultForSearchResult(
|
||||||
|
result: SearchResult<Record<string, any>>
|
||||||
|
): SpotlightSearcherResult {
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
icon: {},
|
||||||
|
text: { type: "text", text: result.doc.text },
|
||||||
|
score: result.score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDocSelected = vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const service = container.bind(TestSearcherServiceD)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("log")
|
||||||
|
const [results] = service.createSearchSession(query)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const doc = results.value.results[0]
|
||||||
|
|
||||||
|
service.onResultSelect(doc)
|
||||||
|
|
||||||
|
expect(service.onDocSelected).toHaveBeenCalledOnce()
|
||||||
|
expect(service.onDocSelected).toHaveBeenCalledWith(
|
||||||
|
doc.id,
|
||||||
|
service.documents["login"]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns search results from entries as specified by getSearcherResultForSearchResult", async () => {
|
||||||
|
class TestSearcherServiceE extends StaticSpotlightSearcherService<
|
||||||
|
Record<string, any>
|
||||||
|
> {
|
||||||
|
public static readonly ID = "TEST_SEARCHER_SERVICE_E"
|
||||||
|
|
||||||
|
public readonly searcherID = "test"
|
||||||
|
public searcherSectionTitle = "test"
|
||||||
|
|
||||||
|
public documents: Record<string, any> = reactive({
|
||||||
|
login: {
|
||||||
|
text: "Login",
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
text: "Logout",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
searchFields: ["text"],
|
||||||
|
fieldWeights: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setDocuments(this.documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSearcherResultForSearchResult(
|
||||||
|
result: SearchResult<Record<string, any>>
|
||||||
|
): SpotlightSearcherResult {
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
icon: {},
|
||||||
|
text: { type: "text", text: result.doc.text.toUpperCase() },
|
||||||
|
score: result.score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDocSelected(): void {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const service = container.bind(TestSearcherServiceE)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("log")
|
||||||
|
const [results] = service.createSearchSession(query)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(results.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "login",
|
||||||
|
text: { type: "text", text: "LOGIN" },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(results.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "logout",
|
||||||
|
text: { type: "text", text: "LOGOUT" },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("indexes the documents by the 'searchFields' property and obeys multiple index fields", async () => {
|
||||||
|
class TestSearcherServiceF extends StaticSpotlightSearcherService<
|
||||||
|
Record<string, any>
|
||||||
|
> {
|
||||||
|
public static readonly ID = "TEST_SEARCHER_SERVICE_F"
|
||||||
|
|
||||||
|
public readonly searcherID = "test"
|
||||||
|
public searcherSectionTitle = "test"
|
||||||
|
|
||||||
|
public documents: Record<string, any> = reactive({
|
||||||
|
login: {
|
||||||
|
text: "Login",
|
||||||
|
alternate: ["sign in"],
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
text: "Logout",
|
||||||
|
alternate: ["sign out"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
searchFields: ["text", "alternate"],
|
||||||
|
fieldWeights: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setDocuments(this.documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSearcherResultForSearchResult(
|
||||||
|
result: SearchResult<Record<string, any>>
|
||||||
|
): SpotlightSearcherResult {
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
icon: {},
|
||||||
|
text: { type: "text", text: result.doc.text },
|
||||||
|
score: result.score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDocSelected(): void {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = new TestContainer()
|
||||||
|
|
||||||
|
const service = container.bind(TestSearcherServiceF)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const query = ref("sign")
|
||||||
|
const [results] = service.createSearchSession(query)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(results.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "login",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(results.value.results).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "logout",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,47 +3,99 @@ import {
|
|||||||
type SpotlightSearcher,
|
type SpotlightSearcher,
|
||||||
type SpotlightSearcherResult,
|
type SpotlightSearcherResult,
|
||||||
type SpotlightSearcherSessionState,
|
type SpotlightSearcherSessionState,
|
||||||
} from "../"
|
} from "../.."
|
||||||
import MiniSearch, { type SearchResult } from "minisearch"
|
import MiniSearch from "minisearch"
|
||||||
import { Ref, computed, effectScope, ref, watch } from "vue"
|
import { Ref, computed, effectScope, ref, watch } from "vue"
|
||||||
import { MaybeRef, resolveUnref } from "@vueuse/core"
|
import { resolveUnref } from "@vueuse/core"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a search result and additional metadata returned by a StaticSpotlightSearcher
|
||||||
|
*/
|
||||||
|
export type SearchResult<Doc extends object & { excludeFromSearch?: boolean }> =
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
score: number
|
||||||
|
doc: Doc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for StaticSpotlightSearcher initialization
|
||||||
|
*/
|
||||||
|
export type StaticSpotlightSearcherOptions<
|
||||||
|
Doc extends object & { excludeFromSearch?: boolean }
|
||||||
|
> = {
|
||||||
|
/**
|
||||||
|
* The array of field names in the given documents to search against
|
||||||
|
*/
|
||||||
|
searchFields: Array<keyof Doc>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The weights to apply to each field in the search, this allows for certain
|
||||||
|
* fields to have more priority than others in the search and update the score
|
||||||
|
*/
|
||||||
|
fieldWeights?: Partial<Record<keyof Doc, number>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much the score should be boosted if the search matched fuzzily.
|
||||||
|
* Increasing this value generally makes the search ignore typos, but reduces performance
|
||||||
|
*/
|
||||||
|
fuzzyWeight?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much the score should be boosted if the search matched by prefix.
|
||||||
|
* For e.g, when searching for "hop", "hoppscotch" would match by prefix.
|
||||||
|
*/
|
||||||
|
prefixWeight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A base class for SpotlightSearcherServices that have a static set of documents
|
||||||
|
* that can optionally be toggled against (via the `excludeFromSearch` property in the Doc)
|
||||||
|
*/
|
||||||
export abstract class StaticSpotlightSearcherService<
|
export abstract class StaticSpotlightSearcherService<
|
||||||
Doc extends Record<string, unknown> & {
|
Doc extends object & { excludeFromSearch?: boolean }
|
||||||
excludeFromSearch?: boolean
|
|
||||||
} & Record<DocFields[number], string>,
|
|
||||||
DocFields extends Array<keyof Doc>
|
|
||||||
>
|
>
|
||||||
extends Service
|
extends Service
|
||||||
implements SpotlightSearcher
|
implements SpotlightSearcher
|
||||||
{
|
{
|
||||||
public abstract readonly id: string
|
public abstract readonly searcherID: string
|
||||||
public abstract readonly sectionTitle: string
|
public abstract readonly searcherSectionTitle: string
|
||||||
|
|
||||||
private minisearch: MiniSearch
|
private minisearch: MiniSearch
|
||||||
|
|
||||||
private loading = ref(false)
|
private loading = ref(false)
|
||||||
|
|
||||||
constructor(
|
private _documents: Record<string, Doc> = {}
|
||||||
private documents: MaybeRef<Record<string, Doc>>,
|
|
||||||
searchFields: Array<keyof Doc>,
|
constructor(private opts: StaticSpotlightSearcherOptions<Doc>) {
|
||||||
resultFields: DocFields
|
|
||||||
) {
|
|
||||||
super()
|
super()
|
||||||
|
|
||||||
this.minisearch = new MiniSearch({
|
this.minisearch = new MiniSearch({
|
||||||
fields: searchFields as string[],
|
fields: opts.searchFields as string[],
|
||||||
storeFields: resultFields as string[],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.addDocsToSearchIndex(resolveUnref(documents))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addDocsToSearchIndex(docs: Record<string, Doc>) {
|
/**
|
||||||
|
* Sets the documents to search against.
|
||||||
|
* NOTE: We generally expect this function to only be called once and we expect
|
||||||
|
* the documents to not change generally. You can pass a reactive object, if you want to toggle
|
||||||
|
* states if you want to.
|
||||||
|
* @param docs The documents to search against, this is an object, with the key being the document ID
|
||||||
|
*/
|
||||||
|
protected setDocuments(docs: Record<string, Doc>) {
|
||||||
|
this._documents = docs
|
||||||
|
|
||||||
|
this.addDocsToSearchIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addDocsToSearchIndex() {
|
||||||
this.loading.value = true
|
this.loading.value = true
|
||||||
|
|
||||||
|
this.minisearch.removeAll()
|
||||||
|
this.minisearch.vacuum()
|
||||||
|
|
||||||
await this.minisearch.addAllAsync(
|
await this.minisearch.addAllAsync(
|
||||||
Object.entries(docs).map(([id, doc]) => ({
|
Object.entries(this._documents).map(([id, doc]) => ({
|
||||||
id,
|
id,
|
||||||
...doc,
|
...doc,
|
||||||
}))
|
}))
|
||||||
@@ -52,8 +104,12 @@ export abstract class StaticSpotlightSearcherService<
|
|||||||
this.loading.value = false
|
this.loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies how to convert a document into the Spotlight entry format
|
||||||
|
* @param result The search result to convert
|
||||||
|
*/
|
||||||
protected abstract getSearcherResultForSearchResult(
|
protected abstract getSearcherResultForSearchResult(
|
||||||
result: Pick<Doc & SearchResult, DocFields[number] | "id" | "score">
|
result: SearchResult<Doc>
|
||||||
): SpotlightSearcherResult
|
): SpotlightSearcherResult
|
||||||
|
|
||||||
public createSearchSession(
|
public createSearchSession(
|
||||||
@@ -70,26 +126,30 @@ export abstract class StaticSpotlightSearcherService<
|
|||||||
|
|
||||||
scopeHandle.run(() => {
|
scopeHandle.run(() => {
|
||||||
watch(
|
watch(
|
||||||
[query, () => resolveUnref(this.documents)],
|
[query, () => this._documents],
|
||||||
([query, docs]) => {
|
([query, docs]) => {
|
||||||
const searchResults = this.minisearch.search(query, {
|
const searchResults = this.minisearch.search(query, {
|
||||||
prefix: true,
|
prefix: true,
|
||||||
fuzzy: 0.2,
|
boost: (this.opts.fieldWeights as any) ?? {},
|
||||||
weights: {
|
weights: {
|
||||||
fuzzy: 0.2,
|
fuzzy: this.opts.fuzzyWeight ?? 0.2,
|
||||||
prefix: 0.6,
|
prefix: this.opts.prefixWeight ?? 0.6,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
results.value = searchResults
|
results.value = searchResults
|
||||||
.filter(
|
.filter(
|
||||||
(result) =>
|
(result) =>
|
||||||
docs[result.id].excludeFromSearch === undefined ||
|
this._documents[result.id].excludeFromSearch === undefined ||
|
||||||
docs[result.id].excludeFromSearch === false
|
this._documents[result.id].excludeFromSearch === false
|
||||||
)
|
|
||||||
.map((result) =>
|
|
||||||
this.getSearcherResultForSearchResult(result as any)
|
|
||||||
)
|
)
|
||||||
|
.map((result) => {
|
||||||
|
return this.getSearcherResultForSearchResult({
|
||||||
|
id: result.id,
|
||||||
|
score: result.score,
|
||||||
|
doc: docs[result.id],
|
||||||
|
})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -102,5 +162,14 @@ export abstract class StaticSpotlightSearcherService<
|
|||||||
return [resultObj, onSessionEnd]
|
return [resultObj, onSessionEnd]
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract onResultSelect(result: SpotlightSearcherResult): void
|
/**
|
||||||
|
* Called when a document is selected from the search results
|
||||||
|
* @param id The ID of the document selected
|
||||||
|
* @param doc The document information of the document selected
|
||||||
|
*/
|
||||||
|
public abstract onDocSelected(id: string, doc: Doc): void
|
||||||
|
|
||||||
|
public onResultSelect(result: SpotlightSearcherResult): void {
|
||||||
|
this.onDocSelected(result.id, resolveUnref(this._documents)[result.id])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import { useStreamStatic } from "~/composables/stream"
|
|||||||
import { activeActions$, invokeAction } from "~/helpers/actions"
|
import { activeActions$, invokeAction } from "~/helpers/actions"
|
||||||
import { map } from "rxjs/operators"
|
import { map } from "rxjs/operators"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This searcher is responsible for searching through the history.
|
||||||
|
* It also provides actions to clear the history.
|
||||||
|
*
|
||||||
|
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
|
||||||
|
*/
|
||||||
export class HistorySpotlightSearcherService
|
export class HistorySpotlightSearcherService
|
||||||
extends Service
|
extends Service
|
||||||
implements SpotlightSearcher
|
implements SpotlightSearcher
|
||||||
@@ -28,8 +34,8 @@ export class HistorySpotlightSearcherService
|
|||||||
|
|
||||||
private t = getI18n()
|
private t = getI18n()
|
||||||
|
|
||||||
public id = "history"
|
public searcherID = "history"
|
||||||
public sectionTitle = this.t("tab.history")
|
public searcherSectionTitle = this.t("tab.history")
|
||||||
|
|
||||||
private readonly spotlight = this.bind(SpotlightService)
|
private readonly spotlight = this.bind(SpotlightService)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { SpotlightSearcherResult, SpotlightService } from ".."
|
||||||
|
import {
|
||||||
|
SearchResult,
|
||||||
|
StaticSpotlightSearcherService,
|
||||||
|
} from "./base/static.searcher"
|
||||||
|
import { getI18n } from "~/modules/i18n"
|
||||||
|
import { Component, computed, markRaw, reactive } from "vue"
|
||||||
|
import { useStreamStatic } from "~/composables/stream"
|
||||||
|
import IconLogin from "~icons/lucide/log-in"
|
||||||
|
import IconLogOut from "~icons/lucide/log-out"
|
||||||
|
import { activeActions$, invokeAction } from "~/helpers/actions"
|
||||||
|
|
||||||
|
type Doc = {
|
||||||
|
text: string
|
||||||
|
excludeFromSearch?: boolean
|
||||||
|
alternates: string[]
|
||||||
|
icon: object | Component
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This searcher is responsible for providing user related actions on the spotlight results.
|
||||||
|
*
|
||||||
|
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
|
||||||
|
*/
|
||||||
|
export class UserSpotlightSearcherService extends StaticSpotlightSearcherService<Doc> {
|
||||||
|
public static readonly ID = "USER_SPOTLIGHT_SEARCHER_SERVICE"
|
||||||
|
|
||||||
|
private t = getI18n()
|
||||||
|
|
||||||
|
public readonly searcherID = "user"
|
||||||
|
public searcherSectionTitle = this.t("spotlight.section.user")
|
||||||
|
|
||||||
|
private readonly spotlight = this.bind(SpotlightService)
|
||||||
|
|
||||||
|
private activeActions = useStreamStatic(activeActions$, [], () => {
|
||||||
|
/* noop */
|
||||||
|
})[0]
|
||||||
|
|
||||||
|
private hasLoginAction = computed(() =>
|
||||||
|
this.activeActions.value.includes("user.login")
|
||||||
|
)
|
||||||
|
|
||||||
|
private hasLogoutAction = computed(() =>
|
||||||
|
this.activeActions.value.includes("user.logout")
|
||||||
|
)
|
||||||
|
|
||||||
|
private documents: Record<string, Doc> = reactive({
|
||||||
|
login: {
|
||||||
|
text: this.t("auth.login"),
|
||||||
|
excludeFromSearch: computed(() => !this.hasLoginAction.value),
|
||||||
|
alternates: ["sign in"],
|
||||||
|
icon: markRaw(IconLogin),
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
text: this.t("auth.logout"),
|
||||||
|
excludeFromSearch: computed(() => !this.hasLogoutAction.value),
|
||||||
|
alternates: ["sign out"],
|
||||||
|
icon: markRaw(IconLogOut),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
searchFields: ["text", "alternates"],
|
||||||
|
fieldWeights: {
|
||||||
|
text: 2,
|
||||||
|
alternates: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setDocuments(this.documents)
|
||||||
|
this.spotlight.registerSearcher(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSearcherResultForSearchResult(
|
||||||
|
result: SearchResult<Doc>
|
||||||
|
): SpotlightSearcherResult {
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
icon: result.doc.icon,
|
||||||
|
text: { type: "text", text: result.doc.text },
|
||||||
|
score: result.score,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDocSelected(id: string): void {
|
||||||
|
switch (id) {
|
||||||
|
case "login":
|
||||||
|
invokeAction("user.login")
|
||||||
|
break
|
||||||
|
case "logout":
|
||||||
|
invokeAction("user.logout")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user