break some circular imports

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2023-09-18 10:11:09 +02:00
parent 7f3adafa14
commit 3d63e60f3c
15 changed files with 348 additions and 282 deletions
+8
View File
@@ -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({
@@ -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>
);
};
+2 -197
View File
@@ -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> {
+1 -1
View File
@@ -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