From aac2a0c9e495a7a9bd5f53dd9896de992b82f88e Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 7 Mar 2025 12:04:48 -0500 Subject: [PATCH 01/31] inital removal of recompose --- .../src/features/Search/SearchLanding.tsx | 7 +- .../src/features/Search/withStoreSearch.tsx | 145 +++++++++--------- .../features/TopMenu/SearchBar/SearchBar.tsx | 8 +- 3 files changed, 80 insertions(+), 80 deletions(-) diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 6073f119a5d..2bb3e6cdf14 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -40,7 +40,7 @@ import { StyledStack, } from './SearchLanding.styles'; import { emptyResults } from './utils'; -import withStoreSearch from './withStoreSearch'; +import { withStoreSearch } from './withStoreSearch'; import type { SearchProps } from './withStoreSearch'; import type { RouteComponentProps } from 'react-router-dom'; @@ -62,7 +62,7 @@ export interface SearchLandingProps RouteComponentProps<{}> {} export const SearchLanding = (props: SearchLandingProps) => { - const { entities, search, searchResultsByEntity } = props; + const { search, searchResultsByEntity } = props; const { data: regions } = useRegionsQuery(); const isLargeAccount = useIsLargeAccount(); @@ -209,7 +209,6 @@ export const SearchLanding = (props: SearchLandingProps) => { } }, [ query, - entities, search, isLargeAccount, _searchAPI, @@ -336,7 +335,7 @@ export const SearchLanding = (props: SearchLandingProps) => { ); }; -const EnhancedSearchLanding = withStoreSearch()(SearchLanding); +const EnhancedSearchLanding = withStoreSearch(SearchLanding); export const searchLandingLazyRoute = createLazyRoute('/search')({ component: React.lazy(() => diff --git a/packages/manager/src/features/Search/withStoreSearch.tsx b/packages/manager/src/features/Search/withStoreSearch.tsx index 259b0c46b69..63578cf0738 100644 --- a/packages/manager/src/features/Search/withStoreSearch.tsx +++ b/packages/manager/src/features/Search/withStoreSearch.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { compose, withStateHandlers } from 'recompose'; import { bucketToSearchableItem, @@ -49,8 +48,8 @@ interface HandlerProps { } export interface SearchProps extends HandlerProps { combinedResults: SearchableItem[]; - entities: SearchableItem[]; - entitiesLoading: boolean; + // entities: SearchableItem[]; + // entitiesLoading: boolean; searchResultsByEntity: SearchResultsByEntity; } @@ -70,75 +69,77 @@ export const search = ( }; }; -export default () => (Component: React.ComponentType) => { - const WrappedComponent: React.FC = (props) => { - return React.createElement(Component, { - ...props, - }); +export const withStoreSearch = ( + Component: React.ComponentType +) => (props: Props) => { + const [searchResults, setSearchResults] = React.useState({ + searchResultsByEntity: emptyResults, + combinedResults: [], + }); + + const handleSearch = ( + query: string, + objectStorageBuckets: ObjectStorageBucket[], + domains: Domain[], + volumes: Volume[], + clusters: KubernetesCluster[], + images: Image[], + regions: Region[], + searchableLinodes: SearchableItem[], + nodebalancers: NodeBalancer[], + firewalls: Firewall[], + databases: DatabaseInstance[] + ) => { + const searchableBuckets = objectStorageBuckets.map((bucket) => + bucketToSearchableItem(bucket) + ); + const searchableDomains = domains.map((domain) => + domainToSearchableItem(domain) + ); + const searchableVolumes = volumes.map((volume) => + volumeToSearchableItem(volume) + ); + const searchableImages = images.map((image) => + imageToSearchableItem(image) + ); + const searchableClusters = clusters.map((cluster) => + kubernetesClusterToSearchableItem(cluster, regions) + ); + const searchableNodebalancers = nodebalancers.map((nodebalancer) => + nodeBalToSearchableItem(nodebalancer) + ); + const searchableFirewalls = firewalls.map((firewall) => + firewallToSearchableItem(firewall) + ); + const searchableDatabases = databases.map((database) => + databaseToSearchableItem(database) + ); + + const searchableItems = [ + ...searchableLinodes, + ...searchableImages, + ...searchableBuckets, + ...searchableDomains, + ...searchableVolumes, + ...searchableClusters, + ...searchableNodebalancers, + ...searchableFirewalls, + ...searchableDatabases, + ] + + const results = search(searchableItems, query); + + setSearchResults(results); + + return results; }; - return compose( - withStateHandlers( - { searchResultsByEntity: emptyResults }, - { - search: (_) => ( - query: string, - objectStorageBuckets: ObjectStorageBucket[], - domains: Domain[], - volumes: Volume[], - clusters: KubernetesCluster[], - images: Image[], - regions: Region[], - searchableLinodes: SearchableItem[], - nodebalancers: NodeBalancer[], - firewalls: Firewall[], - databases: DatabaseInstance[] - ) => { - const searchableBuckets = objectStorageBuckets.map((bucket) => - bucketToSearchableItem(bucket) - ); - const searchableDomains = domains.map((domain) => - domainToSearchableItem(domain) - ); - const searchableVolumes = volumes.map((volume) => - volumeToSearchableItem(volume) - ); - const searchableImages = images.map((image) => - imageToSearchableItem(image) - ); - const searchableClusters = clusters.map((cluster) => - kubernetesClusterToSearchableItem(cluster, regions) - ); - const searchableNodebalancers = nodebalancers.map((nodebalancer) => - nodeBalToSearchableItem(nodebalancer) - ); - const searchableFirewalls = firewalls.map((firewall) => - firewallToSearchableItem(firewall) - ); - const searchableDatabases = databases.map((database) => - databaseToSearchableItem(database) - ); - const results = search( - [ - ...searchableLinodes, - ...searchableImages, - ...searchableBuckets, - ...searchableDomains, - ...searchableVolumes, - ...searchableClusters, - ...searchableNodebalancers, - ...searchableFirewalls, - ...searchableDatabases, - ], - query - ); - const { combinedResults, searchResultsByEntity } = results; - return { - combinedResults, - searchResultsByEntity, - }; - }, - } - ) - )(WrappedComponent); + return ( + + ); }; diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index 641841ea76b..05412a41b37 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -13,7 +13,7 @@ import Search from 'src/assets/icons/search.svg'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { getImageLabelForLinode } from 'src/features/Images/utils'; import { useAPISearch } from 'src/features/Search/useAPISearch'; -import withStoreSearch from 'src/features/Search/withStoreSearch'; +import { withStoreSearch } from 'src/features/Search/withStoreSearch'; import { useIsLargeAccount } from 'src/hooks/useIsLargeAccount'; import { useAllDatabasesQuery } from 'src/queries/databases/databases'; import { useAllDomainsQuery } from 'src/queries/domains'; @@ -58,7 +58,7 @@ const isSpecialOption = ( }; const SearchBarComponent = (props: SearchProps) => { - const { combinedResults, entitiesLoading, search } = props; + const { combinedResults, search } = props; const [searchText, setSearchText] = React.useState(''); const [value, setValue] = React.useState(null); const [searchActive, setSearchActive] = React.useState(false); @@ -457,7 +457,7 @@ const SearchBarComponent = (props: SearchProps) => { disableClearable inputValue={searchText} label={label} - loading={entitiesLoading} + loading={false} multiple={false} noOptionsText="No results" onBlur={handleBlur} @@ -476,4 +476,4 @@ const SearchBarComponent = (props: SearchProps) => { ); }; -export const SearchBar = withStoreSearch()(SearchBarComponent); +export const SearchBar = withStoreSearch(SearchBarComponent); From 87690d6d2375b945dd9e2f4e90a7c31bee6fdcc1 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 7 Mar 2025 13:01:48 -0500 Subject: [PATCH 02/31] clean up search components --- .../features/Search/SearchLanding.styles.ts | 16 - .../src/features/Search/SearchLanding.tsx | 341 ++---------------- .../src/features/Search/searchLanding.css | 18 - .../src/features/Search/withStoreSearch.tsx | 194 +++++----- .../features/TopMenu/SearchBar/SearchBar.tsx | 167 +-------- 5 files changed, 140 insertions(+), 596 deletions(-) delete mode 100644 packages/manager/src/features/Search/searchLanding.css diff --git a/packages/manager/src/features/Search/SearchLanding.styles.ts b/packages/manager/src/features/Search/SearchLanding.styles.ts index 46628aee503..e23a91fcd21 100644 --- a/packages/manager/src/features/Search/SearchLanding.styles.ts +++ b/packages/manager/src/features/Search/SearchLanding.styles.ts @@ -71,19 +71,3 @@ export const StyledError = styled(Error, { marginBottom: theme.spacing(4), width: 60, })); - -export const StyledH1Header = styled(H1Header, { - label: 'StyledH1Header', -})(({ theme }) => ({ - marginBottom: theme.spacing(), - [theme.breakpoints.down('md')]: { - marginLeft: theme.spacing(), - }, -})); - -export const StyledRootGrid = styled(Grid, { - label: 'StyledRootGrid', -})({ - padding: 0, - width: 'calc(100% + 16px)', -}); diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 2bb3e6cdf14..ba91ea198c6 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -1,49 +1,14 @@ -import { CircleProgress, Notice, Typography } from '@linode/ui'; -import { - getQueryParamFromQueryString, - isNotNullOrUndefined, -} from '@linode/utilities'; -import Grid from '@mui/material/Grid2'; +import { CircleProgress, Stack, Typography } from '@linode/ui'; +import { getQueryParamFromQueryString } from '@linode/utilities'; import { createLazyRoute } from '@tanstack/react-router'; -import { equals } from 'ramda'; -import * as React from 'react'; -import { debounce } from 'throttle-debounce'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; -import { useAPISearch } from 'src/features/Search/useAPISearch'; -import { useIsLargeAccount } from 'src/hooks/useIsLargeAccount'; -import { useAllDatabasesQuery } from 'src/queries/databases/databases'; -import { useAllDomainsQuery } from 'src/queries/domains'; -import { useAllFirewallsQuery } from 'src/queries/firewalls'; -import { useAllImagesQuery } from 'src/queries/images'; -import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; -import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; -import { isBucketError } from 'src/queries/object-storage/requests'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { useSpecificTypes } from 'src/queries/types'; -import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; -import { formatLinode } from 'src/store/selectors/getSearchEntities'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { extendTypesQueryResult } from 'src/utilities/extendType'; -import { isNilOrEmpty } from 'src/utilities/isNilOrEmpty'; - -import { useIsDatabasesEnabled } from '../Databases/utilities'; -import { getImageLabelForLinode } from '../Images/utils'; import { ResultGroup } from './ResultGroup'; -import './searchLanding.css'; -import { - StyledError, - StyledGrid, - StyledH1Header, - StyledRootGrid, - StyledStack, -} from './SearchLanding.styles'; -import { emptyResults } from './utils'; -import { withStoreSearch } from './withStoreSearch'; +import { StyledError, StyledGrid, StyledStack } from './SearchLanding.styles'; +import { useLegacySearch } from './withStoreSearch'; -import type { SearchProps } from './withStoreSearch'; -import type { RouteComponentProps } from 'react-router-dom'; +import type { ResultRowDataOption } from './types'; const displayMap = { buckets: 'Buckets', @@ -57,292 +22,54 @@ const displayMap = { volumes: 'Volumes', }; -export interface SearchLandingProps - extends SearchProps, - RouteComponentProps<{}> {} - -export const SearchLanding = (props: SearchLandingProps) => { - const { search, searchResultsByEntity } = props; - const { data: regions } = useRegionsQuery(); - - const isLargeAccount = useIsLargeAccount(); - const { isDatabasesEnabled } = useIsDatabasesEnabled(); - - // We only want to fetch all entities if we know they - // are not a large account. We do this rather than `!isLargeAccount` - // because we don't want to fetch all entities if isLargeAccount is loading (undefined). - const shouldFetchAllEntities = isLargeAccount === false; - - const shouldMakeDBRequests = - shouldFetchAllEntities && Boolean(isDatabasesEnabled); - - /* - @TODO OBJ Multicluster:'region' will become required, and the - 'cluster' field will be deprecated once the feature is fully rolled out in production. - As part of the process of cleaning up after the 'objMultiCluster' feature flag, we will - remove 'cluster' and retain 'regions'. - */ - const { - data: objectStorageBuckets, - error: bucketsError, - isLoading: areBucketsLoading, - } = useObjectStorageBuckets(shouldFetchAllEntities); - - /* - @TODO DBaaS: Change the passed argument to 'shouldFetchAllEntities' and - remove 'isDatabasesEnabled' once DBaaS V2 is fully rolled out. - */ - const { - data: databases, - error: databasesError, - isLoading: areDatabasesLoading, - } = useAllDatabasesQuery(shouldMakeDBRequests); - - const { - data: domains, - error: domainsError, - isLoading: areDomainsLoading, - } = useAllDomainsQuery(shouldFetchAllEntities); - - const { - data: firewalls, - error: firewallsError, - isLoading: areFirewallsLoading, - } = useAllFirewallsQuery(shouldFetchAllEntities); - - const { - data: kubernetesClusters, - error: kubernetesClustersError, - isLoading: areKubernetesClustersLoading, - } = useAllKubernetesClustersQuery(shouldFetchAllEntities); - - const { - data: nodebalancers, - error: nodebalancersError, - isLoading: areNodeBalancersLoading, - } = useAllNodeBalancersQuery(shouldFetchAllEntities); - - const { - data: volumes, - error: volumesError, - isLoading: areVolumesLoading, - } = useAllVolumesQuery({}, {}, shouldFetchAllEntities); - - const { - data: _privateImages, - error: imagesError, - isLoading: areImagesLoading, - } = useAllImagesQuery({}, { is_public: false }, shouldFetchAllEntities); // We want to display private images (i.e., not Debian, Ubuntu, etc. distros) - - const { data: publicImages } = useAllImagesQuery( - {}, - { is_public: true }, - shouldFetchAllEntities - ); +const SearchLanding = () => { + const location = useLocation(); + const query = getQueryParamFromQueryString(location.search, 'query'); const { - data: linodes, - error: linodesError, - isLoading: areLinodesLoading, - } = useAllLinodesQuery({}, {}, shouldFetchAllEntities); - - const typesQuery = useSpecificTypes( - (linodes ?? []).map((linode) => linode.type).filter(isNotNullOrUndefined) - ); - const types = extendTypesQueryResult(typesQuery); - - const searchableLinodes = (linodes ?? []).map((linode) => { - const imageLabel = getImageLabelForLinode(linode, publicImages ?? []); - return formatLinode(linode, types, imageLabel); - }); - - const [apiResults, setAPIResults] = React.useState({}); - const [apiError, setAPIError] = React.useState(null); - const [apiSearchLoading, setAPILoading] = React.useState(false); - - let query = ''; - let queryError = false; - try { - query = getQueryParamFromQueryString(props.location.search, 'query'); - } catch { - queryError = true; - } - - const { searchAPI } = useAPISearch(!isNilOrEmpty(query)); - - const _searchAPI = React.useRef( - debounce(500, false, (_searchText: string) => { - setAPILoading(true); - searchAPI(_searchText) - .then((searchResults) => { - setAPIResults(searchResults.searchResultsByEntity); - setAPILoading(false); - setAPIError(null); - }) - .catch((error) => { - setAPIError( - getAPIErrorOrDefault(error, 'Error loading search results')[0] - .reason - ); - setAPILoading(false); - }); - }) - ).current; - - React.useEffect(() => { - if (isLargeAccount) { - _searchAPI(query); - } else { - search( - query, - objectStorageBuckets?.buckets ?? [], - domains ?? [], - volumes ?? [], - kubernetesClusters ?? [], - _privateImages ?? [], - regions ?? [], - searchableLinodes ?? [], - nodebalancers ?? [], - firewalls ?? [], - databases ?? [] - ); - } - }, [ - query, - search, - isLargeAccount, - _searchAPI, - objectStorageBuckets, - domains, - volumes, - kubernetesClusters, - _privateImages, - regions, - nodebalancers, - linodes, - firewalls, - databases, - ]); - - const getErrorMessage = () => { - const errorConditions: [unknown, string][] = [ - [linodesError, 'Linodes'], - [bucketsError, 'Buckets'], - [domainsError, 'Domains'], - [volumesError, 'Volumes'], - [imagesError, 'Images'], - [nodebalancersError, 'NodeBalancers'], - [kubernetesClustersError, 'Kubernetes'], - [firewallsError, 'Firewalls'], - [databasesError, 'Databases'], - [ - objectStorageBuckets && objectStorageBuckets.errors.length > 0, - `Object Storage in ${objectStorageBuckets?.errors - .map((e) => (isBucketError(e) ? e.cluster.region : e.endpoint.region)) - .join(', ')}`, - ], - ]; - - const matchingConditions = errorConditions.filter( - (condition) => condition[0] - ); - - if (matchingConditions.length > 0) { - return `Could not retrieve search results for: ${matchingConditions - .map((condition) => condition[1]) - .join(', ')}`; - } else { - return false; - } - }; - - const finalResults = isLargeAccount ? apiResults : searchResultsByEntity; - - const resultsEmpty = equals(finalResults, emptyResults); - - const loading = isLargeAccount - ? apiSearchLoading - : areLinodesLoading || - areBucketsLoading || - areDomainsLoading || - areVolumesLoading || - areKubernetesClustersLoading || - areImagesLoading || - areNodeBalancersLoading || - areFirewallsLoading || - areDatabasesLoading; - - const errorMessage = getErrorMessage(); + combinedResults, + isLoading, + searchResultsByEntity, + } = useLegacySearch({ query }); return ( - - - {!resultsEmpty && !loading && ( - - )} - - {errorMessage && ( - - - + + {combinedResults.length > 0 && !isLoading && ( + + Search Results {query && `for "${query}"`} + )} - {apiError && ( - - - - )} - {queryError && ( - - - - )} - {(loading || apiSearchLoading) && ( - - - - )} - {resultsEmpty && !loading && ( + {isLoading && } + {!isLoading && combinedResults.length === 0 && ( You searched for ... - {query} + {query} Sorry, no results for this one. )} - {!loading && ( - - {Object.keys(finalResults).map( - (entityType: keyof typeof displayMap, idx: number) => ( - - ) - )} - + {Object.keys(searchResultsByEntity).map( + (entityType: keyof typeof displayMap, idx: number) => ( + + ) )} - + ); }; -const EnhancedSearchLanding = withStoreSearch(SearchLanding); - export const searchLandingLazyRoute = createLazyRoute('/search')({ - component: React.lazy(() => - import('./SearchLanding').then(() => ({ - default: (props: any) => , - })) - ), + component: SearchLanding, }); -export default EnhancedSearchLanding; +export default SearchLanding; diff --git a/packages/manager/src/features/Search/searchLanding.css b/packages/manager/src/features/Search/searchLanding.css deleted file mode 100644 index 885c0efee55..00000000000 --- a/packages/manager/src/features/Search/searchLanding.css +++ /dev/null @@ -1,18 +0,0 @@ -@keyframes fadein { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -.resultq { - font-size: 2.5rem; - line-height: 0.75; -} - -.nothing { - opacity: 0; - animation: fadein 0.2s linear 2.5s 1 normal forwards; -} diff --git a/packages/manager/src/features/Search/withStoreSearch.tsx b/packages/manager/src/features/Search/withStoreSearch.tsx index 63578cf0738..09be7ff8750 100644 --- a/packages/manager/src/features/Search/withStoreSearch.tsx +++ b/packages/manager/src/features/Search/withStoreSearch.tsx @@ -1,57 +1,31 @@ -import * as React from 'react'; - +import { useIsLargeAccount } from 'src/hooks/useIsLargeAccount'; +import { useAllDatabasesQuery } from 'src/queries/databases/databases'; +import { useAllDomainsQuery } from 'src/queries/domains'; +import { useAllFirewallsQuery } from 'src/queries/firewalls'; +import { useAllImagesQuery } from 'src/queries/images'; +import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; +import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; +import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; import { bucketToSearchableItem, databaseToSearchableItem, domainToSearchableItem, firewallToSearchableItem, + formatLinode, imageToSearchableItem, kubernetesClusterToSearchableItem, nodeBalToSearchableItem, volumeToSearchableItem, } from 'src/store/selectors/getSearchEntities'; +import { getImageLabelForLinode } from '../Images/utils'; import { refinedSearch } from './refinedSearch'; import { emptyResults, separateResultsByEntity } from './utils'; -import type { - SearchResults, - SearchResultsByEntity, - SearchableItem, -} from './search.interfaces'; -import type { - DatabaseInstance, - Firewall, - Image, - KubernetesCluster, - NodeBalancer, - Region, - Volume, -} from '@linode/api-v4'; -import type { Domain } from '@linode/api-v4/lib/domains'; -import type { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; - -interface HandlerProps { - search: ( - query: string, - buckets: ObjectStorageBucket[], - domains: Domain[], - volumes: Volume[], - clusters: KubernetesCluster[], - images: Image[], - regions: Region[], - searchableLinodes: SearchableItem[], - nodebalancers: NodeBalancer[], - firewalls: Firewall[], - databases: DatabaseInstance[] - ) => SearchResults; -} -export interface SearchProps extends HandlerProps { - combinedResults: SearchableItem[]; - // entities: SearchableItem[]; - // entitiesLoading: boolean; - searchResultsByEntity: SearchResultsByEntity; -} +import type { SearchResults, SearchableItem } from './search.interfaces'; export const search = ( entities: SearchableItem[], @@ -69,77 +43,89 @@ export const search = ( }; }; -export const withStoreSearch = ( - Component: React.ComponentType -) => (props: Props) => { - const [searchResults, setSearchResults] = React.useState({ - searchResultsByEntity: emptyResults, - combinedResults: [], +interface Props { + query: string; +} + +export const useLegacySearch = ({ query }: Props) => { + const isSearching = Boolean(query); + const isLargeAccount = useIsLargeAccount(isSearching); + const shouldFetchAll = + isSearching && isLargeAccount !== undefined && !isLargeAccount; + + const { data: regions } = useRegionsQuery(); + const { data: objectStorageBuckets } = useObjectStorageBuckets( + shouldFetchAll + ); + const { data: domains } = useAllDomainsQuery(shouldFetchAll); + const { data: clusters } = useAllKubernetesClustersQuery(shouldFetchAll); + const { data: volumes } = useAllVolumesQuery({}, {}, shouldFetchAll); + const { data: nodebalancers } = useAllNodeBalancersQuery(shouldFetchAll); + const { data: firewalls } = useAllFirewallsQuery(shouldFetchAll); + const { data: databases } = useAllDatabasesQuery(shouldFetchAll); + const { data: _privateImages, isLoading: imagesLoading } = useAllImagesQuery( + {}, + { is_public: false }, // We want to display private images (i.e., not Debian, Ubuntu, etc. distros) + shouldFetchAll + ); + const { data: publicImages } = useAllImagesQuery( + {}, + { is_public: true }, + isSearching + ); + const { data: linodes, isLoading: linodesLoading } = useAllLinodesQuery( + {}, + {}, + shouldFetchAll + ); + + const searchableLinodes = (linodes ?? []).map((linode) => { + const imageLabel = getImageLabelForLinode(linode, publicImages ?? []); + return formatLinode(linode, [], imageLabel); }); - const handleSearch = ( - query: string, - objectStorageBuckets: ObjectStorageBucket[], - domains: Domain[], - volumes: Volume[], - clusters: KubernetesCluster[], - images: Image[], - regions: Region[], - searchableLinodes: SearchableItem[], - nodebalancers: NodeBalancer[], - firewalls: Firewall[], - databases: DatabaseInstance[] - ) => { - const searchableBuckets = objectStorageBuckets.map((bucket) => + const searchableBuckets = + objectStorageBuckets?.buckets.map((bucket) => bucketToSearchableItem(bucket) - ); - const searchableDomains = domains.map((domain) => - domainToSearchableItem(domain) - ); - const searchableVolumes = volumes.map((volume) => - volumeToSearchableItem(volume) - ); - const searchableImages = images.map((image) => - imageToSearchableItem(image) - ); - const searchableClusters = clusters.map((cluster) => - kubernetesClusterToSearchableItem(cluster, regions) - ); - const searchableNodebalancers = nodebalancers.map((nodebalancer) => + ) ?? []; + const searchableDomains = + domains?.map((domain) => domainToSearchableItem(domain)) ?? []; + const searchableVolumes = + volumes?.map((volume) => volumeToSearchableItem(volume)) ?? []; + const searchableImages = + _privateImages?.map((image) => imageToSearchableItem(image)) ?? []; + const searchableClusters = + clusters?.map((cluster) => + kubernetesClusterToSearchableItem(cluster, regions ?? []) + ) ?? []; + const searchableNodebalancers = + nodebalancers?.map((nodebalancer) => nodeBalToSearchableItem(nodebalancer) - ); - const searchableFirewalls = firewalls.map((firewall) => - firewallToSearchableItem(firewall) - ); - const searchableDatabases = databases.map((database) => - databaseToSearchableItem(database) - ); + ) ?? []; + const searchableFirewalls = + firewalls?.map((firewall) => firewallToSearchableItem(firewall)) ?? []; + const searchableDatabases = + databases?.map((database) => databaseToSearchableItem(database)) ?? []; - const searchableItems = [ - ...searchableLinodes, - ...searchableImages, - ...searchableBuckets, - ...searchableDomains, - ...searchableVolumes, - ...searchableClusters, - ...searchableNodebalancers, - ...searchableFirewalls, - ...searchableDatabases, - ] + const searchableItems = [ + ...searchableLinodes, + ...searchableImages, + ...searchableBuckets, + ...searchableDomains, + ...searchableVolumes, + ...searchableClusters, + ...searchableNodebalancers, + ...searchableFirewalls, + ...searchableDatabases, + ]; - const results = search(searchableItems, query); + const isLoading = linodesLoading || imagesLoading; - setSearchResults(results); + const results = search(searchableItems, query); - return results; + return { + ...results, + isLargeAccount, + isLoading, }; - - return ( - - ); }; diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index 05412a41b37..5de91c7e423 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -1,44 +1,20 @@ import { Autocomplete, Box, IconButton, TextField } from '@linode/ui'; -import { - getQueryParamsFromQueryString, - isNotNullOrUndefined, -} from '@linode/utilities'; +import { getQueryParamsFromQueryString } from '@linode/utilities'; import Close from '@mui/icons-material/Close'; import { useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { debounce } from 'throttle-debounce'; import Search from 'src/assets/icons/search.svg'; -import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; -import { getImageLabelForLinode } from 'src/features/Images/utils'; -import { useAPISearch } from 'src/features/Search/useAPISearch'; -import { withStoreSearch } from 'src/features/Search/withStoreSearch'; -import { useIsLargeAccount } from 'src/hooks/useIsLargeAccount'; -import { useAllDatabasesQuery } from 'src/queries/databases/databases'; -import { useAllDomainsQuery } from 'src/queries/domains'; -import { useAllFirewallsQuery } from 'src/queries/firewalls'; -import { useAllImagesQuery } from 'src/queries/images'; -import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; -import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { useSpecificTypes } from 'src/queries/types'; -import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; -import { formatLinode } from 'src/store/selectors/getSearchEntities'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { extendTypesQueryResult } from 'src/utilities/extendType'; -import { isNilOrEmpty } from 'src/utilities/isNilOrEmpty'; +import { useLegacySearch } from 'src/features/Search/withStoreSearch'; import { StyledIconButton, StyledSearchIcon } from './SearchBar.styles'; import { SearchSuggestion } from './SearchSuggestion'; import { StyledSearchSuggestion } from './SearchSuggestion.styles'; import { SearchSuggestionContainer } from './SearchSuggestionContainer'; -import { createFinalOptions } from './utils'; import type { SearchableItem } from 'src/features/Search/search.interfaces'; -import type { SearchProps } from 'src/features/Search/withStoreSearch'; +import { createFinalOptions } from './utils'; export interface ExtendedSearchableItem extends Omit { @@ -57,87 +33,23 @@ const isSpecialOption = ( return ['error', 'info', 'redirect'].includes(String(option.value)); }; -const SearchBarComponent = (props: SearchProps) => { - const { combinedResults, search } = props; +export const SearchBar = () => { + // Search state const [searchText, setSearchText] = React.useState(''); + const { combinedResults, isLargeAccount, isLoading } = useLegacySearch({ + query: searchText, + }); + + // MUI Autocomplete state const [value, setValue] = React.useState(null); const [searchActive, setSearchActive] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false); - const [apiResults, setAPIResults] = React.useState([]); - const [apiError, setAPIError] = React.useState(null); - const [apiSearchLoading, setAPILoading] = React.useState(false); + + // Hooks const history = useHistory(); - const isLargeAccount = useIsLargeAccount(searchActive); - const { isDatabasesEnabled } = useIsDatabasesEnabled(); const theme = useTheme(); - // Only request things if the search bar is open/active and we - // know if the account is large or not - const shouldMakeRequests = - searchActive && isLargeAccount !== undefined && !isLargeAccount; - const shouldMakeDBRequests = - shouldMakeRequests && Boolean(isDatabasesEnabled); - const { data: regions } = useRegionsQuery(); - const { data: objectStorageBuckets } = useObjectStorageBuckets( - shouldMakeRequests - ); - const { data: domains } = useAllDomainsQuery(shouldMakeRequests); - const { data: clusters } = useAllKubernetesClustersQuery(shouldMakeRequests); - const { data: volumes } = useAllVolumesQuery({}, {}, shouldMakeRequests); - const { data: nodebalancers } = useAllNodeBalancersQuery(shouldMakeRequests); - const { data: firewalls } = useAllFirewallsQuery(shouldMakeRequests); - /* - @TODO DBaaS: Change the passed argument to 'shouldMakeRequests' and - remove 'isDatabasesEnabled' once DBaaS V2 is fully rolled out. - */ - const { data: databases } = useAllDatabasesQuery(shouldMakeDBRequests); - const { data: _privateImages, isLoading: imagesLoading } = useAllImagesQuery( - {}, - { is_public: false }, // We want to display private images (i.e., not Debian, Ubuntu, etc. distros) - shouldMakeRequests - ); - const { data: publicImages } = useAllImagesQuery( - {}, - { is_public: true }, - searchActive - ); - const { data: linodes, isLoading: linodesLoading } = useAllLinodesQuery( - {}, - {}, - shouldMakeRequests - ); - const typesQuery = useSpecificTypes( - (linodes ?? []).map((linode) => linode.type).filter(isNotNullOrUndefined), - shouldMakeRequests - ); - const extendedTypes = extendTypesQueryResult(typesQuery); - const searchableLinodes = (linodes ?? []).map((linode) => { - const imageLabel = getImageLabelForLinode(linode, publicImages ?? []); - return formatLinode(linode, extendedTypes, imageLabel); - }); - const { searchAPI } = useAPISearch(!isNilOrEmpty(searchText)); - - const _searchAPI = React.useRef( - debounce(500, false, (_searchText: string) => { - setAPILoading(true); - searchAPI(_searchText) - .then((searchResults) => { - setAPIResults(searchResults.combinedResults); - setAPILoading(false); - setAPIError(null); - }) - .catch((error) => { - setAPIError( - getAPIErrorOrDefault(error, 'Error loading search results')[0] - .reason - ); - setAPILoading(false); - }); - }) - ).current; - - const buckets = objectStorageBuckets?.buckets || []; - + // No idea React.useEffect(() => { const { pathname, search } = history.location; const query = getQueryParamsFromQueryString(search); @@ -155,43 +67,6 @@ const SearchBarComponent = (props: SearchProps) => { } }, [history.location]); - React.useEffect(() => { - // We can't store all data for large accounts for client side search, - // so use the API's filtering instead. - if (isLargeAccount) { - _searchAPI(searchText); - } else { - search( - searchText, - buckets, - domains ?? [], - volumes ?? [], - clusters ?? [], - _privateImages ?? [], - regions ?? [], - searchableLinodes ?? [], - nodebalancers ?? [], - firewalls ?? [], - databases ?? [] - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - imagesLoading, - search, - searchText, - _searchAPI, - isLargeAccount, - objectStorageBuckets, - domains, - volumes, - _privateImages, - regions, - nodebalancers, - firewalls, - databases, - ]); - const handleSearchChange = (_searchText: string): void => { setSearchText(_searchText); }; @@ -259,21 +134,13 @@ const SearchBarComponent = (props: SearchProps) => { handleClose(); }; + const options = createFinalOptions(combinedResults, searchText, isLoading, false); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const label = isSmallScreen ? 'Search...' : 'Search Products, IP Addresses, Tags...'; - const options = createFinalOptions( - isLargeAccount ? apiResults : combinedResults, - searchText, - isLargeAccount ? apiSearchLoading : linodesLoading || imagesLoading, - // Ignore "Unauthorized" errors, since these will always happen on LKE - // endpoints for restricted users. It's not really an "error" in this case. - // We still want these users to be able to use the search feature. - Boolean(apiError) && apiError !== 'Unauthorized' - ); - return ( { disableClearable inputValue={searchText} label={label} - loading={false} + loading={isLoading} multiple={false} noOptionsText="No results" onBlur={handleBlur} @@ -475,5 +342,3 @@ const SearchBarComponent = (props: SearchProps) => { ); }; - -export const SearchBar = withStoreSearch(SearchBarComponent); From 1b54973569d07876af9fd71bd14f189d5e6bfca9 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 7 Mar 2025 13:12:01 -0500 Subject: [PATCH 03/31] save progress --- packages/manager/src/features/Search/utils.ts | 18 ++++++++++++++++ .../src/features/Search/withStoreSearch.tsx | 21 +------------------ .../features/TopMenu/SearchBar/SearchBar.tsx | 4 ++-- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/manager/src/features/Search/utils.ts b/packages/manager/src/features/Search/utils.ts index 0361e378d31..36c3aaf3b8a 100644 --- a/packages/manager/src/features/Search/utils.ts +++ b/packages/manager/src/features/Search/utils.ts @@ -1,4 +1,6 @@ +import { refinedSearch } from './refinedSearch'; import type { + SearchResults, SearchResultsByEntity, SearchableItem, } from './search.interfaces'; @@ -39,3 +41,19 @@ export const separateResultsByEntity = ( }); return separatedResults; }; + +export const search = ( + entities: SearchableItem[], + inputValue: string +): SearchResults => { + if (!inputValue || inputValue === '') { + return { combinedResults: [], searchResultsByEntity: emptyResults }; + } + + const combinedResults = refinedSearch(inputValue, entities); + + return { + combinedResults, + searchResultsByEntity: separateResultsByEntity(combinedResults), + }; +}; diff --git a/packages/manager/src/features/Search/withStoreSearch.tsx b/packages/manager/src/features/Search/withStoreSearch.tsx index 09be7ff8750..99e4848a6a6 100644 --- a/packages/manager/src/features/Search/withStoreSearch.tsx +++ b/packages/manager/src/features/Search/withStoreSearch.tsx @@ -22,26 +22,7 @@ import { } from 'src/store/selectors/getSearchEntities'; import { getImageLabelForLinode } from '../Images/utils'; -import { refinedSearch } from './refinedSearch'; -import { emptyResults, separateResultsByEntity } from './utils'; - -import type { SearchResults, SearchableItem } from './search.interfaces'; - -export const search = ( - entities: SearchableItem[], - inputValue: string -): SearchResults => { - if (!inputValue || inputValue === '') { - return { combinedResults: [], searchResultsByEntity: emptyResults }; - } - - const combinedResults = refinedSearch(inputValue, entities); - - return { - combinedResults, - searchResultsByEntity: separateResultsByEntity(combinedResults), - }; -}; +import { search } from './utils'; interface Props { query: string; diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index 5de91c7e423..659b47ab7d6 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -2,7 +2,7 @@ import { Autocomplete, Box, IconButton, TextField } from '@linode/ui'; import { getQueryParamsFromQueryString } from '@linode/utilities'; import Close from '@mui/icons-material/Close'; import { useMediaQuery, useTheme } from '@mui/material'; -import * as React from 'react'; +import React from 'react'; import { useHistory } from 'react-router-dom'; import Search from 'src/assets/icons/search.svg'; @@ -12,9 +12,9 @@ import { StyledIconButton, StyledSearchIcon } from './SearchBar.styles'; import { SearchSuggestion } from './SearchSuggestion'; import { StyledSearchSuggestion } from './SearchSuggestion.styles'; import { SearchSuggestionContainer } from './SearchSuggestionContainer'; +import { createFinalOptions } from './utils'; import type { SearchableItem } from 'src/features/Search/search.interfaces'; -import { createFinalOptions } from './utils'; export interface ExtendedSearchableItem extends Omit { From c7024948fb522f5293e8cedf4e79862313e79637 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 7 Mar 2025 13:17:12 -0500 Subject: [PATCH 04/31] clean up more --- .../src/features/Search/withStoreSearch.tsx | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/packages/manager/src/features/Search/withStoreSearch.tsx b/packages/manager/src/features/Search/withStoreSearch.tsx index 99e4848a6a6..f1ee82aa252 100644 --- a/packages/manager/src/features/Search/withStoreSearch.tsx +++ b/packages/manager/src/features/Search/withStoreSearch.tsx @@ -35,18 +35,18 @@ export const useLegacySearch = ({ query }: Props) => { isSearching && isLargeAccount !== undefined && !isLargeAccount; const { data: regions } = useRegionsQuery(); - const { data: objectStorageBuckets } = useObjectStorageBuckets( - shouldFetchAll - ); const { data: domains } = useAllDomainsQuery(shouldFetchAll); const { data: clusters } = useAllKubernetesClustersQuery(shouldFetchAll); const { data: volumes } = useAllVolumesQuery({}, {}, shouldFetchAll); - const { data: nodebalancers } = useAllNodeBalancersQuery(shouldFetchAll); + const { data: nodebals } = useAllNodeBalancersQuery(shouldFetchAll); const { data: firewalls } = useAllFirewallsQuery(shouldFetchAll); const { data: databases } = useAllDatabasesQuery(shouldFetchAll); - const { data: _privateImages, isLoading: imagesLoading } = useAllImagesQuery( + const { data: objectStorageBuckets } = useObjectStorageBuckets( + shouldFetchAll + ); + const { data: privateImages, isLoading: imagesLoading } = useAllImagesQuery( {}, - { is_public: false }, // We want to display private images (i.e., not Debian, Ubuntu, etc. distros) + { is_public: false }, shouldFetchAll ); const { data: publicImages } = useAllImagesQuery( @@ -65,28 +65,18 @@ export const useLegacySearch = ({ query }: Props) => { return formatLinode(linode, [], imageLabel); }); - const searchableBuckets = - objectStorageBuckets?.buckets.map((bucket) => - bucketToSearchableItem(bucket) - ) ?? []; - const searchableDomains = - domains?.map((domain) => domainToSearchableItem(domain)) ?? []; - const searchableVolumes = - volumes?.map((volume) => volumeToSearchableItem(volume)) ?? []; - const searchableImages = - _privateImages?.map((image) => imageToSearchableItem(image)) ?? []; const searchableClusters = clusters?.map((cluster) => kubernetesClusterToSearchableItem(cluster, regions ?? []) ) ?? []; - const searchableNodebalancers = - nodebalancers?.map((nodebalancer) => - nodeBalToSearchableItem(nodebalancer) - ) ?? []; - const searchableFirewalls = - firewalls?.map((firewall) => firewallToSearchableItem(firewall)) ?? []; - const searchableDatabases = - databases?.map((database) => databaseToSearchableItem(database)) ?? []; + const searchableBuckets = + objectStorageBuckets?.buckets.map(bucketToSearchableItem) ?? []; + const searchableDomains = domains?.map(domainToSearchableItem) ?? []; + const searchableVolumes = volumes?.map(volumeToSearchableItem) ?? []; + const searchableImages = privateImages?.map(imageToSearchableItem) ?? []; + const searchableNodebalancers = nodebals?.map(nodeBalToSearchableItem) ?? []; + const searchableFirewalls = firewalls?.map(firewallToSearchableItem) ?? []; + const searchableDatabases = databases?.map(databaseToSearchableItem) ?? []; const searchableItems = [ ...searchableLinodes, From 56fdbc3a06cc7b15dc9a9d6485755fed4ac1af45 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 7 Mar 2025 13:21:39 -0500 Subject: [PATCH 05/31] small fix --- packages/manager/src/features/Search/SearchLanding.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index ba91ea198c6..e8c8e253e52 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -8,6 +8,7 @@ import { ResultGroup } from './ResultGroup'; import { StyledError, StyledGrid, StyledStack } from './SearchLanding.styles'; import { useLegacySearch } from './withStoreSearch'; +import type { SearchResultsByEntity } from './search.interfaces'; import type { ResultRowDataOption } from './types'; const displayMap = { @@ -55,7 +56,7 @@ const SearchLanding = () => { )} {Object.keys(searchResultsByEntity).map( - (entityType: keyof typeof displayMap, idx: number) => ( + (entityType: keyof SearchResultsByEntity, idx: number) => ( Date: Fri, 7 Mar 2025 13:47:02 -0500 Subject: [PATCH 06/31] clean up more --- .../features/Search/SearchLanding.styles.ts | 73 -------- .../features/Search/SearchLanding.test.tsx | 103 ------------ .../src/features/Search/SearchLanding.tsx | 32 ++-- .../src/features/Search/useAPISearch.tsx | 156 ------------------ .../{withStoreSearch.tsx => useSearch.ts} | 8 +- .../features/TopMenu/SearchBar/SearchBar.tsx | 4 +- .../manager/src/queries/regions/regions.ts | 3 +- .../src/store/selectors/getSearchEntities.ts | 7 +- 8 files changed, 22 insertions(+), 364 deletions(-) delete mode 100644 packages/manager/src/features/Search/SearchLanding.styles.ts delete mode 100644 packages/manager/src/features/Search/SearchLanding.test.tsx delete mode 100644 packages/manager/src/features/Search/useAPISearch.tsx rename packages/manager/src/features/Search/{withStoreSearch.tsx => useSearch.ts} (94%) diff --git a/packages/manager/src/features/Search/SearchLanding.styles.ts b/packages/manager/src/features/Search/SearchLanding.styles.ts deleted file mode 100644 index e23a91fcd21..00000000000 --- a/packages/manager/src/features/Search/SearchLanding.styles.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { keyframes } from '@emotion/react'; -import { H1Header, Stack } from '@linode/ui'; -import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Grid2'; - -import Error from 'src/assets/icons/error.svg'; - -export const StyledStack = styled(Stack, { - label: 'StyledStack', -})(({ theme }) => ({ - alignItems: 'center', - padding: `${theme.spacing(10)} ${theme.spacing(4)}`, - [theme.breakpoints.down('md')]: { - padding: theme.spacing(4), - }, -})); - -export const StyledGrid = styled(Grid, { - label: 'StyledGrid', -})(({ theme }) => ({ - alignItems: 'center', - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - padding: `${theme.spacing(10)} ${theme.spacing(4)}`, -})); - -const blink = keyframes` - 0%, 50%, 100% { - transform: scaleY(0.1); - } -`; - -const rotate = keyframes` - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -`; - -const shake = keyframes` - 0%, 100% { - transform: translateX(0); - } - 25%, 75% { - transform: translateX(-5px); - } - 50% { - transform: translateX(5px); - } -`; - -export const StyledError = styled(Error, { - label: 'StyledError', -})(({ theme }) => ({ - '& path:nth-of-type(4)': { - animation: `${blink} 1s`, - transformBox: 'fill-box', - transformOrigin: 'center', - }, - '& path:nth-of-type(5)': { - animation: `${rotate} 3s`, - transformBox: 'fill-box', - transformOrigin: 'center', - }, - animation: `${shake} 0.5s`, - color: theme.palette.text.primary, - height: 60, - marginBottom: theme.spacing(4), - width: 60, -})); diff --git a/packages/manager/src/features/Search/SearchLanding.test.tsx b/packages/manager/src/features/Search/SearchLanding.test.tsx deleted file mode 100644 index f2e660bd447..00000000000 --- a/packages/manager/src/features/Search/SearchLanding.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { render } from '@testing-library/react'; -import { assocPath } from 'ramda'; -import * as React from 'react'; - -import { reactRouterProps } from 'src/__data__/reactRouterProps'; -import { searchbarResult1 } from 'src/__data__/searchResults'; -import { linodeTypeFactory } from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; - -import { SearchLanding } from './SearchLanding'; -import { emptyResults } from './utils'; - -import type { SearchLandingProps as Props } from './SearchLanding'; - -const props: Props = { - combinedResults: [], - entities: [], - entitiesLoading: false, - search: vi.fn(), - searchResultsByEntity: emptyResults, - ...reactRouterProps, -}; - -const propsWithResults: Props = { - ...props, - combinedResults: [searchbarResult1], - searchResultsByEntity: { ...emptyResults, linodes: [searchbarResult1] }, -}; - -describe('Component', () => { - beforeEach(() => { - server.use( - http.get('*/domains', () => { - return HttpResponse.json(makeResourcePage([])); - }), - http.get('*/linode/types/*', () => { - return HttpResponse.json(linodeTypeFactory.build()); - }) - ); - }); - - it('should render', async () => { - const { findByText } = renderWithTheme(); - expect(await findByText(/searched/i)); - }); - - it('should search on mount', async () => { - const newProps = assocPath( - ['location', 'search'], - '?query=search', - propsWithResults - ); - const { getByText } = renderWithTheme(); - getByText(/search/i); - expect(props.search).toHaveBeenCalled(); - }); - - it('should search when the entity list (from Redux) changes', () => { - vi.clearAllMocks(); - const { rerender } = render(wrapWithTheme()); - expect(props.search).toHaveBeenCalledTimes(1); - - const newEntities = [searchbarResult1]; - rerender( - wrapWithTheme() - ); - expect(props.search).toHaveBeenCalledTimes(2); - }); - - it('should show an empty state', async () => { - const { findByText } = renderWithTheme(); - await findByText(/no results/i); - }); - - it('should display the query term', async () => { - const { findByText } = renderWithTheme( - - ); - await findByText('Search Results for "search"'); - }); - - it('should parse multi-word queries correctly', async () => { - const newProps = assocPath( - ['location', 'search'], - '?query=two%20words', - propsWithResults - ); - const { findByText } = renderWithTheme(); - expect(await findByText('Search Results for "two words"')); - }); - - it('should handle blank or unusual queries without crashing', async () => { - const newProps = assocPath( - ['location', 'search'], - '?query=', - propsWithResults - ); - const { findByText } = renderWithTheme(); - await findByText(/search/i); - }); -}); diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index e8c8e253e52..3de0ada957c 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -5,8 +5,7 @@ import React from 'react'; import { useLocation } from 'react-router-dom'; import { ResultGroup } from './ResultGroup'; -import { StyledError, StyledGrid, StyledStack } from './SearchLanding.styles'; -import { useLegacySearch } from './withStoreSearch'; +import { useSearch } from './useSearch'; import type { SearchResultsByEntity } from './search.interfaces'; import type { ResultRowDataOption } from './types'; @@ -27,11 +26,9 @@ const SearchLanding = () => { const location = useLocation(); const query = getQueryParamFromQueryString(location.search, 'query'); - const { - combinedResults, - isLoading, - searchResultsByEntity, - } = useLegacySearch({ query }); + const { combinedResults, isLoading, searchResultsByEntity } = useSearch({ + query, + }); return ( @@ -42,18 +39,15 @@ const SearchLanding = () => { )} {isLoading && } {!isLoading && combinedResults.length === 0 && ( - - - - - You searched for ... - - {query} - - Sorry, no results for this one. - - - + + + You searched for ... + + {query} + + Sorry, no results for this one. + + )} {Object.keys(searchResultsByEntity).map( (entityType: keyof SearchResultsByEntity, idx: number) => ( diff --git a/packages/manager/src/features/Search/useAPISearch.tsx b/packages/manager/src/features/Search/useAPISearch.tsx deleted file mode 100644 index 241a9a0de58..00000000000 --- a/packages/manager/src/features/Search/useAPISearch.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { getDomains } from '@linode/api-v4/lib/domains'; -import { getImages } from '@linode/api-v4/lib/images'; -import { getKubernetesClusters } from '@linode/api-v4/lib/kubernetes'; -import { getLinodes } from '@linode/api-v4/lib/linodes'; -import { getNodeBalancers } from '@linode/api-v4/lib/nodebalancers'; -import { getVolumes } from '@linode/api-v4/lib/volumes'; -import { isNotNullOrUndefined } from '@linode/utilities'; -import { flatten } from 'ramda'; -import { useCallback } from 'react'; -import React from 'react'; - -import { API_MAX_PAGE_SIZE } from 'src/constants'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useAllImagesQuery } from 'src/queries/images'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { useSpecificTypes } from 'src/queries/types'; -import { - domainToSearchableItem, - formatLinode, - imageToSearchableItem, - kubernetesClusterToSearchableItem, - nodeBalToSearchableItem, - volumeToSearchableItem, -} from 'src/store/selectors/getSearchEntities'; -import { extendTypesQueryResult } from 'src/utilities/extendType'; - -import { getImageLabelForLinode } from '../Images/utils'; -import { refinedSearch } from './refinedSearch'; -import { emptyResults, separateResultsByEntity } from './utils'; - -import type { SearchResults, SearchableItem } from './search.interfaces'; -import type { Image } from '@linode/api-v4/lib/images'; -import type { Region } from '@linode/api-v4/lib/regions'; -import type { ExtendedType } from 'src/utilities/extendType'; - -interface Search { - searchAPI: (query: string) => Promise; -} - -export const useAPISearch = (conductedSearch: boolean): Search => { - const { _isRestrictedUser } = useAccountManagement(); - const { data: images } = useAllImagesQuery({}, {}, conductedSearch); - const { data: regions } = useRegionsQuery(); - - const [requestedTypes, setRequestedTypes] = React.useState([]); - const typesQuery = useSpecificTypes(requestedTypes); - const types = extendTypesQueryResult(typesQuery); - - const searchAPI = useCallback( - (searchText: string) => { - if (!searchText || searchText === '') { - return Promise.resolve({ - combinedResults: [], - searchResultsByEntity: emptyResults, - }); - } - - return requestEntities( - searchText, - types ?? [], - setRequestedTypes, - images ?? [], - regions ?? [], - _isRestrictedUser - ).then((results) => { - const combinedResults = refinedSearch(searchText, results); - return { - combinedResults, - searchResultsByEntity: separateResultsByEntity(combinedResults), - }; - }); - }, - [_isRestrictedUser, images, types] - ); - - return { searchAPI }; -}; - -const generateFilter = ( - text: string, - labelFieldName: string = 'label', - filterByIp?: boolean -) => { - return { - '+or': [ - { - [labelFieldName]: { '+contains': text }, - }, - { - tags: { '+contains': text }, - }, - ...(filterByIp - ? [ - { - ipv4: { '+contains': text }, - }, - ] - : []), - ], - }; -}; - -const params = { page_size: API_MAX_PAGE_SIZE }; - -const requestEntities = ( - searchText: string, - types: ExtendedType[], - setRequestedTypes: (types: string[]) => void, - images: Image[], - regions: Region[], - isRestricted: boolean = false -) => { - return Promise.all([ - getDomains(params, generateFilter(searchText, 'domain')).then((results) => - results.data.map(domainToSearchableItem) - ), - getLinodes(params, generateFilter(searchText, 'label', true)).then( - (results) => { - setRequestedTypes( - results.data.map((result) => result.type).filter(isNotNullOrUndefined) - ); - return results.data.map((linode) => { - const imageLabel = getImageLabelForLinode(linode, images); - return formatLinode(linode, types, imageLabel); - }); - } - ), - getImages( - params, - // Images can't be tagged and we have to filter only private Images - // Use custom filters for this - { - '+and': [{ label: { '+contains': searchText } }, { is_public: false }], - } - ).then((results) => results.data.map(imageToSearchableItem)), - getVolumes(params, generateFilter(searchText)).then((results) => - results.data.map(volumeToSearchableItem) - ), - getNodeBalancers( - params, - generateFilter(searchText, 'label', true) - ).then((results) => results.data.map(nodeBalToSearchableItem)), - // Restricted users always get a 403 when requesting clusters - !isRestricted - ? getKubernetesClusters().then((results) => - // Can't filter LKE by label (or anything maybe?) - // But no one has more than 500, so this is fine for the short term. - // @todo replace with generateFilter() when LKE-1889 is complete - results.data.map((cluster) => - kubernetesClusterToSearchableItem(cluster, regions) - ) - ) - : Promise.resolve([]), - // API filtering on Object Storage buckets does not work. - ]).then((results) => (flatten(results) as unknown) as SearchableItem[]); -}; diff --git a/packages/manager/src/features/Search/withStoreSearch.tsx b/packages/manager/src/features/Search/useSearch.ts similarity index 94% rename from packages/manager/src/features/Search/withStoreSearch.tsx rename to packages/manager/src/features/Search/useSearch.ts index f1ee82aa252..a42c40f2d5b 100644 --- a/packages/manager/src/features/Search/withStoreSearch.tsx +++ b/packages/manager/src/features/Search/useSearch.ts @@ -14,9 +14,9 @@ import { databaseToSearchableItem, domainToSearchableItem, firewallToSearchableItem, - formatLinode, imageToSearchableItem, kubernetesClusterToSearchableItem, + linodeToSearchableItem, nodeBalToSearchableItem, volumeToSearchableItem, } from 'src/store/selectors/getSearchEntities'; @@ -28,13 +28,13 @@ interface Props { query: string; } -export const useLegacySearch = ({ query }: Props) => { +export const useSearch = ({ query }: Props) => { const isSearching = Boolean(query); const isLargeAccount = useIsLargeAccount(isSearching); const shouldFetchAll = isSearching && isLargeAccount !== undefined && !isLargeAccount; - const { data: regions } = useRegionsQuery(); + const { data: regions } = useRegionsQuery(shouldFetchAll); const { data: domains } = useAllDomainsQuery(shouldFetchAll); const { data: clusters } = useAllKubernetesClustersQuery(shouldFetchAll); const { data: volumes } = useAllVolumesQuery({}, {}, shouldFetchAll); @@ -62,7 +62,7 @@ export const useLegacySearch = ({ query }: Props) => { const searchableLinodes = (linodes ?? []).map((linode) => { const imageLabel = getImageLabelForLinode(linode, publicImages ?? []); - return formatLinode(linode, [], imageLabel); + return linodeToSearchableItem(linode, [], imageLabel); }); const searchableClusters = diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index 659b47ab7d6..db954d5ad1b 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { useHistory } from 'react-router-dom'; import Search from 'src/assets/icons/search.svg'; -import { useLegacySearch } from 'src/features/Search/withStoreSearch'; +import { useSearch } from 'src/features/Search/useSearch'; import { StyledIconButton, StyledSearchIcon } from './SearchBar.styles'; import { SearchSuggestion } from './SearchSuggestion'; @@ -36,7 +36,7 @@ const isSpecialOption = ( export const SearchBar = () => { // Search state const [searchText, setSearchText] = React.useState(''); - const { combinedResults, isLargeAccount, isLoading } = useLegacySearch({ + const { combinedResults, isLargeAccount, isLoading } = useSearch({ query: searchText, }); diff --git a/packages/manager/src/queries/regions/regions.ts b/packages/manager/src/queries/regions/regions.ts index 6bc8a3298e8..5fadae23035 100644 --- a/packages/manager/src/queries/regions/regions.ts +++ b/packages/manager/src/queries/regions/regions.ts @@ -55,10 +55,11 @@ export const useRegionQuery = (regionId: string) => { }); }; -export const useRegionsQuery = () => +export const useRegionsQuery = (enabled?: boolean) => useQuery({ ...regionQueries.regions, ...queryPresets.longLived, + enabled, select: (regions: Region[]) => regions.map((region) => ({ ...region, diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index dcaa2b40236..87bff84ff90 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -39,7 +39,7 @@ export const getNodebalIps = (nodebal: NodeBalancer): string[] => { return ips; }; -export const formatLinode = ( +export const linodeToSearchableItem = ( linode: Linode, types: ExtendedType[], imageLabel: null | string @@ -80,11 +80,6 @@ export const volumeToSearchableItem = (volume: Volume): SearchableItem => ({ value: volume.id, }); -export const imageReducer = (accumulator: SearchableItem[], image: Image) => - image.is_public - ? accumulator - : [...accumulator, imageToSearchableItem(image)]; - export const imageToSearchableItem = (image: Image): SearchableItem => ({ data: { created: image.created, From b94656d384d3dcad1d46432657373052801ce7a3 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 7 Mar 2025 14:25:51 -0500 Subject: [PATCH 07/31] fix up loading state --- .../src/features/Search/SearchLanding.tsx | 29 ++++----- .../manager/src/features/Search/useSearch.ts | 59 ++++++++++++++----- .../src/store/selectors/getSearchEntities.ts | 2 +- 3 files changed, 60 insertions(+), 30 deletions(-) diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 3de0ada957c..1208898f2e3 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -31,22 +31,23 @@ const SearchLanding = () => { }); return ( - - {combinedResults.length > 0 && !isLoading && ( + + - Search Results {query && `for "${query}"`} - - )} - {isLoading && } + Search Results {query && `for "${query}"`} + {isLoading && } + {!isLoading && combinedResults.length === 0 && ( - - - You searched for ... - - {query} - - Sorry, no results for this one. - + + You searched for ... + {query} + Sorry, no results for this one. )} {Object.keys(searchResultsByEntity).map( diff --git a/packages/manager/src/features/Search/useSearch.ts b/packages/manager/src/features/Search/useSearch.ts index a42c40f2d5b..96f5f395325 100644 --- a/packages/manager/src/features/Search/useSearch.ts +++ b/packages/manager/src/features/Search/useSearch.ts @@ -34,26 +34,43 @@ export const useSearch = ({ query }: Props) => { const shouldFetchAll = isSearching && isLargeAccount !== undefined && !isLargeAccount; - const { data: regions } = useRegionsQuery(shouldFetchAll); - const { data: domains } = useAllDomainsQuery(shouldFetchAll); - const { data: clusters } = useAllKubernetesClustersQuery(shouldFetchAll); - const { data: volumes } = useAllVolumesQuery({}, {}, shouldFetchAll); - const { data: nodebals } = useAllNodeBalancersQuery(shouldFetchAll); - const { data: firewalls } = useAllFirewallsQuery(shouldFetchAll); - const { data: databases } = useAllDatabasesQuery(shouldFetchAll); - const { data: objectStorageBuckets } = useObjectStorageBuckets( + const { data: regions, isLoading: regionsLoading } = useRegionsQuery( shouldFetchAll ); - const { data: privateImages, isLoading: imagesLoading } = useAllImagesQuery( - {}, - { is_public: false }, + const { data: domains, isLoading: domainsLoading } = useAllDomainsQuery( shouldFetchAll ); - const { data: publicImages } = useAllImagesQuery( + const { + data: clusters, + isLoading: lkeClustersLoading, + } = useAllKubernetesClustersQuery(shouldFetchAll); + const { data: volumes, isLoading: volumesLoading } = useAllVolumesQuery( + {}, {}, - { is_public: true }, - isSearching + shouldFetchAll + ); + const { + data: nodebals, + isLoading: nodebalancersLoading, + } = useAllNodeBalancersQuery(shouldFetchAll); + const { data: firewalls, isLoading: firewallsLoading } = useAllFirewallsQuery( + shouldFetchAll + ); + const { data: databases, isLoading: databasesLoading } = useAllDatabasesQuery( + shouldFetchAll ); + const { + data: objectStorageBuckets, + isLoading: bucketsLoading, + } = useObjectStorageBuckets(shouldFetchAll); + const { + data: privateImages, + isLoading: privateImagesLoading, + } = useAllImagesQuery({}, { is_public: false }, shouldFetchAll); + const { + data: publicImages, + isLoading: publicIamgesLoading, + } = useAllImagesQuery({}, { is_public: true }, isSearching); const { data: linodes, isLoading: linodesLoading } = useAllLinodesQuery( {}, {}, @@ -90,7 +107,19 @@ export const useSearch = ({ query }: Props) => { ...searchableDatabases, ]; - const isLoading = linodesLoading || imagesLoading; + const isLoading = + linodesLoading || + privateImagesLoading || + publicIamgesLoading || + bucketsLoading || + lkeClustersLoading || + databasesLoading || + nodebalancersLoading || + domainsLoading || + regionsLoading || + volumesLoading || + firewallsLoading || + useIsLargeAccount === undefined; const results = search(searchableItems, query); diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index 87bff84ff90..55e4f9424b2 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -87,7 +87,7 @@ export const imageToSearchableItem = (image: Image): SearchableItem => ({ icon: 'image', /* TODO: Choose a real location for this to link to */ path: `/images?query="${image.label}"`, - tags: [], + tags: image.tags, }, entityType: 'image', label: image.label, From be9b93d9cbe1b5e9164477009ffd1a21937aeb48 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 7 Mar 2025 15:45:57 -0500 Subject: [PATCH 08/31] add image decription --- .../manager/src/store/selectors/getSearchEntities.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index 55e4f9424b2..853cda05626 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -1,3 +1,5 @@ +import { pluralize } from '@linode/utilities'; + import { getDatabasesDescription } from 'src/features/Databases/utilities'; import { getFirewallDescription } from 'src/features/Firewalls/shared'; import { getDescriptionForCluster } from 'src/features/Kubernetes/kubeUtils'; @@ -83,7 +85,13 @@ export const volumeToSearchableItem = (volume: Volume): SearchableItem => ({ export const imageToSearchableItem = (image: Image): SearchableItem => ({ data: { created: image.created, - description: image.description || '', + description: image.description + ? image.description + : `${image.size} MB, ${pluralize( + 'region', + 'regions', + image.regions.length + )}`, icon: 'image', /* TODO: Choose a real location for this to link to */ path: `/images?query="${image.label}"`, From f18a1451b4208ca04e88867f040c50ba650491a8 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 7 Mar 2025 18:14:23 -0500 Subject: [PATCH 09/31] get new api search working --- packages/manager/src/constants.ts | 9 ++ packages/manager/src/env.d.ts | 1 + .../ServiceTargets/LinodeOrIPSelect.tsx | 128 ------------------ .../src/features/Search/SearchLanding.tsx | 29 ++-- .../src/features/Search/search.interfaces.ts | 23 ++-- .../src/features/Search/useAPISearch.ts | 85 ++++++++++++ .../features/Search/useClientSideSearch.ts | 116 ++++++++++++++++ .../manager/src/features/Search/useSearch.ts | 126 ++--------------- .../manager/src/features/Search/utils.test.ts | 36 ++--- packages/manager/src/features/Search/utils.ts | 52 +++---- .../manager/src/queries/linodes/linodes.ts | 6 +- .../src/store/selectors/getSearchEntities.ts | 36 ++--- 12 files changed, 323 insertions(+), 324 deletions(-) delete mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx create mode 100644 packages/manager/src/features/Search/useAPISearch.ts create mode 100644 packages/manager/src/features/Search/useClientSideSearch.ts diff --git a/packages/manager/src/constants.ts b/packages/manager/src/constants.ts index 9d110cf545d..785e9021bb1 100644 --- a/packages/manager/src/constants.ts +++ b/packages/manager/src/constants.ts @@ -15,6 +15,15 @@ export const ENABLE_DEV_TOOLS = getBooleanEnv( export const ENABLE_MAINTENANCE_MODE = import.meta.env.REACT_APP_ENABLE_MAINTENANCE_MODE === 'true'; +/** + * Because Cloud Manager uses two different search implementations depending on the account's + * size, we have this environment variable which allows us to force Cloud Manager to use + * a desired implementation. + * + * @example REACT_APP_FORCE_SEARCH_TYPE=api + */ +export const FORCE_SEARCH_TYPE = import.meta.env.REACT_APP_FORCE_SEARCH_TYPE; + /** required for the app to function */ export const APP_ROOT = import.meta.env.REACT_APP_APP_ROOT || 'http://localhost:3000'; diff --git a/packages/manager/src/env.d.ts b/packages/manager/src/env.d.ts index 3eeae8b5d89..9f68e1692eb 100644 --- a/packages/manager/src/env.d.ts +++ b/packages/manager/src/env.d.ts @@ -18,6 +18,7 @@ interface ImportMetaEnv { REACT_APP_DISABLE_NEW_RELIC?: boolean; REACT_APP_ENABLE_DEV_TOOLS?: boolean; REACT_APP_ENABLE_MAINTENANCE_MODE?: string; + REACT_APP_FORCE_SEARCH_TYPE?: 'api' | 'client'; REACT_APP_GPAY_ENV?: 'PRODUCTION' | 'TEST'; REACT_APP_GPAY_MERCHANT_ID?: string; REACT_APP_LAUNCH_DARKLY_ID?: string; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx deleted file mode 100644 index 0c90c645cca..00000000000 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { Autocomplete, Box, SelectedIcon, Stack } from '@linode/ui'; -import React from 'react'; - -import { linodeFactory } from 'src/factories'; -import { useInfiniteLinodesQuery } from 'src/queries/linodes/linodes'; -import { useRegionsQuery } from 'src/queries/regions/regions'; - -import type { Filter } from '@linode/api-v4'; -import type { TextFieldProps } from '@linode/ui'; - -interface Props { - /** - * Error text to display as helper text under the TextField. Useful for validation errors. - */ - errorText?: string; - /** - * Called when the value of the Select changes - */ - onChange: (ip: string) => void; - /** - * Optional props passed to the TextField - */ - textFieldProps?: Partial; - /** - * The id of the selected certificate - */ - value: null | string; -} - -export const LinodeOrIPSelect = (props: Props) => { - const { errorText, onChange, textFieldProps, value } = props; - - const [inputValue, setInputValue] = React.useState(''); - - const filter: Filter = {}; - - // If the user types in the Autocomplete, API filter for Linodes. - if (inputValue) { - filter['+or'] = [ - { label: { '+contains': inputValue } }, - { ipv4: { '+contains': inputValue } }, - ]; - } - - const { - data, - error, - fetchNextPage, - hasNextPage, - isLoading, - } = useInfiniteLinodesQuery(filter); - - const { data: regions } = useRegionsQuery(); - - const linodes = data?.pages.flatMap((page) => page.data) ?? []; - - const selectedLinode = value - ? linodes?.find((linode) => linode.ipv4.includes(value)) ?? null - : null; - - const onScroll = (event: React.SyntheticEvent) => { - const listboxNode = event.currentTarget; - if ( - listboxNode.scrollTop + listboxNode.clientHeight >= - listboxNode.scrollHeight && - hasNextPage - ) { - fetchNextPage(); - } - }; - - const customIpPlaceholder = linodeFactory.build({ - ipv4: [inputValue], - label: `Use IP ${inputValue}`, - }); - - const options = [...linodes]; - - if (linodes.length === 0 && !isLoading) { - options.push(customIpPlaceholder); - } - - return ( - { - if (reason === 'input' || reason === 'clear') { - setInputValue(value); - onChange(value); - } - }} - renderOption={(props, option, state) => { - const { key, ...rest } = props; - const region = - regions?.find((r) => r.id === option.region)?.label ?? option.region; - - const isCustomIp = option === customIpPlaceholder; - - return ( -
  • - - - {isCustomIp ? 'Custom IP' : option.label} - - - {isCustomIp ? option.ipv4[0] : `${option.ipv4[0]} - ${region}`} - - - -
  • - ); - }} - errorText={error?.[0]?.reason ?? errorText} - filterOptions={(x) => x} - fullWidth - inputValue={selectedLinode ? selectedLinode.label : inputValue} - label="Linode or Public IP Address" - loading={isLoading} - onChange={(e, value) => onChange(value?.ipv4[0] ?? '')} - options={options} - placeholder="Select Linode or Enter IP Address" - textFieldProps={textFieldProps} - value={linodes.length === 0 ? customIpPlaceholder : selectedLinode} - /> - ); -}; diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 1208898f2e3..f3b53ea9acf 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -7,19 +7,23 @@ import { useLocation } from 'react-router-dom'; import { ResultGroup } from './ResultGroup'; import { useSearch } from './useSearch'; -import type { SearchResultsByEntity } from './search.interfaces'; +import type { + SearchResultsByEntity, + SearchableEntityType, +} from './search.interfaces'; import type { ResultRowDataOption } from './types'; -const displayMap = { - buckets: 'Buckets', - databases: 'Databases', - domains: 'Domains', - firewalls: 'Firewalls', - images: 'Images', - kubernetesClusters: 'Kubernetes', - linodes: 'Linodes', - nodebalancers: 'NodeBalancers', - volumes: 'Volumes', +const displayMap: Record = { + bucket: 'Buckets', + database: 'Databases', + domain: 'Domains', + firewall: 'Firewalls', + image: 'Images', + kubernetesCluster: 'Kubernetes', + linode: 'Linodes', + nodebalancer: 'NodeBalancers', + stackscript: 'StackScripts', + volume: 'Volumes', }; const SearchLanding = () => { @@ -40,7 +44,8 @@ const SearchLanding = () => { spacing={1} > - Search Results {query && `for "${query}"`} + Search Results {query && `for "${query}"`} + {isLoading && }
    {!isLoading && combinedResults.length === 0 && ( diff --git a/packages/manager/src/features/Search/search.interfaces.ts b/packages/manager/src/features/Search/search.interfaces.ts index 8a0338b541f..59d0f67b636 100644 --- a/packages/manager/src/features/Search/search.interfaces.ts +++ b/packages/manager/src/features/Search/search.interfaces.ts @@ -19,20 +19,21 @@ export type SearchableEntityType = | 'kubernetesCluster' | 'linode' | 'nodebalancer' - | 'volume' - | null; + | 'stackscript' + | 'volume'; // These are the properties on our entities we'd like to search export type SearchField = 'ips' | 'label' | 'tags' | 'type' | 'value'; export interface SearchResultsByEntity { - buckets: SearchableItem[]; - databases: SearchableItem[]; - domains: SearchableItem[]; - firewalls: SearchableItem[]; - images: SearchableItem[]; - kubernetesClusters: SearchableItem[]; - linodes: SearchableItem[]; - nodebalancers: SearchableItem[]; - volumes: SearchableItem[]; + bucket: SearchableItem[]; + database: SearchableItem[]; + domain: SearchableItem[]; + firewall: SearchableItem[]; + image: SearchableItem[]; + kubernetesCluster: SearchableItem[]; + linode: SearchableItem[]; + nodebalancer: SearchableItem[]; + stackscript: SearchableItem[]; + volume: SearchableItem[]; } diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts new file mode 100644 index 00000000000..164bd79552d --- /dev/null +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -0,0 +1,85 @@ +import { getAPIFilterFromQuery } from '@linode/search'; +import { useDebouncedValue } from '@linode/utilities'; + +import { useInfiniteLinodesQuery } from 'src/queries/linodes/linodes'; +import { useStackScriptsInfiniteQuery } from 'src/queries/stackscripts'; +import { useInfiniteVolumesQuery } from 'src/queries/volumes/volumes'; +import { + linodeToSearchableItem, + stackscriptToSearchableItem, + volumeToSearchableItem, +} from 'src/store/selectors/getSearchEntities'; + +import { separateResultsByEntity } from './utils'; + +import type { SearchableItem } from './search.interfaces'; + +interface Props { + enabled: boolean; + query: string; +} + +const entities = [ + { + getSearchableItem: linodeToSearchableItem, + name: 'linode' as const, + query: useInfiniteLinodesQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['id', 'label', 'tags', 'ipv4'], + }, + }, + { + getSearchableItem: volumeToSearchableItem, + name: 'volume' as const, + query: useInfiniteVolumesQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['label', 'tags'], + }, + }, + { + baseFilter: { mine: true }, + getSearchableItem: stackscriptToSearchableItem, + name: 'stackscript' as const, + query: useStackScriptsInfiniteQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['label'], + }, + }, +]; + +export const useAPISearch = ({ enabled, query }: Props) => { + const deboundedQuery = useDebouncedValue(query); + + const result = entities.map((entity) => { + const { error, filter } = getAPIFilterFromQuery( + deboundedQuery, + entity.searchOptions + ); + + return { + ...entity, + parseError: error, + ...entity.query( + entity.baseFilter ? { ...entity.baseFilter, ...filter } : filter, + enabled && error === null + ), + }; + }); + + const isLoading = result.some((r) => r.isLoading); + + const combinedResults = result.flatMap( + (r) => + r.data?.pages.flatMap((p) => + p.data.map(r.getSearchableItem as (i: unknown) => SearchableItem) + ) ?? [] + ); + + const searchResultsByEntity = separateResultsByEntity(combinedResults); + + return { + combinedResults, + isLoading, + searchResultsByEntity, + }; +}; diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts new file mode 100644 index 00000000000..9cafdab01ab --- /dev/null +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -0,0 +1,116 @@ +import { useAllDatabasesQuery } from 'src/queries/databases/databases'; +import { useAllDomainsQuery } from 'src/queries/domains'; +import { useAllFirewallsQuery } from 'src/queries/firewalls'; +import { useAllImagesQuery } from 'src/queries/images'; +import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; +import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; +import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; +import { + bucketToSearchableItem, + databaseToSearchableItem, + domainToSearchableItem, + firewallToSearchableItem, + imageToSearchableItem, + kubernetesClusterToSearchableItem, + linodeToSearchableItem, + nodeBalToSearchableItem, + volumeToSearchableItem, +} from 'src/store/selectors/getSearchEntities'; + +import { search } from './utils'; + +interface Props { + enabled: boolean; + query: string; +} + +export const useClientSideSearch = ({ enabled, query }: Props) => { + const { data: regions, isLoading: regionsLoading } = useRegionsQuery(enabled); + const { data: domains, isLoading: domainsLoading } = useAllDomainsQuery( + enabled + ); + const { + data: clusters, + isLoading: lkeClustersLoading, + } = useAllKubernetesClustersQuery(enabled); + const { data: volumes, isLoading: volumesLoading } = useAllVolumesQuery( + {}, + {}, + enabled + ); + const { data: linodes, isLoading: linodesLoading } = useAllLinodesQuery( + {}, + {}, + enabled + ); + const { + data: nodebals, + isLoading: nodebalancersLoading, + } = useAllNodeBalancersQuery(enabled); + const { data: firewalls, isLoading: firewallsLoading } = useAllFirewallsQuery( + enabled + ); + const { data: databases, isLoading: databasesLoading } = useAllDatabasesQuery( + enabled + ); + const { + data: objectStorageBuckets, + isLoading: bucketsLoading, + } = useObjectStorageBuckets(enabled); + const { + data: privateImages, + isLoading: privateImagesLoading, + } = useAllImagesQuery({}, { is_public: false }, enabled); + + const searchableDomains = domains?.map(domainToSearchableItem) ?? []; + const searchableVolumes = volumes?.map(volumeToSearchableItem) ?? []; + const searchableImages = privateImages?.map(imageToSearchableItem) ?? []; + const searchableNodebalancers = nodebals?.map(nodeBalToSearchableItem) ?? []; + const searchableFirewalls = firewalls?.map(firewallToSearchableItem) ?? []; + const searchableDatabases = databases?.map(databaseToSearchableItem) ?? []; + const searchableBuckets = + objectStorageBuckets?.buckets.map(bucketToSearchableItem) ?? []; + const searchableLinodes = linodes?.map(linodeToSearchableItem) ?? []; + const searchableClusters = + clusters?.map((cluster) => + kubernetesClusterToSearchableItem(cluster, regions ?? []) + ) ?? []; + + const searchableItems = [ + ...searchableLinodes, + ...searchableImages, + ...searchableBuckets, + ...searchableDomains, + ...searchableVolumes, + ...searchableClusters, + ...searchableNodebalancers, + ...searchableFirewalls, + ...searchableDatabases, + ]; + + const isLoading = + linodesLoading || + privateImagesLoading || + bucketsLoading || + lkeClustersLoading || + databasesLoading || + nodebalancersLoading || + domainsLoading || + regionsLoading || + volumesLoading || + firewallsLoading; + + const { combinedResults, searchResultsByEntity } = search( + searchableItems, + query + ); + + return { + combinedResults, + isLoading, + searchResultsByEntity, + }; +}; diff --git a/packages/manager/src/features/Search/useSearch.ts b/packages/manager/src/features/Search/useSearch.ts index 96f5f395325..05be5d68249 100644 --- a/packages/manager/src/features/Search/useSearch.ts +++ b/packages/manager/src/features/Search/useSearch.ts @@ -1,28 +1,8 @@ +import { FORCE_SEARCH_TYPE } from 'src/constants'; import { useIsLargeAccount } from 'src/hooks/useIsLargeAccount'; -import { useAllDatabasesQuery } from 'src/queries/databases/databases'; -import { useAllDomainsQuery } from 'src/queries/domains'; -import { useAllFirewallsQuery } from 'src/queries/firewalls'; -import { useAllImagesQuery } from 'src/queries/images'; -import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; -import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; -import { - bucketToSearchableItem, - databaseToSearchableItem, - domainToSearchableItem, - firewallToSearchableItem, - imageToSearchableItem, - kubernetesClusterToSearchableItem, - linodeToSearchableItem, - nodeBalToSearchableItem, - volumeToSearchableItem, -} from 'src/store/selectors/getSearchEntities'; -import { getImageLabelForLinode } from '../Images/utils'; -import { search } from './utils'; +import { useAPISearch } from './useAPISearch'; +import { useClientSideSearch } from './useClientSideSearch'; interface Props { query: string; @@ -31,101 +11,25 @@ interface Props { export const useSearch = ({ query }: Props) => { const isSearching = Boolean(query); const isLargeAccount = useIsLargeAccount(isSearching); - const shouldFetchAll = - isSearching && isLargeAccount !== undefined && !isLargeAccount; - const { data: regions, isLoading: regionsLoading } = useRegionsQuery( - shouldFetchAll - ); - const { data: domains, isLoading: domainsLoading } = useAllDomainsQuery( - shouldFetchAll - ); - const { - data: clusters, - isLoading: lkeClustersLoading, - } = useAllKubernetesClustersQuery(shouldFetchAll); - const { data: volumes, isLoading: volumesLoading } = useAllVolumesQuery( - {}, - {}, - shouldFetchAll - ); - const { - data: nodebals, - isLoading: nodebalancersLoading, - } = useAllNodeBalancersQuery(shouldFetchAll); - const { data: firewalls, isLoading: firewallsLoading } = useAllFirewallsQuery( - shouldFetchAll - ); - const { data: databases, isLoading: databasesLoading } = useAllDatabasesQuery( - shouldFetchAll - ); - const { - data: objectStorageBuckets, - isLoading: bucketsLoading, - } = useObjectStorageBuckets(shouldFetchAll); - const { - data: privateImages, - isLoading: privateImagesLoading, - } = useAllImagesQuery({}, { is_public: false }, shouldFetchAll); - const { - data: publicImages, - isLoading: publicIamgesLoading, - } = useAllImagesQuery({}, { is_public: true }, isSearching); - const { data: linodes, isLoading: linodesLoading } = useAllLinodesQuery( - {}, - {}, - shouldFetchAll - ); + const shouldUseClientSideSearch = FORCE_SEARCH_TYPE + ? FORCE_SEARCH_TYPE === 'client' + : isLargeAccount === false; - const searchableLinodes = (linodes ?? []).map((linode) => { - const imageLabel = getImageLabelForLinode(linode, publicImages ?? []); - return linodeToSearchableItem(linode, [], imageLabel); + const clientSideSearchData = useClientSideSearch({ + enabled: shouldUseClientSideSearch, + query, }); - const searchableClusters = - clusters?.map((cluster) => - kubernetesClusterToSearchableItem(cluster, regions ?? []) - ) ?? []; - const searchableBuckets = - objectStorageBuckets?.buckets.map(bucketToSearchableItem) ?? []; - const searchableDomains = domains?.map(domainToSearchableItem) ?? []; - const searchableVolumes = volumes?.map(volumeToSearchableItem) ?? []; - const searchableImages = privateImages?.map(imageToSearchableItem) ?? []; - const searchableNodebalancers = nodebals?.map(nodeBalToSearchableItem) ?? []; - const searchableFirewalls = firewalls?.map(firewallToSearchableItem) ?? []; - const searchableDatabases = databases?.map(databaseToSearchableItem) ?? []; - - const searchableItems = [ - ...searchableLinodes, - ...searchableImages, - ...searchableBuckets, - ...searchableDomains, - ...searchableVolumes, - ...searchableClusters, - ...searchableNodebalancers, - ...searchableFirewalls, - ...searchableDatabases, - ]; - - const isLoading = - linodesLoading || - privateImagesLoading || - publicIamgesLoading || - bucketsLoading || - lkeClustersLoading || - databasesLoading || - nodebalancersLoading || - domainsLoading || - regionsLoading || - volumesLoading || - firewallsLoading || - useIsLargeAccount === undefined; + const apiSearchData = useAPISearch({ + enabled: !shouldUseClientSideSearch, + query, + }); - const results = search(searchableItems, query); + const data = shouldUseClientSideSearch ? clientSideSearchData : apiSearchData; return { - ...results, + ...data, isLargeAccount, - isLoading, }; }; diff --git a/packages/manager/src/features/Search/utils.test.ts b/packages/manager/src/features/Search/utils.test.ts index 4162c8a96ad..5f5a764e9ae 100644 --- a/packages/manager/src/features/Search/utils.test.ts +++ b/packages/manager/src/features/Search/utils.test.ts @@ -21,29 +21,29 @@ describe('separate results by entity', () => { }); it('the value of each entity type is an array', () => { - expect(results.linodes).toBeInstanceOf(Array); - expect(results.volumes).toBeInstanceOf(Array); - expect(results.domains).toBeInstanceOf(Array); - expect(results.images).toBeInstanceOf(Array); - expect(results.nodebalancers).toBeInstanceOf(Array); - expect(results.kubernetesClusters).toBeInstanceOf(Array); - expect(results.buckets).toBeInstanceOf(Array); - expect(results.firewalls).toBeInstanceOf(Array); - expect(results.databases).toBeInstanceOf(Array); + expect(results.linode).toBeInstanceOf(Array); + expect(results.volume).toBeInstanceOf(Array); + expect(results.domain).toBeInstanceOf(Array); + expect(results.image).toBeInstanceOf(Array); + expect(results.nodebalancer).toBeInstanceOf(Array); + expect(results.kubernetesCluster).toBeInstanceOf(Array); + expect(results.bucket).toBeInstanceOf(Array); + expect(results.firewall).toBeInstanceOf(Array); + expect(results.database).toBeInstanceOf(Array); }); it('returns empty results if there is no data', () => { const newResults = separateResultsByEntity([]); expect(newResults).toEqual({ - buckets: [], - databases: [], - domains: [], - firewalls: [], - images: [], - kubernetesClusters: [], - linodes: [], - nodebalancers: [], - volumes: [], + bucket: [], + database: [], + domain: [], + firewall: [], + image: [], + kubernetesCluster: [], + linode: [], + nodebalancer: [], + volume: [], }); }); }); diff --git a/packages/manager/src/features/Search/utils.ts b/packages/manager/src/features/Search/utils.ts index 36c3aaf3b8a..a005652d940 100644 --- a/packages/manager/src/features/Search/utils.ts +++ b/packages/manager/src/features/Search/utils.ts @@ -1,44 +1,44 @@ import { refinedSearch } from './refinedSearch'; + import type { - SearchResults, + SearchResults, SearchResultsByEntity, SearchableItem, } from './search.interfaces'; export const emptyResults: SearchResultsByEntity = { - buckets: [], - databases: [], - domains: [], - firewalls: [], - images: [], - kubernetesClusters: [], - linodes: [], - nodebalancers: [], - volumes: [], + bucket: [], + database: [], + domain: [], + firewall: [], + image: [], + kubernetesCluster: [], + linode: [], + nodebalancer: [], + stackscript: [], + volume: [], }; export const separateResultsByEntity = ( searchResults: SearchableItem[] ): SearchResultsByEntity => { const separatedResults: SearchResultsByEntity = { - buckets: [], - databases: [], - domains: [], - firewalls: [], - images: [], - kubernetesClusters: [], - linodes: [], - nodebalancers: [], - volumes: [], + bucket: [], + database: [], + domain: [], + firewall: [], + image: [], + kubernetesCluster: [], + linode: [], + nodebalancer: [], + stackscript: [], + volume: [], }; - searchResults.forEach((result) => { - // EntityTypes are singular; we'd like the resulting keys to be plural - const pluralizedEntityType = result.entityType + 's'; - separatedResults[ - pluralizedEntityType as keyof typeof separatedResults - ].push(result); - }); + for (const result of searchResults) { + separatedResults[result.entityType].push(result); + } + return separatedResults; }; diff --git a/packages/manager/src/queries/linodes/linodes.ts b/packages/manager/src/queries/linodes/linodes.ts index 77a7e1b6335..19580f6100d 100644 --- a/packages/manager/src/queries/linodes/linodes.ts +++ b/packages/manager/src/queries/linodes/linodes.ts @@ -205,9 +205,13 @@ export const useAllLinodesQuery = ( }); }; -export const useInfiniteLinodesQuery = (filter: Filter = {}) => +export const useInfiniteLinodesQuery = ( + filter: Filter = {}, + enabled: boolean +) => useInfiniteQuery, APIError[]>({ ...linodeQueries.linodes._ctx.infinite(filter), + enabled, getNextPageParam: ({ page, pages }) => { if (page === pages) { return undefined; diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index 853cda05626..de9cb1d98c9 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -3,8 +3,6 @@ import { pluralize } from '@linode/utilities'; import { getDatabasesDescription } from 'src/features/Databases/utilities'; import { getFirewallDescription } from 'src/features/Firewalls/shared'; import { getDescriptionForCluster } from 'src/features/Kubernetes/kubeUtils'; -import { displayType } from 'src/features/Linodes/presentation'; -import { getLinodeDescription } from 'src/utilities/getLinodeDescription'; import { readableBytes } from 'src/utilities/unitConversions'; import type { @@ -17,10 +15,10 @@ import type { NodeBalancer, ObjectStorageBucket, Region, + StackScript, Volume, } from '@linode/api-v4'; import type { SearchableItem } from 'src/features/Search/search.interfaces'; -import type { ExtendedType } from 'src/utilities/extendType'; export const getLinodeIps = (linode: Linode): string[] => { const { ipv4, ipv6 } = linode; @@ -41,25 +39,14 @@ export const getNodebalIps = (nodebal: NodeBalancer): string[] => { return ips; }; -export const linodeToSearchableItem = ( - linode: Linode, - types: ExtendedType[], - imageLabel: null | string -): SearchableItem => ({ +export const linodeToSearchableItem = (linode: Linode): SearchableItem => ({ data: { created: linode.created, - description: getLinodeDescription( - displayType(linode.type, types), - linode.specs.memory, - linode.specs.disk, - linode.specs.vcpus, - imageLabel - ), + description: `${linode.image} ${linode.specs.vcpus} CPUs`, icon: 'linode', ips: getLinodeIps(linode), path: `/linodes/${linode.id}`, region: linode.region, - searchText: '', // @todo update this, either here or in the consumer. Probably in the consumer. status: linode.status, tags: linode.tags, }, @@ -93,7 +80,6 @@ export const imageToSearchableItem = (image: Image): SearchableItem => ({ image.regions.length )}`, icon: 'image', - /* TODO: Choose a real location for this to link to */ path: `/images?query="${image.label}"`, tags: image.tags, }, @@ -200,3 +186,19 @@ export const databaseToSearchableItem = ( label: database.label, value: `${database.engine}/${database.id}`, }); + +export const stackscriptToSearchableItem = ( + stackscript: StackScript +): SearchableItem => ({ + data: { + created: stackscript.created, + description: stackscript.description + ? stackscript.description + : `${stackscript.deployments_total} deploys, ${stackscript.deployments_active} active deployments`, + icon: 'stackscript', + path: `/stackscripts/${stackscript.id}`, + }, + entityType: 'stackscript', + label: stackscript.label, + value: stackscript.id, +}); From db8b214edee4608466d4dad4e95c385c846a21cc Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 11:19:40 -0400 Subject: [PATCH 10/31] save progress --- .../manager/src/features/Search/useAPISearch.ts | 17 ++++++++++++++--- .../src/features/Search/useClientSideSearch.ts | 4 ++++ packages/manager/src/queries/volumes/volumes.ts | 3 ++- packages/search/src/search.ts | 2 +- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts index 164bd79552d..b6f0331d573 100644 --- a/packages/manager/src/features/Search/useAPISearch.ts +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -22,7 +22,7 @@ interface Props { const entities = [ { getSearchableItem: linodeToSearchableItem, - name: 'linode' as const, + name: 'linode', query: useInfiniteLinodesQuery, searchOptions: { searchableFieldsWithoutOperator: ['id', 'label', 'tags', 'ipv4'], @@ -30,7 +30,7 @@ const entities = [ }, { getSearchableItem: volumeToSearchableItem, - name: 'volume' as const, + name: 'volume', query: useInfiniteVolumesQuery, searchOptions: { searchableFieldsWithoutOperator: ['label', 'tags'], @@ -39,7 +39,7 @@ const entities = [ { baseFilter: { mine: true }, getSearchableItem: stackscriptToSearchableItem, - name: 'stackscript' as const, + name: 'stackscript', query: useStackScriptsInfiniteQuery, searchOptions: { searchableFieldsWithoutOperator: ['label'], @@ -47,6 +47,17 @@ const entities = [ }, ]; +/** + * Fetches entities on a user's account using server-side filtering + * based on a user's seach query. + * + * We have to fetch the first page of each entity because API-v4 + * does not provide a dedicated search endpoint. + * + * The main advantage of this hook over useClientSideSearch is that it uses + * server-side filtering (X-Filters) so that we don't need to fetch all entities + * and do the filtering client-side. + */ export const useAPISearch = ({ enabled, query }: Props) => { const deboundedQuery = useDebouncedValue(query); diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index 9cafdab01ab..c27d44f1d5d 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -27,6 +27,10 @@ interface Props { query: string; } +/** + * Fetches all entities on a user's account and performs client-side filtering + * based on a user's seach query. + */ export const useClientSideSearch = ({ enabled, query }: Props) => { const { data: regions, isLoading: regionsLoading } = useRegionsQuery(enabled); const { data: domains, isLoading: domainsLoading } = useAllDomainsQuery( diff --git a/packages/manager/src/queries/volumes/volumes.ts b/packages/manager/src/queries/volumes/volumes.ts index e96b6ce85af..9d7b43e6807 100644 --- a/packages/manager/src/queries/volumes/volumes.ts +++ b/packages/manager/src/queries/volumes/volumes.ts @@ -96,9 +96,10 @@ export const useVolumeTypesQuery = () => ...queryPresets.oneTimeFetch, }); -export const useInfiniteVolumesQuery = (filter: Filter) => +export const useInfiniteVolumesQuery = (filter: Filter, enabled?: boolean) => useInfiniteQuery, APIError[]>({ ...volumeQueries.lists._ctx.infinite(filter), + enabled, getNextPageParam: ({ page, pages }) => { if (page === pages) { return undefined; diff --git a/packages/search/src/search.ts b/packages/search/src/search.ts index 580ccba8476..43601859433 100644 --- a/packages/search/src/search.ts +++ b/packages/search/src/search.ts @@ -4,7 +4,7 @@ import grammar from './search.peggy?raw'; const parser = generate(grammar); -interface Options { +export interface Options { /** * Defines the API fields filtered against (currently using +contains) * when the search query contains no operators. From 53236a9e7108253bbbef386af80fb771b3a6936a Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 12:27:30 -0400 Subject: [PATCH 11/31] save progress --- .../src/features/Kubernetes/kubeUtils.ts | 9 ++----- .../src/features/Search/SearchLanding.tsx | 19 ++++++++++++-- .../src/features/Search/useAPISearch.ts | 26 +++++++++++++++---- .../features/Search/useClientSideSearch.ts | 23 +++++++++++----- .../manager/src/features/Search/utils.test.ts | 19 +++++++------- packages/manager/src/features/Search/utils.ts | 26 +++++++++++++++++++ .../features/TopMenu/SearchBar/SearchBar.tsx | 7 ++++- .../manager/src/queries/linodes/linodes.ts | 1 + packages/manager/src/queries/stackscripts.ts | 1 + .../manager/src/queries/volumes/volumes.ts | 1 + .../src/store/selectors/getSearchEntities.ts | 6 ++--- 11 files changed, 104 insertions(+), 34 deletions(-) diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index 175f160b067..ed597f9bc9e 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -17,7 +17,6 @@ import type { KubernetesTieredVersion, KubernetesVersion, } from '@linode/api-v4/lib/kubernetes'; -import type { Region } from '@linode/api-v4/lib/regions'; import type { ExtendedType } from 'src/utilities/extendType'; interface ClusterData { @@ -52,14 +51,10 @@ export const getTotalClusterMemoryCPUAndStorage = ( ); }; -export const getDescriptionForCluster = ( - cluster: KubernetesCluster, - regions: Region[] -) => { - const region = regions.find((r) => r.id === cluster.region); +export const getDescriptionForCluster = (cluster: KubernetesCluster) => { const description: string[] = [ `Kubernetes ${cluster.k8s_version}`, - region?.label ?? cluster.region, + cluster.region, ]; if (cluster.control_plane.high_availability) { diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index f3b53ea9acf..7f0e498b396 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -1,4 +1,4 @@ -import { CircleProgress, Stack, Typography } from '@linode/ui'; +import { CircleProgress, Notice, Stack, Typography } from '@linode/ui'; import { getQueryParamFromQueryString } from '@linode/utilities'; import { createLazyRoute } from '@tanstack/react-router'; import React from 'react'; @@ -12,6 +12,7 @@ import type { SearchableEntityType, } from './search.interfaces'; import type { ResultRowDataOption } from './types'; +import { getErrorsFromErrorMap } from './utils'; const displayMap: Record = { bucket: 'Buckets', @@ -30,10 +31,17 @@ const SearchLanding = () => { const location = useLocation(); const query = getQueryParamFromQueryString(location.search, 'query'); - const { combinedResults, isLoading, searchResultsByEntity } = useSearch({ + const { + combinedResults, + entityErrors, + isLoading, + searchResultsByEntity, + } = useSearch({ query, }); + const errors = getErrorsFromErrorMap(entityErrors); + return ( { {isLoading && } + {errors.length > 0 && ( + + {errors.map((error) => ( + {error} + ))} + + )} {!isLoading && combinedResults.length === 0 && ( You searched for ... diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts index b6f0331d573..2c3add61d10 100644 --- a/packages/manager/src/features/Search/useAPISearch.ts +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -10,9 +10,9 @@ import { volumeToSearchableItem, } from 'src/store/selectors/getSearchEntities'; -import { separateResultsByEntity } from './utils'; +import { emptyErrors, separateResultsByEntity } from './utils'; -import type { SearchableItem } from './search.interfaces'; +import type { SearchableEntityType, SearchableItem } from './search.interfaces'; interface Props { enabled: boolean; @@ -22,7 +22,7 @@ interface Props { const entities = [ { getSearchableItem: linodeToSearchableItem, - name: 'linode', + name: 'linode' as const, query: useInfiniteLinodesQuery, searchOptions: { searchableFieldsWithoutOperator: ['id', 'label', 'tags', 'ipv4'], @@ -30,7 +30,7 @@ const entities = [ }, { getSearchableItem: volumeToSearchableItem, - name: 'volume', + name: 'volume' as const, query: useInfiniteVolumesQuery, searchOptions: { searchableFieldsWithoutOperator: ['label', 'tags'], @@ -39,7 +39,7 @@ const entities = [ { baseFilter: { mine: true }, getSearchableItem: stackscriptToSearchableItem, - name: 'stackscript', + name: 'stackscript' as const, query: useStackScriptsInfiniteQuery, searchOptions: { searchableFieldsWithoutOperator: ['label'], @@ -88,8 +88,24 @@ export const useAPISearch = ({ enabled, query }: Props) => { const searchResultsByEntity = separateResultsByEntity(combinedResults); + const entityErrors = result.reduce< + Record + >( + (acc, r) => { + if (r.parseError) { + acc[r.name] = r.parseError.message; + } + if (r.error) { + acc[r.name] = r.error[0].reason; + } + return acc; + }, + { ...emptyErrors } + ); + return { combinedResults, + entityErrors, isLoading, searchResultsByEntity, }; diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index c27d44f1d5d..858d2e13924 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -6,7 +6,6 @@ import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; -import { useRegionsQuery } from 'src/queries/regions/regions'; import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; import { bucketToSearchableItem, @@ -22,6 +21,8 @@ import { import { search } from './utils'; +import type { SearchableEntityType } from './search.interfaces'; + interface Props { enabled: boolean; query: string; @@ -32,7 +33,6 @@ interface Props { * based on a user's seach query. */ export const useClientSideSearch = ({ enabled, query }: Props) => { - const { data: regions, isLoading: regionsLoading } = useRegionsQuery(enabled); const { data: domains, isLoading: domainsLoading } = useAllDomainsQuery( enabled ); @@ -79,9 +79,7 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { objectStorageBuckets?.buckets.map(bucketToSearchableItem) ?? []; const searchableLinodes = linodes?.map(linodeToSearchableItem) ?? []; const searchableClusters = - clusters?.map((cluster) => - kubernetesClusterToSearchableItem(cluster, regions ?? []) - ) ?? []; + clusters?.map((cluster) => kubernetesClusterToSearchableItem(cluster)) ?? []; const searchableItems = [ ...searchableLinodes, @@ -103,10 +101,22 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { databasesLoading || nodebalancersLoading || domainsLoading || - regionsLoading || volumesLoading || firewallsLoading; + const entityErrors: Record = { + bucket: null, + database: null, + domain: null, + firewall: null, + image: null, + kubernetesCluster: null, + linode: null, + nodebalancer: null, + stackscript: null, + volume: null + }; + const { combinedResults, searchResultsByEntity } = search( searchableItems, query @@ -114,6 +124,7 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { return { combinedResults, + entityErrors, isLoading, searchResultsByEntity, }; diff --git a/packages/manager/src/features/Search/utils.test.ts b/packages/manager/src/features/Search/utils.test.ts index 5f5a764e9ae..a9a3107e454 100644 --- a/packages/manager/src/features/Search/utils.test.ts +++ b/packages/manager/src/features/Search/utils.test.ts @@ -9,15 +9,15 @@ const data = searchableItems as SearchableItem[]; describe('separate results by entity', () => { const results = separateResultsByEntity(data); it('contains keys of each entity type', () => { - expect(results).toHaveProperty('linodes'); - expect(results).toHaveProperty('volumes'); - expect(results).toHaveProperty('domains'); - expect(results).toHaveProperty('images'); - expect(results).toHaveProperty('nodebalancers'); - expect(results).toHaveProperty('kubernetesClusters'); - expect(results).toHaveProperty('buckets'); - expect(results).toHaveProperty('firewalls'); - expect(results).toHaveProperty('databases'); + expect(results).toHaveProperty('linode'); + expect(results).toHaveProperty('volume'); + expect(results).toHaveProperty('domain'); + expect(results).toHaveProperty('image'); + expect(results).toHaveProperty('nodebalancer'); + expect(results).toHaveProperty('kubernetesCluster'); + expect(results).toHaveProperty('bucket'); + expect(results).toHaveProperty('firewall'); + expect(results).toHaveProperty('database'); }); it('the value of each entity type is an array', () => { @@ -43,6 +43,7 @@ describe('separate results by entity', () => { kubernetesCluster: [], linode: [], nodebalancer: [], + stackscript: [], volume: [], }); }); diff --git a/packages/manager/src/features/Search/utils.ts b/packages/manager/src/features/Search/utils.ts index a005652d940..a6159c02a2e 100644 --- a/packages/manager/src/features/Search/utils.ts +++ b/packages/manager/src/features/Search/utils.ts @@ -3,6 +3,7 @@ import { refinedSearch } from './refinedSearch'; import type { SearchResults, SearchResultsByEntity, + SearchableEntityType, SearchableItem, } from './search.interfaces'; @@ -19,6 +20,31 @@ export const emptyResults: SearchResultsByEntity = { volume: [], }; +export const emptyErrors: Record = { + bucket: null, + database: null, + domain: null, + firewall: null, + image: null, + kubernetesCluster: null, + linode: null, + nodebalancer: null, + stackscript: null, + volume: null, +}; + +export const getErrorsFromErrorMap = ( + errorMap: Record +) => { + const errors = []; + for (const [entityName, error] of Object.entries(errorMap)) { + if (error) { + errors.push(`Unable to fetch ${entityName}s: ${error}`); + } + } + return errors; +}; + export const separateResultsByEntity = ( searchResults: SearchableItem[] ): SearchResultsByEntity => { diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index db954d5ad1b..3d562daae8b 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -134,7 +134,12 @@ export const SearchBar = () => { handleClose(); }; - const options = createFinalOptions(combinedResults, searchText, isLoading, false); + const options = createFinalOptions( + combinedResults, + searchText, + isLoading, + false + ); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const label = isSmallScreen diff --git a/packages/manager/src/queries/linodes/linodes.ts b/packages/manager/src/queries/linodes/linodes.ts index 19580f6100d..17281745b65 100644 --- a/packages/manager/src/queries/linodes/linodes.ts +++ b/packages/manager/src/queries/linodes/linodes.ts @@ -219,6 +219,7 @@ export const useInfiniteLinodesQuery = ( return page + 1; }, initialPageParam: 1, + retry: false, }); export const useLinodeQuery = (id: number, enabled = true) => { diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index 055a5bafa37..52920f6a417 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -97,6 +97,7 @@ export const useStackScriptsInfiniteQuery = ( }, initialPageParam: 1, placeholderData: keepPreviousData, + retry: false, }); export const useUpdateStackScriptMutation = ( diff --git a/packages/manager/src/queries/volumes/volumes.ts b/packages/manager/src/queries/volumes/volumes.ts index 9d7b43e6807..98bed9bf452 100644 --- a/packages/manager/src/queries/volumes/volumes.ts +++ b/packages/manager/src/queries/volumes/volumes.ts @@ -107,6 +107,7 @@ export const useInfiniteVolumesQuery = (filter: Filter, enabled?: boolean) => return page + 1; }, initialPageParam: 1, + retry: false, }); export const useAllVolumesQuery = ( diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index de9cb1d98c9..833023761da 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -14,7 +14,6 @@ import type { Linode, NodeBalancer, ObjectStorageBucket, - Region, StackScript, Volume, } from '@linode/api-v4'; @@ -120,12 +119,11 @@ export const nodeBalToSearchableItem = ( }); export const kubernetesClusterToSearchableItem = ( - kubernetesCluster: KubernetesCluster, - regions: Region[] + kubernetesCluster: KubernetesCluster ): SearchableItem => ({ data: { created: kubernetesCluster.created, - description: getDescriptionForCluster(kubernetesCluster, regions), + description: getDescriptionForCluster(kubernetesCluster), icon: 'kube', k8s_version: kubernetesCluster.k8s_version, label: kubernetesCluster.label, From 5ce8b2ca523d2543c46de3a4261da5a52d1c6b20 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 12:37:35 -0400 Subject: [PATCH 12/31] improve search --- packages/manager/src/components/Tag/Tag.tsx | 2 +- .../manager/src/features/TopMenu/SearchBar/SearchBar.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/manager/src/components/Tag/Tag.tsx b/packages/manager/src/components/Tag/Tag.tsx index b1dab233322..830c2be13cc 100644 --- a/packages/manager/src/components/Tag/Tag.tsx +++ b/packages/manager/src/components/Tag/Tag.tsx @@ -59,7 +59,7 @@ export const Tag = (props: TagProps) => { if (closeMenu) { closeMenu(); } - history.push(`/search/?query=tag:${label}`); + history.push(`/search?query=tag:${label}`); }; // If maxLength is set, truncate display to that length. diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index 3d562daae8b..1ba2b30c71f 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -79,7 +79,9 @@ export const SearchBar = () => { const handleClose = () => { document.body.classList.remove('searchOverlay'); setSearchActive(false); - setSearchText(''); + if (history.location.pathname !== '/search') { + setSearchText(''); + } setMenuOpen(false); }; @@ -91,7 +93,9 @@ export const SearchBar = () => { const handleFocus = () => { setSearchActive(true); - setSearchText(''); + if (history.location.pathname !== '/search') { + setSearchText(''); + } }; const handleBlur = () => { From 0f72f143e9ea7f498ca8ddf44e0c1425c0f6e4de Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 13:29:43 -0400 Subject: [PATCH 13/31] fully support all entities with API search --- .../src/features/Search/useAPISearch.ts | 63 ++++++++++++++++++- .../manager/src/features/Search/useSearch.ts | 5 +- .../src/queries/databases/databases.ts | 21 +++++++ packages/manager/src/queries/domains.ts | 21 +++++++ packages/manager/src/queries/firewalls.ts | 21 +++++++ packages/manager/src/queries/images.ts | 20 ++++++ packages/manager/src/queries/kubernetes.ts | 25 +++++++- packages/manager/src/queries/nodebalancers.ts | 7 ++- 8 files changed, 178 insertions(+), 5 deletions(-) diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts index 2c3add61d10..c681b2692ac 100644 --- a/packages/manager/src/features/Search/useAPISearch.ts +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -1,11 +1,22 @@ import { getAPIFilterFromQuery } from '@linode/search'; import { useDebouncedValue } from '@linode/utilities'; +import { useDatabasesInfiniteQuery } from 'src/queries/databases/databases'; +import { useDomainsInfiniteQuery } from 'src/queries/domains'; +import { useFirewallsInfiniteQuery } from 'src/queries/firewalls'; +import { useImagesInfiniteQuery } from 'src/queries/images'; +import { useKubernetesClustersInfiniteQuery } from 'src/queries/kubernetes'; import { useInfiniteLinodesQuery } from 'src/queries/linodes/linodes'; import { useStackScriptsInfiniteQuery } from 'src/queries/stackscripts'; import { useInfiniteVolumesQuery } from 'src/queries/volumes/volumes'; import { + databaseToSearchableItem, + domainToSearchableItem, + firewallToSearchableItem, + imageToSearchableItem, + kubernetesClusterToSearchableItem, linodeToSearchableItem, + nodeBalToSearchableItem, stackscriptToSearchableItem, volumeToSearchableItem, } from 'src/store/selectors/getSearchEntities'; @@ -13,6 +24,7 @@ import { import { emptyErrors, separateResultsByEntity } from './utils'; import type { SearchableEntityType, SearchableItem } from './search.interfaces'; +import { useInfiniteNodebalancersQuery } from 'src/queries/nodebalancers'; interface Props { enabled: boolean; @@ -45,6 +57,55 @@ const entities = [ searchableFieldsWithoutOperator: ['label'], }, }, + { + getSearchableItem: kubernetesClusterToSearchableItem, + name: 'kubernetesCluster' as const, + query: useKubernetesClustersInfiniteQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['label', 'tags'], + }, + }, + { + getSearchableItem: domainToSearchableItem, + name: 'domain' as const, + query: useDomainsInfiniteQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['domain', 'tags'], + }, + }, + { + getSearchableItem: firewallToSearchableItem, + name: 'firewall' as const, + query: useFirewallsInfiniteQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['label'], + }, + }, + { + getSearchableItem: databaseToSearchableItem, + name: 'database' as const, + query: useDatabasesInfiniteQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['label'], + }, + }, + { + baseFilter: { is_public: false }, + getSearchableItem: imageToSearchableItem, + name: 'image' as const, + query: useImagesInfiniteQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['label'], + }, + }, + { + getSearchableItem: nodeBalToSearchableItem, + name: 'nodebalancer' as const, + query: useInfiniteNodebalancersQuery, + searchOptions: { + searchableFieldsWithoutOperator: ['label', 'ipv4', 'tags'], + }, + }, ]; /** @@ -72,7 +133,7 @@ export const useAPISearch = ({ enabled, query }: Props) => { parseError: error, ...entity.query( entity.baseFilter ? { ...entity.baseFilter, ...filter } : filter, - enabled && error === null + enabled && error === null && Boolean(deboundedQuery) ), }; }); diff --git a/packages/manager/src/features/Search/useSearch.ts b/packages/manager/src/features/Search/useSearch.ts index 05be5d68249..d9144597400 100644 --- a/packages/manager/src/features/Search/useSearch.ts +++ b/packages/manager/src/features/Search/useSearch.ts @@ -11,18 +11,19 @@ interface Props { export const useSearch = ({ query }: Props) => { const isSearching = Boolean(query); const isLargeAccount = useIsLargeAccount(isSearching); + const isAccountSizeKnown = isLargeAccount !== undefined; const shouldUseClientSideSearch = FORCE_SEARCH_TYPE ? FORCE_SEARCH_TYPE === 'client' : isLargeAccount === false; const clientSideSearchData = useClientSideSearch({ - enabled: shouldUseClientSideSearch, + enabled: isSearching && isAccountSizeKnown && shouldUseClientSideSearch, query, }); const apiSearchData = useAPISearch({ - enabled: !shouldUseClientSideSearch, + enabled: isSearching && isAccountSizeKnown && !shouldUseClientSideSearch, query, }); diff --git a/packages/manager/src/queries/databases/databases.ts b/packages/manager/src/queries/databases/databases.ts index ebc176b45c5..cb5b68cbfac 100644 --- a/packages/manager/src/queries/databases/databases.ts +++ b/packages/manager/src/queries/databases/databases.ts @@ -16,6 +16,7 @@ import { import { createQueryKeys } from '@lukemorales/query-key-factory'; import { keepPreviousData, + useInfiniteQuery, useMutation, useQuery, useQueryClient, @@ -67,6 +68,11 @@ export const databaseQueries = createQueryKeys('databases', { queryFn: () => getAllDatabases(params, filter), queryKey: [params, filter], }), + infinite: (filter: Filter) => ({ + queryFn: ({ pageParam }) => + getDatabases({ page: pageParam as number }, filter), + queryKey: [filter], + }), paginated: (params: Params, filter: Filter) => ({ queryFn: () => getDatabases(params, filter), queryKey: [params, filter], @@ -112,6 +118,21 @@ export const useDatabasesQuery = ( refetchInterval: 20000, }); +export const useDatabasesInfiniteQuery = (filter: Filter, enabled: boolean) => { + return useInfiniteQuery, APIError[]>({ + ...databaseQueries.databases._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); +}; + export const useAllDatabasesQuery = ( enabled: boolean = true, params: Params = {}, diff --git a/packages/manager/src/queries/domains.ts b/packages/manager/src/queries/domains.ts index c2ece0f036e..7725b9eb26c 100644 --- a/packages/manager/src/queries/domains.ts +++ b/packages/manager/src/queries/domains.ts @@ -11,6 +11,7 @@ import { import { createQueryKeys } from '@lukemorales/query-key-factory'; import { keepPreviousData, + useInfiniteQuery, useMutation, useQuery, useQueryClient, @@ -59,6 +60,11 @@ const domainQueries = createQueryKeys('domains', { queryFn: getAllDomains, queryKey: null, }, + infinite: (filter: Filter) => ({ + queryFn: ({ pageParam }) => + getDomains({ page: pageParam as number }, filter), + queryKey: [filter], + }), paginated: (params: Params = {}, filter: Filter = {}) => ({ queryFn: () => getDomains(params, filter), queryKey: [params, filter], @@ -80,6 +86,21 @@ export const useAllDomainsQuery = (enabled: boolean = false) => enabled, }); +export const useDomainsInfiniteQuery = (filter: Filter, enabled: boolean) => { + return useInfiniteQuery, APIError[]>({ + ...domainQueries.domains._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); +}; + export const useDomainQuery = (id: number, enabled: boolean = true) => useQuery({ ...domainQueries.domain(id), diff --git a/packages/manager/src/queries/firewalls.ts b/packages/manager/src/queries/firewalls.ts index d26fa3b9750..7e9e8aa1303 100644 --- a/packages/manager/src/queries/firewalls.ts +++ b/packages/manager/src/queries/firewalls.ts @@ -14,6 +14,7 @@ import { import { createQueryKeys } from '@lukemorales/query-key-factory'; import { keepPreviousData, + useInfiniteQuery, useMutation, useQuery, useQueryClient, @@ -79,6 +80,11 @@ export const firewallQueries = createQueryKeys('firewalls', { queryFn: getAllFirewallsRequest, queryKey: null, }, + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getFirewalls({ page: pageParam as number }, filter), + queryKey: [filter], + }), paginated: (params: Params = {}, filter: Filter = {}) => ({ queryFn: () => getFirewalls(params, filter), queryKey: [params, filter], @@ -101,6 +107,21 @@ export const useAllFirewallDevicesQuery = (id: number) => firewallQueries.firewall(id)._ctx.devices ); +export const useFirewallsInfiniteQuery = (filter: Filter, enabled: boolean) => { + return useInfiniteQuery, APIError[]>({ + ...firewallQueries.firewalls._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); +}; + export const useAddFirewallDeviceMutation = () => { const queryClient = useQueryClient(); return useMutation< diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index 16aa84813cc..a1c45948454 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -10,6 +10,7 @@ import { import { createQueryKeys } from '@lukemorales/query-key-factory'; import { keepPreviousData, + useInfiniteQuery, useMutation, useQuery, useQueryClient, @@ -50,6 +51,10 @@ export const imageQueries = createQueryKeys('images', { queryFn: () => getImage(imageId), queryKey: [imageId], }), + infinite: (filters: Filter) => ({ + queryFn: ({ pageParam }) => getImages({ page: pageParam as number }, filters), + queryKey: [filters], + }), paginated: (params: Params, filters: Filter) => ({ queryFn: () => getImages(params, filters), queryKey: [params, filters], @@ -73,6 +78,21 @@ export const useImageQuery = (imageId: string, enabled = true) => enabled, }); +export const useImagesInfiniteQuery = (filter: Filter, enabled: boolean) => { + return useInfiniteQuery, APIError[]>({ + ...imageQueries.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); +}; + export const useCreateImageMutation = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index fcd54276163..d4af9827542 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -28,6 +28,7 @@ import { import { createQueryKeys } from '@lukemorales/query-key-factory'; import { keepPreviousData, + useInfiniteQuery, useMutation, useQuery, useQueryClient, @@ -152,6 +153,10 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { : getAllKubernetesClusters(), queryKey: [useBetaEndpoint ? 'v4beta' : 'v4'], }), + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => getKubernetesClusters({ page: pageParam }, filter), + queryKey: [filter], + }), paginated: ( params: Params, filter: Filter, @@ -162,7 +167,7 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { ? getKubernetesClustersBeta(params, filter) : getKubernetesClusters(params, filter), queryKey: [params, filter, useBetaEndpoint ? 'v4beta' : 'v4'], - }), + }) }, queryKey: null, }, @@ -198,6 +203,24 @@ export const useKubernetesClusterQuery = ( }); }; +export const useKubernetesClustersInfiniteQuery = ( + filter: Filter, + enabled: boolean +) => { + return useInfiniteQuery, APIError[]>({ + ...kubernetesQueries.lists._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); +}; + export const useKubernetesClustersQuery = ( params: Params, filter: Filter, diff --git a/packages/manager/src/queries/nodebalancers.ts b/packages/manager/src/queries/nodebalancers.ts index d9c7a891322..a8473aebaa1 100644 --- a/packages/manager/src/queries/nodebalancers.ts +++ b/packages/manager/src/queries/nodebalancers.ts @@ -294,9 +294,13 @@ export const useAllNodeBalancersQuery = (enabled = true) => enabled, }); -export const useInfiniteNodebalancersQuery = (filter: Filter) => +export const useInfiniteNodebalancersQuery = ( + filter: Filter, + enabled: boolean +) => useInfiniteQuery, APIError[]>({ ...nodebalancerQueries.nodebalancers._ctx.infinite(filter), + enabled, getNextPageParam: ({ page, pages }) => { if (page === pages) { return undefined; @@ -304,6 +308,7 @@ export const useInfiniteNodebalancersQuery = (filter: Filter) => return page + 1; }, initialPageParam: 1, + retry: false, }); export const useNodeBalancersFirewallsQuery = (nodebalancerId: number) => From 682083a21cb46de18b82a461d3d8d9d906dfe64b Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 13:29:49 -0400 Subject: [PATCH 14/31] fully support all entities with API search --- packages/manager/src/queries/kubernetes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index d4af9827542..3abb50873b4 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -154,7 +154,7 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { queryKey: [useBetaEndpoint ? 'v4beta' : 'v4'], }), infinite: (filter: Filter = {}) => ({ - queryFn: ({ pageParam }) => getKubernetesClusters({ page: pageParam }, filter), + queryFn: ({ pageParam }) => getKubernetesClusters({ page: pageParam as number }, filter), queryKey: [filter], }), paginated: ( From ea20290e2af0e2a71891b51c43d8d24e1c6def9e Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 13:31:31 -0400 Subject: [PATCH 15/31] allow images to be searched by tag --- packages/manager/src/features/Search/useAPISearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts index c681b2692ac..a6add11f741 100644 --- a/packages/manager/src/features/Search/useAPISearch.ts +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -95,7 +95,7 @@ const entities = [ name: 'image' as const, query: useImagesInfiniteQuery, searchOptions: { - searchableFieldsWithoutOperator: ['label'], + searchableFieldsWithoutOperator: ['label', 'tags'], }, }, { From 59c611b33649a7300fb5f1ada529d6a9415e8db8 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 13:32:41 -0400 Subject: [PATCH 16/31] revert extra change --- packages/manager/src/queries/regions/regions.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/manager/src/queries/regions/regions.ts b/packages/manager/src/queries/regions/regions.ts index 5fadae23035..6bc8a3298e8 100644 --- a/packages/manager/src/queries/regions/regions.ts +++ b/packages/manager/src/queries/regions/regions.ts @@ -55,11 +55,10 @@ export const useRegionQuery = (regionId: string) => { }); }; -export const useRegionsQuery = (enabled?: boolean) => +export const useRegionsQuery = () => useQuery({ ...regionQueries.regions, ...queryPresets.longLived, - enabled, select: (regions: Region[]) => regions.map((region) => ({ ...region, From aa5e67abb4472bce8f162184a29837cb0b8da409 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 13:33:20 -0400 Subject: [PATCH 17/31] revert extra change --- packages/search/src/search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/search/src/search.ts b/packages/search/src/search.ts index 43601859433..580ccba8476 100644 --- a/packages/search/src/search.ts +++ b/packages/search/src/search.ts @@ -4,7 +4,7 @@ import grammar from './search.peggy?raw'; const parser = generate(grammar); -export interface Options { +interface Options { /** * Defines the API fields filtered against (currently using +contains) * when the search query contains no operators. From a45094c898791843a7db206227a8363eabc33336 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 13:57:53 -0400 Subject: [PATCH 18/31] sort import --- packages/manager/src/features/Search/useAPISearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts index a6add11f741..2d6d490643b 100644 --- a/packages/manager/src/features/Search/useAPISearch.ts +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -7,6 +7,7 @@ import { useFirewallsInfiniteQuery } from 'src/queries/firewalls'; import { useImagesInfiniteQuery } from 'src/queries/images'; import { useKubernetesClustersInfiniteQuery } from 'src/queries/kubernetes'; import { useInfiniteLinodesQuery } from 'src/queries/linodes/linodes'; +import { useInfiniteNodebalancersQuery } from 'src/queries/nodebalancers'; import { useStackScriptsInfiniteQuery } from 'src/queries/stackscripts'; import { useInfiniteVolumesQuery } from 'src/queries/volumes/volumes'; import { @@ -24,7 +25,6 @@ import { import { emptyErrors, separateResultsByEntity } from './utils'; import type { SearchableEntityType, SearchableItem } from './search.interfaces'; -import { useInfiniteNodebalancersQuery } from 'src/queries/nodebalancers'; interface Props { enabled: boolean; From 2475c9d0ad55eca4b20786433b1dbb4dfe80101e Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 13:59:18 -0400 Subject: [PATCH 19/31] add changesets --- .../manager/.changeset/pr-11819-changed-1741629531708.md | 5 +++++ .../.changeset/pr-11819-tech-stories-1741629552993.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 packages/manager/.changeset/pr-11819-changed-1741629531708.md create mode 100644 packages/manager/.changeset/pr-11819-tech-stories-1741629552993.md diff --git a/packages/manager/.changeset/pr-11819-changed-1741629531708.md b/packages/manager/.changeset/pr-11819-changed-1741629531708.md new file mode 100644 index 00000000000..0f5dce2f2fb --- /dev/null +++ b/packages/manager/.changeset/pr-11819-changed-1741629531708.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Updates main search to use new API search implimentation for large accounts ([#11819](https://github.com/linode/manager/pull/11819)) diff --git a/packages/manager/.changeset/pr-11819-tech-stories-1741629552993.md b/packages/manager/.changeset/pr-11819-tech-stories-1741629552993.md new file mode 100644 index 00000000000..4806cc13348 --- /dev/null +++ b/packages/manager/.changeset/pr-11819-tech-stories-1741629552993.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Updates main search to not depend on recompose ([#11819](https://github.com/linode/manager/pull/11819)) From 7f40f25c1a3bdb0d166bd061538c24f4c470ea24 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 14:24:22 -0400 Subject: [PATCH 20/31] error handling for client side search --- .../features/Search/useClientSideSearch.ts | 90 +++++++++++-------- packages/manager/src/queries/stackscripts.ts | 19 ++++ 2 files changed, 74 insertions(+), 35 deletions(-) diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index 858d2e13924..46558f486e8 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -6,6 +6,7 @@ import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; +import { useAllAccountStackScriptsQuery } from 'src/queries/stackscripts'; import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; import { bucketToSearchableItem, @@ -16,6 +17,7 @@ import { kubernetesClusterToSearchableItem, linodeToSearchableItem, nodeBalToSearchableItem, + stackscriptToSearchableItem, volumeToSearchableItem, } from 'src/store/selectors/getSearchEntities'; @@ -33,41 +35,55 @@ interface Props { * based on a user's seach query. */ export const useClientSideSearch = ({ enabled, query }: Props) => { - const { data: domains, isLoading: domainsLoading } = useAllDomainsQuery( - enabled - ); + const { + data: domains, + error: domainsError, + isLoading: domainsLoading, + } = useAllDomainsQuery(enabled); const { data: clusters, + error: lkeClustersError, isLoading: lkeClustersLoading, } = useAllKubernetesClustersQuery(enabled); - const { data: volumes, isLoading: volumesLoading } = useAllVolumesQuery( - {}, - {}, - enabled - ); - const { data: linodes, isLoading: linodesLoading } = useAllLinodesQuery( - {}, - {}, - enabled - ); + const { + data: volumes, + error: volumesError, + isLoading: volumesLoading, + } = useAllVolumesQuery({}, {}, enabled); + const { + data: linodes, + error: linodesError, + isLoading: linodesLoading, + } = useAllLinodesQuery({}, {}, enabled); const { data: nodebals, + error: nodebalancersError, isLoading: nodebalancersLoading, } = useAllNodeBalancersQuery(enabled); - const { data: firewalls, isLoading: firewallsLoading } = useAllFirewallsQuery( - enabled - ); - const { data: databases, isLoading: databasesLoading } = useAllDatabasesQuery( - enabled - ); + const { + data: firewalls, + error: firewallsError, + isLoading: firewallsLoading, + } = useAllFirewallsQuery(enabled); + const { + data: databases, + error: databasesError, + isLoading: databasesLoading, + } = useAllDatabasesQuery(enabled); const { data: objectStorageBuckets, - isLoading: bucketsLoading, + error: bucketsError, } = useObjectStorageBuckets(enabled); const { data: privateImages, + error: imagesError, isLoading: privateImagesLoading, } = useAllImagesQuery({}, { is_public: false }, enabled); + const { + data: stackscripts, + error: stackscriptsError, + isLoading: stackscriptsLoading, + } = useAllAccountStackScriptsQuery(enabled); const searchableDomains = domains?.map(domainToSearchableItem) ?? []; const searchableVolumes = volumes?.map(volumeToSearchableItem) ?? []; @@ -75,11 +91,14 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { const searchableNodebalancers = nodebals?.map(nodeBalToSearchableItem) ?? []; const searchableFirewalls = firewalls?.map(firewallToSearchableItem) ?? []; const searchableDatabases = databases?.map(databaseToSearchableItem) ?? []; + const searchableLinodes = linodes?.map(linodeToSearchableItem) ?? []; + const searchableStackScripts = + stackscripts?.map(stackscriptToSearchableItem) ?? []; const searchableBuckets = objectStorageBuckets?.buckets.map(bucketToSearchableItem) ?? []; - const searchableLinodes = linodes?.map(linodeToSearchableItem) ?? []; const searchableClusters = - clusters?.map((cluster) => kubernetesClusterToSearchableItem(cluster)) ?? []; + clusters?.map((cluster) => kubernetesClusterToSearchableItem(cluster)) ?? + []; const searchableItems = [ ...searchableLinodes, @@ -91,30 +110,31 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { ...searchableNodebalancers, ...searchableFirewalls, ...searchableDatabases, + ...searchableStackScripts, ]; const isLoading = linodesLoading || privateImagesLoading || - bucketsLoading || lkeClustersLoading || databasesLoading || nodebalancersLoading || domainsLoading || volumesLoading || - firewallsLoading; + firewallsLoading || + stackscriptsLoading; - const entityErrors: Record = { - bucket: null, - database: null, - domain: null, - firewall: null, - image: null, - kubernetesCluster: null, - linode: null, - nodebalancer: null, - stackscript: null, - volume: null + const entityErrors: Record = { + bucket: bucketsError?.[0].reason ?? null, + database: databasesError?.[0].reason ?? null, + domain: domainsError?.[0].reason ?? null, + firewall: firewallsError?.[0].reason ?? null, + image: imagesError?.[0].reason ?? null, + kubernetesCluster: lkeClustersError?.[0].reason ?? null, + linode: linodesError?.[0].reason ?? null, + nodebalancer: nodebalancersError?.[0].reason ?? null, + stackscript: stackscriptsError?.[0].reason ?? null, + volume: volumesError?.[0].reason ?? null, }; const { combinedResults, searchResultsByEntity } = search( diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index 52920f6a417..97a470b0bfc 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -35,7 +35,16 @@ export const getAllOCAsRequest = (passedParams: Params = {}) => getOneClickApps({ ...params, ...passedParams }) )().then((data) => data.data); +export const getAllAccountStackScripts = () => + getAll((params) => + getStackScripts(params, { mine: true }) + )().then((data) => data.data); + export const stackscriptQueries = createQueryKeys('stackscripts', { + all: { + queryFn: () => getAllAccountStackScripts(), + queryKey: null, + }, infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => getStackScripts({ page: pageParam as number, page_size: 25 }, filter), @@ -65,6 +74,16 @@ export const useStackScriptQuery = (id: number, enabled = true) => enabled, }); +/** + * Don't use this! It only exists so users can search for their StackScripts + * in the legacy main search. + */ +export const useAllAccountStackScriptsQuery = (enabled: boolean) => + useQuery({ + ...stackscriptQueries.all, + enabled, + }); + export const useCreateStackScriptMutation = () => { const queryClient = useQueryClient(); From 36d57a66a230aa788c1f90cdeb4d9212fb8eace5 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 14:34:15 -0400 Subject: [PATCH 21/31] clean up a bit --- packages/manager/src/features/Search/useClientSideSearch.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index 46558f486e8..37df2948944 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -97,8 +97,7 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { const searchableBuckets = objectStorageBuckets?.buckets.map(bucketToSearchableItem) ?? []; const searchableClusters = - clusters?.map((cluster) => kubernetesClusterToSearchableItem(cluster)) ?? - []; + clusters?.map(kubernetesClusterToSearchableItem) ?? []; const searchableItems = [ ...searchableLinodes, From 96cca3de16e8f69439e5d9309fe1efcb5bd520ce Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 14:46:31 -0400 Subject: [PATCH 22/31] restore linode description --- packages/manager/src/store/selectors/getSearchEntities.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index 833023761da..6204ef34122 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -41,7 +41,9 @@ export const getNodebalIps = (nodebal: NodeBalancer): string[] => { export const linodeToSearchableItem = (linode: Linode): SearchableItem => ({ data: { created: linode.created, - description: `${linode.image} ${linode.specs.vcpus} CPUs`, + description: `${linode.image}, ${linode.specs.vcpus} CPU, ${ + linode.specs.disk / 1024 + } GB Storage, ${linode.specs.memory / 1024} GB RAM`, icon: 'linode', ips: getLinodeIps(linode), path: `/linodes/${linode.id}`, From dc611ba535287e0b95661d2b147a0f4366b65b8a Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 15:16:36 -0400 Subject: [PATCH 23/31] clean up a bit more --- .../src/features/Search/SearchLanding.tsx | 22 +++---------------- packages/manager/src/features/Search/utils.ts | 22 ++++++++++++++++++- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 7f0e498b396..05f4bc3b84b 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -6,26 +6,10 @@ import { useLocation } from 'react-router-dom'; import { ResultGroup } from './ResultGroup'; import { useSearch } from './useSearch'; +import { getErrorsFromErrorMap, searchableEntityDisplayNameMap } from './utils'; -import type { - SearchResultsByEntity, - SearchableEntityType, -} from './search.interfaces'; +import type { SearchResultsByEntity } from './search.interfaces'; import type { ResultRowDataOption } from './types'; -import { getErrorsFromErrorMap } from './utils'; - -const displayMap: Record = { - bucket: 'Buckets', - database: 'Databases', - domain: 'Domains', - firewall: 'Firewalls', - image: 'Images', - kubernetesCluster: 'Kubernetes', - linode: 'Linodes', - nodebalancer: 'NodeBalancers', - stackscript: 'StackScripts', - volume: 'Volumes', -}; const SearchLanding = () => { const location = useLocation(); @@ -73,7 +57,7 @@ const SearchLanding = () => { {Object.keys(searchResultsByEntity).map( (entityType: keyof SearchResultsByEntity, idx: number) => ( = { volume: null, }; +export const searchableEntityDisplayNameMap: Record< + SearchableEntityType, + string +> = { + bucket: 'Buckets', + database: 'Databases', + domain: 'Domains', + firewall: 'Firewalls', + image: 'Images', + kubernetesCluster: 'Kubernetes', + linode: 'Linodes', + nodebalancer: 'NodeBalancers', + stackscript: 'StackScripts', + volume: 'Volumes', +}; + export const getErrorsFromErrorMap = ( errorMap: Record ) => { const errors = []; for (const [entityName, error] of Object.entries(errorMap)) { if (error) { - errors.push(`Unable to fetch ${entityName}s: ${error}`); + errors.push( + `Unable to fetch ${ + searchableEntityDisplayNameMap[entityName as SearchableEntityType] + }: ${error}` + ); } } return errors; From 1b68d0d95979dae43fe26cbb58ca006ef1e0b65c Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 15:19:52 -0400 Subject: [PATCH 24/31] add todo --- packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index 1ba2b30c71f..ca86a2602ac 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -49,7 +49,7 @@ export const SearchBar = () => { const history = useHistory(); const theme = useTheme(); - // No idea + // Sync state with query params React.useEffect(() => { const { pathname, search } = history.location; const query = getQueryParamsFromQueryString(search); @@ -142,7 +142,7 @@ export const SearchBar = () => { combinedResults, searchText, isLoading, - false + false // @todo handle errors. Because we make many API calls, we need a good way to handle partial errors. ); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); From 366689bf037e1308892e9e6618859092f667cbcf Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Mon, 10 Mar 2025 15:56:10 -0400 Subject: [PATCH 25/31] fix random dbaas failures --- .../DatabaseSummaryClusterConfiguration.test.tsx | 10 +++++++--- .../DatabaseSummaryConnectionDetails.test.tsx | 10 +++++++--- .../manager/src/features/Databases/utilities.test.ts | 10 +++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx index 1131b04d64e..59450e53b1e 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx @@ -27,9 +27,13 @@ vi.mock('src/queries/regions/regions', () => ({ useRegionsQuery: queryMocks.useRegionsQuery, })); -vi.mock('src/queries/databases/databases', () => ({ - useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery, -})); +vi.mock(import('src/queries/databases/databases'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery, + }; +}); describe('DatabaseSummaryClusterConfiguration', () => { it('should display correctly for default db', async () => { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx index 9643fe9a5b0..d9c95bc0533 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx @@ -22,9 +22,13 @@ const queryMocks = vi.hoisted(() => ({ useDatabaseCredentialsQuery: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/databases/databases', () => ({ - useDatabaseCredentialsQuery: queryMocks.useDatabaseCredentialsQuery, -})); +vi.mock(import('src/queries/databases/databases'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDatabaseCredentialsQuery: queryMocks.useDatabaseCredentialsQuery, + }; +}); describe('DatabaseSummaryConnectionDetails', () => { it('should display correctly for default db', async () => { diff --git a/packages/manager/src/features/Databases/utilities.test.ts b/packages/manager/src/features/Databases/utilities.test.ts index 2946366fdd0..2f251e5e373 100644 --- a/packages/manager/src/features/Databases/utilities.test.ts +++ b/packages/manager/src/features/Databases/utilities.test.ts @@ -48,9 +48,13 @@ const queryMocks = vi.hoisted(() => ({ useDatabaseTypesQuery: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/databases/databases', () => ({ - useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery, -})); +vi.mock(import('src/queries/databases/databases'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery, + }; +}); describe('useIsDatabasesEnabled', () => { it('should return correctly for non V1/V2 user', async () => { From 81d28e566e4c9da417d542bb7ba8607a19a152dd Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 11 Mar 2025 10:23:47 -0400 Subject: [PATCH 26/31] fix incorrect icons in searchbar --- .../manager/src/__data__/searchResults.ts | 3 +- .../Images/ImagesLanding/EditImageDrawer.tsx | 4 +- .../src/features/Search/ResultGroup.tsx | 4 +- .../manager/src/features/Search/ResultRow.tsx | 8 ++-- .../src/features/Search/SearchLanding.tsx | 3 +- .../src/features/Search/refinedSearch.test.ts | 28 ++++++------ .../src/features/Search/refinedSearch.ts | 17 ++++++-- .../src/features/Search/search.interfaces.ts | 14 ++++-- packages/manager/src/features/Search/types.ts | 11 ----- packages/manager/src/features/Search/utils.ts | 21 +++++++++ .../TopMenu/SearchBar/SearchBar.test.tsx | 3 +- .../features/TopMenu/SearchBar/SearchBar.tsx | 8 +--- .../TopMenu/SearchBar/SearchSuggestion.tsx | 43 ++++++------------- .../src/features/TopMenu/SearchBar/utils.tsx | 14 +++++- .../src/store/selectors/getSearchEntities.ts | 23 ++++------ 15 files changed, 110 insertions(+), 94 deletions(-) delete mode 100644 packages/manager/src/features/Search/types.ts diff --git a/packages/manager/src/__data__/searchResults.ts b/packages/manager/src/__data__/searchResults.ts index 408f349a060..9dc532e6b69 100644 --- a/packages/manager/src/__data__/searchResults.ts +++ b/packages/manager/src/__data__/searchResults.ts @@ -42,7 +42,7 @@ export const searchbarResult1 = { searchText: 'result', tags: [], }, - entityType: 'linode' as any, + entityType: 'linode' as const, label: 'result1', value: '111111', }; @@ -57,6 +57,7 @@ export const searchbarResult2 = { searchText: 'result', tags: ['tag1', 'tag2'], }, + entityType: 'nodebalancer' as const, label: 'result2', value: '222222', }; diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx index 9c591ab1ba5..0b2a6ca7890 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx @@ -64,8 +64,8 @@ export const EditImageDrawer = (props: Props) => { for (const error of errors) { if ( error.field === 'label' || - error.field == 'description' || - error.field == 'tags' + error.field === 'description' || + error.field === 'tags' ) { setError(error.field, { message: error.reason }); } else { diff --git a/packages/manager/src/features/Search/ResultGroup.tsx b/packages/manager/src/features/Search/ResultGroup.tsx index 444d6653c89..2b038f57509 100644 --- a/packages/manager/src/features/Search/ResultGroup.tsx +++ b/packages/manager/src/features/Search/ResultGroup.tsx @@ -14,12 +14,12 @@ import { splitAt } from 'src/utilities/splitAt'; import { StyledButton, StyledTypography } from './ResultGroup.styles'; import { ResultRow } from './ResultRow'; -import type { ResultRowDataOption } from './types'; +import type { SearchableItem } from './search.interfaces'; interface ResultGroupProps { entity: string; groupSize: number; - results: ResultRowDataOption[]; + results: SearchableItem[]; } export const ResultGroup = (props: ResultGroupProps) => { diff --git a/packages/manager/src/features/Search/ResultRow.tsx b/packages/manager/src/features/Search/ResultRow.tsx index ce2a5594fb5..4956140ff2b 100644 --- a/packages/manager/src/features/Search/ResultRow.tsx +++ b/packages/manager/src/features/Search/ResultRow.tsx @@ -15,10 +15,10 @@ import { StyledTagTableCell, } from './ResultRow.styles'; -import type { ResultRowDataOption } from './types'; +import type { SearchableItem } from './search.interfaces'; interface ResultRowProps { - result: ResultRowDataOption; + result: SearchableItem; } export const ResultRow = (props: ResultRowProps) => { @@ -47,7 +47,9 @@ export const ResultRow = (props: ResultRowProps) => { - + {result.data.tags && ( + + )} diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 05f4bc3b84b..464cde2dcbe 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -9,7 +9,6 @@ import { useSearch } from './useSearch'; import { getErrorsFromErrorMap, searchableEntityDisplayNameMap } from './utils'; import type { SearchResultsByEntity } from './search.interfaces'; -import type { ResultRowDataOption } from './types'; const SearchLanding = () => { const location = useLocation(); @@ -60,7 +59,7 @@ const SearchLanding = () => { entity={searchableEntityDisplayNameMap[entityType]} groupSize={100} key={idx} - results={searchResultsByEntity[entityType] as ResultRowDataOption[]} + results={searchResultsByEntity[entityType]} /> ) )} diff --git a/packages/manager/src/features/Search/refinedSearch.test.ts b/packages/manager/src/features/Search/refinedSearch.test.ts index 06b3e30eac8..8acf3c0aa44 100644 --- a/packages/manager/src/features/Search/refinedSearch.test.ts +++ b/packages/manager/src/features/Search/refinedSearch.test.ts @@ -1,12 +1,12 @@ -// import { searchableItems } from 'src/__data__/searchableItems import searchString from 'search-string'; import { searchableItems } from 'src/__data__/searchableItems'; -import { COMPRESSED_IPV6_REGEX } from './refinedSearch'; -import { QueryJSON } from './refinedSearch'; import * as RefinedSearch from './refinedSearch'; -import { SearchableItem } from './search.interfaces'; +import { COMPRESSED_IPV6_REGEX } from './refinedSearch'; + +import type { QueryJSON } from './refinedSearch'; +import type { SearchableItem } from './search.interfaces'; const { areAllTrue, @@ -197,24 +197,28 @@ describe('formatQuery', () => { }); const mockLinode: SearchableItem = { - value: 1234, - label: 'my-linode', - entityType: 'linode', data: { - tags: ['my-app', 'production'], + description: '', ips: ['1234'], + path: '/linode/1234', + tags: ['my-app', 'production'], }, + entityType: 'linode', + label: 'my-linode', + value: 1234, }; // Identical to above, but satisfies search queries. const mockLinodeMatch: SearchableItem = { - value: 1234, - label: 'my-2nd-linode', - entityType: 'linode', data: { - tags: ['production', 'beta', 'lab'], + description: '', ips: ['1234'], + path: '/linode/1234', + tags: ['production', 'beta', 'lab'], }, + entityType: 'linode', + label: 'my-2nd-linode', + value: 1234, }; describe('recursivelyTestItem', () => { diff --git a/packages/manager/src/features/Search/refinedSearch.ts b/packages/manager/src/features/Search/refinedSearch.ts index d844566d379..70e1a55e07d 100644 --- a/packages/manager/src/features/Search/refinedSearch.ts +++ b/packages/manager/src/features/Search/refinedSearch.ts @@ -164,7 +164,9 @@ export const doesSearchTermMatchItemField = ( ): boolean => { const flattenedItem = flattenSearchableItem(item); - const fieldValue = ensureValueIsString(flattenedItem[field]); + const fieldValue = ensureValueIsString( + flattenedItem[field as keyof typeof flattenedItem] + ); // Handle numeric comparison (e.g., for the "value" field to search linode by id) if (typeof fieldValue === 'number') { @@ -186,8 +188,17 @@ export const flattenSearchableItem = (item: SearchableItem) => ({ ...item.data, }); -export const ensureValueIsString = (value: any[] | string): string => - Array.isArray(value) ? value.join(' ') : value ? value : ''; +export const ensureValueIsString = ( + value: any[] | number | string | undefined +): string => { + if (Array.isArray(value)) { + return value.join(' '); + } + if (value) { + return String(value); + } + return ''; +}; export const getQueryInfo = (parsedQuery: any) => { // getParsedQuery() always includes an object called `excluded`. If search diff --git a/packages/manager/src/features/Search/search.interfaces.ts b/packages/manager/src/features/Search/search.interfaces.ts index 59d0f67b636..c6cbbe38a8d 100644 --- a/packages/manager/src/features/Search/search.interfaces.ts +++ b/packages/manager/src/features/Search/search.interfaces.ts @@ -3,11 +3,19 @@ export interface SearchResults { searchResultsByEntity: SearchResultsByEntity; } -export interface SearchableItem { - data?: any; +interface SearchItemData extends Record { + created?: string; + description: string; + path: string; + region?: string; + tags?: string[]; +} + +export interface SearchableItem { + data: SearchItemData; entityType: SearchableEntityType; label: string; - value: T; + value: number | string; } export type SearchableEntityType = diff --git a/packages/manager/src/features/Search/types.ts b/packages/manager/src/features/Search/types.ts deleted file mode 100644 index c2100c7ac53..00000000000 --- a/packages/manager/src/features/Search/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { SelectOption } from '@linode/ui'; - -export interface ResultRowDataOption extends SelectOption { - data: { - created: string; - description: string; - path: string; - region: string; - tags: string[]; - }; -} diff --git a/packages/manager/src/features/Search/utils.ts b/packages/manager/src/features/Search/utils.ts index ae7ff6edd25..c807ffdea39 100644 --- a/packages/manager/src/features/Search/utils.ts +++ b/packages/manager/src/features/Search/utils.ts @@ -1,3 +1,8 @@ +import Compute from 'src/assets/icons/entityIcons/compute.svg'; +import Database from 'src/assets/icons/entityIcons/database.svg'; +import Networking from 'src/assets/icons/entityIcons/networking.svg'; +import Storage from 'src/assets/icons/entityIcons/storage.svg'; + import { refinedSearch } from './refinedSearch'; import type { @@ -33,6 +38,22 @@ export const emptyErrors: Record = { volume: null, }; +export const searchableEntityIconMap: Record< + SearchableEntityType, + React.ComponentType +> = { + bucket: Storage, + database: Database, + domain: Networking, + firewall: Networking, + image: Storage, + kubernetesCluster: Compute, + linode: Compute, + nodebalancer: Networking, + stackscript: Compute, + volume: Storage, +}; + export const searchableEntityDisplayNameMap: Record< SearchableEntityType, string diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.test.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.test.tsx index 7eb265bf31f..66ca0d30424 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.test.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.test.tsx @@ -7,7 +7,8 @@ const createMockItems = (numberOfItemsToCreate: number) => { for (let i = 0; i < numberOfItemsToCreate; i++) { mockItems.push({ data: { - searchText: `test-search-text-${i}`, + description: '', + path: '', }, label: `test-label-${i}`, value: `test-value-${i}`, diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx index ca86a2602ac..4672131e535 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchBar.tsx @@ -124,11 +124,10 @@ export const SearchBar = () => { } if (isSpecialOption(item)) { - const text = item.data.searchText; if (item.value === 'redirect') { history.push({ pathname: `/search`, - search: `?query=${encodeURIComponent(text)}`, + search: `?query=${encodeURIComponent(searchText)}`, }); } return; @@ -298,10 +297,7 @@ export const SearchBar = () => { return ( onSelect(option)} diff --git a/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx b/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx index 9427bfa866a..28b52c2f174 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/SearchSuggestion.tsx @@ -1,10 +1,7 @@ -import { Box, Chip } from '@linode/ui'; +import { Box, Chip, SvgIcon } from '@linode/ui'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { EntityIcon } from 'src/components/EntityIcon/EntityIcon'; -import { linodeInTransition } from 'src/features/Linodes/transitions'; - import { StyledSearchSuggestion, StyledSegment, @@ -14,23 +11,11 @@ import { StyledTagContainer, } from './SearchSuggestion.styles'; -import type { LinodeStatus } from '@linode/api-v4/lib/linodes'; -import type { EntityVariants } from 'src/components/EntityIcon/EntityIcon'; - -export interface SearchSuggestionT { - description: string; - icon: EntityVariants; - path: string; - searchText: string; - status?: LinodeStatus; - tags?: string[]; -} +import type { SearchableItem } from 'src/features/Search/search.interfaces'; +import { searchableEntityIconMap } from 'src/features/Search/utils'; export interface SearchSuggestionProps { - data: { - data: SearchSuggestionT; - label: string; - }; + data: SearchableItem; searchText: string; selectOption: (option: unknown) => void; selectProps: { @@ -41,9 +26,8 @@ export interface SearchSuggestionProps { export const SearchSuggestion = (props: SearchSuggestionProps) => { const { data, searchText, selectOption, selectProps, ...rest } = props; const history = useHistory(); - const { data: suggestionData, label } = data; - const { description, icon, status, tags } = suggestionData; - const searchResultIcon = icon || 'default'; + + const Icon = searchableEntityIconMap[data.entityType]; const handleClick = () => { selectOption(data); @@ -116,12 +100,9 @@ export const SearchSuggestion = (props: SearchSuggestionProps) => { width="100%" > - + + + { > - {maybeStyleSegment(label, searchText)} + {maybeStyleSegment(data.label, searchText)} - {description} + {data.data.description} - {tags && renderTags(tags)} + {data.data.tags && renderTags(data.data.tags)} diff --git a/packages/manager/src/features/TopMenu/SearchBar/utils.tsx b/packages/manager/src/features/TopMenu/SearchBar/utils.tsx index b27b38b8530..074f5adba1c 100644 --- a/packages/manager/src/features/TopMenu/SearchBar/utils.tsx +++ b/packages/manager/src/features/TopMenu/SearchBar/utils.tsx @@ -11,7 +11,8 @@ export const createFinalOptions = ( ): SearchResultItem[] => { const redirectOption: ExtendedSearchableItem = { data: { - searchText, + description: '', + path: `/search?query=${searchText}`, }, icon: , label: `View search results page for "${searchText}"`, @@ -19,11 +20,19 @@ export const createFinalOptions = ( }; const loadingResults: ExtendedSearchableItem = { + data: { + description: '', + path: '', + }, label: 'Loading results...', value: 'info', }; const searchError: ExtendedSearchableItem = { + data: { + description: '', + path: '', + }, label: 'Error retrieving search results', value: 'error', }; @@ -50,7 +59,8 @@ export const createFinalOptions = ( // MORE THAN 20 RESULTS: const lastOption: ExtendedSearchableItem = { data: { - searchText, + description: '', + path: `/search?query=${searchText}`, }, icon: , label: `View all ${results.length} results for "${searchText}"`, diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index 6204ef34122..4c8c645edb6 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -44,7 +44,6 @@ export const linodeToSearchableItem = (linode: Linode): SearchableItem => ({ description: `${linode.image}, ${linode.specs.vcpus} CPU, ${ linode.specs.disk / 1024 } GB Storage, ${linode.specs.memory / 1024} GB RAM`, - icon: 'linode', ips: getLinodeIps(linode), path: `/linodes/${linode.id}`, region: linode.region, @@ -60,7 +59,6 @@ export const volumeToSearchableItem = (volume: Volume): SearchableItem => ({ data: { created: volume.created, description: volume.size + ' GB', - icon: 'volume', path: `/volumes?query=${volume.label}`, region: volume.region, tags: volume.tags, @@ -73,13 +71,14 @@ export const volumeToSearchableItem = (volume: Volume): SearchableItem => ({ export const imageToSearchableItem = (image: Image): SearchableItem => ({ data: { created: image.created, - description: image.description - ? image.description - : `${image.size} MB, ${pluralize( - 'region', - 'regions', - image.regions.length - )}`, + description: + image.description && image.description.length > 1 + ? image.description + : `${image.size} MB, Replicated in ${pluralize( + 'region', + 'regions', + image.regions.length + )}`, icon: 'image', path: `/images?query="${image.label}"`, tags: image.tags, @@ -92,7 +91,6 @@ export const imageToSearchableItem = (image: Image): SearchableItem => ({ export const domainToSearchableItem = (domain: Domain): SearchableItem => ({ data: { description: domain.type === 'master' ? 'primary' : 'secondary', - icon: 'domain', ips: getDomainIps(domain), path: `/domains/${domain.id}`, status: domain.status, @@ -109,7 +107,6 @@ export const nodeBalToSearchableItem = ( data: { created: nodebal.created, description: nodebal.hostname, - icon: 'nodebalancer', ips: getNodebalIps(nodebal), path: `/nodebalancers/${nodebal.id}`, region: nodebal.region, @@ -126,7 +123,6 @@ export const kubernetesClusterToSearchableItem = ( data: { created: kubernetesCluster.created, description: getDescriptionForCluster(kubernetesCluster), - icon: 'kube', k8s_version: kubernetesCluster.k8s_version, label: kubernetesCluster.label, path: `/kubernetes/clusters/${kubernetesCluster.id}/summary`, @@ -162,7 +158,6 @@ export const firewallToSearchableItem = ( data: { created: firewall.created, description: getFirewallDescription(firewall), - icon: 'firewall', path: `/firewalls/${firewall.id}`, tags: firewall.tags, }, @@ -177,7 +172,6 @@ export const databaseToSearchableItem = ( data: { created: database.created, description: getDatabasesDescription(database), - icon: 'database', path: `/databases/${database.engine}/${database.id}`, region: database.region, status: database.status, @@ -195,7 +189,6 @@ export const stackscriptToSearchableItem = ( description: stackscript.description ? stackscript.description : `${stackscript.deployments_total} deploys, ${stackscript.deployments_active} active deployments`, - icon: 'stackscript', path: `/stackscripts/${stackscript.id}`, }, entityType: 'stackscript', From 8c336b7f7f850da60961376b24a3dbb867ceefcc Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 11 Mar 2025 11:07:18 -0400 Subject: [PATCH 27/31] add invalidations for new stackscripts query key --- packages/manager/src/queries/stackscripts.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index 97a470b0bfc..5e836f2422f 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -97,6 +97,9 @@ export const useCreateStackScriptMutation = () => { queryClient.invalidateQueries({ queryKey: stackscriptQueries.infinite._def, }); + queryClient.invalidateQueries({ + queryKey: stackscriptQueries.all.queryKey, + }); }, }); }; @@ -136,6 +139,9 @@ export const useUpdateStackScriptMutation = ( queryClient.invalidateQueries({ queryKey: stackscriptQueries.infinite._def, }); + queryClient.invalidateQueries({ + queryKey: stackscriptQueries.all.queryKey, + }); queryClient.setQueryData( stackscriptQueries.stackscript(id).queryKey, stackscript @@ -160,6 +166,9 @@ export const useDeleteStackScriptMutation = ( queryClient.invalidateQueries({ queryKey: stackscriptQueries.infinite._def, }); + queryClient.invalidateQueries({ + queryKey: stackscriptQueries.all.queryKey, + }); queryClient.removeQueries({ queryKey: stackscriptQueries.stackscript(id).queryKey, }); @@ -178,6 +187,9 @@ export const stackScriptEventHandler = ({ invalidateQueries({ queryKey: stackscriptQueries.infinite._def, }); + invalidateQueries({ + queryKey: stackscriptQueries.all.queryKey, + }); // If the event has a StackScript entity attached, invalidate it if (event.entity?.id) { From b863fbe75219519dc030da8297f33f0fb78737e3 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Tue, 11 Mar 2025 14:13:02 -0400 Subject: [PATCH 28/31] update imports to use queries package --- packages/manager/src/features/Search/useAPISearch.ts | 6 +++--- .../manager/src/features/Search/useClientSideSearch.ts | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts index 2d6d490643b..575174e65b0 100644 --- a/packages/manager/src/features/Search/useAPISearch.ts +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -1,13 +1,13 @@ +import { useInfiniteNodebalancersQuery } from '@linode/queries'; +import { useFirewallsInfiniteQuery } from '@linode/queries'; +import { useInfiniteLinodesQuery } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; import { useDebouncedValue } from '@linode/utilities'; import { useDatabasesInfiniteQuery } from 'src/queries/databases/databases'; import { useDomainsInfiniteQuery } from 'src/queries/domains'; -import { useFirewallsInfiniteQuery } from 'src/queries/firewalls'; import { useImagesInfiniteQuery } from 'src/queries/images'; import { useKubernetesClustersInfiniteQuery } from 'src/queries/kubernetes'; -import { useInfiniteLinodesQuery } from 'src/queries/linodes/linodes'; -import { useInfiniteNodebalancersQuery } from 'src/queries/nodebalancers'; import { useStackScriptsInfiniteQuery } from 'src/queries/stackscripts'; import { useInfiniteVolumesQuery } from 'src/queries/volumes/volumes'; import { diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index 37df2948944..82e8e34c5f5 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -1,10 +1,11 @@ +import { useAllNodeBalancersQuery } from '@linode/queries'; +import { useAllFirewallsQuery } from '@linode/queries'; +import { useAllLinodesQuery } from '@linode/queries'; + import { useAllDatabasesQuery } from 'src/queries/databases/databases'; import { useAllDomainsQuery } from 'src/queries/domains'; -import { useAllFirewallsQuery } from 'src/queries/firewalls'; import { useAllImagesQuery } from 'src/queries/images'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { useAllAccountStackScriptsQuery } from 'src/queries/stackscripts'; import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; From 0670da8d256d7fee906023c5f8a27e04fde041eb Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Wed, 12 Mar 2025 18:04:09 -0400 Subject: [PATCH 29/31] fix incorrect object storage error type --- packages/manager/src/features/Search/useClientSideSearch.ts | 2 +- .../SupportTickets/SupportTicketProductSelectionFields.tsx | 2 +- packages/manager/src/queries/object-storage/queries.ts | 5 +---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index 82e8e34c5f5..b06c3200e2a 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -125,7 +125,7 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { stackscriptsLoading; const entityErrors: Record = { - bucket: bucketsError?.[0].reason ?? null, + bucket: bucketsError?.message ?? null, database: databasesError?.[0].reason ?? null, domain: domainsError?.[0].reason ?? null, firewall: firewallsError?.[0].reason ?? null, diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx index 9c102ec20b8..b0141ad749b 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -171,7 +171,7 @@ export const SupportTicketProductSelectionFields = (props: Props) => { }; const errorMap: Record = { - bucket: bucketsError, + bucket: bucketsError ? [{ reason: bucketsError.message }] : null, database_id: databasesError, domain_id: domainsError, firewall_id: firewallsError, diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index 0fe55aba15a..fd403dae3b1 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -188,10 +188,7 @@ export const useObjectStorageBuckets = (enabled = true) => { ? () => getAllBucketsFromRegions(regions) : () => getAllBucketsFromClusters(clusters); - return useQuery< - BucketsResponseType, - APIError[] - >({ + return useQuery>({ enabled: queryEnabled, queryFn, queryKey: objectStorageQueries.buckets.queryKey, From 1ad1e52be42125c2025f64889eb35a2d52bc9cbd Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 18 Mar 2025 13:23:25 -0400 Subject: [PATCH 30/31] Update packages/manager/.changeset/pr-11819-tech-stories-1741629552993.md Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --- .../manager/.changeset/pr-11819-tech-stories-1741629552993.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/.changeset/pr-11819-tech-stories-1741629552993.md b/packages/manager/.changeset/pr-11819-tech-stories-1741629552993.md index 4806cc13348..b58cfda4f6a 100644 --- a/packages/manager/.changeset/pr-11819-tech-stories-1741629552993.md +++ b/packages/manager/.changeset/pr-11819-tech-stories-1741629552993.md @@ -2,4 +2,4 @@ "@linode/manager": Tech Stories --- -Updates main search to not depend on recompose ([#11819](https://github.com/linode/manager/pull/11819)) +Updates main search to not depend on `recompose` library ([#11819](https://github.com/linode/manager/pull/11819)) From 7c6f87398f4ce3f1100f7874d0906a53a265a34e Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 18 Mar 2025 13:23:39 -0400 Subject: [PATCH 31/31] Update packages/manager/.changeset/pr-11819-changed-1741629531708.md Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --- packages/manager/.changeset/pr-11819-changed-1741629531708.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/.changeset/pr-11819-changed-1741629531708.md b/packages/manager/.changeset/pr-11819-changed-1741629531708.md index 0f5dce2f2fb..f4bcbde48eb 100644 --- a/packages/manager/.changeset/pr-11819-changed-1741629531708.md +++ b/packages/manager/.changeset/pr-11819-changed-1741629531708.md @@ -2,4 +2,4 @@ "@linode/manager": Changed --- -Updates main search to use new API search implimentation for large accounts ([#11819](https://github.com/linode/manager/pull/11819)) +Updates main search to use new API search implementation for large accounts ([#11819](https://github.com/linode/manager/pull/11819))