PluginProbe
Code Snippets / trunk
Code Snippets vtrunk
4.0.0-beta.2 3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 All 65 releases
code-snippets / js / components / ManageMenu / CommunityCloud / WithCloudSearchContext.tsx

WithCloudSearchContext.tsx in Code Snippets trunk, at js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx

273 lines 8.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useCallback, useEffect, useRef, useState } from 'react'
2 import { createContextHook } from '../../../utils/bootstrap'
3 import { useRestAPI } from '../../../hooks/useRestAPI'
4 import { REST_BASES } from '../../../utils/restAPI'
5 import { buildUrl, fetchQueryParam, updateQueryParams } from '../../../utils/urls'
6 import type { CloudSnippetsSchema } from '../../../types/schema/CloudSnippetsSchema'
7 import type { PropsWithChildren } from 'react'
8 import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema'
9 import type { AxiosResponse } from 'axios'
10
11 const DEFAULT_SNIPPETS_PER_PAGE = 10
12 const MAX_CLOUD_RESULTS_PER_PAGE = 100
13 const SNIPPETS_PER_PAGE = Math.min(
14 window.CODE_SNIPPETS_MANAGE?.cloudSearchPerPage ?? window.CODE_SNIPPETS_MANAGE?.snippetsPerPage ?? DEFAULT_SNIPPETS_PER_PAGE,
15 MAX_CLOUD_RESULTS_PER_PAGE
16 )
17
18 const SEARCH_URLS = {
19 SEARCH_QUERY: REST_BASES.cloud.snippets,
20 FEATURED: `${REST_BASES.cloud.snippets}/featured`
21 } as const
22
23 export interface AvailableCloudFilters {
24 types?: CloudFilterOption[]
25 statuses?: CloudFilterOption[]
26 categories?: CloudFilterOption[]
27 }
28
29 const SEARCH_PARAM_VARS: Record<keyof CloudSearchParams, string> = {
30 page: 'paged',
31 query: 's',
32 method: 'by',
33 type: 'type',
34 status: 'status',
35 category: 'category'
36 }
37
38 const SEARCH_METHODS = ['term', 'codevault', 'ai'] as const
39
40 export type CloudSearchMethod = typeof SEARCH_METHODS[number]
41
42 const isSearchMethod = (value: unknown): value is CloudSearchMethod =>
43 SEARCH_METHODS.some(method => method === value)
44
45 export const parseSearchMethod = (value: unknown): CloudSearchMethod =>
46 isSearchMethod(value) ? value : 'term'
47
48 export interface CloudSearchParams {
49 page: number
50 query: string
51 method: CloudSearchMethod
52 type: string
53 status: number
54 category: string
55 }
56
57 export interface CloudSearchResults {
58 page: number
59 snippets: CloudSnippetSchema[]
60 totalItems: number
61 totalPages: number
62 isFeatured: boolean
63 }
64
65 export interface CloudFilterOption {
66 id: number
67 name: string
68 }
69
70 const fetchSearchQueryParams = (): CloudSearchParams => {
71 const page = Number(fetchQueryParam(SEARCH_PARAM_VARS.page) ?? 1)
72 const query = fetchQueryParam(SEARCH_PARAM_VARS.query) ?? ''
73 const type = fetchQueryParam(SEARCH_PARAM_VARS.type) ?? ''
74 const status = fetchQueryParam(SEARCH_PARAM_VARS.status) ?? 0
75 const method = fetchQueryParam(SEARCH_PARAM_VARS.method)
76 const category = fetchQueryParam(SEARCH_PARAM_VARS.category) ?? ''
77
78 return {
79 page: isNaN(page) ? 1 : page,
80 type,
81 query,
82 status: Number(status),
83 method: parseSearchMethod(method),
84 category
85 }
86 }
87
88 const updateSearchQueryParams = (params: CloudSearchParams) => {
89 updateQueryParams({
90 [SEARCH_PARAM_VARS.page]: params.page,
91 [SEARCH_PARAM_VARS.type]: params.type || undefined,
92 [SEARCH_PARAM_VARS.query]: params.query || undefined,
93 [SEARCH_PARAM_VARS.status]: params.status || undefined,
94 [SEARCH_PARAM_VARS.method]: 'codevault' === params.method ? 'codevault' : undefined,
95 [SEARCH_PARAM_VARS.category]: params.category || undefined
96 })
97 }
98
99 const buildSearchUrl = (
100 baseUrl: string,
101 { query, type, method, status, category, page }: CloudSearchParams
102 ) =>
103 buildUrl(baseUrl, {
104 query,
105 searchByCodevault: 'codevault' === method ? true : undefined,
106 searchMethod: 'ai' === method ? 'ai' : undefined,
107 per_page: SNIPPETS_PER_PAGE,
108 type: type || undefined,
109 category: category || undefined,
110 status: status || undefined,
111 page
112 })
113
114 const processSearchParams = (params: CloudSearchParams, previous: CloudSearchParams): CloudSearchParams => {
115 const queryDiff = params.query.trim() !== previous.query.trim()
116 const methodDiff = params.method !== previous.method
117
118 const typeDiff = params.type !== previous.type
119 const statusDiff = params.status !== previous.status
120 const categoryDiff = params.category !== previous.category
121
122 const filterDiff = typeDiff || statusDiff || categoryDiff
123
124 if (queryDiff || methodDiff) {
125 return { ...params, page: 1, type: '', status: 0, category: '' }
126 } else if (filterDiff) {
127 return { ...params, page: 1 }
128 }
129
130 return params
131 }
132
133 const unpackSearchResponse = ({ data }: AxiosResponse<CloudSnippetsSchema>, baseUrl: string) => ({
134 page: data.page,
135 isFeatured: baseUrl === SEARCH_URLS.FEATURED,
136 snippets: data.snippets,
137 totalItems: data.total_snippets,
138 totalPages: data.total_pages
139 })
140
141 const isFilterOption = (value: unknown): value is CloudFilterOption =>
142 'object' === typeof value && null !== value &&
143 'id' in value && 'number' === typeof value.id &&
144 'name' in value && 'string' === typeof value.name
145
146 const unpackFilterOptions = (data: unknown): CloudFilterOption[] | undefined =>
147 Array.isArray(data)
148 ? data.filter(isFilterOption)
149 : undefined
150
151 const unpackFiltersFromResponse = (
152 { data: { available_filters } }: AxiosResponse<CloudSnippetsSchema>
153 ): AvailableCloudFilters | undefined =>
154 'object' === typeof available_filters && available_filters
155 ? {
156 ...'types' in available_filters && { types: unpackFilterOptions(available_filters.types) },
157 ...'statuses' in available_filters && { statuses: unpackFilterOptions(available_filters.statuses) },
158 ...'categories' in available_filters && { categories: unpackFilterOptions(available_filters.categories) }
159 }
160 : undefined
161
162 const useRequestIds = () => {
163 const activeRequestRef = useRef(0)
164
165 const nextRequestId = useCallback(() => {
166 activeRequestRef.current += 1
167 return activeRequestRef.current
168 }, [])
169
170 const isCurrentRequest = useCallback((requestId: number) => requestId === activeRequestRef.current, [])
171
172 return { isCurrentRequest, nextRequestId }
173 }
174
175 export interface CloudSearchContext {
176 isErrored: boolean
177 doSearch: (paramsDelta?: Partial<CloudSearchParams>) => void
178 isLoading: boolean
179 isSearching: boolean
180 searchParams: CloudSearchParams
181 searchResults: CloudSearchResults | undefined
182 availableFilters: AvailableCloudFilters
183 updateSearchParams: (params: Partial<CloudSearchParams>) => void
184 }
185
186 const useSearchApi = () => {
187 const { api } = useRestAPI()
188 const { isCurrentRequest, nextRequestId } = useRequestIds()
189 const [currentSearch, setCurrentSearch] = useState(false)
190 const [isLoading, setIsLoading] = useState(false)
191 const [searchResults, setSearchResults] = useState<CloudSearchResults | false | undefined>()
192 const [availableFilters, setAvailableFilters] = useState<AvailableCloudFilters>({})
193
194 const makeSearchRequest = useCallback((params: CloudSearchParams) => {
195 const requestId = nextRequestId()
196 const isFeaturedSearch = '' === params.query.trim()
197
198 setIsLoading(true)
199 setCurrentSearch(!isFeaturedSearch)
200 const baseUrl = isFeaturedSearch ? SEARCH_URLS.FEATURED : SEARCH_URLS.SEARCH_QUERY
201
202 api.getResponse<CloudSnippetsSchema>(buildSearchUrl(baseUrl, params))
203 .then(response => {
204 if (isCurrentRequest(requestId)) {
205 setSearchResults(unpackSearchResponse(response, baseUrl))
206 setAvailableFilters(previous => unpackFiltersFromResponse(response) ?? previous)
207 }
208 })
209 .catch(() => {
210 if (isCurrentRequest(requestId)) {
211 setSearchResults(false)
212 }
213 })
214 .finally(() => {
215 if (isCurrentRequest(requestId)) {
216 setCurrentSearch(false)
217 setIsLoading(false)
218 }
219 })
220 }, [api, nextRequestId, isCurrentRequest])
221
222 return {
223 isLoading,
224 isSearching: currentSearch,
225 searchResults,
226 availableFilters,
227 makeSearchRequest
228 }
229 }
230
231 const [Context, useCloudSearch] = createContextHook<CloudSearchContext>('useCloudSearch')
232
233 export const WithCloudSearchContext: React.FC<PropsWithChildren> = ({ children }) => {
234 const { isLoading, isSearching, makeSearchRequest, availableFilters, searchResults } = useSearchApi()
235 const [searchParams, setSearchParams] = useState<CloudSearchParams>(fetchSearchQueryParams)
236 const [madeInitialRequest, setMadeInitialRequest] = useState(false)
237
238 const updateSearchParams = useCallback(
239 (delta: Partial<CloudSearchParams>) => setSearchParams(previous => ({ ...previous, ...delta })),
240 [])
241
242 const doSearch = useCallback((paramsDelta?: Partial<CloudSearchParams>) => {
243 // An empty query is the featured view: makeSearchRequest routes it to the featured endpoint.
244 const params = processSearchParams({ ...searchParams, ...paramsDelta }, searchParams)
245 updateSearchQueryParams(params)
246 setSearchParams(params)
247 makeSearchRequest(params)
248 }, [makeSearchRequest, searchParams])
249
250 useEffect(() => {
251 if (!madeInitialRequest) {
252 setMadeInitialRequest(true)
253 makeSearchRequest(searchParams)
254 }
255 }, [makeSearchRequest, searchParams, madeInitialRequest])
256
257 const value: CloudSearchContext = {
258 doSearch,
259 isLoading,
260 isSearching,
261 searchParams,
262 availableFilters,
263 updateSearchParams,
264 ...false === searchResults
265 ? { isErrored: true, searchResults: undefined }
266 : { isErrored: false, searchResults }
267 }
268
269 return <Context.Provider value={value}>{children}</Context.Provider>
270 }
271
272 export { useCloudSearch }
273