feat: revamped spotlight (#3171)

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Andrew Bastin
2023-07-11 23:02:33 +05:30
committed by GitHub
parent c3531c9d8b
commit 5230d2d3b8
36 changed files with 3941 additions and 1043 deletions

View File

@@ -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/),
})
)
})
})

View File

@@ -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()
})
})

View File

@@ -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",
})
)
})
})

View File

@@ -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])
}
}

View File

@@ -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,
})
}
}
}

View File

@@ -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
}
}
}