| 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 |
export interface CloudSearchParams { |
| 39 |
page: number |
| 40 |
query: string |
| 41 |
method: 'term' | 'codevault' |
| 42 |
type: string |
| 43 |
status: number |
| 44 |
category: string |
| 45 |
} |
| 46 |
|
| 47 |
export interface CloudSearchResults { |
| 48 |
page: number |
| 49 |
snippets: CloudSnippetSchema[] |
| 50 |
totalItems: number |
| 51 |
totalPages: number |
| 52 |
isFeatured: boolean |
| 53 |
} |
| 54 |
|
| 55 |
export interface CloudFilterOption { |
| 56 |
id: number |
| 57 |
name: string |
| 58 |
} |
| 59 |
|
| 60 |
const fetchSearchQueryParams = (): CloudSearchParams => { |
| 61 |
const page = Number(fetchQueryParam(SEARCH_PARAM_VARS.page) ?? 1) |
| 62 |
const query = fetchQueryParam(SEARCH_PARAM_VARS.query) ?? '' |
| 63 |
const type = fetchQueryParam(SEARCH_PARAM_VARS.type) ?? '' |
| 64 |
const status = fetchQueryParam(SEARCH_PARAM_VARS.status) ?? 0 |
| 65 |
const method = fetchQueryParam(SEARCH_PARAM_VARS.method) |
| 66 |
const category = fetchQueryParam(SEARCH_PARAM_VARS.category) ?? '' |
| 67 |
|
| 68 |
return { |
| 69 |
page: isNaN(page) ? 1 : page, |
| 70 |
type, |
| 71 |
query, |
| 72 |
status: Number(status), |
| 73 |
method: 'codevault' === method ? 'codevault' : 'term', |
| 74 |
category |
| 75 |
} |
| 76 |
} |
| 77 |
|
| 78 |
const updateSearchQueryParams = (params: CloudSearchParams) => { |
| 79 |
updateQueryParams({ |
| 80 |
[SEARCH_PARAM_VARS.page]: params.page, |
| 81 |
[SEARCH_PARAM_VARS.type]: params.type || undefined, |
| 82 |
[SEARCH_PARAM_VARS.query]: params.query || undefined, |
| 83 |
[SEARCH_PARAM_VARS.status]: params.status || undefined, |
| 84 |
[SEARCH_PARAM_VARS.method]: 'codevault' === params.method ? 'codevault' : undefined, |
| 85 |
[SEARCH_PARAM_VARS.category]: params.category || undefined |
| 86 |
}) |
| 87 |
} |
| 88 |
|
| 89 |
const buildSearchUrl = ( |
| 90 |
baseUrl: string, |
| 91 |
{ query, type, method, status, category, page }: CloudSearchParams |
| 92 |
) => |
| 93 |
buildUrl(baseUrl, { |
| 94 |
query, |
| 95 |
searchByCodevault: 'codevault' === method ? true : undefined, |
| 96 |
per_page: SNIPPETS_PER_PAGE, |
| 97 |
type: type || undefined, |
| 98 |
category: category || undefined, |
| 99 |
status: status || undefined, |
| 100 |
page |
| 101 |
}) |
| 102 |
|
| 103 |
const processSearchParams = (params: CloudSearchParams, previous: CloudSearchParams): CloudSearchParams => { |
| 104 |
const queryDiff = params.query.trim() !== previous.query.trim() |
| 105 |
const methodDiff = params.method !== previous.method |
| 106 |
|
| 107 |
const typeDiff = params.type !== previous.type |
| 108 |
const statusDiff = params.status !== previous.status |
| 109 |
const categoryDiff = params.category !== previous.category |
| 110 |
|
| 111 |
const filterDiff = typeDiff || statusDiff || categoryDiff |
| 112 |
|
| 113 |
if (queryDiff || methodDiff) { |
| 114 |
return { ...params, page: 1, type: '', status: 0, category: '' } |
| 115 |
} else if (filterDiff) { |
| 116 |
return { ...params, page: 1 } |
| 117 |
} |
| 118 |
|
| 119 |
return params |
| 120 |
} |
| 121 |
|
| 122 |
const unpackSearchResponse = ({ data }: AxiosResponse<CloudSnippetsSchema>, baseUrl: string) => ({ |
| 123 |
page: data.page, |
| 124 |
isFeatured: baseUrl === SEARCH_URLS.FEATURED, |
| 125 |
snippets: data.snippets, |
| 126 |
totalItems: data.total_snippets, |
| 127 |
totalPages: data.total_pages |
| 128 |
}) |
| 129 |
|
| 130 |
const isFilterOption = (value: unknown): value is CloudFilterOption => |
| 131 |
'object' === typeof value && null !== value && |
| 132 |
'id' in value && 'number' === typeof value.id && |
| 133 |
'name' in value && 'string' === typeof value.name |
| 134 |
|
| 135 |
const unpackFilterOptions = (data: unknown): CloudFilterOption[] | undefined => |
| 136 |
Array.isArray(data) |
| 137 |
? data.filter(isFilterOption) |
| 138 |
: undefined |
| 139 |
|
| 140 |
const unpackFiltersFromResponse = ( |
| 141 |
{ data: { available_filters } }: AxiosResponse<CloudSnippetsSchema> |
| 142 |
): AvailableCloudFilters | undefined => |
| 143 |
'object' === typeof available_filters && available_filters |
| 144 |
? { |
| 145 |
...'types' in available_filters && { types: unpackFilterOptions(available_filters.types) }, |
| 146 |
...'statuses' in available_filters && { statuses: unpackFilterOptions(available_filters.statuses) }, |
| 147 |
...'categories' in available_filters && { categories: unpackFilterOptions(available_filters.categories) } |
| 148 |
} |
| 149 |
: undefined |
| 150 |
|
| 151 |
const useRequestIds = () => { |
| 152 |
const activeRequestRef = useRef(0) |
| 153 |
|
| 154 |
const nextRequestId = useCallback(() => { |
| 155 |
activeRequestRef.current += 1 |
| 156 |
return activeRequestRef.current |
| 157 |
}, []) |
| 158 |
|
| 159 |
const isCurrentRequest = useCallback((requestId: number) => requestId === activeRequestRef.current, []) |
| 160 |
|
| 161 |
return { isCurrentRequest, nextRequestId } |
| 162 |
} |
| 163 |
|
| 164 |
export interface CloudSearchContext { |
| 165 |
isErrored: boolean |
| 166 |
doSearch: (paramsDelta?: Partial<CloudSearchParams>) => void |
| 167 |
isLoading: boolean |
| 168 |
isSearching: boolean |
| 169 |
searchParams: CloudSearchParams |
| 170 |
searchResults: CloudSearchResults | undefined |
| 171 |
availableFilters: AvailableCloudFilters |
| 172 |
updateSearchParams: (params: Partial<CloudSearchParams>) => void |
| 173 |
} |
| 174 |
|
| 175 |
const useSearchApi = () => { |
| 176 |
const { api } = useRestAPI() |
| 177 |
const { isCurrentRequest, nextRequestId } = useRequestIds() |
| 178 |
const [currentSearch, setCurrentSearch] = useState(false) |
| 179 |
const [isLoading, setIsLoading] = useState(false) |
| 180 |
const [searchResults, setSearchResults] = useState<CloudSearchResults | false | undefined>() |
| 181 |
const [availableFilters, setAvailableFilters] = useState<AvailableCloudFilters>({}) |
| 182 |
|
| 183 |
const makeSearchRequest = useCallback((params: CloudSearchParams) => { |
| 184 |
const requestId = nextRequestId() |
| 185 |
const isFeaturedSearch = '' === params.query.trim() |
| 186 |
|
| 187 |
setIsLoading(true) |
| 188 |
setCurrentSearch(!isFeaturedSearch) |
| 189 |
const baseUrl = isFeaturedSearch ? SEARCH_URLS.FEATURED : SEARCH_URLS.SEARCH_QUERY |
| 190 |
|
| 191 |
api.getResponse<CloudSnippetsSchema>(buildSearchUrl(baseUrl, params)) |
| 192 |
.then(response => { |
| 193 |
if (isCurrentRequest(requestId)) { |
| 194 |
setSearchResults(unpackSearchResponse(response, baseUrl)) |
| 195 |
setAvailableFilters(previous => unpackFiltersFromResponse(response) ?? previous) |
| 196 |
} |
| 197 |
}) |
| 198 |
.catch(() => { |
| 199 |
if (isCurrentRequest(requestId)) { |
| 200 |
setSearchResults(false) |
| 201 |
} |
| 202 |
}) |
| 203 |
.finally(() => { |
| 204 |
if (isCurrentRequest(requestId)) { |
| 205 |
setCurrentSearch(false) |
| 206 |
setIsLoading(false) |
| 207 |
} |
| 208 |
}) |
| 209 |
}, [api, nextRequestId, isCurrentRequest]) |
| 210 |
|
| 211 |
return { |
| 212 |
isLoading, |
| 213 |
isSearching: currentSearch, |
| 214 |
searchResults, |
| 215 |
availableFilters, |
| 216 |
makeSearchRequest |
| 217 |
} |
| 218 |
} |
| 219 |
|
| 220 |
const [Context, useCloudSearch] = createContextHook<CloudSearchContext>('useCloudSearch') |
| 221 |
|
| 222 |
export const WithCloudSearchContext: React.FC<PropsWithChildren> = ({ children }) => { |
| 223 |
const { isLoading, isSearching, makeSearchRequest, availableFilters, searchResults } = useSearchApi() |
| 224 |
const [searchParams, setSearchParams] = useState<CloudSearchParams>(fetchSearchQueryParams) |
| 225 |
const [madeInitialRequest, setMadeInitialRequest] = useState(false) |
| 226 |
|
| 227 |
const updateSearchParams = useCallback( |
| 228 |
(delta: Partial<CloudSearchParams>) => setSearchParams(previous => ({ ...previous, ...delta })), |
| 229 |
[]) |
| 230 |
|
| 231 |
const doSearch = useCallback((paramsDelta?: Partial<CloudSearchParams>) => { |
| 232 |
// An empty query is the featured view: makeSearchRequest routes it to the featured endpoint. |
| 233 |
const params = processSearchParams({ ...searchParams, ...paramsDelta }, searchParams) |
| 234 |
updateSearchQueryParams(params) |
| 235 |
setSearchParams(params) |
| 236 |
makeSearchRequest(params) |
| 237 |
}, [makeSearchRequest, searchParams]) |
| 238 |
|
| 239 |
useEffect(() => { |
| 240 |
if (!madeInitialRequest) { |
| 241 |
setMadeInitialRequest(true) |
| 242 |
makeSearchRequest(searchParams) |
| 243 |
} |
| 244 |
}, [makeSearchRequest, searchParams, madeInitialRequest]) |
| 245 |
|
| 246 |
const value: CloudSearchContext = { |
| 247 |
doSearch, |
| 248 |
isLoading, |
| 249 |
isSearching, |
| 250 |
searchParams, |
| 251 |
availableFilters, |
| 252 |
updateSearchParams, |
| 253 |
...false === searchResults |
| 254 |
? { isErrored: true, searchResults: undefined } |
| 255 |
: { isErrored: false, searchResults } |
| 256 |
} |
| 257 |
|
| 258 |
return <Context.Provider value={value}>{children}</Context.Provider> |
| 259 |
} |
| 260 |
|
| 261 |
export { useCloudSearch } |
| 262 |
|