feat: revamped spotlight (#3171)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
64
packages/hoppscotch-common/src/services/debug.service.ts
Normal file
64
packages/hoppscotch-common/src/services/debug.service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Service } from "dioc"
|
||||
|
||||
/**
|
||||
* This service provice debug utilities for the application and is
|
||||
* supposed to be used only in development.
|
||||
*
|
||||
* This service logs events from the container and also events
|
||||
* from all the services that are bound to the container.
|
||||
*
|
||||
* This service injects couple of utilities into the global scope:
|
||||
* - `_getService(id: string): Service | undefined` - Returns the service instance with the given ID or undefined.
|
||||
* - `_getBoundServiceIDs(): string[]` - Returns the IDs of all the bound services.
|
||||
*/
|
||||
export class DebugService extends Service {
|
||||
public static readonly ID = "DEBUG_SERVICE"
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
console.log("DebugService is initialized...")
|
||||
|
||||
const container = this.getContainer()
|
||||
|
||||
// Log container events
|
||||
container.getEventStream().subscribe((event) => {
|
||||
if (event.type === "SERVICE_BIND") {
|
||||
console.log(
|
||||
"[CONTAINER] Service Bind:",
|
||||
event.bounderID ?? "<CONTAINER>",
|
||||
"->",
|
||||
event.boundeeID
|
||||
)
|
||||
} else if (event.type === "SERVICE_INIT") {
|
||||
console.log("[CONTAINER] Service Init:", event.serviceID)
|
||||
|
||||
// Subscribe to event stream of the newly initialized service
|
||||
const service = container.getBoundServiceWithID(event.serviceID)
|
||||
|
||||
service?.getEventStream().subscribe((ev: any) => {
|
||||
console.log(`[${event.serviceID}] Event:`, ev)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Subscribe to event stream of all already bound services (if any)
|
||||
for (const [id, service] of container.getBoundServices()) {
|
||||
service.getEventStream().subscribe((event: any) => {
|
||||
console.log(`[${id}]`, event)
|
||||
})
|
||||
}
|
||||
|
||||
// Inject debug utilities into the global scope
|
||||
;(window as any)._getService = this.getService.bind(this)
|
||||
;(window as any)._getBoundServiceIDs = this.getBoundServiceIDs.bind(this)
|
||||
}
|
||||
|
||||
private getBoundServiceIDs() {
|
||||
return Array.from(this.getContainer().getBoundServices()).map(([id]) => id)
|
||||
}
|
||||
|
||||
private getService(id: string) {
|
||||
return this.getContainer().getBoundServiceWithID(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
SpotlightSearcher,
|
||||
SpotlightSearcherSessionState,
|
||||
SpotlightSearcherResult,
|
||||
SpotlightService,
|
||||
} from "../"
|
||||
import { Ref, computed, nextTick, ref, watch } from "vue"
|
||||
import { TestContainer } from "dioc/testing"
|
||||
|
||||
const echoSearcher: SpotlightSearcher = {
|
||||
searcherID: "echo-searcher",
|
||||
searcherSectionTitle: "Echo Searcher",
|
||||
createSearchSession: (query: Readonly<Ref<string>>) => {
|
||||
// A basic searcher that returns the query string as the sole result
|
||||
const loading = ref(false)
|
||||
const results = ref<SpotlightSearcherResult[]>([])
|
||||
|
||||
watch(
|
||||
query,
|
||||
(query) => {
|
||||
loading.value = true
|
||||
|
||||
results.value = [
|
||||
{
|
||||
id: "searcher-a-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: query,
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
},
|
||||
]
|
||||
|
||||
loading.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const onSessionEnd = () => {
|
||||
/* noop */
|
||||
}
|
||||
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: results.value,
|
||||
})),
|
||||
onSessionEnd,
|
||||
]
|
||||
},
|
||||
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const emptySearcher: SpotlightSearcher = {
|
||||
searcherID: "empty-searcher",
|
||||
searcherSectionTitle: "Empty Searcher",
|
||||
createSearchSession: () => {
|
||||
const loading = ref(false)
|
||||
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: [],
|
||||
})),
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
describe("SpotlightService", () => {
|
||||
describe("registerSearcher", () => {
|
||||
it("registers a searcher with a given ID", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
|
||||
const [id, searcher] = spotlight.getAllSearchers().next().value
|
||||
|
||||
expect(id).toEqual("echo-searcher")
|
||||
expect(searcher).toBe(echoSearcher)
|
||||
})
|
||||
|
||||
it("if 2 searchers are registered with the same ID, the last one overwrites the first one", () => {
|
||||
const echoSearcherFake: SpotlightSearcher = {
|
||||
searcherID: "echo-searcher",
|
||||
searcherSectionTitle: "Echo Searcher",
|
||||
createSearchSession: () => {
|
||||
throw new Error("not implemented")
|
||||
},
|
||||
onResultSelect: () => {
|
||||
throw new Error("not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
spotlight.registerSearcher(echoSearcherFake)
|
||||
|
||||
const [id, searcher] = spotlight.getAllSearchers().next().value
|
||||
|
||||
expect(id).toEqual("echo-searcher")
|
||||
expect(searcher).toBe(echoSearcherFake)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createSearchSession", () => {
|
||||
it("when the source query changes, the searchers are notified", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const notifiedFn = vi.fn()
|
||||
|
||||
const sampleSearcher: SpotlightSearcher = {
|
||||
searcherID: "searcher",
|
||||
searcherSectionTitle: "Searcher",
|
||||
createSearchSession: (query) => {
|
||||
const stop = watch(query, notifiedFn, { immediate: true })
|
||||
|
||||
return [
|
||||
ref<SpotlightSearcherSessionState>({
|
||||
loading: false,
|
||||
results: [],
|
||||
}),
|
||||
() => {
|
||||
stop()
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(sampleSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
|
||||
const [, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
query.value = "test2"
|
||||
await nextTick()
|
||||
|
||||
expect(notifiedFn).toHaveBeenCalledTimes(2)
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when a searcher returns results, they are added to the results", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(session.value.results).toHaveProperty("echo-searcher")
|
||||
expect(session.value.results["echo-searcher"]).toEqual({
|
||||
title: "Echo Searcher",
|
||||
avgScore: 1,
|
||||
results: [
|
||||
{
|
||||
id: "searcher-a-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when a searcher does not return any results, they are not added to the results", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(emptySearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
expect(session.value.results).not.toHaveProperty("empty-searcher")
|
||||
expect(session.value.results).toEqual({})
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when any of the searchers report they are loading, the search session says it is loading", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const loadingSearcher: SpotlightSearcher = {
|
||||
searcherID: "loading-searcher",
|
||||
searcherSectionTitle: "Loading Searcher",
|
||||
createSearchSession: () => {
|
||||
const loading = ref(true)
|
||||
const results = ref<SpotlightSearcherResult[]>([])
|
||||
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: results.value,
|
||||
})),
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(loadingSearcher)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
expect(session.value.loading).toBe(true)
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when all of the searchers report they are not loading, the search session says it is not loading", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
spotlight.registerSearcher(emptySearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
expect(session.value.loading).toBe(false)
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when a searcher changes its loading state after a while, the search session state updates", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
const loadingSearcher: SpotlightSearcher = {
|
||||
searcherID: "loading-searcher",
|
||||
searcherSectionTitle: "Loading Searcher",
|
||||
createSearchSession: () => {
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: [],
|
||||
})),
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(loadingSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
expect(session.value.loading).toBe(true)
|
||||
|
||||
loading.value = false
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(session.value.loading).toBe(false)
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when the searcher updates its results, the search session state updates", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
expect(session.value.results).toHaveProperty("echo-searcher")
|
||||
expect(session.value.results["echo-searcher"]).toEqual({
|
||||
title: "Echo Searcher",
|
||||
avgScore: 1,
|
||||
results: [
|
||||
{
|
||||
id: "searcher-a-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
query.value = "test2"
|
||||
await nextTick()
|
||||
|
||||
expect(session.value.results).toHaveProperty("echo-searcher")
|
||||
expect(session.value.results["echo-searcher"]).toEqual({
|
||||
title: "Echo Searcher",
|
||||
avgScore: 1,
|
||||
results: [
|
||||
{
|
||||
id: "searcher-a-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test2",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when the returned dispose function is called, the searchers are notified", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const disposeFn = vi.fn()
|
||||
|
||||
const testSearcher: SpotlightSearcher = {
|
||||
searcherID: "test-searcher",
|
||||
searcherSectionTitle: "Test Searcher",
|
||||
createSearchSession: () => {
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: false,
|
||||
results: [],
|
||||
})),
|
||||
disposeFn,
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(testSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
dispose()
|
||||
|
||||
expect(disposeFn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("when the search session is disposed, changes to the query are not notified to the searchers", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const notifiedFn = vi.fn()
|
||||
|
||||
const testSearcher: SpotlightSearcher = {
|
||||
searcherID: "test-searcher",
|
||||
searcherSectionTitle: "Test Searcher",
|
||||
createSearchSession: (query) => {
|
||||
watch(query, notifiedFn, { immediate: true })
|
||||
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: false,
|
||||
results: [],
|
||||
})),
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(testSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
query.value = "test2"
|
||||
await nextTick()
|
||||
|
||||
expect(notifiedFn).toHaveBeenCalledTimes(2)
|
||||
|
||||
dispose()
|
||||
|
||||
query.value = "test3"
|
||||
await nextTick()
|
||||
|
||||
expect(notifiedFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
describe("selectSearchResult", () => {
|
||||
const onResultSelectFn = vi.fn()
|
||||
|
||||
const testSearcher: SpotlightSearcher = {
|
||||
searcherID: "test-searcher",
|
||||
searcherSectionTitle: "Test Searcher",
|
||||
createSearchSession: () => {
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: false,
|
||||
results: [],
|
||||
})),
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: onResultSelectFn,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onResultSelectFn.mockReset()
|
||||
})
|
||||
|
||||
it("does nothing if the searcherID is invalid", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(testSearcher)
|
||||
|
||||
spotlight.selectSearchResult("invalid-searcher-id", {
|
||||
id: "test-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
})
|
||||
|
||||
expect(onResultSelectFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("calls the correspondig searcher's onResultSelect method", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(testSearcher)
|
||||
|
||||
spotlight.selectSearchResult("test-searcher", {
|
||||
id: "test-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
})
|
||||
|
||||
expect(onResultSelectFn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("passes the correct information to the searcher's onResultSelect method", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(testSearcher)
|
||||
|
||||
spotlight.selectSearchResult("test-searcher", {
|
||||
id: "test-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
})
|
||||
|
||||
expect(onResultSelectFn).toHaveBeenCalledWith({
|
||||
id: "test-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllSearchers", () => {
|
||||
it("when no searchers are registered, it returns an empty array", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
|
||||
expect(Array.from(spotlight.getAllSearchers())).toEqual([])
|
||||
})
|
||||
|
||||
it("when a searcher is registered, it returns an array with a tuple of the searcher id and then then searcher", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
|
||||
expect(Array.from(spotlight.getAllSearchers())).toEqual([
|
||||
["echo-searcher", echoSearcher],
|
||||
])
|
||||
})
|
||||
|
||||
it("returns all registered searchers", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
spotlight.registerSearcher(emptySearcher)
|
||||
|
||||
expect(Array.from(spotlight.getAllSearchers())).toEqual([
|
||||
["echo-searcher", echoSearcher],
|
||||
["empty-searcher", emptySearcher],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
216
packages/hoppscotch-common/src/services/spotlight/index.ts
Normal file
216
packages/hoppscotch-common/src/services/spotlight/index.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Service } from "dioc"
|
||||
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> =
|
||||
| {
|
||||
type: "text"
|
||||
/**
|
||||
* The text to render. Passing an array of strings will render each string separated by a chevron
|
||||
*/
|
||||
text: string[] | string
|
||||
}
|
||||
| {
|
||||
type: "custom"
|
||||
/**
|
||||
* The component to render in place of the text
|
||||
*/
|
||||
component: T
|
||||
|
||||
/**
|
||||
* The props to pass to the component
|
||||
*/
|
||||
componentProps: T extends Component<infer Props> ? Props : never
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines info about a spotlight light so the UI can render it
|
||||
*/
|
||||
export type SpotlightSearcherResult = {
|
||||
/**
|
||||
* The unique ID of the result
|
||||
*/
|
||||
id: string
|
||||
|
||||
/**
|
||||
* The text to render in the result
|
||||
*/
|
||||
text: SpotlightResultTextType<any>
|
||||
|
||||
/**
|
||||
* The icon to render as the signifier of the result
|
||||
*/
|
||||
icon: object | Component
|
||||
|
||||
/**
|
||||
* The score of the result, the UI should sort the results by this
|
||||
*/
|
||||
score: number
|
||||
|
||||
/**
|
||||
* Additional metadata about the result
|
||||
*/
|
||||
meta?: {
|
||||
/**
|
||||
* The keyboard shortcut to trigger the result
|
||||
*/
|
||||
keyboardShortcut?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the state of a searcher during a spotlight search session
|
||||
*/
|
||||
export type SpotlightSearcherSessionState = {
|
||||
/**
|
||||
* Whether the searcher is currently loading results
|
||||
*/
|
||||
loading: boolean
|
||||
|
||||
/**
|
||||
* The results presented by the corresponding searcher in a session
|
||||
*/
|
||||
results: SpotlightSearcherResult[]
|
||||
}
|
||||
|
||||
export interface SpotlightSearcher {
|
||||
searcherID: string
|
||||
searcherSectionTitle: string
|
||||
|
||||
createSearchSession(
|
||||
query: Readonly<Ref<string>>
|
||||
): [Ref<SpotlightSearcherSessionState>, () => 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 = {
|
||||
/**
|
||||
* Whether any of the searchers are currently loading results
|
||||
*/
|
||||
loading: boolean
|
||||
|
||||
/**
|
||||
* The results presented by the corresponding searcher in a session
|
||||
*/
|
||||
results: Record<string, SpotlightSearchSearcherState>
|
||||
}
|
||||
|
||||
export class SpotlightService extends Service {
|
||||
public static readonly ID = "SPOTLIGHT_SERVICE"
|
||||
|
||||
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) {
|
||||
this.searchers.set(searcher.searcherID, searcher)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an iterator over all registered searchers and their IDs
|
||||
*/
|
||||
public getAllSearchers(): IterableIterator<[string, SpotlightSearcher]> {
|
||||
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(
|
||||
query: Ref<string>
|
||||
): [Ref<SpotlightSearchState>, () => void] {
|
||||
const searchSessions = Array.from(this.searchers.values()).map(
|
||||
(x) => [x, ...x.createSearchSession(query)] as const
|
||||
)
|
||||
|
||||
const loadingSearchers = reactive(new Set())
|
||||
const onSessionEndList: Array<() => void> = []
|
||||
|
||||
const resultObj = ref<SpotlightSearchState>({
|
||||
loading: false,
|
||||
results: {},
|
||||
})
|
||||
|
||||
const scopeHandle = effectScope()
|
||||
|
||||
scopeHandle.run(() => {
|
||||
for (const [searcher, state, onSessionEnd] of searchSessions) {
|
||||
watch(
|
||||
state,
|
||||
(newState) => {
|
||||
if (newState.loading) {
|
||||
loadingSearchers.add(searcher.searcherID)
|
||||
} else {
|
||||
loadingSearchers.delete(searcher.searcherID)
|
||||
}
|
||||
|
||||
if (newState.results.length === 0) {
|
||||
delete resultObj.value.results[searcher.searcherID]
|
||||
} else {
|
||||
resultObj.value.results[searcher.searcherID] = {
|
||||
title: searcher.searcherSectionTitle,
|
||||
avgScore:
|
||||
newState.results.reduce((acc, x) => acc + x.score, 0) /
|
||||
newState.results.length,
|
||||
results: newState.results,
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onSessionEndList.push(onSessionEnd)
|
||||
}
|
||||
|
||||
watch(
|
||||
loadingSearchers,
|
||||
(set) => {
|
||||
resultObj.value.loading = set.size > 0
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
const onSearchEnd = () => {
|
||||
scopeHandle.stop()
|
||||
|
||||
for (const onEnd of onSessionEndList) {
|
||||
onEnd()
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
searcherID: string,
|
||||
result: SpotlightSearcherResult
|
||||
) {
|
||||
this.searchers.get(searcherID)?.onResultSelect(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { TestContainer } from "dioc/testing"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { HistorySpotlightSearcherService } from "../history.searcher"
|
||||
import { nextTick, ref } from "vue"
|
||||
import { SpotlightService } from "../.."
|
||||
import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { HoppAction, HoppActionWithArgs } from "~/helpers/actions"
|
||||
import { defaultGQLSession } from "~/newstore/GQLSession"
|
||||
|
||||
async function flushPromises() {
|
||||
return await new Promise((r) => setTimeout(r))
|
||||
}
|
||||
|
||||
const tabMock = vi.hoisted(() => ({
|
||||
createNewTab: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("~/helpers/rest/tab", () => ({
|
||||
__esModule: true,
|
||||
createNewTab: tabMock.createNewTab,
|
||||
}))
|
||||
|
||||
vi.mock("~/modules/i18n", () => ({
|
||||
__esModule: true,
|
||||
getI18n: () => (x: string) => x,
|
||||
}))
|
||||
|
||||
const actionsMock = vi.hoisted(() => ({
|
||||
value: [] as (HoppAction | HoppActionWithArgs)[],
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
const historyMock = vi.hoisted(() => ({
|
||||
restEntries: [] as RESTHistoryEntry[],
|
||||
gqlEntries: [] as GQLHistoryEntry[],
|
||||
}))
|
||||
|
||||
vi.mock("~/newstore/history", () => ({
|
||||
__esModule: true,
|
||||
restHistoryStore: {
|
||||
value: {
|
||||
state: historyMock.restEntries,
|
||||
},
|
||||
},
|
||||
graphqlHistoryStore: {
|
||||
value: {
|
||||
state: historyMock.gqlEntries,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe("HistorySpotlightSearcherService", () => {
|
||||
beforeEach(() => {
|
||||
let x = actionsMock.value.pop()
|
||||
while (x) {
|
||||
x = actionsMock.value.pop()
|
||||
}
|
||||
|
||||
let y = historyMock.restEntries.pop()
|
||||
while (y) {
|
||||
y = historyMock.restEntries.pop()
|
||||
}
|
||||
|
||||
actionsMock.invokeAction.mockReset()
|
||||
tabMock.createNewTab.mockReset()
|
||||
})
|
||||
|
||||
it("registers with the spotlight service upon initialization", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const registerSearcherFn = vi.fn()
|
||||
|
||||
container.bindMock(SpotlightService, {
|
||||
registerSearcher: registerSearcherFn,
|
||||
})
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
expect(registerSearcherFn).toHaveBeenCalledOnce()
|
||||
expect(registerSearcherFn).toHaveBeenCalledWith(history)
|
||||
})
|
||||
|
||||
it("returns a clear history result if the action is available", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
actionsMock.value.push("history.clear")
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("his")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "clear-history",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("doesn't return a clear history result if the action is not available", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("his")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "clear-history",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("selecting a clear history entry invokes the clear history action", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
actionsMock.value.push("history.clear")
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("his")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
history.onResultSelect(result.value.results[0])
|
||||
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledWith("history.clear")
|
||||
})
|
||||
|
||||
it("returns all the valid rest history entries for the search term", async () => {
|
||||
actionsMock.value.push("rest.request.open")
|
||||
|
||||
historyMock.restEntries.push({
|
||||
request: {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "bla.com",
|
||||
},
|
||||
responseMeta: {
|
||||
duration: null,
|
||||
statusCode: null,
|
||||
},
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "rest-0",
|
||||
text: {
|
||||
type: "custom",
|
||||
component: expect.anything(),
|
||||
componentProps: expect.objectContaining({
|
||||
historyEntry: historyMock.restEntries[0],
|
||||
}),
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("selecting a rest history entry invokes action to open the rest request", async () => {
|
||||
actionsMock.value.push("rest.request.open")
|
||||
|
||||
const historyEntry: RESTHistoryEntry = {
|
||||
request: {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "bla.com",
|
||||
},
|
||||
responseMeta: {
|
||||
duration: null,
|
||||
statusCode: null,
|
||||
},
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
}
|
||||
|
||||
historyMock.restEntries.push(historyEntry)
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
const doc = result.value.results[0]
|
||||
|
||||
history.onResultSelect(doc)
|
||||
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledWith("rest.request.open", {
|
||||
doc: {
|
||||
request: historyEntry.request,
|
||||
isDirty: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("returns all the valid graphql history entries for the search term", async () => {
|
||||
actionsMock.value.push("gql.request.open")
|
||||
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "gql-0",
|
||||
text: {
|
||||
type: "custom",
|
||||
component: expect.anything(),
|
||||
componentProps: expect.objectContaining({
|
||||
historyEntry: historyMock.gqlEntries[0],
|
||||
}),
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("selecting a graphql history entry invokes action to open the graphql request", async () => {
|
||||
actionsMock.value.push("gql.request.open")
|
||||
|
||||
const historyEntry: GQLHistoryEntry = {
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
}
|
||||
|
||||
historyMock.gqlEntries.push(historyEntry)
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
const doc = result.value.results[0]
|
||||
|
||||
history.onResultSelect(doc)
|
||||
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledWith("gql.request.open", {
|
||||
request: historyEntry.request,
|
||||
})
|
||||
})
|
||||
|
||||
it("rest history entries are not shown when the rest open action is not registered", async () => {
|
||||
actionsMock.value.push("gql.request.open")
|
||||
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
historyMock.restEntries.push({
|
||||
request: {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "bla.com",
|
||||
},
|
||||
responseMeta: {
|
||||
duration: null,
|
||||
statusCode: null,
|
||||
},
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^gql/),
|
||||
})
|
||||
)
|
||||
expect(result.value.results).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^rest/),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("gql history entries are not shown when the gql open action is not registered", async () => {
|
||||
actionsMock.value.push("rest.request.open")
|
||||
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
historyMock.restEntries.push({
|
||||
request: {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "bla.com",
|
||||
},
|
||||
responseMeta: {
|
||||
duration: null,
|
||||
statusCode: null,
|
||||
},
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^rest/),
|
||||
})
|
||||
)
|
||||
expect(result.value.results).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^gql/),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("none of the history entries are show when neither of the open actions are registered", async () => {
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
historyMock.restEntries.push({
|
||||
request: {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "bla.com",
|
||||
},
|
||||
responseMeta: {
|
||||
duration: null,
|
||||
statusCode: null,
|
||||
},
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^rest/),
|
||||
})
|
||||
)
|
||||
expect(result.value.results).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^gql/),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest"
|
||||
import { TestContainer } from "dioc/testing"
|
||||
import { UserSpotlightSearcherService } from "../user.searcher"
|
||||
import { nextTick, ref } from "vue"
|
||||
import { SpotlightService } from "../.."
|
||||
|
||||
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("registers with the spotlight service upon initialization", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const registerSearcherFn = vi.fn()
|
||||
|
||||
container.bindMock(SpotlightService, {
|
||||
registerSearcher: registerSearcherFn,
|
||||
})
|
||||
|
||||
const user = container.bind(UserSpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
expect(registerSearcherFn).toHaveBeenCalledOnce()
|
||||
expect(registerSearcherFn).toHaveBeenCalledWith(user)
|
||||
})
|
||||
|
||||
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",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Service } from "dioc"
|
||||
import {
|
||||
type SpotlightSearcher,
|
||||
type SpotlightSearcherResult,
|
||||
type SpotlightSearcherSessionState,
|
||||
} from "../.."
|
||||
import MiniSearch from "minisearch"
|
||||
import { Ref, computed, effectScope, ref, watch } from "vue"
|
||||
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<
|
||||
Doc extends object & { excludeFromSearch?: boolean }
|
||||
>
|
||||
extends Service
|
||||
implements SpotlightSearcher
|
||||
{
|
||||
public abstract readonly searcherID: string
|
||||
public abstract readonly searcherSectionTitle: string
|
||||
|
||||
private minisearch: MiniSearch
|
||||
|
||||
private loading = ref(false)
|
||||
|
||||
private _documents: Record<string, Doc> = {}
|
||||
|
||||
constructor(private opts: StaticSpotlightSearcherOptions<Doc>) {
|
||||
super()
|
||||
|
||||
this.minisearch = new MiniSearch({
|
||||
fields: opts.searchFields as string[],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.minisearch.removeAll()
|
||||
this.minisearch.vacuum()
|
||||
|
||||
await this.minisearch.addAllAsync(
|
||||
Object.entries(this._documents).map(([id, doc]) => ({
|
||||
id,
|
||||
...doc,
|
||||
}))
|
||||
)
|
||||
|
||||
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(
|
||||
result: SearchResult<Doc>
|
||||
): SpotlightSearcherResult
|
||||
|
||||
public createSearchSession(
|
||||
query: Readonly<Ref<string>>
|
||||
): [Ref<SpotlightSearcherSessionState>, () => void] {
|
||||
const results = ref<SpotlightSearcherResult[]>([])
|
||||
|
||||
const resultObj = computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: this.loading.value,
|
||||
results: results.value,
|
||||
}))
|
||||
|
||||
const scopeHandle = effectScope()
|
||||
|
||||
scopeHandle.run(() => {
|
||||
watch(
|
||||
[query, () => this._documents],
|
||||
([query, docs]) => {
|
||||
const searchResults = this.minisearch.search(query, {
|
||||
prefix: true,
|
||||
boost: (this.opts.fieldWeights as any) ?? {},
|
||||
weights: {
|
||||
fuzzy: this.opts.fuzzyWeight ?? 0.2,
|
||||
prefix: this.opts.prefixWeight ?? 0.6,
|
||||
},
|
||||
})
|
||||
|
||||
results.value = searchResults
|
||||
.filter(
|
||||
(result) =>
|
||||
this._documents[result.id].excludeFromSearch === undefined ||
|
||||
this._documents[result.id].excludeFromSearch === false
|
||||
)
|
||||
.map((result) => {
|
||||
return this.getSearcherResultForSearchResult({
|
||||
id: result.id,
|
||||
score: result.score,
|
||||
doc: docs[result.id],
|
||||
})
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
const onSessionEnd = () => {
|
||||
scopeHandle.stop()
|
||||
}
|
||||
|
||||
return [resultObj, onSessionEnd]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Service } from "dioc"
|
||||
import {
|
||||
SpotlightSearcher,
|
||||
SpotlightSearcherResult,
|
||||
SpotlightSearcherSessionState,
|
||||
SpotlightService,
|
||||
} from "../"
|
||||
import { Ref, computed, effectScope, markRaw, ref, watch } from "vue"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
import MiniSearch from "minisearch"
|
||||
import { graphqlHistoryStore, restHistoryStore } from "~/newstore/history"
|
||||
import { useTimeAgo } from "@vueuse/core"
|
||||
import IconHistory from "~icons/lucide/history"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import SpotlightRESTHistoryEntry from "~/components/app/spotlight/entry/RESTHistory.vue"
|
||||
import SpotlightGQLHistoryEntry from "~/components/app/spotlight/entry/GQLHistory.vue"
|
||||
import { capitalize } from "lodash-es"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
import { useStreamStatic } from "~/composables/stream"
|
||||
import { activeActions$, invokeAction } from "~/helpers/actions"
|
||||
import { map } from "rxjs/operators"
|
||||
import { HoppRESTDocument } from "~/helpers/rest/document"
|
||||
|
||||
/**
|
||||
* 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
|
||||
extends Service
|
||||
implements SpotlightSearcher
|
||||
{
|
||||
public static readonly ID = "HISTORY_SPOTLIGHT_SEARCHER_SERVICE"
|
||||
|
||||
private t = getI18n()
|
||||
|
||||
public searcherID = "history"
|
||||
public searcherSectionTitle = this.t("tab.history")
|
||||
|
||||
private readonly spotlight = this.bind(SpotlightService)
|
||||
|
||||
private clearHistoryActionEnabled = useStreamStatic(
|
||||
activeActions$.pipe(map((actions) => actions.includes("history.clear"))),
|
||||
activeActions$.value.includes("history.clear"),
|
||||
() => {
|
||||
/* noop */
|
||||
}
|
||||
)[0]
|
||||
|
||||
private restHistoryEntryOpenable = useStreamStatic(
|
||||
activeActions$.pipe(
|
||||
map((actions) => actions.includes("rest.request.open"))
|
||||
),
|
||||
activeActions$.value.includes("rest.request.open"),
|
||||
() => {
|
||||
/* noop */
|
||||
}
|
||||
)[0]
|
||||
|
||||
private gqlHistoryEntryOpenable = useStreamStatic(
|
||||
activeActions$.pipe(map((actions) => actions.includes("gql.request.open"))),
|
||||
activeActions$.value.includes("gql.request.open"),
|
||||
() => {
|
||||
/* noop */
|
||||
}
|
||||
)[0]
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
|
||||
createSearchSession(
|
||||
query: Readonly<Ref<string>>
|
||||
): [Ref<SpotlightSearcherSessionState>, () => void] {
|
||||
const loading = ref(false)
|
||||
const results = ref<SpotlightSearcherResult[]>([])
|
||||
|
||||
const minisearch = new MiniSearch({
|
||||
fields: ["url", "title", "reltime", "date"],
|
||||
storeFields: ["url"],
|
||||
})
|
||||
|
||||
const stopWatchHandle = watch(
|
||||
this.clearHistoryActionEnabled,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
if (minisearch.has("clear-history")) return
|
||||
|
||||
minisearch.add({
|
||||
id: "clear-history",
|
||||
title: this.t("action.clear_history"),
|
||||
})
|
||||
} else {
|
||||
if (!minisearch.has("clear-history")) return
|
||||
|
||||
minisearch.discard("clear-history")
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
if (this.restHistoryEntryOpenable.value) {
|
||||
minisearch.addAll(
|
||||
restHistoryStore.value.state
|
||||
.filter((x) => !!x.updatedOn)
|
||||
.map((entry, index) => {
|
||||
const relTimeString = capitalize(
|
||||
useTimeAgo(entry.updatedOn!, {
|
||||
updateInterval: 0,
|
||||
}).value
|
||||
)
|
||||
|
||||
return {
|
||||
id: `rest-${index}`,
|
||||
url: entry.request.endpoint,
|
||||
reltime: relTimeString,
|
||||
date: shortDateTime(entry.updatedOn!),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (this.gqlHistoryEntryOpenable.value) {
|
||||
minisearch.addAll(
|
||||
graphqlHistoryStore.value.state
|
||||
.filter((x) => !!x.updatedOn)
|
||||
.map((entry, index) => {
|
||||
const relTimeString = capitalize(
|
||||
useTimeAgo(entry.updatedOn!, {
|
||||
updateInterval: 0,
|
||||
}).value
|
||||
)
|
||||
|
||||
return {
|
||||
id: `gql-${index}`,
|
||||
url: entry.request.url,
|
||||
reltime: relTimeString,
|
||||
date: shortDateTime(entry.updatedOn!),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const scopeHandle = effectScope()
|
||||
|
||||
scopeHandle.run(() => {
|
||||
watch(
|
||||
[query, this.clearHistoryActionEnabled],
|
||||
([query]) => {
|
||||
results.value = minisearch
|
||||
.search(query, {
|
||||
prefix: true,
|
||||
fuzzy: true,
|
||||
boost: {
|
||||
reltime: 2,
|
||||
},
|
||||
weights: {
|
||||
fuzzy: 0.2,
|
||||
prefix: 0.8,
|
||||
},
|
||||
})
|
||||
.map((x) => {
|
||||
if (x.id === "clear-history") {
|
||||
return {
|
||||
id: "clear-history",
|
||||
icon: markRaw(IconTrash2),
|
||||
score: x.score,
|
||||
text: {
|
||||
type: "text",
|
||||
text: this.t("action.clear_history"),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (x.id.startsWith("rest-")) {
|
||||
const entry =
|
||||
restHistoryStore.value.state[parseInt(x.id.split("-")[1])]
|
||||
|
||||
return {
|
||||
id: x.id,
|
||||
icon: markRaw(IconHistory),
|
||||
score: x.score,
|
||||
text: {
|
||||
type: "custom",
|
||||
component: markRaw(SpotlightRESTHistoryEntry),
|
||||
componentProps: {
|
||||
historyEntry: entry,
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Assume gql
|
||||
const entry =
|
||||
graphqlHistoryStore.value.state[parseInt(x.id.split("-")[1])]
|
||||
|
||||
return {
|
||||
id: x.id,
|
||||
icon: markRaw(IconHistory),
|
||||
score: x.score,
|
||||
text: {
|
||||
type: "custom",
|
||||
component: markRaw(SpotlightGQLHistoryEntry),
|
||||
componentProps: {
|
||||
historyEntry: entry,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
const onSessionEnd = () => {
|
||||
scopeHandle.stop()
|
||||
stopWatchHandle()
|
||||
minisearch.removeAll()
|
||||
}
|
||||
|
||||
const resultObj = computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: results.value,
|
||||
}))
|
||||
|
||||
return [resultObj, onSessionEnd]
|
||||
}
|
||||
|
||||
onResultSelect(result: SpotlightSearcherResult): void {
|
||||
if (result.id === "clear-history") {
|
||||
invokeAction("history.clear")
|
||||
} else if (result.id.startsWith("rest")) {
|
||||
const req =
|
||||
restHistoryStore.value.state[parseInt(result.id.split("-")[1])].request
|
||||
|
||||
invokeAction("rest.request.open", {
|
||||
doc: <HoppRESTDocument>{
|
||||
request: req,
|
||||
isDirty: false,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Assume gql
|
||||
const req =
|
||||
graphqlHistoryStore.value.state[parseInt(result.id.split("-")[1])]
|
||||
.request
|
||||
|
||||
invokeAction("gql.request.open", {
|
||||
request: req,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "log in"],
|
||||
icon: markRaw(IconLogin),
|
||||
},
|
||||
logout: {
|
||||
text: this.t("auth.logout"),
|
||||
excludeFromSearch: computed(() => !this.hasLogoutAction.value),
|
||||
alternates: ["sign out", "log 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