break some circular imports
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -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
|
||||
@@ -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<React.SetStateAction<number>>;
|
||||
};
|
||||
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<MobileSidebarContextType>({
|
||||
selectedMenuItemIndex: -1,
|
||||
setSelectedMenuItemIndex: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* A navigation component for mobile screens, which sticks to the bottom.
|
||||
*
|
||||
|
||||
@@ -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<React.SetStateAction<number>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Context on which `SidebarGroup` is currently selected
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const MobileSidebarContext = createContext<MobileSidebarContextType>({
|
||||
selectedMenuItemIndex: -1,
|
||||
setSelectedMenuItemIndex: () => {},
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
`<username>`, 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
|
||||
* `<username>`, 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
|
||||
`<organization>/<team>`, 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
|
||||
* `<organization>/<team>`, and is the same as can be seen in the URL location
|
||||
* bar of the browser when viewing that team.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
+2
-1
@@ -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[] = [
|
||||
{
|
||||
@@ -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<string, string | string[] | null> => {
|
||||
return filters.reduce((compoundFilter, filter) => {
|
||||
return {
|
||||
...compoundFilter,
|
||||
...(filter.getBackendFilters ? filter.getBackendFilters() : {}),
|
||||
};
|
||||
}, {} as Record<string, string | string[] | null>);
|
||||
};
|
||||
|
||||
type OutputState<PlaylistFilters extends DefaultPlaylistFilters> = {
|
||||
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<PlaylistSortCompareFunction>(
|
||||
() => DefaultSortCompareFunctions[DefaultPlaylistSortTypes.popular],
|
||||
);
|
||||
const [requestedFilters, setRequestedFilters] = useState<PlaylistFilters>(
|
||||
{} 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<string, string | string[]>,
|
||||
[location],
|
||||
);
|
||||
|
||||
const [outputState, setOutputState] = useState<OutputState<PlaylistFilters>>({
|
||||
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<string, string | string[]>,
|
||||
);
|
||||
|
||||
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<PlaylistFilters>
|
||||
| ((prevFilters: PlaylistFilters) => Partial<PlaylistFilters>),
|
||||
) => {
|
||||
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 (
|
||||
<PlaylistListContext.Provider value={value}>
|
||||
{children}
|
||||
</PlaylistListContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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<any> | undefined
|
||||
>(undefined);
|
||||
|
||||
const reduceBackendFilters = (
|
||||
filters: PlaylistFilter[],
|
||||
): Record<string, string | string[] | null> => {
|
||||
return filters.reduce((compoundFilter, filter) => {
|
||||
return {
|
||||
...compoundFilter,
|
||||
...(filter.getBackendFilters ? filter.getBackendFilters() : {}),
|
||||
};
|
||||
}, {} as Record<string, string | string[] | null>);
|
||||
};
|
||||
|
||||
type OutputState<PlaylistFilters extends DefaultPlaylistFilters> = {
|
||||
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<PlaylistSortCompareFunction>(
|
||||
() => DefaultSortCompareFunctions[DefaultPlaylistSortTypes.popular],
|
||||
);
|
||||
const [requestedFilters, setRequestedFilters] = useState<PlaylistFilters>(
|
||||
{} 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<string, string | string[]>,
|
||||
[location],
|
||||
);
|
||||
|
||||
const [outputState, setOutputState] = useState<OutputState<PlaylistFilters>>({
|
||||
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<string, string | string[]>,
|
||||
);
|
||||
|
||||
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<PlaylistFilters>
|
||||
| ((prevFilters: PlaylistFilters) => Partial<PlaylistFilters>),
|
||||
) => {
|
||||
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 (
|
||||
<PlaylistListContext.Provider value={value}>
|
||||
{children}
|
||||
</PlaylistListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function usePlaylistList<
|
||||
PlaylistFilters extends DefaultPlaylistFilters = DefaultPlaylistFilters,
|
||||
>(): PlaylistListContextProps<PlaylistFilters> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user