diff --git a/packages/app/package.json b/packages/app/package.json index 118b4c15ff..46769e7dfb 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -76,13 +76,6 @@ "pathRewrite": { "^/circleci/api/": "/" } - }, - "/catalog/api": { - "target": "http://localhost:7000", - "changeOrigin": true, - "pathRewrite": { - "^/catalog/api/": "/catalog/" - } } } } diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 65e8ff64e6..26c3466ba1 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -26,6 +26,7 @@ describe('App', () => { { data: { app: { title: 'Test' }, + backend: { baseUrl: 'http://localhost:7000' }, }, context: 'test', }, diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index d2b2706464..fe938b8998 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -56,6 +56,8 @@ export const apis = (config: ConfigApi) => { // eslint-disable-next-line no-console console.log(`Creating APIs for ${config.getString('app.title')}`); + const backendUrl = config.getString('backend.baseUrl'); + const builder = ApiRegistry.builder(); const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); @@ -78,7 +80,7 @@ export const apis = (config: ConfigApi) => { builder.add( googleAuthApiRef, GoogleAuth.create({ - apiOrigin: 'http://localhost:7000', + apiOrigin: backendUrl, basePath: '/auth/', oauthRequestApi, }), @@ -87,7 +89,7 @@ export const apis = (config: ConfigApi) => { const githubAuthApi = builder.add( githubAuthApiRef, GithubAuth.create({ - apiOrigin: 'http://localhost:7000', + apiOrigin: backendUrl, basePath: '/auth/', oauthRequestApi, }), @@ -96,7 +98,7 @@ export const apis = (config: ConfigApi) => { builder.add( oktaAuthApiRef, OktaAuth.create({ - apiOrigin: 'http://localhost:7000', + apiOrigin: backendUrl, basePath: '/auth/', oauthRequestApi, }), @@ -113,8 +115,8 @@ export const apis = (config: ConfigApi) => { builder.add( catalogApiRef, new CatalogClient({ - apiOrigin: 'http://localhost:3000', - basePath: '/catalog/api', + apiOrigin: backendUrl, + basePath: '/catalog', }), ); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 4b92f8281e..a86b92ab43 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -22,6 +22,7 @@ import ExploreIcon from '@material-ui/icons/Explore'; import BuildIcon from '@material-ui/icons/BuildRounded'; import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; @@ -89,6 +90,7 @@ const Root: FC<{}> = ({ children }) => ( {/* Global nav, not org-specific */} + {/* End global nav */} diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index 11db57c1ed..fd2f3a1c8b 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import * as winston from 'winston'; let rootLogger: winston.Logger = winston.createLogger({ diff --git a/packages/backend-common/src/middleware/errorHandler.test.ts b/packages/backend-common/src/middleware/errorHandler.test.ts index 022802dff8..e8a5a7f47a 100644 --- a/packages/backend-common/src/middleware/errorHandler.test.ts +++ b/packages/backend-common/src/middleware/errorHandler.test.ts @@ -34,6 +34,29 @@ describe('errorHandler', () => { expect(response.text).toBe('some message'); }); + it('doesnt try to send the response again if its already been sent', async () => { + const app = express(); + const mockSend = jest.fn(); + + app.use('/works_with_async_fail', (_, res) => { + res.status(200).send('hello'); + + // mutate the response object to test the middlware. + // it's hard to catch errors inside middleware from the outside. + // @ts-ignore + res.send = mockSend; + throw new Error('some message'); + }); + + app.use(errorHandler()); + const response = await request(app).get('/works_with_async_fail'); + + expect(response.status).toBe(200); + expect(response.text).toBe('hello'); + + expect(mockSend).not.toHaveBeenCalled(); + }); + it('takes code from http-errors library errors', async () => { const app = express(); app.use('/breaks', () => { diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts index 14f6cfa8d7..ad8f170dcd 100644 --- a/packages/backend-common/src/middleware/errorHandler.ts +++ b/packages/backend-common/src/middleware/errorHandler.ts @@ -53,7 +53,10 @@ export function errorHandler( next: NextFunction, ) => { if (response.headersSent) { + // If the headers have already been sent, do not send the response again + // as this will throw an error in the backend. next(error); + return; } const status = getStatusCode(error); diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index ddf03034dc..9309cfd437 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -27,7 +27,10 @@ import { } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { + AuthSessionStore, + StaticAuthSessionManager, +} from '../../../../lib/AuthSessionManager'; import { Observable } from '../../../../types'; type CreateOptions = { @@ -91,7 +94,13 @@ class GithubAuth implements OAuthApi, SessionStateApi { sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); - return new GithubAuth(sessionManager); + const authSessionStore = new AuthSessionStore({ + manager: sessionManager, + storageKey: 'githubSession', + sessionScopes: (session: GithubSession) => session.providerInfo.scopes, + }); + + return new GithubAuth(authSessionStore); } sessionState$(): Observable { diff --git a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts index 3e3a40d02a..f2b8558f74 100644 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts +++ b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -36,6 +36,8 @@ type Options = { /** * AuthSessionStore decorates another SessionManager with a functionality * to store the session in local storage. + * + * Session is serialized to JSON with special support for following types: Set. */ export class AuthSessionStore implements SessionManager { private readonly manager: SessionManager; @@ -90,7 +92,12 @@ export class AuthSessionStore implements SessionManager { try { const sessionJson = localStorage.getItem(this.storageKey); if (sessionJson) { - const session = JSON.parse(sessionJson); + const session = JSON.parse(sessionJson, (_key, value) => { + if (value?.__type === 'Set') { + return new Set(value.__value); + } + return value; + }); return session; } @@ -105,7 +112,18 @@ export class AuthSessionStore implements SessionManager { if (session === undefined) { localStorage.removeItem(this.storageKey); } else { - localStorage.setItem(this.storageKey, JSON.stringify(session)); + localStorage.setItem( + this.storageKey, + JSON.stringify(session, (_key, value) => { + if (value instanceof Set) { + return { + __type: 'Set', + __value: Array.from(value), + }; + } + return value; + }), + ); } } } diff --git a/packages/core-api/src/lib/AuthSessionManager/index.ts b/packages/core-api/src/lib/AuthSessionManager/index.ts index 16a8d3c378..5f4dde8662 100644 --- a/packages/core-api/src/lib/AuthSessionManager/index.ts +++ b/packages/core-api/src/lib/AuthSessionManager/index.ts @@ -16,4 +16,5 @@ export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; export { StaticAuthSessionManager } from './StaticAuthSessionManager'; +export { AuthSessionStore } from './AuthSessionStore'; export * from './types'; diff --git a/packages/core/package.json b/packages/core/package.json index 10a6c5d045..e49e4616ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,7 +40,7 @@ "classnames": "^2.2.6", "clsx": "^1.1.0", "lodash": "^4.17.15", - "material-table": "^1.58.0", + "material-table": "1.62.x", "prop-types": "^15.7.2", "rc-progress": "^3.0.0", "react": "^16.12.0", diff --git a/packages/core/src/components/Table/Table.stories.tsx b/packages/core/src/components/Table/Table.stories.tsx index 3709c301fd..11fa0c44dc 100644 --- a/packages/core/src/components/Table/Table.stories.tsx +++ b/packages/core/src/components/Table/Table.stories.tsx @@ -185,3 +185,38 @@ export const SubvalueTable = () => { ); }; + +export const DenseTable = () => { + const columns: TableColumn[] = [ + { + title: 'Column 1', + field: 'col1', + highlight: true, + }, + { + title: 'Column 2', + field: 'col2', + }, + { + title: 'Numeric value', + field: 'number', + type: 'numeric', + }, + { + title: 'A Date', + field: 'date', + type: 'date', + }, + ]; + + return ( +
+ + + ); +}; diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index c02aca5659..89aa2dce1f 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -14,18 +14,8 @@ * limitations under the License. */ -import React, { FC, forwardRef } from 'react'; -import MTable, { - MTableCell, - MTableHeader, - MTableToolbar, - MaterialTableProps, - Options, - Column, -} from 'material-table'; import { BackstageTheme } from '@backstage/theme'; -import { makeStyles, useTheme, Typography } from '@material-ui/core'; - +import { makeStyles, Typography, useTheme } from '@material-ui/core'; // Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51 import AddBox from '@material-ui/icons/AddBox'; import ArrowUpward from '@material-ui/icons/ArrowUpward'; @@ -42,6 +32,14 @@ import Remove from '@material-ui/icons/Remove'; import SaveAlt from '@material-ui/icons/SaveAlt'; import Search from '@material-ui/icons/Search'; import ViewColumn from '@material-ui/icons/ViewColumn'; +import MTable, { + Column, + MaterialTableProps, + MTableHeader, + MTableToolbar, + Options, +} from 'material-table'; +import React, { forwardRef } from 'react'; const tableIcons = { Add: forwardRef((props, ref: React.Ref) => ( @@ -97,14 +95,6 @@ const tableIcons = { )), }; -const useCellStyles = makeStyles(theme => ({ - root: { - color: theme.palette.grey[500], - padding: theme.spacing(0, 2, 0, 2.5), - height: '56px', - }, -})); - const useHeaderStyles = makeStyles(theme => ({ header: { padding: theme.spacing(1, 2, 1, 2.5), @@ -131,10 +121,10 @@ const useToolbarStyles = makeStyles(theme => ({ }, })); -const convertColumns = ( - columns: TableColumn[], +function convertColumns( + columns: TableColumn[], theme: BackstageTheme, -): TableColumn[] => { +): TableColumn[] { return columns.map(column => { const headerStyle: React.CSSProperties = {}; const cellStyle: React.CSSProperties = {}; @@ -150,26 +140,26 @@ const convertColumns = ( cellStyle, }; }); -}; +} -export interface TableColumn extends Column<{}> { +export interface TableColumn extends Column { highlight?: boolean; width?: string; } -export interface TableProps extends MaterialTableProps<{}> { - columns: TableColumn[]; +export interface TableProps + extends MaterialTableProps { + columns: TableColumn[]; subtitle?: string; } -export const Table: FC = ({ +export function Table({ columns, options, title, subtitle, ...props -}) => { - const cellClasses = useCellStyles(); +}: TableProps) { const headerClasses = useHeaderStyles(); const toolbarClasses = useToolbarStyles(); const theme = useTheme(); @@ -183,11 +173,8 @@ export const Table: FC = ({ }; return ( - components={{ - Cell: cellProps => ( - - ), Header: headerProps => ( ), @@ -211,4 +198,4 @@ export const Table: FC = ({ {...props} /> ); -}; +} diff --git a/packages/core/src/components/Table/index.ts b/packages/core/src/components/Table/index.ts index aeade2b24f..c10399dc59 100644 --- a/packages/core/src/components/Table/index.ts +++ b/packages/core/src/components/Table/index.ts @@ -15,5 +15,5 @@ */ export { Table } from './Table'; -export type { TableColumn } from './Table'; +export type { TableColumn, TableProps } from './Table'; export { SubvalueCell } from './SubvalueCell'; diff --git a/packages/core/src/layout/HeaderTabs/index.tsx b/packages/core/src/layout/HeaderTabs/index.tsx index 2617a27aa9..838b126569 100644 --- a/packages/core/src/layout/HeaderTabs/index.tsx +++ b/packages/core/src/layout/HeaderTabs/index.tsx @@ -42,14 +42,15 @@ export type Tab = { id: string; label: string; }; + export const HeaderTabs: React.FC<{ tabs: Tab[]; - onChange?: (index: Number) => void; + onChange?: (index: number) => void; }> = ({ tabs, onChange }) => { - const [selectedTab, setSelectedTab] = useState(0); + const [selectedTab, setSelectedTab] = useState(0); const styles = useStyles(); - const handleChange = (_: React.ChangeEvent<{}>, index: Number) => { + const handleChange = (_: React.ChangeEvent<{}>, index: number) => { setSelectedTab(index); if (onChange) onChange(index); }; diff --git a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx new file mode 100644 index 0000000000..46b93da370 --- /dev/null +++ b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 from 'react'; +import { ItemCard } from '.'; +import { Grid } from '@material-ui/core'; + +export default { + title: 'Item Card', + component: ItemCard, +}; + +export const Default = () => ( + + + {}} + /> + + + {}} + /> + + +); + +export const Tags = () => ( + + + + + + + + +); diff --git a/packages/core/src/layout/ItemCard/ItemCard.tsx b/packages/core/src/layout/ItemCard/ItemCard.tsx new file mode 100644 index 0000000000..86ad53e8dd --- /dev/null +++ b/packages/core/src/layout/ItemCard/ItemCard.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { FC } from 'react'; +import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + header: { + color: theme.palette.common.white, + padding: theme.spacing(2, 2, 6), + backgroundImage: + 'linear-gradient(-137deg, rgb(25, 230, 140) 0%, rgb(29, 127, 110) 100%)', + }, + content: { + padding: theme.spacing(2), + }, + description: { + height: 175, + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + footer: { + display: 'flex', + flexDirection: 'row-reverse', + }, +})); + +type ItemCardProps = { + description: string; + tags?: string[]; + title: string; + type?: string; + label: string; + onClick?: () => void; +}; +export const ItemCard: FC = ({ + description, + tags, + title, + type, + label, + onClick, +}) => { + const classes = useStyles(); + + return ( + +
+ {type ?? {type}} + {title} +
+
+ {tags?.map(tag => ( + + ))} + + {description} + +
+ +
+
+
+ ); +}; diff --git a/packages/core/src/layout/ItemCard/index.ts b/packages/core/src/layout/ItemCard/index.ts new file mode 100644 index 0000000000..b38dd2acc2 --- /dev/null +++ b/packages/core/src/layout/ItemCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +export { ItemCard } from './ItemCard'; diff --git a/packages/core/src/layout/index.ts b/packages/core/src/layout/index.ts index e2de159ec6..e8245119f3 100644 --- a/packages/core/src/layout/index.ts +++ b/packages/core/src/layout/index.ts @@ -26,3 +26,4 @@ export * from './Sidebar'; export * from './SignInPage'; export * from './TabbedCard'; export * from './HeaderTabs'; +export * from './ItemCard'; diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index 955b182ae9..dc68db11d6 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -118,9 +118,12 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides { verticalAlign: 'middle', lineHeight: '1', margin: 0, - padding: '8px', + padding: theme.spacing(3, 2, 3, 2.5), borderBottom: 0, }, + sizeSmall: { + padding: theme.spacing(1, 2, 1, 2.5), + }, head: { wordBreak: 'break-word', overflow: 'hidden', diff --git a/plugins/auth-backend/src/lib/OAuthProvider.ts b/plugins/auth-backend/src/lib/OAuthProvider.ts index b7179659f9..5434b6a4ba 100644 --- a/plugins/auth-backend/src/lib/OAuthProvider.ts +++ b/plugins/auth-backend/src/lib/OAuthProvider.ts @@ -33,6 +33,7 @@ export type Options = { providerId: string; secure: boolean; disableRefresh?: boolean; + persistScopes?: boolean; baseUrl: string; appOrigin: string; tokenIssuer: TokenIssuer; @@ -105,6 +106,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers { throw new InputError('missing scope parameter'); } + if (this.options.persistScopes) { + this.setScopesCookie(res, scope); + } + const nonce = crypto.randomBytes(16).toString('base64'); // set a nonce cookie before redirecting to oauth provider this.setNonceCookie(res, nonce); @@ -137,6 +142,14 @@ export class OAuthProvider implements AuthProviderRouteHandlers { req, ); + if (this.options.persistScopes) { + const grantedScopes = this.getScopesFromCookie( + req, + this.options.providerId, + ); + response.providerInfo.scope = grantedScopes; + } + if (!this.options.disableRefresh) { // throw error if missing refresh token if (!refreshToken) { @@ -241,6 +254,21 @@ export class OAuthProvider implements AuthProviderRouteHandlers { }); }; + private setScopesCookie = (res: express.Response, scope: string) => { + res.cookie(`${this.options.providerId}-scope`, scope, { + maxAge: TEN_MINUTES_MS, + secure: this.options.secure, + sameSite: 'none', + domain: this.domain, + path: `${this.basePath}/${this.options.providerId}/handler`, + httpOnly: true, + }); + }; + + private getScopesFromCookie = (req: express.Request, providerId: string) => { + return req.cookies[`${providerId}-scope`]; + }; + private setRefreshTokenCookie = ( res: express.Response, refreshToken: string, diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index 01652a6d7e..ec444733ba 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -115,6 +115,7 @@ export function createGithubProvider( envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), { disableRefresh: true, + persistScopes: true, providerId: 'github', secure, baseUrl, diff --git a/plugins/catalog-backend/scripts/mock-data b/plugins/catalog-backend/scripts/mock-data index 01675db363..76f97ccf3a 100755 --- a/plugins/catalog-backend/scripts/mock-data +++ b/plugins/catalog-backend/scripts/mock-data @@ -15,4 +15,5 @@ for URL in \ --request POST 'localhost:7000/catalog/locations' \ --header 'Content-Type: application/json' \ --data-raw "{\"type\": \"github\", \"target\": \"https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/${URL}\"}" + echo done diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index cc4cc62fdb..a98eec0875 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -14,195 +14,248 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, + storageApiRef, +} from '@backstage/core'; +import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter'; -import { EntityGroup } from '../../data/filters'; +import { CatalogApi, catalogApiRef } from '../../api/types'; +import { EntityFilterGroupsProvider } from '../../filter'; +import { ButtonGroup, CatalogFilter } from './CatalogFilter'; describe('Catalog Filter', () => { - const comp1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'my-component-1', - }, - spec: { - owner: 'team', - }, + const catalogApi: Partial = { + getEntities: () => + Promise.resolve([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity1', + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity2', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + }, + }, + ] as Entity[]), }; - const comp2 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'my-component-2', - }, - spec: { - owner: 'team', - }, + + const indentityApi: Partial = { + getUserId: () => 'tools@example.com', }; - const comp3 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'my-component-3', - }, - spec: { - owner: '', - }, - }; - const defaultFilterProps = { - selectedFilter: EntityGroup.ALL, - onFilterChange: (type: EntityGroup) => type, - entitiesByFilter: { - [EntityGroup.ALL]: [comp1, comp2, comp3], - [EntityGroup.STARRED]: [comp1], - [EntityGroup.OWNED]: [comp1], - }, - }; - it('should render the different groups', async () => { - const mockGroups: CatalogFilterGroup[] = [ - { name: 'Test Group 1', items: [] }, - { name: 'Test Group 2', items: [] }, - ]; - const { findByText } = render( + + const renderWrapped = (children: React.ReactNode) => + render( wrapInTestApp( - , + + {children}, + , ), ); + it('should render the different groups', async () => { + const mockGroups: ButtonGroup[] = [ + { name: 'Test Group 1', items: [] }, + { name: 'Test Group 2', items: [] }, + ]; + const { findByText } = renderWrapped( + , + ); for (const group of mockGroups) { expect(await findByText(group.name)).toBeInTheDocument(); } }); it('should render the different items and their names', async () => { - const mockGroups: CatalogFilterGroup[] = [ + const mockGroups: ButtonGroup[] = [ { name: 'Test Group 1', items: [ { - id: EntityGroup.ALL, + id: 'all', label: 'First Label', + filterFn: () => true, }, { - id: EntityGroup.STARRED, + id: 'starred', label: 'Second Label', + filterFn: () => false, }, ], }, ]; - const { findByText } = render( - wrapInTestApp( - , - ), + const { findByText } = renderWrapped( + , ); - const [group] = mockGroups; - for (const item of group.items) { + for (const item of mockGroups[0].items) { expect(await findByText(item.label)).toBeInTheDocument(); } }); - it('should render the count in each item', async () => { - const mockGroups: CatalogFilterGroup[] = [ + it('selects the first item if no desired initial one is set', async () => { + const mockGroups: ButtonGroup[] = [ { name: 'Test Group 1', items: [ { - id: EntityGroup.ALL, + id: 'all', label: 'First Label', - count: 3, + filterFn: () => true, }, { - id: EntityGroup.STARRED, + id: 'starred', label: 'Second Label', - count: 1, + filterFn: () => false, }, ], }, ]; - const { getAllByText } = render( - wrapInTestApp( - , - ), + const onChange = jest.fn(); + + renderWrapped( + , ); - for (const key of Object.keys(defaultFilterProps.entitiesByFilter)) { - const matcher = new RegExp( - `(${defaultFilterProps.entitiesByFilter[key as EntityGroup].length})`, - ); - const items = await getAllByText(matcher); - items.forEach(el => expect(el).toBeInTheDocument()); - } + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'all', + label: 'First Label', + }); + }); }); - it('should fire the callback when an item is clicked', async () => { - const mockGroups: CatalogFilterGroup[] = [ + it('selects the initial item', async () => { + const mockGroups: ButtonGroup[] = [ { name: 'Test Group 1', items: [ { - id: EntityGroup.ALL, + id: 'all', label: 'First Label', - count: 100, + filterFn: () => true, }, { - id: EntityGroup.STARRED, + id: 'starred', label: 'Second Label', - count: 400, + filterFn: () => false, }, ], }, ]; - const onSelectedChangeHandler = jest.fn(); + const onChange = jest.fn(); - const { findByText } = render( - wrapInTestApp( - , - ), + renderWrapped( + , ); - const item = mockGroups[0].items[0]; - - const element = await findByText(item.label); - - fireEvent.click(element); - - expect(onSelectedChangeHandler).toHaveBeenCalledWith(item.id); + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'starred', + label: 'Second Label', + }); + }); }); - it('should render a component when a function is passed to the count component', async () => { - const mockGroups: CatalogFilterGroup[] = [ + it('can change the selected item', async () => { + const mockGroups: ButtonGroup[] = [ { name: 'Test Group 1', items: [ { - id: EntityGroup.ALL, + id: 'all', label: 'First Label', - count: () => BACKSTAGE!, + filterFn: () => true, }, { - id: EntityGroup.STARRED, + id: 'starred', label: 'Second Label', - count: 400, + filterFn: () => false, }, ], }, ]; - const { findByText } = render( - wrapInTestApp( - , - ), + + const onChange = jest.fn(); + + const { findByText } = renderWrapped( + , ); - expect(await findByText('Test Group 1')).toBeInTheDocument(); + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'all', + label: 'First Label', + }); + }); + + fireEvent.click(await findByText('Second Label')); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'starred', + label: 'Second Label', + }); + }); + }); + + it('displays match counts properly', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'owned', + label: 'First Label', + filterFn: entity => entity.spec?.owner === 'tools@example.com', + }, + ], + }, + ]; + + const { findByText } = renderWrapped( + , + ); + + expect(await findByText('1')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index 61a83fccd8..90a1e15ad0 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -14,31 +14,36 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { IconComponent } from '@backstage/core'; import { Card, List, ListItemIcon, + ListItemSecondaryAction, ListItemText, - MenuItem, - Typography, - Theme, makeStyles, + MenuItem, + Theme, + Typography, } from '@material-ui/core'; -import type { IconComponent } from '@backstage/core'; -import { EntityGroup } from '../../data/filters'; -import { EntitiesByFilter } from '../../hooks/useEntities'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { FilterGroup, useEntityFilterGroup } from '../../filter'; -export type CatalogFilterItem = { - id: EntityGroup; - label: string; - icon?: IconComponent; - count?: number | FC; -}; - -export type CatalogFilterGroup = { +export type ButtonGroup = { name: string; - items: CatalogFilterItem[]; + items: { + id: string; + label: string; + icon?: IconComponent; + filterFn: (entity: Entity) => boolean; + }[]; }; const useStyles = makeStyles(theme => ({ @@ -67,21 +72,56 @@ const useStyles = makeStyles(theme => ({ }, })); -export const CatalogFilter: FC<{ - selectedFilter: EntityGroup; - onFilterChange: (type: EntityGroup) => void; - entitiesByFilter: EntitiesByFilter; - groups: CatalogFilterGroup[]; -}> = ({ - selectedFilter: selectedId, - onFilterChange: setSelectedFilter, - entitiesByFilter, - groups, -}) => { +type OnChangeCallback = (item: { id: string; label: string }) => void; + +type Props = { + buttonGroups: ButtonGroup[]; + initiallySelected: string; + onChange?: OnChangeCallback; +}; + +/** + * The main filter group in the sidebar, toggling owned/starred/all. + */ +export const CatalogFilter = ({ + buttonGroups, + onChange, + initiallySelected, +}: Props) => { const classes = useStyles(); + const { currentFilter, setCurrentFilter, getFilterCount } = useFilter( + buttonGroups, + initiallySelected, + ); + + const onChangeRef = useRef(); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + const setCurrent = useCallback( + (item: { id: string; label: string }) => { + setCurrentFilter(item.id); + onChangeRef.current?.({ id: item.id, label: item.label }); + }, + [setCurrentFilter], + ); + + // Make one initial onChange to inform the surroundings about the selected + // item + useEffect(() => { + const items = buttonGroups.flatMap(g => g.items); + const item = items.find(i => i.id === initiallySelected) || items[0]; + if (item) { + onChangeRef.current?.({ id: item.id, label: item.label }); + } + // intentionally only happens on startup + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - {groups.map(group => ( + {buttonGroups.map(group => ( {group.name} @@ -93,10 +133,8 @@ export const CatalogFilter: FC<{ key={item.id} button divider - onClick={() => { - setSelectedFilter(item.id); - }} - selected={item.id === selectedId} + onClick={() => setCurrent(item)} + selected={item.id === currentFilter} className={classes.menuItem} > {item.icon && ( @@ -109,7 +147,9 @@ export const CatalogFilter: FC<{ {item.label} - {entitiesByFilter[item.id]?.length ?? '-'} + + {getFilterCount(item.id) ?? '-'} + ))} @@ -119,3 +159,53 @@ export const CatalogFilter: FC<{ ); }; + +function useFilter( + buttonGroups: ButtonGroup[], + initiallySelected: string, +): { + currentFilter: string; + setCurrentFilter: (filterId: string) => void; + getFilterCount: (filterId: string) => number | undefined; +} { + const [currentFilter, setCurrentFilter] = useState(initiallySelected); + + const filterGroup = useMemo( + () => ({ + filters: Object.fromEntries( + buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]), + ), + }), + [buttonGroups], + ); + + const { setSelectedFilters, state } = useEntityFilterGroup( + 'primary-sidebar', + filterGroup, + [initiallySelected], + ); + + const setCurrent = useCallback( + (filterId: string) => { + setCurrentFilter(filterId); + setSelectedFilters([filterId]); + }, + [setCurrentFilter, setSelectedFilters], + ); + + const getFilterCount = useCallback( + (filterId: string) => { + if (state.type !== 'ready') { + return undefined; + } + return state.state.filters[filterId].matchCount; + }, + [state], + ); + + return { + currentFilter, + setCurrentFilter: setCurrent, + getFilterCount, + }; +} diff --git a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx index 65715e04c7..39b3c66013 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx @@ -14,26 +14,29 @@ * limitations under the License. */ -import React, { FC } from 'react'; import { Header, HomepageTimer, + identityApiRef, Page, pageTheme, - identityApiRef, useApi, } from '@backstage/core'; +import React from 'react'; import { getTimeBasedGreeting } from './utils/timeUtil'; -const CatalogLayout: FC<{}> = props => { - const { children } = props; +type Props = { + children?: React.ReactNode; +}; + +const CatalogLayout = ({ children }: Props) => { const greeting = getTimeBasedGreeting(); - const identityApi = useApi(identityApiRef); + const userId = useApi(identityApiRef).getUserId(); return (
{ - const mockErrorApi = new MockErrorApi(); const catalogApi: Partial = { getEntities: () => Promise.resolve([ { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', metadata: { name: 'Entity1', }, - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', spec: { owner: 'tools@example.com', type: 'service', }, }, { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', metadata: { name: 'Entity2', }, - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', spec: { owner: 'not-tools@example.com', type: 'service', @@ -62,49 +60,32 @@ describe('CatalogPage', () => { getLocationByEntity: () => Promise.resolve({ id: 'id', type: 'github', target: 'url' }), }; - const mockIndentityApi: Partial = { + const indentityApi: Partial = { getUserId: () => 'tools@example.com', }; + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + // this test right now causes some red lines in the log output when running tests // related to some theme issues in mui-table // https://github.com/mbrn/material-table/issues/1293 it('should render', async () => { - const { findByText } = render( - wrapInTestApp( - - - , - ), - ); - - const items = await findByText(/All Services \(2\)/); - expect(items).toBeInTheDocument(); - }); - it('should filter by owner', async () => { - const { findByText, getByText } = render( - wrapInTestApp( - - - , - ), - ); - fireEvent.click(getByText(/Owned/)); - const items = await findByText(/Owned \(1\)/); - expect(items).toBeInTheDocument(); + const { findByText, getByText } = renderWrapped(); + expect(await findByText(/Owned \(1\)/)).toBeInTheDocument(); + fireEvent.click(getByText(/All/)); + expect(await findByText(/All \(2\)/)).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index bad303bd53..e59b753363 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -14,35 +14,26 @@ * limitations under the License. */ -import { Entity, LocationSpec } from '@backstage/catalog-model'; import { Content, ContentHeader, - DismissableBanner, - HeaderTabs, + identityApiRef, SupportButton, + useApi, } from '@backstage/core'; -import CatalogLayout from './CatalogLayout'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; -import { Button, Link, makeStyles, Typography } from '@material-ui/core'; -import Edit from '@material-ui/icons/Edit'; -import GitHub from '@material-ui/icons/GitHub'; -import React, { FC } from 'react'; +import { Button, makeStyles } from '@material-ui/core'; +import SettingsIcon from '@material-ui/icons/Settings'; +import StarIcon from '@material-ui/icons/Star'; +import React, { useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { CatalogFilter } from '../CatalogFilter/CatalogFilter'; +import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; +import { useStarredEntities } from '../../hooks/useStarredEntites'; +import { CatalogFilter, ButtonGroup } from '../CatalogFilter/CatalogFilter'; import { CatalogTable } from '../CatalogTable/CatalogTable'; -import { useEntities } from '../../hooks/useEntities'; -import { findLocationForEntityMeta } from '../../data/utils'; -import { - getCatalogFilterItemByType, - EntityGroup, - filterGroups, - labeledEntityTypes, -} from '../../data/filters'; -import { - favouriteEntityIcon, - favouriteEntityTooltip, -} from '../FavouriteEntity/FavouriteEntity'; +import CatalogLayout from './CatalogLayout'; +import { CatalogTabs, LabeledComponentType } from './CatalogTabs'; +import { WelcomeBanner } from './WelcomeBanner'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -51,132 +42,116 @@ const useStyles = makeStyles(theme => ({ gridTemplateColumns: '250px 1fr', gridColumnGap: theme.spacing(2), }, - emoji: { - fontSize: '125%', - marginRight: theme.spacing(2), - }, })); -export const CatalogPage: FC<{}> = () => { - const { - entitiesByFilter, - error, - loading, - selectedFilter, - setSelectedFilter, - toggleStarredEntity, - isStarredEntity, - selectTypeFilter, - } = useEntities(); - - const filteredEntities = entitiesByFilter[selectedFilter ?? EntityGroup.ALL]; - +const CatalogPageContents = () => { const styles = useStyles(); + const { loading, error, matchingEntities } = useFilteredEntities(); + const { isStarredEntity } = useStarredEntities(); + const userId = useApi(identityApiRef).getUserId(); + const [selectedTab, setSelectedTab] = useState(); + const [selectedSidebarItem, setSelectedSidebarItem] = useState(); - const actions = [ - (rowData: Entity) => { - const location = findLocationForEntityMeta(rowData.metadata); - return { - icon: GitHub, - tooltip: 'View on GitHub', - onClick: () => { - if (!location) return; - window.open(location.target, '_blank'); - }, - hidden: location?.type !== 'github', - }; - }, - (rowData: Entity) => { - const createEditLink = (location: LocationSpec): string => { - switch (location.type) { - case 'github': - return location.target.replace('/blob/', '/edit/'); - default: - return location.target; - } - }; + const tabs = useMemo( + () => [ + { + id: 'service', + label: 'Services', + }, + { + id: 'website', + label: 'Websites', + }, + { + id: 'library', + label: 'Libraries', + }, + { + id: 'documentation', + label: 'Documentation', + }, + { + id: 'other', + label: 'Other', + }, + ], + [], + ); - const location = findLocationForEntityMeta(rowData.metadata); - - return { - icon: Edit, - tooltip: 'Edit', - iconProps: { size: 'small' }, - onClick: () => { - if (!location) return; - window.open(createEditLink(location), '_blank'); - }, - hidden: location?.type !== 'github', - }; - }, - (rowData: Entity) => { - const isStarred = isStarredEntity(rowData); - return { - icon: favouriteEntityIcon(isStarred), - tooltip: favouriteEntityTooltip(isStarred), - onClick: () => toggleStarredEntity(rowData), - }; - }, - ]; + const filterGroups = useMemo( + () => [ + { + name: 'Personal', + items: [ + { + id: 'owned', + label: 'Owned', + icon: SettingsIcon, + filterFn: entity => entity.spec?.owner === userId, + }, + { + id: 'starred', + label: 'Starred', + icon: StarIcon, + filterFn: isStarredEntity, + }, + ], + }, + { + name: 'Company', // TODO: Replace with Company name, read from app config. + items: [ + { + id: 'all', + label: 'All', + filterFn: () => true, + }, + ], + }, + ], + [isStarredEntity, userId], + ); return ( - { - selectTypeFilter(labeledEntityTypes[index as number].id); - }} + setSelectedTab(label)} /> - - - 👋🏼 - - Welcome to Backstage, we are happy to have you. Start by checking - out our{' '} - - getting started - {' '} - page. - - } - id="catalog_page_welcome_banner" - /> - + + All your software catalog entities
setSelectedSidebarItem(label)} + initiallySelected="owned" />
); }; + +export const CatalogPage = () => ( + + + +); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx b/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx new file mode 100644 index 0000000000..ae1e2a4e5a --- /dev/null +++ b/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Entity } from '@backstage/catalog-model'; +import { HeaderTabs } from '@backstage/core'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { FilterGroup, useEntityFilterGroup } from '../../filter'; + +/** + * A component type, and a human readable label for it. + */ +export type LabeledComponentType = { + id: string; + label: string; +}; + +/** + * Called on mount, and when the selected tab changes. + */ +export type OnChangeCallback = (tab: LabeledComponentType) => void; + +type Props = { + tabs: LabeledComponentType[]; + onChange?: OnChangeCallback; +}; + +/** + * The tabs at the top of the catalog list page, for component type filtering. + */ +export const CatalogTabs = ({ tabs, onChange }: Props) => { + const filterGroup = useMemo(() => { + return { + filters: Object.fromEntries( + tabs.map(t => [t.id, (entity: Entity) => entity.spec?.type === t.id]), + ), + }; + }, [tabs]); + + const { setSelectedFilters } = useEntityFilterGroup('type', filterGroup, [ + tabs[0].id, + ]); + + const [currentTabIndex, setCurrentTabIndex] = useState(0); + + // Hold a reference to the callback + const onChangeRef = useRef(); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + onChangeRef.current?.(tabs[currentTabIndex]); + }, [tabs, currentTabIndex]); + + const switchTab = useCallback( + (index: number) => { + const tab = tabs[index]; + setSelectedFilters([tab.id]); + setCurrentTabIndex(index); + onChangeRef.current?.(tab); + }, + [tabs, setSelectedFilters], + ); + + return ; +}; diff --git a/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx b/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx new file mode 100644 index 0000000000..ab486081b1 --- /dev/null +++ b/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { DismissableBanner } from '@backstage/core'; +import { Link, makeStyles, Typography } from '@material-ui/core'; +import React from 'react'; + +const useStyles = makeStyles(theme => ({ + contentWrapper: { + display: 'grid', + gridTemplateAreas: "'filters' 'table'", + gridTemplateColumns: '250px 1fr', + gridColumnGap: theme.spacing(2), + }, + emoji: { + fontSize: '125%', + marginRight: theme.spacing(2), + }, +})); + +export const WelcomeBanner = () => { + const classes = useStyles(); + return ( + + + 👋🏼 + + Welcome to Backstage, we are happy to have you. Start by checking out + our{' '} + + getting started + {' '} + page. + + } + id="catalog_page_welcome_banner" + /> + ); +}; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index b50da60a50..707213f3bb 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -66,11 +66,9 @@ describe('CatalogTable component', () => { />, ), ); - expect( - await rendered.findByText(`Owned (${entites.length})`), - ).toBeInTheDocument(); - expect(await rendered.findByText('component1')).toBeInTheDocument(); - expect(await rendered.findByText('component2')).toBeInTheDocument(); - expect(await rendered.findByText('component3')).toBeInTheDocument(); + expect(rendered.getByText(/Owned \(3\)/)).toBeInTheDocument(); + expect(rendered.getByText(/component1/)).toBeInTheDocument(); + expect(rendered.getByText(/component2/)).toBeInTheDocument(); + expect(rendered.getByText(/component3/)).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index f62749ee17..b507b96f05 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -13,16 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { Entity } from '@backstage/catalog-model'; -import { Table, TableColumn } from '@backstage/core'; +import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { Table, TableColumn, TableProps } from '@backstage/core'; import { Link } from '@material-ui/core'; +import Edit from '@material-ui/icons/Edit'; +import GitHub from '@material-ui/icons/GitHub'; +import Star from '@material-ui/icons/Star'; +import StarOutline from '@material-ui/icons/StarBorder'; import { Alert } from '@material-ui/lab'; -import React, { FC } from 'react'; +import React from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; +import { findLocationForEntityMeta } from '../../data/utils'; +import { useStarredEntities } from '../../hooks/useStarredEntites'; import { entityRoute } from '../../routes'; -const columns: TableColumn[] = [ +const columns: TableColumn[] = [ { title: 'Name', field: 'metadata.name', @@ -63,16 +68,16 @@ type CatalogTableProps = { titlePreamble: string; loading: boolean; error?: any; - actions?: any; }; -export const CatalogTable: FC = ({ +export const CatalogTable = ({ entities, loading, error, titlePreamble, - actions, -}) => { +}: CatalogTableProps) => { + const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); + if (error) { return (
@@ -83,8 +88,57 @@ export const CatalogTable: FC = ({ ); } + const actions: TableProps['actions'] = [ + (rowData: Entity) => { + const location = findLocationForEntityMeta(rowData.metadata); + return { + icon: () => , + tooltip: 'View on GitHub', + onClick: () => { + if (!location) return; + window.open(location.target, '_blank'); + }, + hidden: location?.type !== 'github', + }; + }, + (rowData: Entity) => { + const createEditLink = (location: LocationSpec): string => { + switch (location.type) { + case 'github': + return location.target.replace('/blob/', '/edit/'); + default: + return location.target; + } + }; + const location = findLocationForEntityMeta(rowData.metadata); + return { + icon: () => , + tooltip: 'Edit', + onClick: () => { + if (!location) return; + window.open(createEditLink(location), '_blank'); + }, + hidden: location?.type !== 'github', + }; + }, + (rowData: Entity) => { + const isStarred = isStarredEntity(rowData); + return { + cellStyle: { paddingLeft: '1em' }, + icon: () => + isStarred ? ( + + ) : ( + + ), + tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites', + onClick: () => toggleStarredEntity(rowData), + }; + }, + ]; + return ( -
isLoading={loading} columns={columns} options={{ diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx index e6064f8af1..1b966c0bde 100644 --- a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx @@ -27,7 +27,7 @@ jest.mock('react-router-dom', () => { import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; import { wrapInTestApp } from '@backstage/test-utils'; -import { render, wait } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import * as React from 'react'; import { CatalogApi, catalogApiRef } from '../../api/types'; import { EntityPage, getPageTheme } from './EntityPage'; @@ -66,7 +66,7 @@ describe('EntityPage', () => { ), ); - await wait(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog')); + await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog')); }); }); diff --git a/plugins/catalog/src/data/filters.ts b/plugins/catalog/src/data/filters.ts deleted file mode 100644 index 51ed5582f5..0000000000 --- a/plugins/catalog/src/data/filters.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { Entity } from '@backstage/catalog-model'; -import SettingsIcon from '@material-ui/icons/Settings'; -import StarIcon from '@material-ui/icons/Star'; -import { - CatalogFilterGroup, - CatalogFilterItem, -} from '../components/CatalogFilter/CatalogFilter'; - -export enum EntityGroup { - ALL = 'ALL', - STARRED = 'STARRED', - OWNED = 'OWNED', -} - -export const filterGroups: CatalogFilterGroup[] = [ - { - name: 'Personal', - items: [ - { - id: EntityGroup.OWNED, - label: 'Owned', - icon: SettingsIcon, - }, - { - id: EntityGroup.STARRED, - label: 'Starred', - icon: StarIcon, - }, - ], - }, - { - // TODO: Replace with Company name, read from app config. - name: 'Company', - items: [ - { - id: EntityGroup.ALL, - label: 'All Services', - }, - ], - }, -]; - -export const getCatalogFilterItemByType = (filterType: EntityGroup) => { - for (const group of filterGroups) { - for (const filter of group.items) { - if (filter.id === filterType) { - return filter; - } - } - } - return null; -}; - -type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean; - -type EntityFilterOptions = Partial<{ - isStarred: boolean; - userId: string; -}>; - -type Owned = { - owner: string; -}; - -export const entityFilters: Record = { - [EntityGroup.OWNED]: (e, { userId }) => { - const owner = (e.spec! as Owned).owner; - return owner === userId; - }, - [EntityGroup.ALL]: () => true, - [EntityGroup.STARRED]: (_, { isStarred }) => !!isStarred, -}; - -export const entityTypeFilter = (e: Entity, type: string) => - (e.spec as any)?.type === type; - -type EntityType = 'service' | 'website' | 'library' | 'documentation' | 'other'; - -type LabeledEntityType = { - id: EntityType; - label: string; -}; - -export const labeledEntityTypes: LabeledEntityType[] = [ - { - id: 'service', - label: 'Services', - }, - { - id: 'website', - label: 'Websites', - }, - { - id: 'library', - label: 'Libraries', - }, - { - id: 'documentation', - label: 'Documentation', - }, - { - id: 'other', - label: 'Other', - }, -]; - -export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0]; diff --git a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx new file mode 100644 index 0000000000..dd557a4322 --- /dev/null +++ b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx @@ -0,0 +1,207 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Entity } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core'; +import React, { useCallback, useRef, useState } from 'react'; +import { useAsync } from 'react-use'; +import { catalogApiRef } from '../api/types'; +import { filterGroupsContext, FilterGroupsContext } from './context'; +import { + EntityFilterFn, + FilterGroup, + FilterGroupState, + FilterGroupStates, +} from './types'; + +/** + * Implementation of the shared filter groups state. + */ +export const EntityFilterGroupsProvider = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const state = useProvideEntityFilters(); + return ( + + {children} + + ); +}; + +// The hook that implements the actual context building +function useProvideEntityFilters(): FilterGroupsContext { + const catalogApi = useApi(catalogApiRef); + const { value: entities, error } = useAsync(() => catalogApi.getEntities()); + + const filterGroups = useRef<{ + [filterGroupId: string]: FilterGroup; + }>({}); + const selectedFilterKeys = useRef<{ + [filterGroupId: string]: Set; + }>({}); + const [filterGroupStates, setFilterGroupStates] = useState<{ + [filterGroupId: string]: FilterGroupStates; + }>({}); + const [matchingEntities, setMatchingEntities] = useState([]); + + const rebuild = useCallback(() => { + setFilterGroupStates( + buildStates( + filterGroups.current, + selectedFilterKeys.current, + entities, + error, + ), + ); + setMatchingEntities( + buildMatchingEntities( + filterGroups.current, + selectedFilterKeys.current, + entities, + ), + ); + }, [entities, error]); + + const register = useCallback( + ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilterIds?: string[], + ) => { + filterGroups.current[filterGroupId] = filterGroup; + selectedFilterKeys.current[filterGroupId] = new Set( + initialSelectedFilterIds ?? [], + ); + rebuild(); + }, + [rebuild], + ); + + const unregister = useCallback( + (filterGroupId: string) => { + delete filterGroups.current[filterGroupId]; + delete selectedFilterKeys.current[filterGroupId]; + rebuild(); + }, + [rebuild], + ); + + const setGroupSelectedFilters = useCallback( + (filterGroupId: string, filters: string[]) => { + selectedFilterKeys.current[filterGroupId] = new Set(filters); + rebuild(); + }, + [rebuild], + ); + + return { + register, + unregister, + setGroupSelectedFilters, + loading: !error && !entities, + error, + filterGroupStates, + matchingEntities, + }; +} + +// Given all filter groups and what filters are actually selected, along with +// the loading state for entities, generate the state of each individual filter +function buildStates( + filterGroups: { [filterGroupId: string]: FilterGroup }, + selectedFilterKeys: { [filterGroupId: string]: Set }, + entities?: Entity[], + error?: Error, +): { [filterGroupId: string]: FilterGroupStates } { + // On error - all entries are an error state + if (error) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'error', error }, + ]), + ); + } + + // On startup - all entries are a loading state + if (!entities) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'loading' }, + ]), + ); + } + + const result: { [filterGroupId: string]: FilterGroupStates } = {}; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + const otherMatchingEntities = buildMatchingEntities( + filterGroups, + selectedFilterKeys, + entities, + filterGroupId, + ); + const groupState: FilterGroupState = { filters: {} }; + for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) { + const isSelected = !!selectedFilterKeys[filterGroupId]?.has(filterId); + const matchCount = otherMatchingEntities.filter(entity => + filterFn(entity), + ).length; + groupState.filters[filterId] = { isSelected, matchCount }; + } + result[filterGroupId] = { type: 'ready', state: groupState }; + } + + return result; +} + +// Given all filter groups and what filters are actually selected, extract all +// entities that match all those filter groups. +function buildMatchingEntities( + filterGroups: { [filterGroupId: string]: FilterGroup }, + selectedFilterKeys: { [filterGroupId: string]: Set }, + entities?: Entity[], + excludeFilterGroupId?: string, +): Entity[] { + // Build one filter fn per filter group + const allFilters: EntityFilterFn[] = []; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + if (excludeFilterGroupId === filterGroupId) { + continue; + } + + // Pick out all of the filter functions in the group that are actually + // selected + const groupFilters: EntityFilterFn[] = []; + for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) { + if (!!selectedFilterKeys[filterGroupId]?.has(filterId)) { + groupFilters.push(filterFn); + } + } + + // Need to match any of the selected filters in the group - if there is + // any at all + if (groupFilters.length) { + allFilters.push(entity => groupFilters.some(fn => fn(entity))); + } + } + + // All filter groups that had any checked filters need to match. Note that + // every() always returns true for an empty array. + return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? []; +} diff --git a/plugins/catalog/src/filter/context.ts b/plugins/catalog/src/filter/context.ts new file mode 100644 index 0000000000..31e9c91b97 --- /dev/null +++ b/plugins/catalog/src/filter/context.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Entity } from '@backstage/catalog-model'; +import { createContext } from 'react'; +import { FilterGroup, FilterGroupStates } from './types'; + +export type FilterGroupsContext = { + register: ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilterIds?: string[], + ) => void; + unregister: (filterGroupId: string) => void; + setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void; + loading: boolean; + error?: Error; + filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; + matchingEntities: Entity[]; +}; + +/** + * The context that maintains shared state for all visible filter groups. + */ +export const filterGroupsContext = createContext< + FilterGroupsContext | undefined +>(undefined); diff --git a/plugins/catalog/src/filter/index.ts b/plugins/catalog/src/filter/index.ts new file mode 100644 index 0000000000..da73147ef9 --- /dev/null +++ b/plugins/catalog/src/filter/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +export { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; +export type { + EntityFilterFn, + FilterGroup, + FilterGroupState, + FilterGroupStates, + FilterGroupStatesError, + FilterGroupStatesLoading, + FilterGroupStatesReady, +} from './types'; +export { useEntityFilterGroup } from './useEntityFilterGroup'; +export { useFilteredEntities } from './useFilteredEntities'; diff --git a/plugins/catalog/src/filter/types.ts b/plugins/catalog/src/filter/types.ts new file mode 100644 index 0000000000..ed08b131bf --- /dev/null +++ b/plugins/catalog/src/filter/types.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Entity } from '@backstage/catalog-model'; + +export type EntityFilterFn = (entity: Entity) => boolean; + +export type FilterGroup = { + filters: { + [filterId: string]: EntityFilterFn; + }; +}; + +export type FilterGroupState = { + filters: { + [filterId: string]: { + isSelected: boolean; + matchCount: number; + }; + }; +}; + +export type FilterGroupStatesReady = { + type: 'ready'; + state: FilterGroupState; +}; + +export type FilterGroupStatesError = { + type: 'error'; + error: Error; +}; + +export type FilterGroupStatesLoading = { + type: 'loading'; +}; + +export type FilterGroupStates = + | FilterGroupStatesReady + | FilterGroupStatesError + | FilterGroupStatesLoading; diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx new file mode 100644 index 0000000000..60e7d4f412 --- /dev/null +++ b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; +import { act, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { catalogApiRef } from '../api/types'; +import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; +import { FilterGroupStatesReady, FilterGroup } from './types'; +import { useEntityFilterGroup } from './useEntityFilterGroup'; +import { MockStorageApi } from '@backstage/test-utils'; + +describe('useEntityFilterGroup', () => { + let catalogApi: jest.Mocked; + let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element; + + beforeEach(() => { + catalogApi = { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + addLocation: jest.fn((_a, _b) => new Promise(() => {})), + getEntities: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + storageApiRef, + MockStorageApi.create(), + ); + wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + }); + + it('works for an empty set of filters', async () => { + catalogApi.getEntities.mockResolvedValue([]); + const group: FilterGroup = { filters: {} }; + const { result, wait } = renderHook( + () => useEntityFilterGroup('g1', group), + { wrapper }, + ); + + await wait(() => expect(result.current.state.type).toBe('ready')); + }); + + it('works for a single group', async () => { + catalogApi.getEntities.mockResolvedValue([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + }, + ]); + const group: FilterGroup = { + filters: { + f1: e => e.metadata.name === 'n', + f2: e => e.metadata.name !== 'n', + }, + }; + const { result, wait } = renderHook( + () => useEntityFilterGroup('g1', group), + { wrapper }, + ); + + await wait(() => expect(result.current.state.type).toEqual('ready')); + let state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: false, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: false, + matchCount: 0, + }); + + act(() => result.current.setSelectedFilters(['f1'])); + + await wait(() => expect(result.current.state.type).toEqual('ready')); + state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: true, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: false, + matchCount: 0, + }); + + act(() => result.current.setSelectedFilters(['f2'])); + + await wait(() => expect(result.current.state.type).toEqual('ready')); + state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: false, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: true, + matchCount: 0, + }); + }); +}); diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.ts b/plugins/catalog/src/filter/useEntityFilterGroup.ts new file mode 100644 index 0000000000..242238e4f4 --- /dev/null +++ b/plugins/catalog/src/filter/useEntityFilterGroup.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { useCallback, useContext, useEffect, useMemo } from 'react'; +import { filterGroupsContext } from './context'; +import { FilterGroup, FilterGroupStates } from './types'; + +export type EntityFilterGroupOutput = { + state: FilterGroupStates; + setSelectedFilters: (filterIds: string[]) => void; +}; + +/** + * Hook that exposes the relevant data and operations for a single filter + * group. + */ +export const useEntityFilterGroup = ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilters?: string[], +): EntityFilterGroupOutput => { + const context = useContext(filterGroupsContext); + if (!context) { + throw new Error(`Must be used inside an EntityFilterGroupsProvider`); + } + const { + register, + unregister, + setGroupSelectedFilters, + filterGroupStates, + } = context; + + // Intentionally consider initial set only at mount time + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialMemo = useMemo(() => initialSelectedFilters?.slice(), []); + + // Register the group on mount, and unregister on unmount + useEffect(() => { + register(filterGroupId, filterGroup, initialMemo); + return () => unregister(filterGroupId); + }, [register, unregister, filterGroupId, filterGroup, initialMemo]); + + const setSelectedFilters = useCallback( + (filters: string[]) => { + setGroupSelectedFilters(filterGroupId, filters); + }, + [setGroupSelectedFilters, filterGroupId], + ); + + let state = filterGroupStates[filterGroupId]; + if (!state) { + state = { type: 'loading' }; + } + + return { state, setSelectedFilters }; +}; diff --git a/plugins/catalog/src/filter/useFilteredEntities.ts b/plugins/catalog/src/filter/useFilteredEntities.ts new file mode 100644 index 0000000000..256f5247c7 --- /dev/null +++ b/plugins/catalog/src/filter/useFilteredEntities.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { useContext } from 'react'; +import { filterGroupsContext } from './context'; + +/** + * Hook that exposes the result of applying a set of filter groups. + */ +export function useFilteredEntities() { + const context = useContext(filterGroupsContext); + if (!context) { + throw new Error(`Must be used inside an EntityFilterGroupsProvider`); + } + + return { + loading: context.loading, + error: context.error, + matchingEntities: context.matchingEntities, + }; +} diff --git a/plugins/catalog/src/hooks/useEntities.ts b/plugins/catalog/src/hooks/useEntities.ts deleted file mode 100644 index 6509bd9b4b..0000000000 --- a/plugins/catalog/src/hooks/useEntities.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { useState, useMemo } from 'react'; -import { - EntityGroup, - entityFilters, - entityTypeFilter, - labeledEntityTypes, -} from '../data/filters'; -import { useApi, identityApiRef } from '@backstage/core'; -import { catalogApiRef } from '..'; -import { useStarredEntities } from './useStarredEntites'; -import { Entity } from '@backstage/catalog-model'; -import useStaleWhileRevalidate from 'swr'; - -export type EntitiesByFilter = Record; - -type UseEntities = { - selectedFilter: EntityGroup | undefined; - setSelectedFilter: (f: EntityGroup) => void; - error: Error | null; - toggleStarredEntity: any; - isStarredEntity: (e: Entity) => boolean; - entitiesByFilter: EntitiesByFilter; - loading: boolean; - selectedTypeFilter: string; - selectTypeFilter: (id: string) => void; -}; - -export const useEntities = (): UseEntities => { - const [selectedFilter, setSelectedFilter] = useState< - EntityGroup | undefined - >(); - const catalogApi = useApi(catalogApiRef); - const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); - const { data: entities, error } = useStaleWhileRevalidate( - ['catalog/all', entityFilters[selectedFilter ?? EntityGroup.ALL]], - async () => catalogApi.getEntities(), - ); - - const indentityApi = useApi(identityApiRef); - const userId = indentityApi.getUserId(); - - const [selectedTypeFilter, selectTypeFilter] = useState( - labeledEntityTypes[0].id, - ); - - const entitiesByFilter = useMemo(() => { - const filterEntities = ( - ents: Entity[] | undefined, - filterId: EntityGroup, - isStarred: (e: Entity) => boolean, - user: string, - ) => { - return ents - ?.filter((e: Entity) => - entityFilters[filterId](e, { - isStarred: isStarred(e), - userId: user, - }), - ) - .filter(e => entityTypeFilter(e, selectedTypeFilter)); - }; - const data = Object.keys(EntityGroup).reduce( - (res, key) => ({ - ...res, - [key]: filterEntities( - entities, - key as EntityGroup, - isStarredEntity, - userId, - ), - }), - {} as EntitiesByFilter, - ); - return data; - }, [entities, isStarredEntity, userId, selectedTypeFilter]); - - return { - selectedFilter, - setSelectedFilter, - error, - toggleStarredEntity, - isStarredEntity, - entitiesByFilter, - loading: entities === undefined, - selectedTypeFilter, - selectTypeFilter, - }; -}; diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index bfb0328909..67a76ef872 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -28,7 +28,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "^5.2.0", + "react-router-dom": "6.0.0-alpha.5", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/gitops-profiles/src/api.ts b/plugins/gitops-profiles/src/api.ts index 20e4dc4307..3a14dea837 100644 --- a/plugins/gitops-profiles/src/api.ts +++ b/plugins/gitops-profiles/src/api.ts @@ -79,6 +79,14 @@ export interface ListClusterRequest { gitHubToken: string; } +export interface GithubUserInfoRequest { + accessToken: string; +} + +export interface GithubUserInfoResponse { + login: string; +} + export class FetchError extends Error { get name(): string { return this.constructor.name; @@ -100,6 +108,7 @@ export type GitOpsApi = { cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise; applyProfiles(req: ApplyProfileRequest): Promise; listClusters(req: ListClusterRequest): Promise; + fetchUserInfo(req: GithubUserInfoRequest): Promise; }; export const gitOpsApiRef = createApiRef({ @@ -116,6 +125,19 @@ export class GitOpsRestApi implements GitOpsApi { return await resp.json(); } + async fetchUserInfo( + req: GithubUserInfoRequest, + ): Promise { + const resp = await fetch(`https://api.github.com/user`, { + method: 'get', + headers: new Headers({ + Authorization: `token ${req.accessToken}`, + }), + }); + if (!resp.ok) throw await FetchError.forResponse(resp); + return await resp.json(); + } + async fetchLog(req: PollLogRequest): Promise { return await this.fetch(`/api/cluster/run-status`, { method: 'post', diff --git a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx index 8e5aa15aa9..44578560e6 100644 --- a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx +++ b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { FC, useState } from 'react'; import { Content, ContentHeader, @@ -25,32 +25,30 @@ import { Progress, HeaderLabel, useApi, + githubAuthApiRef, } from '@backstage/core'; import ClusterTable from '../ClusterTable/ClusterTable'; import { Button } from '@material-ui/core'; -import { useAsync, useLocalStorage } from 'react-use'; +import { useAsync } from 'react-use'; import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api'; import { Alert } from '@material-ui/lab'; const ClusterList: FC<{}> = () => { - const [loginInfo] = useLocalStorage<{ - token: string; - username: string; - name: string; - }>('githubLoginDetails', { - token: '', - username: '', - name: 'Guest', - }); - const api = useApi(gitOpsApiRef); + const githubAuth = useApi(githubAuthApiRef); + const [githubUsername, setGithubUsername] = useState(String); const { loading, error, value } = useAsync( - () => { + async () => { + const accessToken = await githubAuth.getAccessToken(['repo', 'user']); + if (!githubUsername) { + const userInfo = await api.fetchUserInfo({ accessToken }); + setGithubUsername(userInfo.login); + } return api.listClusters({ - gitHubToken: loginInfo.token, - gitHubUser: loginInfo.username, + gitHubToken: accessToken, + gitHubUser: githubUsername, }); }, ); @@ -73,9 +71,6 @@ const ClusterList: FC<{}> = () => { Please make sure that you start GitOps-API backend on localhost port 3008 before using this plugin. - - If you're Guest, please login via GitHub first. - ); @@ -100,7 +95,7 @@ const ClusterList: FC<{}> = () => { return (
- +
{content}
diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx index d454278255..b2d62e8650 100644 --- a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx +++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx @@ -24,21 +24,16 @@ import { Progress, HeaderLabel, useApi, + githubAuthApiRef, } from '@backstage/core'; import { Link } from '@material-ui/core'; import { useParams } from 'react-router-dom'; -import { useLocalStorage } from 'react-use'; import { gitOpsApiRef, Status } from '../../api'; import { transformRunStatus } from '../ProfileCatalog'; const ClusterPage: FC<{}> = () => { const params = useParams() as { owner: string; repo: string }; - const [loginInfo] = useLocalStorage<{ - token: string; - username: string; - name: string; - }>('githubLoginDetails'); const [pollingLog, setPollingLog] = useState(true); const [runStatus, setRunStatus] = useState([]); @@ -46,6 +41,9 @@ const ClusterPage: FC<{}> = () => { const [showProgress, setShowProgress] = useState(true); const api = useApi(gitOpsApiRef); + const githubAuth = useApi(githubAuthApiRef); + const [githubAccessToken, setGithubAccessToken] = useState(String); + const [githubUsername, setGithubUsername] = useState(String); const columns = [ { field: 'status', title: 'Status' }, @@ -53,31 +51,43 @@ const ClusterPage: FC<{}> = () => { ]; useEffect(() => { - if (pollingLog) { - const interval = setInterval(async () => { - const resp = await api.fetchLog({ - gitHubToken: loginInfo.token, - gitHubUser: loginInfo.username, - targetOrg: params.owner, - targetRepo: params.repo, - }); + const fetchGithubUserInfo = async () => { + const accessToken = await githubAuth.getAccessToken(['repo', 'user']); + const userInfo = await api.fetchUserInfo({ accessToken }); + setGithubAccessToken(accessToken); + setGithubUsername(userInfo.login); + }; - setRunStatus(resp.result); - setRunLink(resp.link); - if (resp.status === 'completed') { - setPollingLog(false); - setShowProgress(false); - } - }, 10000); - return () => clearInterval(interval); + if (!githubAccessToken || !githubUsername) { + fetchGithubUserInfo(); + } else { + if (pollingLog) { + const interval = setInterval(async () => { + const resp = await api.fetchLog({ + gitHubToken: githubAccessToken, + gitHubUser: githubUsername, + targetOrg: params.owner, + targetRepo: params.repo, + }); + + setRunStatus(resp.result); + setRunLink(resp.link); + if (resp.status === 'completed') { + setPollingLog(false); + setShowProgress(false); + } + }, 10000); + return () => clearInterval(interval); + } } + return () => {}; - }, [pollingLog, api, loginInfo, params]); + }, [pollingLog, api, params, githubAuth, githubAccessToken, githubUsername]); return (
- +
diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index a288268670..0ea28e38a9 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -27,7 +27,6 @@ "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "@types/react": "^16.9", "color": "^3.1.2", "d3-force": "^2.0.1", "prop-types": "^15.7.2", @@ -43,6 +42,7 @@ "@testing-library/user-event": "^12.0.7", "@types/color": "^3.0.1", "@types/d3-force": "^1.2.1", + "@types/react": "^16.9", "@types/jest": "^25.2.2", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index 3dec964ddb..856cf4ff3c 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from '@backstage/core'; +import { MovedState } from './utils/types'; /** * Types related to the Radar's visualization. @@ -34,7 +35,7 @@ export interface RadarQuadrant { export interface RadarEntry { key: string; // react key id: string; - moved: number; + moved: MovedState; quadrant: RadarQuadrant; ring: RadarRing; title: string; diff --git a/plugins/tech-radar/src/components/Radar/Radar.test.tsx b/plugins/tech-radar/src/components/Radar/Radar.test.tsx index 469f0d85cf..f0ad0d66ed 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.test.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.test.tsx @@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; -import Radar from './Radar'; +import Radar, { Props } from './Radar'; -const minProps = { +const minProps: Props = { width: 500, height: 200, quadrants: [{ id: 'languages', name: 'Languages' }], diff --git a/plugins/tech-radar/src/components/Radar/Radar.tsx b/plugins/tech-radar/src/components/Radar/Radar.tsx index 1db655730d..124e8bb68a 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import React, { FC, useState, useRef } from 'react'; +import React, { useState, useRef } from 'react'; import RadarPlot from '../RadarPlot'; -import { Ring, Quadrant, Entry } from '../../utils/types'; +import type { Ring, Quadrant, Entry } from '../../utils/types'; import { adjustQuadrants, adjustRings, adjustEntries } from './utils'; -type Props = { +export type Props = { width: number; height: number; quadrants: Quadrant[]; @@ -28,17 +28,17 @@ type Props = { svgProps?: object; }; -const Radar: FC = props => { +const Radar = (props: Props): JSX.Element => { const { width, height, quadrants, rings, entries } = props; const radius = Math.min(width, height) / 2; - const [activeEntry, setActiveEntry] = useState(); + const [activeEntry, setActiveEntry] = useState(); const node = useRef(null); // TODO(dflemstr): most of this can be heavily memoized if performance becomes a problem adjustQuadrants(quadrants, radius, width, height); adjustRings(rings, radius); - adjustEntries(entries, activeEntry, quadrants, rings, radius); + adjustEntries(entries, quadrants, rings, radius, activeEntry); return ( @@ -49,9 +49,9 @@ const Radar: FC = props => { entries={entries} quadrants={quadrants} rings={rings} - activeEntry={activeEntry || undefined} + activeEntry={activeEntry} onEntryMouseEnter={entry => setActiveEntry(entry)} - onEntryMouseLeave={() => setActiveEntry(null)} + onEntryMouseLeave={() => setActiveEntry(undefined)} /> ); diff --git a/plugins/tech-radar/src/components/Radar/utils.ts b/plugins/tech-radar/src/components/Radar/utils.ts index a8896ddf31..d7a826543c 100644 --- a/plugins/tech-radar/src/components/Radar/utils.ts +++ b/plugins/tech-radar/src/components/Radar/utils.ts @@ -17,7 +17,7 @@ import color from 'color'; import { forceCollide, forceSimulation } from 'd3-force'; import Segment from '../../utils/segment'; -import { Ring, Quadrant, Entry } from '../../utils/types'; +import type { Ring, Quadrant, Entry } from '../../utils/types'; export const adjustQuadrants = ( quadrants: Quadrant[], @@ -81,14 +81,14 @@ export const adjustQuadrants = ( }, ]; - quadrants.forEach((quadrant, idx) => { - const legendParam = legendParams[idx % 4]; + quadrants.forEach((quadrant, index) => { + const legendParam = legendParams[index % 4]; - quadrant.idx = idx; - quadrant.radialMin = (idx * Math.PI) / 2; - quadrant.radialMax = ((idx + 1) * Math.PI) / 2; - quadrant.offsetX = idx % 4 === 0 || idx % 4 === 3 ? 1 : -1; - quadrant.offsetY = idx % 4 === 0 || idx % 4 === 1 ? 1 : -1; + quadrant.index = index; + quadrant.radialMin = (index * Math.PI) / 2; + quadrant.radialMax = ((index + 1) * Math.PI) / 2; + quadrant.offsetX = index % 4 === 0 || index % 4 === 3 ? 1 : -1; + quadrant.offsetY = index % 4 === 0 || index % 4 === 1 ? 1 : -1; quadrant.legendX = legendParam.x; quadrant.legendY = legendParam.y; quadrant.legendWidth = legendParam.width; @@ -98,13 +98,13 @@ export const adjustQuadrants = ( export const adjustEntries = ( entries: Entry[], - activeEntry: Entry | null | undefined, quadrants: Quadrant[], rings: Ring[], radius: number, + activeEntry?: Entry, ) => { let seed = 42; - entries.forEach((entry, idx) => { + entries.forEach((entry, index) => { const quadrant = quadrants.find(q => { const match = typeof entry.quadrant === 'object' ? entry.quadrant.id : entry.quadrant; @@ -124,7 +124,7 @@ export const adjustEntries = ( throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`); } - entry.idx = idx; + entry.index = index; entry.quadrant = quadrant; entry.ring = ring; entry.segment = new Segment(quadrant, ring, radius, () => seed++); @@ -163,10 +163,10 @@ export const adjustEntries = ( }; export const adjustRings = (rings: Ring[], radius: number) => { - rings.forEach((ring, idx) => { - ring.idx = idx; - ring.outerRadius = ((idx + 2) / (rings.length + 1)) * radius; + rings.forEach((ring, index) => { + ring.index = index; + ring.outerRadius = ((index + 2) / (rings.length + 1)) * radius; ring.innerRadius = - ((idx === 0 ? 0 : idx + 1) / (rings.length + 1)) * radius; + ((index === 0 ? 0 : index + 1) / (rings.length + 1)) * radius; }); }; diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx new file mode 100644 index 0000000000..d56ca743c2 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarBubble, { Props } from './RadarBubble'; + +const minProps: Props = { + visible: true, + text: 'RadarBubble', + x: 2, + y: 2, +}; + +describe('RadarBubble', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered.getByText(minProps.text)).toBeInTheDocument(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx index dcf9a37ed7..abaed92253 100644 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React, { FC, useRef, useLayoutEffect } from 'react'; +import React, { useRef, useLayoutEffect } from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -type Props = { +export type Props = { visible: boolean; text: string; x: number; @@ -46,7 +46,7 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarBubble: FC = props => { +const RadarBubble = (props: Props): JSX.Element => { const classes = useStyles(props); const { visible, text } = props; @@ -98,6 +98,7 @@ const RadarBubble: FC = props => { x={0} y={0} className={visible ? classes.visibleBubble : classes.bubble} + data-testid="radar-bubble" > diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 981d1f1369..99190202b9 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -14,50 +14,38 @@ * limitations under the License. */ -import React, { useEffect, useState, FC } from 'react'; +import React, { useEffect } from 'react'; import { Progress, useApi, errorApiRef, ErrorApi } from '@backstage/core'; +import { useAsync } from 'react-use'; import Radar from '../components/Radar'; import { TechRadarComponentProps, TechRadarLoaderResponse } from '../api'; import getSampleData from '../sampleData'; const useTechRadarLoader = (props: TechRadarComponentProps) => { const errorApi = useApi(errorApiRef); - const [state, setState] = useState<{ - loading: boolean; - error?: Error; - data?: TechRadarLoaderResponse; - }>({ - loading: true, - error: undefined, - data: undefined, - }); const { getData } = props; - useEffect(() => { - if (!getData) { - return; + const state = useAsync(async () => { + if (getData) { + const response: TechRadarLoaderResponse = await getData(); + return response; } - - getData() - .then((payload: TechRadarLoaderResponse) => { - setState({ loading: false, error: undefined, data: payload }); - }) - .catch((err: Error) => { - errorApi.post(err); - setState({ - loading: false, - error: err, - data: undefined, - }); - }); + return undefined; }, [getData, errorApi]); + useEffect(() => { + const { error } = state; + if (error) { + errorApi.post(error); + } + }, [errorApi, state]); + return state; }; -const RadarComponent: FC = props => { - const { loading, error, data } = useTechRadarLoader(props); +const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { + const { loading, error, value: data } = useTechRadarLoader(props); return ( <> diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx new file mode 100644 index 0000000000..2d24f301d1 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarEntry, { Props } from './RadarEntry'; + +const minProps: Props = { + x: 2, + y: 2, + value: 2, + color: 'red', +}; + +describe('RadarEntry', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + const radarEntry = rendered.getByTestId('radar-entry'); + const { x, y } = minProps; + expect(radarEntry).toBeInTheDocument(); + expect(radarEntry.getAttribute('transform')).toBe(`translate(${x}, ${y})`); + expect(rendered.getByText(String(minProps.value))).toBeInTheDocument(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx index 6006979e67..29d35e01e2 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; +import { WithLink } from '../../utils/components'; -type Props = { +export type Props = { x: number; y: number; - number: number; + value: number; color: string; url?: string; moved?: number; @@ -43,14 +44,27 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarEntry: FC = props => { +const makeBlip = (color: string, moved?: number) => { + const style = { fill: color }; + + let blip = ; + if (moved && moved > 0) { + blip = ; // triangle pointing up + } else if (moved && moved < 0) { + blip = ; // triangle pointing down + } + + return blip; +}; + +const RadarEntry = (props: Props): JSX.Element => { const classes = useStyles(props); const { moved, color, url, - number, + value, x, y, onMouseEnter, @@ -58,24 +72,7 @@ const RadarEntry: FC = props => { onClick, } = props; - const style = { fill: color }; - - let blip; - if (moved && moved > 0) { - blip = ; // triangle pointing up - } else if (moved && moved < 0) { - blip = ; // triangle pointing down - } else { - blip = ; - } - - if (url) { - blip = ( - - {blip} - - ); - } + const blip = makeBlip(color, moved); return ( = props => { onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick} + data-testid="radar-entry" > - {blip} + + {blip} + - {number} + {value} ); diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx new file mode 100644 index 0000000000..77f21b6e63 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarFooter, { Props } from './RadarFooter'; + +const minProps: Props = { + x: 2, + y: 2, +}; + +describe('RadarFooter', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + const radarFooter = rendered.getByTestId('radar-footer'); + const { x, y } = minProps; + expect(radarFooter).toBeInTheDocument(); + expect(radarFooter.getAttribute('transform')).toBe(`translate(${x}, ${y})`); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx index 7d60a63f59..ced1ab8054 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -type Props = { +export type Props = { x: number; y: number; }; @@ -31,12 +31,16 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarFooter: FC = props => { +const RadarFooter = (props: Props): JSX.Element => { const { x, y } = props; const classes = useStyles(props); return ( - + {'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'} ); diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx new file mode 100644 index 0000000000..3027cc4adb --- /dev/null +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarGrid, { Props } from './RadarGrid'; + +const minProps: Props = { + radius: 5, + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], +}; + +describe('RadarGrid', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered.getByTestId('radar-grid-x-line')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-grid-y-line')).toBeInTheDocument(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx index 514afbe958..eae85d4e64 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx @@ -16,9 +16,9 @@ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -import { Ring } from '../../utils/types'; +import type { Ring } from '../../utils/types'; -type Props = { +export type Props = { radius: number; rings: Ring[]; }; @@ -49,7 +49,7 @@ const RadarGrid = (props: Props) => { const { radius, rings } = props; const classes = useStyles(props); - const makeRingNode = (ringRadius: number | undefined, ringIndex: number) => [ + const makeRingNode = (ringIndex: number, ringRadius?: number) => [ { x2={0} y2={radius} className={classes.axis} + data-testid="radar-grid-x-line" />, // Y axis { x2={radius} y2={0} className={classes.axis} + data-testid="radar-grid-y-line" />, ]; - const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode); + const ringNodes = rings + .map(r => r.outerRadius) + .map((ringRadius, ringIndex) => makeRingNode(ringIndex, ringRadius)); return <>{axisNodes.concat(...ringNodes)}; }; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx new file mode 100644 index 0000000000..68bfe892ea --- /dev/null +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarLegend, { Props } from './RadarLegend'; + +const minProps: Props = { + quadrants: [{ id: 'languages', name: 'Languages' }], + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], + entries: [ + { + id: 'typescript', + title: 'TypeScript', + quadrant: { id: 'languages', name: 'Languages' }, + moved: 0, + ring: { id: 'use', name: 'USE', color: '#93c47d' }, + url: '#', + }, + ], +}; + +describe('RadarLegend', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered.getByTestId('radar-legend')).toBeInTheDocument(); + expect(rendered.getAllByTestId('radar-quadrant')).toHaveLength(1); + expect(rendered.getAllByTestId('radar-ring')).toHaveLength(1); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index 03146a8071..e319846d78 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -13,15 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -import { Quadrant, Ring, Entry } from '../../utils/types'; +import type { Quadrant, Ring, Entry } from '../../utils/types'; +import { WithLink } from '../../utils/components'; type Segments = { [k: number]: { [k: number]: Entry[] }; }; -type Props = { +export type Props = { quadrants: Quadrant[]; rings: Ring[]; entries: Entry[]; @@ -29,7 +30,7 @@ type Props = { onEntryMouseLeave?: (entry: Entry) => void; }; -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles(theme => ({ quadrant: { height: '100%', width: '100%', @@ -40,7 +41,7 @@ const useStyles = makeStyles(() => ({ pointerEvents: 'none', userSelect: 'none', marginTop: 0, - marginBottom: 'calc(18px * 0.375)', + marginBottom: theme.spacing(8 / (18 * 0.375)), fontSize: '18px', }, rings: { @@ -56,7 +57,7 @@ const useStyles = makeStyles(() => ({ pointerEvents: 'none', userSelect: 'none', marginTop: 0, - marginBottom: 'calc(12px * 0.375)', + marginBottom: theme.spacing(8 / (12 * 0.375)), fontSize: '12px', fontWeight: 800, }, @@ -79,73 +80,81 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarLegend: FC = props => { +const RadarLegend = (props: Props): JSX.Element => { const classes = useStyles(props); - const _getSegment = ( + const getSegment = ( segmented: Segments, quadrant: Quadrant, ring: Ring, ringOffset = 0, ) => { - const qidx = quadrant.idx; - const ridx = ring.idx; - const segmentedData = qidx === undefined ? {} : segmented[qidx] || {}; - return ridx === undefined ? [] : segmentedData[ridx + ringOffset] || []; + const quadrantIndex = quadrant.index; + const ringIndex = ring.index; + const segmentedData = + quadrantIndex === undefined ? {} : segmented[quadrantIndex] || {}; + return ringIndex === undefined + ? [] + : segmentedData[ringIndex + ringOffset] || []; }; - const _renderRing = ( - ring: Ring, - entries: Entry[], - onEntryMouseEnter?: Props['onEntryMouseEnter'], - onEntryMouseLeave?: Props['onEntryMouseEnter'], - ) => { + type RadarLegendRingProps = { + ring: Ring; + entries: Entry[]; + onEntryMouseEnter?: Props['onEntryMouseEnter']; + onEntryMouseLeave?: Props['onEntryMouseEnter']; + }; + + const RadarLegendRing = ({ + ring, + entries, + onEntryMouseEnter, + onEntryMouseLeave, + }: RadarLegendRingProps) => { return ( -
+

{ring.name}

{entries.length === 0 ? (

(empty)

) : (
    - {entries.map(entry => { - let node = {entry.title}; - - if (entry.url) { - node = ( - - {node} - - ); - } - - return ( -
  1. onEntryMouseEnter(entry)) - } - onMouseLeave={ - onEntryMouseLeave && (() => onEntryMouseLeave(entry)) - } - > - {node} -
  2. - ); - })} + {entries.map(entry => ( +
  3. onEntryMouseEnter(entry)) + } + onMouseLeave={ + onEntryMouseLeave && (() => onEntryMouseLeave(entry)) + } + > + + {entry.title} + +
  4. + ))}
)}
); }; - const _renderQuadrant = ( - segments: Segments, - quadrant: Quadrant, - rings: Ring[], - onEntryMouseEnter: Props['onEntryMouseEnter'], - onEntryMouseLeave: Props['onEntryMouseLeave'], - ) => { + type RadarLegendQuadrantProps = { + segments: Segments; + quadrant: Quadrant; + rings: Ring[]; + onEntryMouseEnter: Props['onEntryMouseEnter']; + onEntryMouseLeave: Props['onEntryMouseLeave']; + }; + + const RadarLegendQuadrant = ({ + segments, + quadrant, + rings, + onEntryMouseEnter, + onEntryMouseLeave, + }: RadarLegendQuadrantProps) => { return ( = props => { y={quadrant.legendY} width={quadrant.legendWidth} height={quadrant.legendHeight} + data-testid="radar-quadrant" >

{quadrant.name}

- {rings.map(ring => - _renderRing( - ring, - _getSegment(segments, quadrant, ring), - onEntryMouseEnter, - onEntryMouseLeave, - ), - )} + {rings.map(ring => ( + + ))}
); }; - const _setupSegments = (entries: Entry[]) => { + const setupSegments = (entries: Entry[]) => { const segments: Segments = {}; for (const entry of entries) { - const qidx = entry.quadrant.idx; - const ridx = entry.ring.idx; + const quadrantIndex = entry.quadrant.index; + const ringIndex = entry.ring.index; let quadrantData: { [k: number]: Entry[] } = {}; - if (qidx !== undefined) { - if (segments[qidx] === undefined) { - segments[qidx] = {}; + if (quadrantIndex !== undefined) { + if (segments[quadrantIndex] === undefined) { + segments[quadrantIndex] = {}; } - quadrantData = segments[qidx]; + quadrantData = segments[quadrantIndex]; } let ringData = []; - if (ridx !== undefined) { - if (quadrantData[ridx] === undefined) { - quadrantData[ridx] = []; + if (ringIndex !== undefined) { + if (quadrantData[ringIndex] === undefined) { + quadrantData[ringIndex] = []; } - ringData = quadrantData[ridx]; + ringData = quadrantData[ringIndex]; } ringData.push(entry); @@ -209,19 +220,20 @@ const RadarLegend: FC = props => { onEntryMouseLeave, } = props; - const segments: Segments = _setupSegments(entries); + const segments: Segments = setupSegments(entries); return ( - - {quadrants.map(quadrant => - _renderQuadrant( - segments, - quadrant, - rings, - onEntryMouseEnter, - onEntryMouseLeave, - ), - )} + + {quadrants.map(quadrant => ( + + ))} ); }; diff --git a/plugins/tech-radar/src/components/RadarPage.tsx b/plugins/tech-radar/src/components/RadarPage.tsx index 4c7236e01f..1aefb72eca 100644 --- a/plugins/tech-radar/src/components/RadarPage.tsx +++ b/plugins/tech-radar/src/components/RadarPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Grid } from '@material-ui/core'; import { Content, @@ -29,7 +29,7 @@ import { import RadarComponent from '../components/RadarComponent'; import { techRadarApiRef, TechRadarApi } from '../api'; -const RadarPage: FC<{}> = () => { +const RadarPage = (): JSX.Element => { const techRadarApi = useApi(techRadarApiRef); return ( diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx new file mode 100644 index 0000000000..5b17578478 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarPlot, { Props } from './RadarPlot'; + +const minProps: Props = { + width: 500, + height: 200, + radius: 50, + quadrants: [{ id: 'languages', name: 'Languages' }], + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], + entries: [ + { + id: 'typescript', + title: 'TypeScript', + quadrant: { id: 'languages', name: 'Languages' }, + moved: 0, + ring: { id: 'use', name: 'USE', color: '#93c47d' }, + url: '#', + }, + ], +}; + +describe('RadarPlot', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered.getByTestId('radar-plot')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-legend')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-footer')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-bubble')).toBeInTheDocument(); + expect(rendered.getAllByTestId('radar-entry')).toHaveLength(1); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index 48ad660dde..7f68ffe021 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import React, { FC } from 'react'; -import { Quadrant, Ring, Entry } from '../../utils/types'; +import React from 'react'; +import type { Quadrant, Ring, Entry } from '../../utils/types'; import RadarGrid from '../RadarGrid'; import RadarEntry from '../RadarEntry'; @@ -23,7 +23,7 @@ import RadarBubble from '../RadarBubble'; import RadarFooter from '../RadarFooter'; import RadarLegend from '../RadarLegend'; -type Props = { +export type Props = { width: number; height: number; radius: number; @@ -36,7 +36,7 @@ type Props = { }; // A component that draws the radar circle. -const RadarPlot: FC = props => { +const RadarPlot = (props: Props): JSX.Element => { const { width, height, @@ -50,7 +50,7 @@ const RadarPlot: FC = props => { } = props; return ( - + = props => { x={entry.x || 0} y={entry.y || 0} color={entry.color || ''} - number={((entry && entry.idx) || 0) + 1} + value={(entry?.index || 0) + 1} url={entry.url} moved={entry.moved} onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))} @@ -80,9 +80,9 @@ const RadarPlot: FC = props => { ))} diff --git a/plugins/tech-radar/src/utils/components.tsx b/plugins/tech-radar/src/utils/components.tsx new file mode 100644 index 0000000000..af5a7b05bd --- /dev/null +++ b/plugins/tech-radar/src/utils/components.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 from 'react'; + +type WithLinkProps = { + url?: string; + className: string; + children: React.ReactNode; +}; + +export const WithLink = ({ + url, + className, + children, +}: WithLinkProps): JSX.Element => + url ? ( + + {children} + + ) : ( + <>{children} + ); diff --git a/plugins/tech-radar/src/utils/types.ts b/plugins/tech-radar/src/utils/types.ts index 14a0554a1f..f3933ee584 100644 --- a/plugins/tech-radar/src/utils/types.ts +++ b/plugins/tech-radar/src/utils/types.ts @@ -17,7 +17,7 @@ // Parameters for a ring; its index in an array determines how close to the center this ring is. export type Ring = { id: string; - idx?: number; + index?: number; name: string; color: string; outerRadius?: number; @@ -27,7 +27,7 @@ export type Ring = { // Parameters for a quadrant (there should be exactly 4 of course) export type Quadrant = { id: string; - idx?: number; + index?: number; name: string; legendX?: number; legendY?: number; @@ -45,9 +45,15 @@ export type Segment = { random: Function; }; +export enum MovedState { + Down = -1, + NoChange = 0, + Up = 1, +} + export type Entry = { id: string; - idx?: number; + index?: number; x?: number; y?: number; color?: string; @@ -61,7 +67,7 @@ export type Entry = { // An URL to a longer description as to why this entry is where it is url?: string; // How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved - moved?: number; + moved?: MovedState; active?: boolean; }; diff --git a/plugins/techdocs/README.md b/plugins/techdocs/README.md index 10ddcf9411..36d00fc970 100644 --- a/plugins/techdocs/README.md +++ b/plugins/techdocs/README.md @@ -10,7 +10,7 @@ Welcome to the TechDocs plugin - Spotify's docs-like-code approach built directl ## Getting started -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/techdocs](http://localhost:3000/techdocs). +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/docs](http://localhost:3000/docs). You can also serve the plugin in isolation by running `yarn start` in the plugin directory. This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index fec2b1cf74..89a5ddeb73 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -33,6 +33,11 @@ import { createPlugin, createRouteRef } from '@backstage/core'; import { Reader } from './reader/components/Reader'; export const rootRouteRef = createRouteRef({ + path: '/docs', + title: 'TechDocs Landing Page', +}); + +export const rootDocsRouteRef = createRouteRef({ path: '/docs/:componentId/*', title: 'Docs', }); @@ -41,5 +46,6 @@ export const plugin = createPlugin({ id: 'techdocs', register({ router }) { router.addRoute(rootRouteRef, Reader); + router.addRoute(rootDocsRouteRef, Reader); }, }); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 0175462cc3..1d7a85a6ba 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -17,14 +17,19 @@ import React from 'react'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; +import { useLocation, useParams, useNavigate } from 'react-router-dom'; + +import { Grid } from '@material-ui/core'; +import { Header, Content, ItemCard } from '@backstage/core'; + import transformer, { addBaseUrl, rewriteDocLinks, addEventListener, + removeMkdocsHeader, + modifyCss, } from '../transformers'; import { docStorageURL } from '../../config'; -import { Link } from '@backstage/core'; -import { useLocation, useParams, useNavigate } from 'react-router-dom'; import URLParser from '../urlParser'; const useFetch = (url: string) => { @@ -73,6 +78,16 @@ export const Reader = () => { rewriteDocLinks({ componentId, }), + modifyCss({ + cssTransforms: { + '.md-main__inner': [{ 'margin-top': '0' }], + '.md-sidebar': [{ top: '0' }, { width: '20rem' }], + '.md-typeset': [{ 'font-size': '1rem' }], + '.md-nav': [{ 'font-size': '1rem' }], + '.md-grid': [{ 'max-width': '80vw' }], + }, + }), + removeMkdocsHeader(), ]); divElement.shadowRoot.innerHTML = ''; @@ -89,11 +104,37 @@ export const Reader = () => { return ( <> - -
+
+ + + {componentId ? ( +
+ ) : ( + + + navigate('/docs/mkdocs')} + tags={['Developer Tool']} + title="MkDocs" + label="Read Docs" + description="MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. " + /> + + + navigate('/docs/backstage-microsite')} + tags={['Service']} + title="Backstage" + label="Read Docs" + description="Getting started guides, API Overview, documentation around how to Create a Plugin and more. " + /> + + + )} + ); }; diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index 64c5527fe7..f49f496277 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -17,6 +17,8 @@ export * from './addBaseUrl'; export * from './rewriteDocLinks'; export * from './addEventListener'; +export * from './removeMkdocsHeader'; +export * from './modifyCss'; export type Transformer = (dom: Element) => Element; diff --git a/plugins/techdocs/src/reader/transformers/modifyCss.ts b/plugins/techdocs/src/reader/transformers/modifyCss.ts new file mode 100644 index 0000000000..5116baac45 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/modifyCss.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 type { Transformer } from './index'; + +type ModifyCssOptions = { + // Example: { '.md-container': { 'marginTop': '10px' }} + cssTransforms: { [key: string]: { [key: string]: string }[] }; +}; + +export const modifyCss = ({ cssTransforms }: ModifyCssOptions): Transformer => { + return dom => { + Object.entries(cssTransforms).forEach(([cssSelector, cssChanges]) => { + const elementsToChange = Array.from( + dom.querySelectorAll(cssSelector), + ); + if (elementsToChange.length < 1) return; + + cssChanges.forEach(changes => { + elementsToChange.forEach((element: HTMLElement) => { + Object.entries(changes).forEach(([cssProperty, cssValue]) => { + element.style.setProperty(cssProperty, cssValue); + }); + }); + }); + }); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts new file mode 100644 index 0000000000..087574b2de --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 type { Transformer } from './index'; + +export const removeMkdocsHeader = (): Transformer => { + return dom => { + // Remove the header + dom.querySelector('.md-header')?.remove(); + + return dom; + }; +}; diff --git a/yarn.lock b/yarn.lock index f4fa34b993..9b25df19cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -868,14 +868,6 @@ "@babel/plugin-transform-react-jsx-self" "^7.9.0" "@babel/plugin-transform-react-jsx-source" "^7.9.0" -"@babel/runtime-corejs2@^7.4.4": - version "7.9.2" - resolved "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.9.2.tgz#f11d074ff99b9b4319b5ecf0501f12202bf2bf4d" - integrity sha512-ayjSOxuK2GaSDJFCtLgHnYjuMyIpViNujWrZo8GUpN60/n7juzJKK5yOo6RFVb0zdU9ACJFK+MsZrUnj3OmXMw== - dependencies: - core-js "^2.6.5" - regenerator-runtime "^0.13.4" - "@babel/runtime-corejs3@^7.10.2": version "7.10.3" resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.3.tgz#931ed6941d3954924a7aa967ee440e60c507b91a" @@ -6544,7 +6536,7 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz#4bf1ba866e25814f149d4e9aaa08c36173506e3a" integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== -core-js@^2.4.0, core-js@^2.6.5: +core-js@^2.4.0: version "2.6.11" resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -6701,10 +6693,10 @@ crypto-random-string@^2.0.0: resolved "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== -css-box-model@^1.1.2: - version "1.2.0" - resolved "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.0.tgz#3a26377b4162b3200d2ede4b064ec5b6a75186d0" - integrity sha512-lri0br+jSNV0kkkiGEp9y9y3Njq2PmpqbeGWRFQJuZteZzY9iC9GZhQ8Y4WpPwM/2YocjHePxy14igJY7YKzkA== +css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== dependencies: tiny-invariant "^1.0.6" @@ -9776,18 +9768,6 @@ history@5.0.0-beta.9: dependencies: "@babel/runtime" "^7.7.6" -history@^4.9.0: - version "4.10.1" - resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" - integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== - dependencies: - "@babel/runtime" "^7.1.2" - loose-envify "^1.2.0" - resolve-pathname "^3.0.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - value-equal "^1.0.1" - hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -9797,7 +9777,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -10920,11 +10900,6 @@ is-yarn-global@^0.3.0: resolved "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= - isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -12246,7 +12221,7 @@ loglevel@^1.6.8: resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" integrity sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -12427,10 +12402,10 @@ markdown-to-jsx@^6.11.4: prop-types "^15.6.2" unquote "^1.1.0" -material-table@^1.58.0: - version "1.58.2" - resolved "https://registry.npmjs.org/material-table/-/material-table-1.58.2.tgz#dc0d19652848e6bb92f747d122bd7d4681cca6dc" - integrity sha512-s/m6ebyXFXmg07zxv1Fl6qPySKaiQhASXaOB3ubRKUFA1DkryUy3PGSEVWTjUYnRyi63kGq+N6b5nsokLR6m5A== +material-table@1.62.x: + version "1.62.0" + resolved "https://registry.npmjs.org/material-table/-/material-table-1.62.0.tgz#117793ebf16ab0fccbb6f8a670d849a7be0b5995" + integrity sha512-+3tnk32lXtkXeKM7k/hZ82jpSzlXU5CsWXqJHq4Tl0Un7ycjK2Kef6EMPqeE3i58vKNqbIvbrFf/ESH0D/Qwig== dependencies: "@date-io/date-fns" "^1.1.0" "@material-ui/pickers" "^3.2.2" @@ -12440,7 +12415,7 @@ material-table@^1.58.0: fast-deep-equal "2.0.1" filefy "0.1.10" prop-types "^15.6.2" - react-beautiful-dnd "11.0.3" + react-beautiful-dnd "^13.0.0" react-double-scrollbar "0.0.15" md5.js@^1.3.4: @@ -12479,7 +12454,7 @@ media-typer@0.3.0: resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -memoize-one@^5.0.4: +memoize-one@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== @@ -12688,14 +12663,6 @@ min-indent@^1.0.0: resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= -mini-create-react-context@^0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz#df60501c83151db69e28eac0ef08b4002efab040" - integrity sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA== - dependencies: - "@babel/runtime" "^7.5.5" - tiny-warning "^1.0.3" - mini-css-extract-plugin@^0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0" @@ -14159,13 +14126,6 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= -path-to-regexp@^1.7.0: - version "1.8.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" - integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== - dependencies: - isarray "0.0.1" - path-type@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -15128,7 +15088,7 @@ quick-lru@^1.0.0: resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" integrity sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g= -raf-schd@^4.0.0: +raf-schd@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ== @@ -15211,19 +15171,18 @@ rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-beautiful-dnd@11.0.3: - version "11.0.3" - resolved "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-11.0.3.tgz#5678bb3e725d8b56cb7cf57f56e952105fc4f2af" - integrity sha512-2FX2SnOlKMmfn90xUHCav7cxRWXwY7FeRa6TzdxWeX7DdP5JTvVQcsWgiOkdbJSj+J+1q1nA9QO4/HQ52D0DAA== +react-beautiful-dnd@^13.0.0: + version "13.0.0" + resolved "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#f70cc8ff82b84bc718f8af157c9f95757a6c3b40" + integrity sha512-87It8sN0ineoC3nBW0SbQuTFXM6bUqM62uJGY4BtTf0yzPl8/3+bHMWkgIe0Z6m8e+gJgjWxefGRVfpE3VcdEg== dependencies: - "@babel/runtime-corejs2" "^7.4.4" - css-box-model "^1.1.2" - memoize-one "^5.0.4" - raf-schd "^4.0.0" - react-redux "^7.0.3" - redux "^4.0.1" - tiny-invariant "^1.0.4" - use-memo-one "^1.1.0" + "@babel/runtime" "^7.8.4" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.1.1" + redux "^4.0.4" + use-memo-one "^1.1.1" react-clientside-effect@^1.2.2: version "1.2.2" @@ -15413,7 +15372,7 @@ react-inspector@^4.0.0: is-dom "^1.0.9" prop-types "^15.6.1" -react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -15473,7 +15432,7 @@ react-popper@^1.3.6: typed-styles "^0.0.7" warning "^4.0.2" -react-redux@^7.0.3: +react-redux@^7.1.1: version "7.2.0" resolved "https://registry.npmjs.org/react-redux/-/react-redux-7.2.0.tgz#f970f62192b3981642fec46fd0db18a074fe879d" integrity sha512-EvCAZYGfOLqwV7gh849xy9/pt55rJXPwmYvI4lilPM5rUT/1NxuuN59ipdBksRVSvz0KInbPnp4IfoXJXCqiDA== @@ -15492,35 +15451,6 @@ react-router-dom@6.0.0-alpha.5, react-router-dom@^6.0.0-alpha.5: history "5.0.0-beta.9" prop-types "^15.7.2" -react-router-dom@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" - integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - loose-envify "^1.3.1" - prop-types "^15.6.2" - react-router "5.2.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - -react-router@5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" - integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - hoist-non-react-statics "^3.1.0" - loose-envify "^1.3.1" - mini-create-react-context "^0.4.0" - path-to-regexp "^1.7.0" - prop-types "^15.6.2" - react-is "^16.6.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - react-router@6.0.0-alpha.5, react-router@^6.0.0-alpha.5: version "6.0.0-alpha.5" resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-alpha.5.tgz#c98805e50dc0e64787aa8aa4fa6753b435f2496b" @@ -15849,7 +15779,7 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -redux@^4.0.1: +redux@^4.0.4: version "4.0.5" resolved "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== @@ -16139,11 +16069,6 @@ resolve-from@^5.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve-pathname@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" - integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== - resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -17948,12 +17873,12 @@ tiny-emitter@^2.0.0: resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -tiny-invariant@^1.0.2, tiny-invariant@^1.0.4, tiny-invariant@^1.0.6: +tiny-invariant@^1.0.6: version "1.1.0" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== -tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: +tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== @@ -18587,7 +18512,7 @@ use-callback-ref@^1.2.1: resolved "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.1.tgz#898759ccb9e14be6c7a860abafa3ffbd826c89bb" integrity sha512-C3nvxh0ZpaOxs9RCnWwAJ+7bJPwQI8LHF71LzbQ3BvzH5XkdtlkMadqElGevg5bYBDFip4sAnD4m06zAKebg1w== -use-memo-one@^1.1.0: +use-memo-one@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.1.tgz#39e6f08fe27e422a7d7b234b5f9056af313bd22c" integrity sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ== @@ -18674,6 +18599,11 @@ uuid@^8.0.0: resolved "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d" integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg== +uuid@^8.2.0: + version "8.2.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-8.2.0.tgz#cb10dd6b118e2dada7d0cd9730ba7417c93d920e" + integrity sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" @@ -18710,11 +18640,6 @@ validate-npm-package-name@^3.0.0: dependencies: builtins "^1.0.3" -value-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" - integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== - vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"