From 3d63e60f3c36e6fb35aaf15c53ad9a70c439fbf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 18 Sep 2023 10:11:09 +0200 Subject: [PATCH] break some circular imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/ten-wasps-serve.md | 8 + .../src/layout/Sidebar/MobileSidebar.tsx | 23 +- .../layout/Sidebar/MobileSidebarContext.ts | 37 +++ .../src/layout/Sidebar/SidebarGroup.test.tsx | 2 +- .../src/layout/Sidebar/SidebarGroup.tsx | 2 +- .../src/lib/annotation.ts | 16 +- .../src/lib/github.ts | 2 +- .../src/lib/withLocations.ts | 61 +++++ .../providers/GithubOrgEntityProvider.test.ts | 6 +- .../src/providers/GithubOrgEntityProvider.ts | 49 +--- .../PlaylistIndexPage/PlaylistIndexPage.tsx | 3 +- ...test.tsx => PlaylistListProvider.test.tsx} | 3 +- .../src/hooks/PlaylistListProvider.tsx | 217 ++++++++++++++++++ .../playlist/src/hooks/usePlaylistList.tsx | 199 +--------------- plugins/search-react/src/extensions.tsx | 2 +- 15 files changed, 348 insertions(+), 282 deletions(-) create mode 100644 .changeset/ten-wasps-serve.md create mode 100644 packages/core-components/src/layout/Sidebar/MobileSidebarContext.ts create mode 100644 plugins/catalog-backend-module-github/src/lib/withLocations.ts rename plugins/playlist/src/hooks/{usePlaylistList.test.tsx => PlaylistListProvider.test.tsx} (98%) create mode 100644 plugins/playlist/src/hooks/PlaylistListProvider.tsx diff --git a/.changeset/ten-wasps-serve.md b/.changeset/ten-wasps-serve.md new file mode 100644 index 0000000000..ee93c82c3b --- /dev/null +++ b/.changeset/ten-wasps-serve.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog-backend-module-github': patch +'@backstage/core-components': patch +'@backstage/plugin-search-react': patch +'@backstage/plugin-playlist': patch +--- + +Internal restructure to avoid circular imports diff --git a/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx b/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx index bf96703613..5dcf31d884 100644 --- a/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx +++ b/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx @@ -25,21 +25,12 @@ import Typography from '@material-ui/core/Typography'; import CloseIcon from '@material-ui/icons/Close'; import MenuIcon from '@material-ui/icons/Menu'; import { orderBy } from 'lodash'; -import React, { createContext, useEffect, useState, useContext } from 'react'; +import React, { useEffect, useState, useContext } from 'react'; import { useLocation } from 'react-router-dom'; import { SidebarOpenStateProvider } from './SidebarOpenStateContext'; import { SidebarGroup } from './SidebarGroup'; import { SidebarConfigContext, SidebarConfig } from './config'; - -/** - * Type of `MobileSidebarContext` - * - * @internal - */ -export type MobileSidebarContextType = { - selectedMenuItemIndex: number; - setSelectedMenuItemIndex: React.Dispatch>; -}; +import { MobileSidebarContext } from './MobileSidebarContext'; /** * Props of MobileSidebar @@ -149,16 +140,6 @@ const OverlayMenu = ({ ); }; -/** - * Context on which `SidebarGroup` is currently selected - * - * @internal - */ -export const MobileSidebarContext = createContext({ - selectedMenuItemIndex: -1, - setSelectedMenuItemIndex: () => {}, -}); - /** * A navigation component for mobile screens, which sticks to the bottom. * diff --git a/packages/core-components/src/layout/Sidebar/MobileSidebarContext.ts b/packages/core-components/src/layout/Sidebar/MobileSidebarContext.ts new file mode 100644 index 0000000000..92635be5c0 --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/MobileSidebarContext.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { createContext } from 'react'; + +/** + * Type of `MobileSidebarContext` + * + * @internal + */ +export type MobileSidebarContextType = { + selectedMenuItemIndex: number; + setSelectedMenuItemIndex: React.Dispatch>; +}; + +/** + * Context on which `SidebarGroup` is currently selected + * + * @internal + */ +export const MobileSidebarContext = createContext({ + selectedMenuItemIndex: -1, + setSelectedMenuItemIndex: () => {}, +}); diff --git a/packages/core-components/src/layout/Sidebar/SidebarGroup.test.tsx b/packages/core-components/src/layout/Sidebar/SidebarGroup.test.tsx index ce60b8cb8a..687140277a 100644 --- a/packages/core-components/src/layout/Sidebar/SidebarGroup.test.tsx +++ b/packages/core-components/src/layout/Sidebar/SidebarGroup.test.tsx @@ -21,7 +21,7 @@ import LibraryBooks from '@material-ui/icons/LibraryBooks'; import { fireEvent } from '@testing-library/react'; import React from 'react'; import { SidebarItem } from './Items'; -import { MobileSidebarContext } from './MobileSidebar'; +import { MobileSidebarContext } from './MobileSidebarContext'; import { SidebarPage } from './Page'; import { SidebarGroup } from './SidebarGroup'; diff --git a/packages/core-components/src/layout/Sidebar/SidebarGroup.tsx b/packages/core-components/src/layout/Sidebar/SidebarGroup.tsx index 01adfd12aa..2ce68cd51a 100644 --- a/packages/core-components/src/layout/Sidebar/SidebarGroup.tsx +++ b/packages/core-components/src/layout/Sidebar/SidebarGroup.tsx @@ -24,7 +24,7 @@ import React, { useContext } from 'react'; import { useLocation } from 'react-router-dom'; import { Link } from '../../components/Link/Link'; import { SidebarConfig, SidebarConfigContext } from './config'; -import { MobileSidebarContext } from './MobileSidebar'; +import { MobileSidebarContext } from './MobileSidebarContext'; import { useSidebarPinState } from './SidebarPinStateContext'; /** diff --git a/plugins/catalog-backend-module-github/src/lib/annotation.ts b/plugins/catalog-backend-module-github/src/lib/annotation.ts index 3b2f7b87a8..4db6ffb975 100644 --- a/plugins/catalog-backend-module-github/src/lib/annotation.ts +++ b/plugins/catalog-backend-module-github/src/lib/annotation.ts @@ -16,10 +16,10 @@ /** * The value of this annotation is the so-called login that identifies a user on -[GitHub](https://github.com) (either the public one, or a private GitHub -Enterprise installation) that is related to this entity. It is on the format -``, and is the same as can be seen in the URL location bar of the -browser when viewing that user. + * [GitHub](https://github.com) (either the public one, or a private GitHub + * Enterprise installation) that is related to this entity. It is on the format + * ``, and is the same as can be seen in the URL location bar of the + * browser when viewing that user. * * @public */ @@ -27,10 +27,10 @@ export const ANNOTATION_GITHUB_USER_LOGIN = 'github.com/user-login'; /** * The value of this annotation is the so-called slug that identifies a team on -[GitHub](https://github.com) (either the public one, or a private GitHub -Enterprise installation) that is related to this entity. It is on the format -`/`, and is the same as can be seen in the URL location bar -of the browser when viewing that team. + * [GitHub](https://github.com) (either the public one, or a private GitHub + * Enterprise installation) that is related to this entity. It is on the format + * `/`, and is the same as can be seen in the URL location + * bar of the browser when viewing that team. * * @public */ diff --git a/plugins/catalog-backend-module-github/src/lib/github.ts b/plugins/catalog-backend-module-github/src/lib/github.ts index 6b490f363a..073e6c1587 100644 --- a/plugins/catalog-backend-module-github/src/lib/github.ts +++ b/plugins/catalog-backend-module-github/src/lib/github.ts @@ -24,7 +24,7 @@ import { TransformerContext, UserTransformer, } from './defaultTransformers'; -import { withLocations } from '../providers/GithubOrgEntityProvider'; +import { withLocations } from './withLocations'; import { DeferredEntity } from '@backstage/plugin-catalog-node'; diff --git a/plugins/catalog-backend-module-github/src/lib/withLocations.ts b/plugins/catalog-backend-module-github/src/lib/withLocations.ts new file mode 100644 index 0000000000..50dedd9a2d --- /dev/null +++ b/plugins/catalog-backend-module-github/src/lib/withLocations.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ANNOTATION_LOCATION, + ANNOTATION_ORIGIN_LOCATION, + Entity, +} from '@backstage/catalog-model'; +import { merge } from 'lodash'; +import { + ANNOTATION_GITHUB_TEAM_SLUG, + ANNOTATION_GITHUB_USER_LOGIN, +} from './annotation'; +import { splitTeamSlug } from './util'; + +// Makes sure that emitted entities have a proper location +export function withLocations( + baseUrl: string, + org: string, + entity: Entity, +): Entity { + const login = + entity.metadata.annotations?.[ANNOTATION_GITHUB_USER_LOGIN] || + entity.metadata.name; + + let team = entity.metadata.name; + const slug = entity.metadata.annotations?.[ANNOTATION_GITHUB_TEAM_SLUG]; + if (slug) { + const [_, slugTeam] = splitTeamSlug(slug); + team = slugTeam; + } + + const location = + entity.kind === 'Group' + ? `url:${baseUrl}/orgs/${org}/teams/${team}` + : `url:${baseUrl}/${login}`; + return merge( + { + metadata: { + annotations: { + [ANNOTATION_LOCATION]: location, + [ANNOTATION_ORIGIN_LOCATION]: location, + }, + }, + }, + entity, + ) as Entity; +} diff --git a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts index 3e58c1473f..c054f26522 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts @@ -23,10 +23,8 @@ import { import { EntityProviderConnection } from '@backstage/plugin-catalog-node'; import { graphql } from '@octokit/graphql'; import { EventParams } from '@backstage/plugin-events-node'; -import { - GithubOrgEntityProvider, - withLocations, -} from './GithubOrgEntityProvider'; +import { GithubOrgEntityProvider } from './GithubOrgEntityProvider'; +import { withLocations } from '../lib/withLocations'; jest.mock('@octokit/graphql'); diff --git a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts index 22f1f12e8d..4e43b5d67e 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts @@ -15,12 +15,7 @@ */ import { TaskRunner } from '@backstage/backend-tasks'; -import { - ANNOTATION_LOCATION, - ANNOTATION_ORIGIN_LOCATION, - Entity, - isGroupEntity, -} from '@backstage/catalog-model'; +import { Entity, isGroupEntity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { DefaultGithubCredentialsProvider, @@ -43,13 +38,8 @@ import { TeamEditedEvent, TeamEvent, } from '@octokit/webhooks-types'; -import { merge } from 'lodash'; import * as uuid from 'uuid'; import { Logger } from 'winston'; -import { - ANNOTATION_GITHUB_TEAM_SLUG, - ANNOTATION_GITHUB_USER_LOGIN, -} from '../lib/annotation'; import { TeamTransformer, UserTransformer, @@ -68,7 +58,8 @@ import { getOrganizationUsers, } from '../lib/github'; import { assignGroupsToUsers, buildOrgHierarchy } from '../lib/org'; -import { parseGithubOrgUrl, splitTeamSlug } from '../lib/util'; +import { parseGithubOrgUrl } from '../lib/util'; +import { withLocations } from '../lib/withLocations'; import { areGroupEntities, areUserEntities } from '../lib/guards'; /** @@ -623,37 +614,3 @@ function trackProgress(logger: Logger) { return { markReadComplete }; } - -// Makes sure that emitted entities have a proper location -export function withLocations( - baseUrl: string, - org: string, - entity: Entity, -): Entity { - const login = - entity.metadata.annotations?.[ANNOTATION_GITHUB_USER_LOGIN] || - entity.metadata.name; - - let team = entity.metadata.name; - const slug = entity.metadata.annotations?.[ANNOTATION_GITHUB_TEAM_SLUG]; - if (slug) { - const [_, slugTeam] = splitTeamSlug(slug); - team = slugTeam; - } - - const location = - entity.kind === 'Group' - ? `url:${baseUrl}/orgs/${org}/teams/${team}` - : `url:${baseUrl}/${login}`; - return merge( - { - metadata: { - annotations: { - [ANNOTATION_LOCATION]: location, - [ANNOTATION_ORIGIN_LOCATION]: location, - }, - }, - }, - entity, - ) as Entity; -} diff --git a/plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.tsx b/plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.tsx index f25df19ee7..9f8b4e1576 100644 --- a/plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.tsx +++ b/plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.tsx @@ -23,13 +23,14 @@ import { } from '@backstage/core-components'; import { CatalogFilterLayout } from '@backstage/plugin-catalog-react'; -import { PlaylistListProvider, useTitle } from '../../hooks'; import { CreatePlaylistButton } from '../CreatePlaylistButton'; import { PersonalListPicker } from '../PersonalListPicker'; import { PlaylistList } from '../PlaylistList'; import { PlaylistOwnerPicker } from '../PlaylistOwnerPicker'; import { PlaylistSearchBar } from '../PlaylistSearchBar'; import { PlaylistSortPicker } from '../PlaylistSortPicker'; +import { PlaylistListProvider } from '../../hooks/PlaylistListProvider'; +import { useTitle } from '../../hooks/useTitle'; export const PlaylistIndexPage = () => { const pluralTitle = useTitle({ diff --git a/plugins/playlist/src/hooks/usePlaylistList.test.tsx b/plugins/playlist/src/hooks/PlaylistListProvider.test.tsx similarity index 98% rename from plugins/playlist/src/hooks/usePlaylistList.test.tsx rename to plugins/playlist/src/hooks/PlaylistListProvider.test.tsx index 9b4b0ca0fc..26e162ebf7 100644 --- a/plugins/playlist/src/hooks/usePlaylistList.test.tsx +++ b/plugins/playlist/src/hooks/PlaylistListProvider.test.tsx @@ -35,7 +35,8 @@ import { PersonalListPicker, PlaylistOwnerPicker, } from '../components'; -import { PlaylistListProvider, usePlaylistList } from './usePlaylistList'; +import { PlaylistListProvider } from './PlaylistListProvider'; +import { usePlaylistList } from './usePlaylistList'; const playlists: Playlist[] = [ { diff --git a/plugins/playlist/src/hooks/PlaylistListProvider.tsx b/plugins/playlist/src/hooks/PlaylistListProvider.tsx new file mode 100644 index 0000000000..b931e469e7 --- /dev/null +++ b/plugins/playlist/src/hooks/PlaylistListProvider.tsx @@ -0,0 +1,217 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi } from '@backstage/core-plugin-api'; +import { Playlist } from '@backstage/plugin-playlist-common'; +import { compact, isEqual } from 'lodash'; +import qs from 'qs'; +import React, { + PropsWithChildren, + useCallback, + useMemo, + useState, +} from 'react'; +import { useLocation } from 'react-router-dom'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import useDebounce from 'react-use/lib/useDebounce'; +import useMountedState from 'react-use/lib/useMountedState'; + +import { playlistApiRef } from '../api'; +import { + DefaultPlaylistSortTypes, + DefaultSortCompareFunctions, +} from '../components/PlaylistSortPicker'; +import { PlaylistFilter, PlaylistSortCompareFunction } from '../types'; +import { + DefaultPlaylistFilters, + NoopFilter, + PlaylistListContext, +} from './usePlaylistList'; + +const reduceBackendFilters = ( + filters: PlaylistFilter[], +): Record => { + return filters.reduce((compoundFilter, filter) => { + return { + ...compoundFilter, + ...(filter.getBackendFilters ? filter.getBackendFilters() : {}), + }; + }, {} as Record); +}; + +type OutputState = { + appliedFilters: PlaylistFilters; + playlists: Playlist[]; + backendPlaylists: Playlist[]; +}; + +export const PlaylistListProvider = < + PlaylistFilters extends DefaultPlaylistFilters, +>({ + children, +}: PropsWithChildren<{}>) => { + const isMounted = useMountedState(); + const playlistApi = useApi(playlistApiRef); + const [sortCompareFn, setSortCompareFn] = + useState( + () => DefaultSortCompareFunctions[DefaultPlaylistSortTypes.popular], + ); + const [requestedFilters, setRequestedFilters] = useState( + {} as PlaylistFilters, + ); + + // We use react-router's useLocation hook so updates from external sources trigger an update to + // the queryParameters in outputState. Updates from this hook use replaceState below and won't + // trigger a useLocation change; this would instead come from an external source, such as a manual + // update of the URL or two sidebar links with different filters. + const location = useLocation(); + const queryParameters = useMemo( + () => + (qs.parse(location.search, { + ignoreQueryPrefix: true, + }).filters ?? {}) as Record, + [location], + ); + + const [outputState, setOutputState] = useState>({ + appliedFilters: { + noop: new NoopFilter(), // Init with a noop filter to trigger initial request + } as PlaylistFilters, + playlists: [], + backendPlaylists: [], + }); + + // The main async filter worker. Note that while it has a lot of dependencies + // in terms of its implementation, the triggering only happens (debounced) + // based on the requested filters/sortCompareFn changing. + const [{ loading, error }, refresh] = useAsyncFn( + async () => { + const compacted: PlaylistFilter[] = compact( + Object.values(requestedFilters), + ); + const playlistFilter = (p: Playlist) => + compacted.every( + filter => !filter.filterPlaylist || filter.filterPlaylist(p), + ); + const backendFilter = reduceBackendFilters(compacted); + const previousBackendFilter = reduceBackendFilters( + compact(Object.values(outputState.appliedFilters)), + ); + + const queryParams = Object.keys(requestedFilters).reduce( + (params, key) => { + const filter = requestedFilters[key as keyof PlaylistFilters] as + | PlaylistFilter + | undefined; + if (filter?.toQueryValue) { + params[key] = filter.toQueryValue(); + } + return params; + }, + {} as Record, + ); + + if (!isEqual(previousBackendFilter, backendFilter)) { + const response = await playlistApi.getAllPlaylists({ + filter: backendFilter, + }); + setOutputState({ + appliedFilters: requestedFilters, + backendPlaylists: response, + playlists: response.filter(playlistFilter).sort(sortCompareFn), + }); + } else { + setOutputState({ + appliedFilters: requestedFilters, + backendPlaylists: outputState.backendPlaylists, + playlists: outputState.backendPlaylists + .filter(playlistFilter) + .sort(sortCompareFn), + }); + } + + if (isMounted()) { + const oldParams = qs.parse(location.search, { + ignoreQueryPrefix: true, + }); + const newParams = qs.stringify( + { ...oldParams, filters: queryParams }, + { addQueryPrefix: true, arrayFormat: 'repeat' }, + ); + const newUrl = `${window.location.pathname}${newParams}`; + // We use direct history manipulation since useSearchParams and + // useNavigate in react-router-dom cause unnecessary extra rerenders. + // Also make sure to replace the state rather than pushing, since we + // don't want there to be back/forward slots for every single filter + // change. + window.history?.replaceState(null, document.title, newUrl); + } + }, + [ + playlistApi, + queryParameters, + requestedFilters, + sortCompareFn, + outputState, + ], + { loading: true }, + ); + + // Slight debounce on the refresh, since (especially on page load) several + // filters will be calling this in rapid succession. + useDebounce(refresh, 10, [requestedFilters, sortCompareFn]); + + const updateFilters = useCallback( + ( + update: + | Partial + | ((prevFilters: PlaylistFilters) => Partial), + ) => { + setRequestedFilters(prevFilters => { + const newFilters = + typeof update === 'function' ? update(prevFilters) : update; + return { ...prevFilters, ...newFilters }; + }); + }, + [], + ); + + const updateSort = useCallback( + (compareFn: PlaylistSortCompareFunction) => + setSortCompareFn(() => compareFn), + [], + ); + + const value = useMemo( + () => ({ + filters: outputState.appliedFilters, + playlists: outputState.playlists, + backendPlaylists: outputState.backendPlaylists, + updateFilters, + updateSort, + queryParameters, + loading, + error, + }), + [outputState, updateFilters, updateSort, queryParameters, loading, error], + ); + + return ( + + {children} + + ); +}; diff --git a/plugins/playlist/src/hooks/usePlaylistList.tsx b/plugins/playlist/src/hooks/usePlaylistList.tsx index b20f82bb5c..0c71a66e65 100644 --- a/plugins/playlist/src/hooks/usePlaylistList.tsx +++ b/plugins/playlist/src/hooks/usePlaylistList.tsx @@ -14,34 +14,14 @@ * limitations under the License. */ -import { useApi } from '@backstage/core-plugin-api'; import { Playlist } from '@backstage/plugin-playlist-common'; -import { compact, isEqual } from 'lodash'; -import qs from 'qs'; -import React, { - createContext, - PropsWithChildren, - useCallback, - useContext, - useMemo, - useState, -} from 'react'; -import { useLocation } from 'react-router-dom'; -import useAsyncFn from 'react-use/lib/useAsyncFn'; -import useDebounce from 'react-use/lib/useDebounce'; -import useMountedState from 'react-use/lib/useMountedState'; - -import { playlistApiRef } from '../api'; +import { createContext, useContext } from 'react'; import { PersonalListFilter } from '../components/PersonalListPicker'; import { PlaylistOwnerFilter } from '../components/PlaylistOwnerPicker'; import { PlaylistTextFilter } from '../components/PlaylistSearchBar'; -import { - DefaultPlaylistSortTypes, - DefaultSortCompareFunctions, -} from '../components/PlaylistSortPicker'; import { PlaylistFilter, PlaylistSortCompareFunction } from '../types'; -class NoopFilter implements PlaylistFilter { +export class NoopFilter implements PlaylistFilter { getBackendFilters() { return { '': null }; } @@ -100,181 +80,6 @@ export const PlaylistListContext = createContext< PlaylistListContextProps | undefined >(undefined); -const reduceBackendFilters = ( - filters: PlaylistFilter[], -): Record => { - return filters.reduce((compoundFilter, filter) => { - return { - ...compoundFilter, - ...(filter.getBackendFilters ? filter.getBackendFilters() : {}), - }; - }, {} as Record); -}; - -type OutputState = { - appliedFilters: PlaylistFilters; - playlists: Playlist[]; - backendPlaylists: Playlist[]; -}; - -export const PlaylistListProvider = < - PlaylistFilters extends DefaultPlaylistFilters, ->({ - children, -}: PropsWithChildren<{}>) => { - const isMounted = useMountedState(); - const playlistApi = useApi(playlistApiRef); - const [sortCompareFn, setSortCompareFn] = - useState( - () => DefaultSortCompareFunctions[DefaultPlaylistSortTypes.popular], - ); - const [requestedFilters, setRequestedFilters] = useState( - {} as PlaylistFilters, - ); - - // We use react-router's useLocation hook so updates from external sources trigger an update to - // the queryParameters in outputState. Updates from this hook use replaceState below and won't - // trigger a useLocation change; this would instead come from an external source, such as a manual - // update of the URL or two sidebar links with different filters. - const location = useLocation(); - const queryParameters = useMemo( - () => - (qs.parse(location.search, { - ignoreQueryPrefix: true, - }).filters ?? {}) as Record, - [location], - ); - - const [outputState, setOutputState] = useState>({ - appliedFilters: { - noop: new NoopFilter(), // Init with a noop filter to trigger intial request - } as PlaylistFilters, - playlists: [], - backendPlaylists: [], - }); - - // The main async filter worker. Note that while it has a lot of dependencies - // in terms of its implementation, the triggering only happens (debounced) - // based on the requested filters/sortCompareFn changing. - const [{ loading, error }, refresh] = useAsyncFn( - async () => { - const compacted: PlaylistFilter[] = compact( - Object.values(requestedFilters), - ); - const playlistFilter = (p: Playlist) => - compacted.every( - filter => !filter.filterPlaylist || filter.filterPlaylist(p), - ); - const backendFilter = reduceBackendFilters(compacted); - const previousBackendFilter = reduceBackendFilters( - compact(Object.values(outputState.appliedFilters)), - ); - - const queryParams = Object.keys(requestedFilters).reduce( - (params, key) => { - const filter = requestedFilters[key as keyof PlaylistFilters] as - | PlaylistFilter - | undefined; - if (filter?.toQueryValue) { - params[key] = filter.toQueryValue(); - } - return params; - }, - {} as Record, - ); - - if (!isEqual(previousBackendFilter, backendFilter)) { - const response = await playlistApi.getAllPlaylists({ - filter: backendFilter, - }); - setOutputState({ - appliedFilters: requestedFilters, - backendPlaylists: response, - playlists: response.filter(playlistFilter).sort(sortCompareFn), - }); - } else { - setOutputState({ - appliedFilters: requestedFilters, - backendPlaylists: outputState.backendPlaylists, - playlists: outputState.backendPlaylists - .filter(playlistFilter) - .sort(sortCompareFn), - }); - } - - if (isMounted()) { - const oldParams = qs.parse(location.search, { - ignoreQueryPrefix: true, - }); - const newParams = qs.stringify( - { ...oldParams, filters: queryParams }, - { addQueryPrefix: true, arrayFormat: 'repeat' }, - ); - const newUrl = `${window.location.pathname}${newParams}`; - // We use direct history manipulation since useSearchParams and - // useNavigate in react-router-dom cause unnecessary extra rerenders. - // Also make sure to replace the state rather than pushing, since we - // don't want there to be back/forward slots for every single filter - // change. - window.history?.replaceState(null, document.title, newUrl); - } - }, - [ - playlistApi, - queryParameters, - requestedFilters, - sortCompareFn, - outputState, - ], - { loading: true }, - ); - - // Slight debounce on the refresh, since (especially on page load) several - // filters will be calling this in rapid succession. - useDebounce(refresh, 10, [requestedFilters, sortCompareFn]); - - const updateFilters = useCallback( - ( - update: - | Partial - | ((prevFilters: PlaylistFilters) => Partial), - ) => { - setRequestedFilters(prevFilters => { - const newFilters = - typeof update === 'function' ? update(prevFilters) : update; - return { ...prevFilters, ...newFilters }; - }); - }, - [], - ); - - const updateSort = useCallback( - (compareFn: PlaylistSortCompareFunction) => - setSortCompareFn(() => compareFn), - [], - ); - - const value = useMemo( - () => ({ - filters: outputState.appliedFilters, - playlists: outputState.playlists, - backendPlaylists: outputState.backendPlaylists, - updateFilters, - updateSort, - queryParameters, - loading, - error, - }), - [outputState, updateFilters, updateSort, queryParameters, loading, error], - ); - - return ( - - {children} - - ); -}; - export function usePlaylistList< PlaylistFilters extends DefaultPlaylistFilters = DefaultPlaylistFilters, >(): PlaylistListContextProps { diff --git a/plugins/search-react/src/extensions.tsx b/plugins/search-react/src/extensions.tsx index 6c854e24e0..a29311b1ff 100644 --- a/plugins/search-react/src/extensions.tsx +++ b/plugins/search-react/src/extensions.tsx @@ -35,7 +35,7 @@ import { SearchDocument, SearchResult } from '@backstage/plugin-search-common'; import { ListItem, List, ListProps, ListItemProps } from '@material-ui/core'; -import { DefaultResultListItem } from './components'; +import { DefaultResultListItem } from './components/DefaultResultListItem'; /** * @internal