power-compass 0.0.1-alpha.1 → 0.0.1-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,173 +0,0 @@
1
- /* global fetchMock */
2
-
3
- import React from "react"
4
- import { renderHook } from "@testing-library/react-hooks"
5
- import { expect, describe, beforeEach, it } from "vitest"
6
-
7
- import { Compass } from "../components/context"
8
-
9
- import { useCompassMenu } from "./useCompassMenu"
10
- import { type MenuItem } from "./types"
11
- import { type FlatMenuItem } from "./transformations"
12
- import { createMenuItem } from "./clientMenu"
13
-
14
- const currentUserId = "123"
15
-
16
- describe("useCompassMenu", () => {
17
- const artItem = {
18
- label: "Art",
19
- icon: "art",
20
- url: "http://example.com/art",
21
- gravity: 1,
22
- }
23
- const testItem = {
24
- label: "Test",
25
- icon: "test1",
26
- gravity: 1,
27
- items: [
28
- {
29
- gravity: 1,
30
- label: "Test 2",
31
- url: "http://example.com/test2",
32
- },
33
- {
34
- gravity: 1,
35
- label: "Test 3",
36
- url: "http://example.com/test3",
37
- items: [
38
- {
39
- gravity: 1,
40
- label: "Test 4",
41
- url: "http://example.com/test4",
42
- },
43
- ],
44
- },
45
- ],
46
- }
47
- const items: MenuItem[] = [artItem, testItem]
48
-
49
- beforeEach(() => {
50
- fetchMock.mockIf(
51
- `http://example.com/compass/${currentUserId}/menu`,
52
- JSON.stringify(items),
53
- )
54
- })
55
-
56
- it("loads items from the given backends", async () => {
57
- const options = {
58
- backends: ["http://example.com/compass"],
59
- contextId: currentUserId,
60
- }
61
- const { result, waitForNextUpdate } = renderHook(() =>
62
- useCompassMenu(options),
63
- )
64
-
65
- await waitForNextUpdate()
66
-
67
- expect(result.current.items).toEqual(items)
68
- })
69
-
70
- it("loads items from the given context backends", async () => {
71
- const wrapper: React.FC = ({
72
- children,
73
- }: {
74
- children?: React.ReactNode
75
- }) => (
76
- <Compass.Provider
77
- value={{
78
- backends: ["http://example.com/compass"],
79
- contextId: currentUserId,
80
- }}
81
- >
82
- {children}
83
- </Compass.Provider>
84
- )
85
-
86
- const { result, waitForNextUpdate } = renderHook(() => useCompassMenu({}), {
87
- wrapper,
88
- })
89
-
90
- await waitForNextUpdate()
91
-
92
- expect(result.current.items).toEqual(items)
93
- })
94
-
95
- it("flattens the items when using the transformation option", async () => {
96
- const options = {
97
- backends: ["http://example.com/compass"],
98
- contextId: currentUserId,
99
- flatten: true,
100
- }
101
- const { result, waitForNextUpdate } = renderHook(() =>
102
- useCompassMenu(options),
103
- )
104
-
105
- await waitForNextUpdate()
106
-
107
- const flatItems: FlatMenuItem[] = [
108
- {
109
- label: "Art",
110
- icon: "art",
111
- url: "http://example.com/art",
112
- parent: undefined,
113
- gravity: 1,
114
- },
115
- {
116
- label: "Test 2",
117
- url: "http://example.com/test2",
118
- gravity: 1,
119
- parent: { label: "Test", icon: "test1", gravity: 1, parent: undefined },
120
- },
121
- {
122
- label: "Test 4",
123
- url: "http://example.com/test4",
124
- gravity: 1,
125
- parent: {
126
- label: "Test 3",
127
- url: "http://example.com/test3",
128
- gravity: 1,
129
- parent: {
130
- label: "Test",
131
- icon: "test1",
132
- gravity: 1,
133
- parent: undefined,
134
- },
135
- },
136
- },
137
- ]
138
-
139
- expect(result.current.items).toEqual(flatItems)
140
- })
141
-
142
- it("sorts client and remote items together by gravity", async () => {
143
- const clientFloatingItem = {
144
- label: "Client Floating Item",
145
- url: "http://example.com/client-floating",
146
- gravity: 0,
147
- }
148
- const clientItem = {
149
- label: "Client Item",
150
- url: "http://example.com/client",
151
- gravity: 1,
152
- }
153
- createMenuItem(clientItem)
154
- createMenuItem(clientFloatingItem)
155
-
156
- const options = {
157
- backends: ["http://example.com/compass"],
158
- contextId: currentUserId,
159
- }
160
- const { result, waitForNextUpdate } = renderHook(() =>
161
- useCompassMenu(options),
162
- )
163
-
164
- await waitForNextUpdate()
165
-
166
- expect(result.current.items).toEqual([
167
- clientFloatingItem,
168
- artItem,
169
- clientItem,
170
- testItem,
171
- ])
172
- })
173
- })
@@ -1,67 +0,0 @@
1
- import { useState, useContext, useEffect } from "react"
2
-
3
- import { type CompassMenuFilters, fetchMenu } from "./fetchMenu"
4
- import { type CompassContext, Compass } from "../components/context"
5
- import {
6
- type TransformOptions,
7
- type TransformedMenuItem,
8
- applyTransformations,
9
- } from "./transformations"
10
- import { fetchPlugin } from "./clientMenu"
11
-
12
- type UseCompassMenuOptions = Partial<CompassContext> &
13
- TransformOptions & {
14
- filters?: CompassMenuFilters
15
- }
16
-
17
- type UseCompassMenu = {
18
- items: TransformedMenuItem[]
19
- loading: boolean
20
- }
21
-
22
- function sortItems(
23
- resolvedItems: TransformedMenuItem[],
24
- ): TransformedMenuItem[] {
25
- return resolvedItems.sort(
26
- (a, b) =>
27
- (a?.gravity || 0) - (b?.gravity || 0) || a.label.localeCompare(b.label),
28
- )
29
- }
30
-
31
- export function useCompassMenu({
32
- filters = {},
33
- backends: backendsOverride,
34
- contextId: contextIdOverride,
35
- ...options
36
- }: UseCompassMenuOptions): UseCompassMenu {
37
- const [items, setMenuItems] = useState<TransformedMenuItem[]>([])
38
- const [loading, setLoading] = useState(false)
39
- const context = useContext(Compass)
40
- const backends = backendsOverride || context?.backends || []
41
- const contextId = contextIdOverride || context?.contextId
42
- if (!contextId) {
43
- throw new Error("contextId is required")
44
- }
45
-
46
- useEffect(() => {
47
- setLoading(true)
48
- const updatedMenu = [
49
- ...backends.map((backend) =>
50
- fetchMenu(backend, contextId.toString(), filters),
51
- ),
52
- fetchPlugin(filters),
53
- ]
54
- Promise.all(updatedMenu)
55
- .then((results) => {
56
- const menuItems = results
57
- .filter((result) => !result.error)
58
- .flatMap((result) => result.items)
59
- return applyTransformations(menuItems, options)
60
- })
61
- .then(sortItems)
62
- .then(setMenuItems)
63
- .finally(() => setLoading(false))
64
- }, [])
65
-
66
- return { items, loading }
67
- }
@@ -1,109 +0,0 @@
1
- import chain from "lodash/chain"
2
- import sortBy from "lodash/sortBy"
3
- import Fuse from "fuse.js"
4
-
5
- import type { ResultEntry, SearchResult, SourceDefinition } from "./types"
6
-
7
- const DefaultEntriesPerSource = 10
8
- const RequiredOptions = {
9
- includeScore: true,
10
- shouldSort: true,
11
- }
12
- const DefaultOptions = {
13
- keys: [
14
- { name: "label", weight: 0.6 },
15
- { name: "category", weight: 0.3 },
16
- { name: "tag", weight: 1 },
17
- ],
18
- matchAllTokens: true,
19
- maxPatternLength: 16,
20
- minMatchCharLength: 3,
21
- threshold: 0.4,
22
- tokenize: true,
23
- }
24
-
25
- /**
26
- * Scores and sorts matches.
27
- */
28
- function scoreEntries<T>(
29
- query: string,
30
- entries: T[],
31
- source: SourceDefinition<T>,
32
- ): ResultEntry<T>[] {
33
- const fuse = new Fuse(entries, {
34
- ...DefaultOptions,
35
- ...source.searchOptions,
36
- ...RequiredOptions,
37
- })
38
- return chain(fuse.search(query))
39
- .map(({ item, score }: ResultEntry<T>) => ({ item, score, source: source }))
40
- .take(source.maxEntries || DefaultEntriesPerSource)
41
- .value() as ResultEntry<T>[]
42
- }
43
-
44
- /**
45
- * Sorts sources by best match's score.
46
- *
47
- * @returns {[SearchResult, SearchResult]} sources without `insertSourceAtIndex` and sources with `insertSourceAtIndex`.
48
- */
49
- function partitionGroups<T>(
50
- data: SearchResult<T>[],
51
- ): [SearchResult<T>[], SearchResult<T>[]] {
52
- return chain(data)
53
- .sortBy("entries[0].score")
54
- .partition(({ source }) => source.insertSourceAtIndex === undefined)
55
- .value()
56
- }
57
-
58
- /**
59
- * Sorts matches by score.
60
- * Sorts sources by best match's score and move them as per specified by their `insertSourceAtIndex` key.
61
- *
62
- * @param {*} sources all sources added to Spotnitro. See `app/javascript/packs/spotnitro.ts`
63
- * @param {*} data GraphQL data
64
- * @param {*} search user's input
65
- * @see source-scores-spec.js
66
- */
67
- export function sortSearchResults<T>(
68
- results: SearchResult<T>[],
69
- ): SearchResult<T>[] {
70
- const [sortedGroups, groupsToInsertAtIndex] = partitionGroups(results)
71
-
72
- return sortBy(groupsToInsertAtIndex, "source.insertSourceAtIndex").reduce(
73
- (groups, group) => [
74
- ...groups.slice(0, group.source.insertSourceAtIndex),
75
- group,
76
- ...groups.slice(group.source.insertSourceAtIndex),
77
- ],
78
- sortedGroups,
79
- ) as SearchResult<T>[]
80
- }
81
-
82
- type OnResultCallback = (result: SearchResult<unknown>) => void
83
-
84
- export function search(
85
- value: string,
86
- sources: SourceDefinition<unknown>[],
87
- abortController?: AbortController,
88
- onResult?: OnResultCallback,
89
- ): Promise<SearchResult<unknown>[]> {
90
- const promises = sources.map(
91
- (source: SourceDefinition<unknown>): Promise<SearchResult<unknown>> =>
92
- source.fetch(value, abortController).then(
93
- (entries: unknown[]) => {
94
- const result = {
95
- source,
96
- entries: scoreEntries(value, entries, source),
97
- }
98
- onResult?.call(null, result)
99
- return result
100
- },
101
- (error) => {
102
- console.debug(`Failed to search on source: ${source.label}`, error)
103
- return { source, entries: [] as ResultEntry<unknown>[] }
104
- },
105
- ),
106
- )
107
-
108
- return Promise.all(promises)
109
- }
@@ -1 +0,0 @@
1
- export * from "./useSearch"
@@ -1,232 +0,0 @@
1
- import get from "lodash/get"
2
-
3
- import { fetchMenu } from "../menu/fetchMenu"
4
- import { type CompassMenuResult } from "../menu/types"
5
- import { type SourceDefinition } from "./types"
6
-
7
- export const sources: SourceDefinition<unknown>[] = []
8
-
9
- type SourceDefinitionCreation<T> = Omit<
10
- SourceDefinition<T>,
11
- "open" | "display"
12
- > &
13
- Partial<Pick<SourceDefinition<T>, "open" | "display">>
14
-
15
- export async function createCompassProviderSource(
16
- contextId: string | number | undefined,
17
- providerName: string,
18
- search: string,
19
- abortController?: AbortController,
20
- ): Promise<SourceDefinition<unknown> | null> {
21
- if (!search || search.length < 3 || !contextId || !providerName) return null
22
-
23
- try {
24
- const response = await fetch(
25
- `/compass/${contextId}/search/${providerName}?q=${encodeURIComponent(search)}`,
26
- { signal: abortController?.signal },
27
- )
28
-
29
- if (!response.ok) {
30
- console.debug(`Failed to fetch ${providerName} search results:`, response)
31
- return null
32
- }
33
-
34
- const data = await response.json()
35
-
36
- return {
37
- label: data.label || providerName,
38
- searchOptions: data.search_options || { threshold: 0.2 },
39
- fetch: () => Promise.resolve(data.results || []),
40
- display: {
41
- label: (item: any) => item.label ?? "",
42
- category: (item: any) => item.category ?? "",
43
- tag: (item: any) => item.tag ?? "",
44
- },
45
- open: (item: any) => {
46
- if (item.requires_confirmation && item.data) {
47
- if (!window.confirm(item.data)) {
48
- return null
49
- }
50
- }
51
- return item.url
52
- },
53
- }
54
- } catch (error) {
55
- if ((error as Error).name !== "AbortError") {
56
- console.debug(`Error creating compass ${providerName} source:`, error)
57
- }
58
- return null
59
- }
60
- }
61
-
62
- export async function getCompassProviders(
63
- contextId: string | number,
64
- abortController?: AbortController,
65
- ): Promise<string[]> {
66
- try {
67
- const response = await fetch(`/compass/${contextId}/search/providers`, {
68
- signal: abortController?.signal,
69
- })
70
-
71
- if (!response.ok) {
72
- console.debug("Failed to fetch providers:", response.status)
73
- return []
74
- }
75
-
76
- const data = await response.json()
77
-
78
- return data.providers?.map((provider: any) => provider.name) || []
79
- } catch (error) {
80
- if ((error as Error).name !== "AbortError") {
81
- console.debug("Error fetching available providers:", error)
82
- }
83
- return []
84
- }
85
- }
86
-
87
- export async function createCompassSources(
88
- contextId: string | number | undefined,
89
- search: string,
90
- abortController?: AbortController,
91
- ): Promise<SourceDefinition<unknown>[]> {
92
- if (!search || search.length < 3 || !contextId) return []
93
-
94
- try {
95
- const providerNames = await getCompassProviders(contextId, abortController)
96
-
97
- if (!providerNames.length) {
98
- console.debug("No providers available")
99
- return []
100
- }
101
-
102
- console.debug(`Found ${providerNames.length} providers:`, providerNames)
103
-
104
- const promises = providerNames.map((providerName) =>
105
- createCompassProviderSource(
106
- contextId,
107
- providerName,
108
- search,
109
- abortController,
110
- ),
111
- )
112
-
113
- const sources = await Promise.all(promises)
114
-
115
- const validSources = sources.filter(
116
- (source): source is SourceDefinition<unknown> => source !== null,
117
- )
118
-
119
- console.debug(
120
- `Successfully created ${validSources.length}/${providerNames.length} sources`,
121
- )
122
-
123
- return validSources
124
- } catch (error) {
125
- if ((error as Error).name !== "AbortError") {
126
- console.debug("Error creating compass individual sources:", error)
127
- }
128
- return []
129
- }
130
- }
131
-
132
- export function createMenuSources(
133
- backends: string[],
134
- contextId: string,
135
- ): SourceDefinition<unknown>[] {
136
- if (!backends || !backends.length || !contextId) {
137
- return []
138
- }
139
-
140
- return backends.map((backend) => {
141
- return {
142
- fetch: async (_search: string) => {
143
- try {
144
- const menuResult: CompassMenuResult = await fetchMenu(
145
- backend,
146
- contextId,
147
- )
148
- if (menuResult.error) {
149
- return []
150
- }
151
-
152
- const searchableItems: Array<{
153
- label: string
154
- url: string
155
- category: string
156
- icon?: string
157
- }> = []
158
-
159
- menuResult.items.forEach((topItem: any) => {
160
- const category = topItem.label
161
- const icon = topItem.icon
162
-
163
- if (topItem.url) {
164
- searchableItems.push({
165
- label: topItem.label,
166
- url: topItem.url,
167
- category: category,
168
- icon: icon,
169
- })
170
- }
171
-
172
- if (topItem.items && Array.isArray(topItem.items)) {
173
- topItem.items.forEach((item: any) => {
174
- searchableItems.push({
175
- label: item.label,
176
- url: item.url,
177
- category: category,
178
- icon: item.icon || icon,
179
- })
180
- })
181
- }
182
- })
183
-
184
- return searchableItems
185
- } catch (error) {
186
- return []
187
- }
188
- },
189
-
190
- display: {
191
- label: (item: any) => {
192
- return item.label
193
- },
194
- category: (item: any) => {
195
- return item.category
196
- },
197
- tag: () => {
198
- return ""
199
- },
200
- },
201
-
202
- open: ({ url, label }: { url: string; label: string }) => {
203
- const fullUrl = new URL(url, document.location.href)
204
- fullUrl.searchParams.append("mt", label)
205
- return fullUrl.toString()
206
- },
207
- searchOptions: { threshold: 0.2 },
208
- maxEntries: 20,
209
- insertSourceAtIndex: 0,
210
- } as SourceDefinition<unknown>
211
- })
212
- }
213
-
214
- export function createSource<T>(source: SourceDefinitionCreation<T>) {
215
- sources.push({
216
- open(obj: T) {
217
- return get(obj, "url")
218
- },
219
- display: {
220
- label(obj: T) {
221
- return get(obj, "label")
222
- },
223
- category(obj: T) {
224
- return get(obj, "category")
225
- },
226
- tag(obj: T) {
227
- return get(obj, "tag")
228
- },
229
- },
230
- ...source,
231
- })
232
- }
@@ -1,39 +0,0 @@
1
- import { type ComponentType } from "react"
2
-
3
- export type SourceDefinition<T> = {
4
- label?: string
5
- tag?: string
6
- searchOptions?: Record<string, unknown>
7
- newTab?: boolean
8
- insertSourceAtIndex?: number
9
- maxEntries?: number
10
-
11
- display: ComponentType<ResultEntry<T>> | DisplayProps<T>
12
-
13
- fetch: (arg0: string, options?: RequestInit) => Promise<T[]>
14
- open: (arg0: T) => string | Promise<string> | void
15
- }
16
-
17
- export type ResultEntry<T> = {
18
- item: T
19
- score: number
20
- source: SourceDefinition<T>
21
- }
22
-
23
- export type SearchResult<T> = {
24
- source: SourceDefinition<T>
25
- entries: ResultEntry<T>[]
26
- }
27
-
28
- export interface SearchItem {
29
- html?: string
30
- label?: string
31
- tag?: string
32
- url?: string
33
- }
34
-
35
- export type DisplayProps<T> = {
36
- label: (arg0: T) => Promise<string> | string
37
- category: (arg0: T) => Promise<string> | string
38
- tag: (arg0: T) => Promise<string> | string
39
- }
@@ -1,84 +0,0 @@
1
- import {
2
- useCallback,
3
- useEffect,
4
- useRef,
5
- useState,
6
- useContext,
7
- useMemo,
8
- } from "react"
9
- import { sortSearchResults, search } from "./engine"
10
- import { type SearchResult } from "./types"
11
- import { Compass } from "../components/context"
12
- import { createMenuSources, createCompassSources } from "./sources"
13
-
14
- type UseSpotnitroReturn = [SearchResult<unknown>[], boolean, boolean]
15
-
16
- export function useSearch(value?: string): UseSpotnitroReturn {
17
- const [results, setResults] = useState<SearchResult<unknown>[]>([])
18
- const [loading, setLoading] = useState(false)
19
- const [finished, setFinished] = useState(false)
20
- const compassContext = useContext(Compass)
21
- const backends = compassContext?.backends
22
- const contextId = compassContext?.contextId
23
- const abort = useRef<AbortController | undefined>(undefined)
24
- const finish = useCallback(() => {
25
- setLoading(false)
26
- setFinished(true)
27
- }, [])
28
- const menuSources = useMemo(
29
- () =>
30
- contextId && backends
31
- ? createMenuSources(backends, contextId.toString())
32
- : [],
33
- [contextId, backends],
34
- )
35
-
36
- const updateScoredResults = useCallback((result: SearchResult<unknown>) => {
37
- if (result.entries.length > 0) {
38
- setResults((results) => {
39
- const filteredResults = results.filter(
40
- (r) => r.source.label !== result.source.label,
41
- )
42
- return sortSearchResults([result, ...filteredResults])
43
- })
44
- }
45
- }, [])
46
-
47
- useEffect(() => {
48
- const performSearch = async () => {
49
- if (!value || value.length < 3) {
50
- setResults([])
51
- setLoading(false)
52
- setFinished(false)
53
- return
54
- }
55
-
56
- abort.current?.abort()
57
- abort.current = new AbortController()
58
- setFinished(false)
59
- setResults([])
60
- setLoading(true)
61
-
62
- try {
63
- const allIndividualSources = await createCompassSources(
64
- contextId,
65
- value,
66
- abort.current,
67
- )
68
- const allSources = [...allIndividualSources, ...menuSources]
69
-
70
- await search(value, allSources, abort.current, updateScoredResults)
71
- } catch (error) {
72
- if ((error as Error).name !== "AbortError") {
73
- console.debug("Search failed:", error)
74
- }
75
- } finally {
76
- finish()
77
- }
78
- }
79
-
80
- performSearch()
81
- }, [value, contextId, backends, menuSources, updateScoredResults, finish])
82
-
83
- return [results, loading, finished]
84
- }
@@ -1 +0,0 @@
1
- export * from "./useNotificationCenter"