diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index 98155f76..e451bc88 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -26,6 +26,8 @@ "dependencies": { "@backstage/core-components": "^0.13.10", "@backstage/core-plugin-api": "^1.8.2", + "@backstage/integration-react": "^1.1.24", + "@backstage/plugin-catalog-react": "^1.10.0", "@backstage/theme": "^0.5.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -43,7 +45,7 @@ "@backstage/dev-utils": "^1.0.26", "@backstage/test-utils": "^1.4.7", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^12.1.3", + "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.0.0", "msw": "^1.0.0" }, diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx new file mode 100644 index 00000000..1a63a24a --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { CoderLogo } from '../CoderLogo'; +import { LinkButton } from '@backstage/core-components'; +import { makeStyles } from '@material-ui/core'; +import { useCoderAuth } from '../CoderProvider'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'center', + maxWidth: '30em', + marginLeft: 'auto', + marginRight: 'auto', + rowGap: theme.spacing(2), + }, + + button: { + maxWidth: 'fit-content', + marginLeft: 'auto', + marginRight: 'auto', + }, + + coderLogo: { + display: 'block', + width: 'fit-content', + marginLeft: 'auto', + marginRight: 'auto', + }, +})); + +export const CoderAuthDistrustedForm = () => { + const styles = useStyles(); + const { ejectToken } = useCoderAuth(); + + return ( +
+
+ +

+ Unable to verify token authenticity. Please check your internet + connection, or try ejecting the token. +

+
+ + + Eject token + +
+ ); +}; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx new file mode 100644 index 00000000..2b0fdeee --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx @@ -0,0 +1,181 @@ +import React, { FormEvent } from 'react'; +import { useId } from '../../hooks/hookPolyfills'; +import { + type CoderAuthStatus, + useCoderAppConfig, + useCoderAuth, +} from '../CoderProvider'; + +import { Theme, makeStyles } from '@material-ui/core'; +import TextField from '@material-ui/core/TextField'; +import { CoderLogo } from '../CoderLogo'; +import { Link, LinkButton } from '@backstage/core-components'; +import { VisuallyHidden } from '../VisuallyHidden'; + +type UseStyleInput = Readonly<{ status: CoderAuthStatus }>; +type StyleKeys = + | 'formContainer' + | 'authInputFieldset' + | 'coderLogo' + | 'authButton' + | 'warningBanner' + | 'warningBannerContainer'; + +const useStyles = makeStyles(theme => ({ + formContainer: { + maxWidth: '30em', + marginLeft: 'auto', + marginRight: 'auto', + }, + + authInputFieldset: { + display: 'flex', + flexFlow: 'column nowrap', + rowGap: theme.spacing(2), + margin: `${theme.spacing(-0.5)} 0 0 0`, + border: 'none', + padding: 0, + }, + + coderLogo: { + display: 'block', + width: 'fit-content', + marginLeft: 'auto', + marginRight: 'auto', + }, + + authButton: { + display: 'block', + width: 'fit-content', + marginLeft: 'auto', + marginRight: 'auto', + }, + + warningBannerContainer: { + paddingTop: theme.spacing(4), + paddingLeft: theme.spacing(6), + paddingRight: theme.spacing(6), + }, + + warningBanner: ({ status }) => { + let color: string; + let backgroundColor: string; + + if (status === 'invalid') { + color = theme.palette.error.contrastText; + backgroundColor = theme.palette.banner.error; + } else { + color = theme.palette.text.primary; + backgroundColor = theme.palette.background.default; + } + + return { + color, + backgroundColor, + borderRadius: theme.shape.borderRadius, + textAlign: 'center', + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + }; + }, +})); + +export const CoderAuthInputForm = () => { + const hookId = useId(); + const appConfig = useCoderAppConfig(); + const { status, registerNewToken } = useCoderAuth(); + const styles = useStyles({ status }); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + const formData = Object.fromEntries(new FormData(event.currentTarget)); + const newToken = + typeof formData.authToken === 'string' ? formData.authToken : ''; + + registerNewToken(newToken); + }; + + const legendId = `${hookId}-legend`; + const authTokenInputId = `${hookId}-auth-token`; + const warningBannerId = `${hookId}-warning-banner`; + + return ( +
+
+ +

+ Your Coder session token is {mapAuthStatusToText(status)}. Please + enter a new token from your{' '} + + Coder deployment's token page + (link opens in new tab) + + . +

+
+ +
+ + + + + + Authenticate + +
+ + {(status === 'invalid' || status === 'authenticating') && ( +
+
+ {status === 'invalid' && 'Invalid token'} + {status === 'authenticating' && <>Authenticating…} +
+
+ )} +
+ ); +}; + +function mapAuthStatusToText(status: CoderAuthStatus): string { + switch (status) { + case 'tokenMissing': { + return 'missing'; + } + + case 'initializing': + case 'authenticating': { + return status; + } + + default: { + return 'invalid'; + } + } +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx new file mode 100644 index 00000000..1ed9749a --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react'; +import { CoderLogo } from '../CoderLogo'; +import { makeStyles } from '@material-ui/core'; +import { VisuallyHidden } from '../VisuallyHidden'; + +const MAX_DOTS = 3; +const dotRange = new Array(MAX_DOTS).fill(null).map((_, i) => i + 1); + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'center', + }, + + text: { + lineHeight: theme.typography.body1.lineHeight, + paddingLeft: theme.spacing(1), + }, + + coderLogo: { + display: 'block', + width: 'fit-content', + marginLeft: 'auto', + marginRight: 'auto', + }, +})); + +export const CoderAuthLoadingState = () => { + const [visibleDots, setVisibleDots] = useState(0); + const styles = useStyles(); + + useEffect(() => { + const intervalId = window.setInterval(() => { + setVisibleDots(current => (current + 1) % (MAX_DOTS + 1)); + }, 1_000); + + return () => window.clearInterval(intervalId); + }, []); + + return ( +
+ +

+ Loading + {/* Exposing the more semantic ellipses for screen readers, but + rendering the individual dots for sighted viewers so that they can + be animated */} + + {dotRange.map(dotPosition => ( + = dotPosition ? 1 : 0 }} + aria-hidden + > + . + + ))} +

+
+ ); +}; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx index 496a087a..0bfdff65 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx @@ -1,72 +1,25 @@ import React, { type FC, type PropsWithChildren } from 'react'; -import { - type CoderAuth, - type CoderAuthStatus, - useCoderAuth, - useCoderAppConfig, -} from '../CoderProvider'; - -import { LinkButton } from '@backstage/core-components'; -import { VisuallyHidden } from '../VisuallyHidden'; -import { Card } from '../Card'; - -type FormProps = Readonly>; - -const CoderAuthForm = ({ registerNewToken, status }: FormProps) => { - const appConfig = useCoderAppConfig(); - +import { useCoderAuth } from '../CoderProvider'; +import { InfoCard } from '@backstage/core-components'; +import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; +import { makeStyles } from '@material-ui/core'; +import { CoderAuthLoadingState } from './CoderAuthLoadingState'; +import { CoderAuthInputForm } from './CoderAuthInputForm'; + +const useStyles = makeStyles(theme => ({ + cardContent: { + paddingTop: theme.spacing(5), + paddingBottom: theme.spacing(5), + }, +})); + +function CoderAuthCard({ children }: PropsWithChildren) { + const styles = useStyles(); return ( -
{ - event.preventDefault(); - const formData = Object.fromEntries(new FormData(event.currentTarget)); - const newToken = - typeof formData.authToken === 'string' ? formData.authToken : ''; - - registerNewToken(newToken); - }} - > -

PLACEHOLDER STYLING

-

Status: {status}

- -

- Your Coder session token is {mapAuthStatusToText(status)}. Please enter - a new token from our{' '} - - Token page - (link opens in new tab) - - . -

- - - - - Authenticate - -
+ +
{children}
+
); -}; - -type LayoutComponentProps = PropsWithChildren; -function CoderAuthCard({ children }: LayoutComponentProps) { - return {children}; } type WrapperProps = Readonly< @@ -77,7 +30,7 @@ type WrapperProps = Readonly< export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { const auth = useCoderAuth(); - if (auth.isAuthed) { + if (auth.isAuthenticated) { return <>{children}; } @@ -87,41 +40,58 @@ export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { Wrapper = CoderAuthCard; break; } - default: { - throw new Error( - `Unknown CoderAuthWrapper display type ${type} encountered`, - ); + assertExhaustion(type); } } return ( - {auth.status === 'initializing' ? ( -

Loading…

- ) : ( - - )} + {/* Slightly awkward syntax with the IIFE, but need something switch-like + to make sure that all status cases are handled exhaustively */} + {(() => { + switch (auth.status) { + case 'initializing': { + return ; + } + + case 'distrusted': + case 'noInternetConnection': + case 'deploymentUnavailable': { + return ; + } + + case 'authenticating': + case 'invalid': + case 'tokenMissing': { + return ; + } + + case 'authenticated': + case 'distrustedWithGracePeriod': { + throw new Error( + 'This code should be unreachable because of the auth check near the start of the component', + ); + } + + default: { + return assertExhaustion(auth); + } + } + })()}
); }; -function mapAuthStatusToText(status: CoderAuthStatus): string { - switch (status) { - case 'tokenMissing': { - return 'missing'; - } - - case 'initializing': - case 'reauthenticating': { - return status; - } - - default: { - return 'invalid'; - } +function assertExhaustion(...inputs: readonly never[]): never { + let inputsToLog: unknown; + try { + inputsToLog = JSON.stringify(inputs); + } catch { + inputsToLog = inputs; } + + throw new Error( + `Not all possibilities for inputs (${inputsToLog}) have been exhausted`, + ); } diff --git a/plugins/backstage-plugin-coder/src/components/CoderLogo/CoderLogo.tsx b/plugins/backstage-plugin-coder/src/components/CoderLogo/CoderLogo.tsx new file mode 100644 index 00000000..7649a48f --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderLogo/CoderLogo.tsx @@ -0,0 +1,36 @@ +import { makeStyles } from '@material-ui/core'; +import React, { type HTMLAttributes } from 'react'; + +type CoderLogoProps = Readonly< + Omit, 'children'> +>; + +const useStyles = makeStyles(theme => ({ + root: { + fill: theme.palette.text.primary, + opacity: 1, + }, +})); + +export const CoderLogo = ({ + className = '', + ...delegatedProps +}: CoderLogoProps) => { + const styles = useStyles(); + return ( + + + + + + + + + ); +}; diff --git a/plugins/backstage-plugin-coder/src/components/CoderLogo/index.ts b/plugins/backstage-plugin-coder/src/components/CoderLogo/index.ts new file mode 100644 index 00000000..3819c2f9 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderLogo/index.ts @@ -0,0 +1 @@ +export * from './CoderLogo'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 9a43c5ca..3192198e 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -42,10 +42,11 @@ type AuthState = Readonly< // Distrusted represents a token that could be valid, but we are unable to // verify it within an allowed window. invalid is definitely, 100% invalid status: - | 'reauthenticating' + | 'authenticating' | 'invalid' | 'distrusted' - | 'noInternetConnection'; + | 'noInternetConnection' + | 'deploymentUnavailable'; token: undefined; error: unknown; } @@ -54,7 +55,8 @@ type AuthState = Readonly< export type CoderAuthStatus = AuthState['status']; export type CoderAuth = Readonly< AuthState & { - isAuthed: boolean; + isAuthenticated: boolean; + tokenLoadedOnMount: boolean; registerNewToken: (newToken: string) => void; ejectToken: () => void; } @@ -98,20 +100,24 @@ type CoderAuthProviderProps = Readonly>; export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { const { baseUrl } = useBackstageEndpoints(); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); - const [authToken, setAuthToken] = useState( - () => window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? '', - ); + + // Need to split hairs, because the query object can be disabled. Only want to + // expose the initializing state if the app mounts with a token already in + // localStorage + const [authToken, setAuthToken] = useState(readAuthToken); + const [readonlyInitialAuthToken] = useState(authToken); const authValidityQuery = useQuery({ ...authValidation({ baseUrl, authToken }), refetchOnWindowFocus: query => query.state.data !== false, }); - const authState = generateAuthState( + const authState = generateAuthState({ authToken, authValidityQuery, isInsideGracePeriod, - ); + initialAuthToken: readonlyInitialAuthToken, + }); // Mid-render state sync to avoid unnecessary re-renders that useEffect would // introduce, especially since we don't know how costly re-renders could be in @@ -143,15 +149,23 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { // outside React because we let the user connect their own queryClient const queryClient = useQueryClient(); useEffect(() => { + let isRefetchingTokenQuery = false; const queryCache = queryClient.getQueryCache(); - const unsubscribe = queryCache.subscribe(event => { + + const unsubscribe = queryCache.subscribe(async event => { const queryError = event.query.state.error; const shouldRevalidate = - queryError instanceof BackstageHttpError && !queryError.ok; + !isRefetchingTokenQuery && + queryError instanceof BackstageHttpError && + queryError.status === 401; - if (shouldRevalidate) { - queryClient.refetchQueries({ queryKey: authQueryKey }); + if (!shouldRevalidate) { + return; } + + isRefetchingTokenQuery = true; + await queryClient.refetchQueries({ queryKey: authQueryKey }); + isRefetchingTokenQuery = false; }); return unsubscribe; @@ -161,7 +175,8 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { { if (newToken !== '') { setAuthToken(newToken); @@ -179,19 +194,28 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { ); }; +type GenerateAuthStateInputs = Readonly<{ + authToken: string; + initialAuthToken: string; + authValidityQuery: UseQueryResult; + isInsideGracePeriod: boolean; +}>; + /** * This function isn't too big, but it is accounting for a lot of possible * configurations that authValidityQuery can be in while background fetches and - * refetches are happening. Can't get away with checking the .status alone + * re-fetches are happening. Can't get away with checking the .status alone * * @see {@link https://tkdodo.eu/blog/status-checks-in-react-query} */ -function generateAuthState( - authToken: string, - authValidityQuery: UseQueryResult, - isInsideAuthGracePeriod: boolean, -): AuthState { +function generateAuthState({ + authToken, + initialAuthToken, + authValidityQuery, + isInsideGracePeriod, +}: GenerateAuthStateInputs): AuthState { const isInitializing = + initialAuthToken !== '' && authValidityQuery.isLoading && authValidityQuery.isFetching && !authValidityQuery.isFetchedAfterMount; @@ -214,6 +238,21 @@ function generateAuthState( }; } + if (authValidityQuery.error instanceof BackstageHttpError) { + const deploymentLikelyUnavailable = + authValidityQuery.error.status === 504 || + (authValidityQuery.error.status === 200 && + authValidityQuery.error.contentType !== 'application/json'); + + if (deploymentLikelyUnavailable) { + return { + status: 'deploymentUnavailable', + token: undefined, + error: authValidityQuery.error, + }; + } + } + const isTokenValidFromPrevFetch = authValidityQuery.data === true; if (isTokenValidFromPrevFetch) { const canTrustAuthThisRender = @@ -226,7 +265,7 @@ function generateAuthState( }; } - if (isInsideAuthGracePeriod) { + if (isInsideGracePeriod) { return { status: 'distrustedWithGracePeriod', token: authToken, @@ -244,15 +283,15 @@ function generateAuthState( // Have to include isLoading here because the auth query uses the // isPreviousData property to mask the fact that we're shifting to different // query keys and cache pockets each time the token value changes - const isReauthenticating = + const isAuthenticating = authValidityQuery.isLoading || (authValidityQuery.isRefetching && ((authValidityQuery.isError && authValidityQuery.data !== true) || (authValidityQuery.isSuccess && authValidityQuery.data === false))); - if (isReauthenticating) { + if (isAuthenticating) { return { - status: 'reauthenticating', + status: 'authenticating', token: undefined, error: authValidityQuery.error, }; @@ -288,3 +327,7 @@ function generateAuthState( error: authValidityQuery.error, }; } + +function readAuthToken(): string { + return window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''; +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index cce89258..2a240a75 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -1,8 +1,8 @@ import React, { PropsWithChildren } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { act, waitFor } from '@testing-library/react'; -import { TestApiProvider } from '@backstage/test-utils'; +import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; import { configApiRef, errorApiRef } from '@backstage/core-plugin-api'; import { CoderProvider } from './CoderProvider'; @@ -28,13 +28,13 @@ describe(`${CoderProvider.name}`, () => { }); } - test(`Context hook exposes the same config that the provider has`, () => { - const { result } = renderUseAppConfig(); + test(`Context hook exposes the same config that the provider has`, async () => { + const { result } = await renderUseAppConfig(); expect(result.current).toBe(mockAppConfig); }); - test('Context value remains stable across re-renders if appConfig is defined outside', () => { - const { result, rerender } = renderUseAppConfig(); + test('Context value remains stable across re-renders if appConfig is defined outside', async () => { + const { result, rerender } = await renderUseAppConfig(); expect(result.current).toBe(mockAppConfig); for (let i = 0; i < 10; i++) { @@ -47,11 +47,11 @@ describe(`${CoderProvider.name}`, () => { // just to stabilize the memory reference for the value, and make sure that // memoization caches don't get invalidated too often. This test is just a // safety net to catch what happens if someone forgets - test('Context value will change by reference on re-render if defined inline in a parent', () => { + test('Context value will change by reference on re-render if defined inline inside a parent', () => { const ParentComponent = ({ children }: PropsWithChildren) => { const configThatChangesEachRender = { ...mockAppConfig }; - return ( + return wrapInTestApp( { {children} - + , ); }; @@ -101,7 +101,7 @@ describe(`${CoderProvider.name}`, () => { }); }; - it('Should clear out the auth token when the user logs out', async () => { + it('Should let the user eject their auth token', async () => { const { result } = renderUseCoderAuth(); act(() => result.current.registerNewToken(mockCoderAuthToken)); diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx index f76be309..4c8d0898 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx @@ -6,31 +6,29 @@ import { CoderAppConfigProvider } from './CoderAppConfigProvider'; import { CoderErrorBoundary } from '../CoderErrorBoundary'; import { BackstageHttpError } from '../../api'; +const MAX_FETCH_FAILURES = 3; + export type CoderProviderProps = ComponentProps & ComponentProps & { queryClient?: QueryClient; }; const shouldRetryRequest = (failureCount: number, error: unknown): boolean => { - const tooManyFailures = failureCount >= 3; - - // Have to duplicate a logic a little bit to improve type narrowing + const isBelowThreshold = failureCount < MAX_FETCH_FAILURES; if (!(error instanceof BackstageHttpError)) { - return tooManyFailures; + return isBelowThreshold; } - // This should trigger when there is an issue with the Backstage auth setup; - // just immediately give up on retries - if (error.status === 401) { - return false; - } + const isAuthenticationError = error.status === 401; + const isLikelyProxyConfigurationError = + error.status === 504 || + (error.status === 200 && error.contentType !== 'application/json'); - // This is rare, but a likely a sign that the proxy isn't set up properly - if (error.status === 200 && error.contentType !== 'application/json') { - return false; - } - - return tooManyFailures; + return ( + !isAuthenticationError && + !isLikelyProxyConfigurationError && + isBelowThreshold + ); }; const defaultClient = new QueryClient({ diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx index 9e54d711..64bff808 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx @@ -5,8 +5,8 @@ import { type WorkspacesCardProps, Root } from './Root'; import { HeaderRow } from './HeaderRow'; import { SearchBox } from './SearchBox'; import { WorkspacesList } from './WorkspacesList'; -import { RefreshButton } from './RefreshButton'; import { CreateWorkspaceLink } from './CreateWorkspaceLink'; +import { ExtraActionsButton } from './ExtraActionsButton'; const useStyles = makeStyles(theme => ({ searchWrapper: { @@ -26,8 +26,8 @@ export const CoderWorkspacesCard = ( headerLevel="h2" actions={ <> - + } /> diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx new file mode 100644 index 00000000..07f6d9db --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx @@ -0,0 +1,220 @@ +import React, { + type ButtonHTMLAttributes, + type ForwardedRef, + forwardRef, + useEffect, + useRef, + useState, +} from 'react'; + +import { useId } from '../../hooks/hookPolyfills'; +import { useCoderAuth } from '../CoderProvider'; +import { useWorkspacesCardContext } from './Root'; +import { VisuallyHidden } from '../VisuallyHidden'; + +import Menu, { type MenuProps } from '@material-ui/core/Menu'; +import { type MenuListProps } from '@material-ui/core/MenuList'; +import MenuItem from '@material-ui/core/MenuItem'; +import MoreItemsIcon from '@material-ui/icons/MoreVert'; +import Tooltip, { type TooltipProps } from '@material-ui/core/Tooltip'; +import { makeStyles } from '@material-ui/core'; + +const REFRESH_THROTTLE_MS = 1_000; + +const useStyles = makeStyles(theme => { + const padding = theme.spacing(0.5); + return { + root: { + padding, + margin: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + color: theme.palette.text.primary, + width: theme.spacing(4) + padding, + height: theme.spacing(4) + padding, + border: 'none', + borderRadius: '9999px', + backgroundColor: 'inherit', + lineHeight: 1, + + // Buttons don't traditionally have the pointer style, but it's being + // changed to match the cursor style for CreateWorkspaceButtonLink + cursor: 'pointer', + + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + }, + + menuList: { + '& > li:first-child:focus': { + backgroundColor: theme.palette.action.hover, + }, + }, + }; +}); + +type ExtraActionsMenuProps = Readonly< + Omit< + MenuProps, + | 'id' + | 'open' + | 'anchorEl' + | 'MenuListProps' + | 'children' + | 'onClose' + | 'getContentAnchorEl' + > & { + MenuListProps: Omit; + } +>; + +type ExtraActionsButtonProps = Readonly< + Omit, 'id' | 'aria-controls'> & { + onClose?: MenuProps['onClose']; + menuProps?: ExtraActionsMenuProps; + toolTipProps?: Omit; + tooltipText?: string; + tooltipRef?: ForwardedRef; + } +>; + +export const ExtraActionsButton = forwardRef< + HTMLButtonElement, + ExtraActionsButtonProps +>((props, ref) => { + const { + menuProps, + toolTipProps, + tooltipRef, + children, + className, + onClick: outerOnClick, + onClose: outerOnClose, + tooltipText = 'See additional workspace actions', + ...delegatedButtonProps + } = props; + + const { + className: menuListClassName, + ref: menuListRef, + MenuListProps = {}, + ...delegatedMenuProps + } = menuProps ?? {}; + + const hookId = useId(); + const [loadedAnchor, setLoadedAnchor] = useState(); + const refreshWorkspaces = useRefreshWorkspaces(); + const { ejectToken } = useCoderAuth(); + const styles = useStyles(); + + const closeMenu = () => setLoadedAnchor(undefined); + const isOpen = loadedAnchor !== undefined; + const menuId = `${hookId}-menu`; + const buttonId = `${hookId}-button`; + const keyboardInstructionsId = `${hookId}-instructions`; + + return ( + <> + + + + + + + {/* Warning: all direct children of Menu must be MenuItem components, or + else the auto-focus behavior will break. Even a custom component that + returns out nothing but a MenuItem will break it. (Guessing that MUI + uses something like cloneElement under the hood, and that they're + interacting with the raw JSX metadata objects before they're turned + into new UI.) */} + { + closeMenu(); + outerOnClose?.(event, reason); + }} + {...delegatedMenuProps} + > + { + refreshWorkspaces(); + closeMenu(); + }} + > + Refresh workspaces list + + + { + ejectToken(); + closeMenu(); + }} + > + Eject token + + + + ); +}); + +function useRefreshWorkspaces() { + const { workspacesQuery } = useWorkspacesCardContext(); + const refreshThrottleIdRef = useRef(); + + useEffect(() => { + const clearThrottleOnUnmount = () => { + window.clearTimeout(refreshThrottleIdRef.current); + }; + + return clearThrottleOnUnmount; + }, []); + + const refreshWorkspaces = () => { + if (refreshThrottleIdRef.current !== undefined) { + return; + } + + workspacesQuery.refetch(); + refreshThrottleIdRef.current = window.setTimeout(() => { + refreshThrottleIdRef.current = undefined; + }, REFRESH_THROTTLE_MS); + }; + + return refreshWorkspaces; +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/RefreshButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/RefreshButton.tsx deleted file mode 100644 index ee6e550b..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/RefreshButton.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { - type ButtonHTMLAttributes, - type ForwardedRef, - forwardRef, - useEffect, - useRef, -} from 'react'; -import { makeStyles } from '@material-ui/core'; - -import { useWorkspacesCardContext } from './Root'; -import { VisuallyHidden } from '../VisuallyHidden'; - -import Tooltip, { type TooltipProps } from '@material-ui/core/Tooltip'; -import RefreshIcon from '@material-ui/icons/Cached'; - -const REFRESH_THROTTLE_MS = 1_000; - -const useStyles = makeStyles(theme => { - const padding = theme.spacing(0.5); - - return { - root: { - padding, - margin: 0, - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - color: theme.palette.text.primary, - width: theme.spacing(4) + padding, - height: theme.spacing(4) + padding, - border: 'none', - borderRadius: '9999px', - backgroundColor: 'inherit', - lineHeight: 1, - - // Buttons don't traditionally have the pointer style, but it's being - // changed to match the cursor style for CreateWorkspaceButtonLink - cursor: 'pointer', - - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, - }, - }; -}); - -type RefreshButtonProps = Readonly< - ButtonHTMLAttributes & { - tooltipText?: string; - toolTipProps?: Omit; - tooltipRef?: ForwardedRef; - } ->; - -export const RefreshButton = forwardRef( - (props: RefreshButtonProps, ref?: ForwardedRef) => { - const { - children, - className, - tooltipRef, - onClick: outerOnClick, - toolTipProps = {}, - type = 'button', - tooltipText = 'Refresh workspaces list', - ...delegatedProps - } = props; - - const { workspacesQuery } = useWorkspacesCardContext(); - const refreshThrottleIdRef = useRef(); - const styles = useStyles(); - - useEffect(() => { - const clearThrottleOnUnmount = () => { - window.clearTimeout(refreshThrottleIdRef.current); - }; - - return clearThrottleOnUnmount; - }, []); - - const refreshWorkspaces = () => { - if (refreshThrottleIdRef.current !== undefined) { - return; - } - - refreshThrottleIdRef.current = window.setTimeout(() => { - workspacesQuery.refetch(); - refreshThrottleIdRef.current = undefined; - }, REFRESH_THROTTLE_MS); - }; - - return ( - - - - ); - }, -); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index ea15c1dc..452a663f 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -109,14 +109,13 @@ export const Root = forwardRef( aria-labelledby={headerId} {...delegatedProps} > -
element for semantics, but never want to go - // through a full native HTML form submission when the user does - // things like hitting the Enter key - onSubmit={event => event.preventDefault()} - > - {children} -
+ {/* Want to expose the overall container as a form for good + semantics and screen reader support, but since there isn't an + explicit submission process (queries happen automatically), + using a base div with a role override to side-step edge cases + around keyboard input and button children seems like the easiest + approach */} +
{children}
diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.tsx index 1a20f4d8..a1af15a0 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.tsx @@ -90,6 +90,7 @@ const useStyles = makeStyles(theme => ({ color: theme.palette.text.primary, opacity: isInputEmpty ? '40%' : '100%', outline: 'none', + cursor: 'pointer', '&:focus': { boxShadow: '0 0 0 1px hsl(213deg, 94%, 68%)', diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx index ca71e9ba..173beac6 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx @@ -9,6 +9,7 @@ import { type Theme, makeStyles } from '@material-ui/core'; import type { Workspace } from '../../typesConstants'; import { useWorkspacesCardContext } from './Root'; import { WorkspacesListItem } from './WorkspacesListItem'; +import { CoderLogo } from '../CoderLogo'; const usePlaceholderStyles = makeStyles(theme => ({ root: { @@ -18,12 +19,9 @@ const usePlaceholderStyles = makeStyles(theme => ({ alignItems: 'center', }, - coderLogo: { - fill: theme.palette.text.primary, - opacity: 1, - }, - text: { + textAlign: 'center', + padding: `0 ${theme.spacing(2.5)}px`, fontWeight: 400, fontSize: '1.125rem', color: theme.palette.text.secondary, @@ -40,20 +38,7 @@ const Placeholder = ({ children }: PlaceholderProps) => { return (
- - - - - - - - - +

{children}

); @@ -133,7 +118,7 @@ export const WorkspacesList = ({ {workspacesQuery.fetchStatus === 'fetching' ? ( <>Loading… ) : ( - <>Use the searchbar to find matching Coder workspaces + <>Use the search bar to find matching Coder workspaces )} )} diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts index 0ff4361a..55b94206 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts @@ -1,7 +1,7 @@ export * from './CoderWorkspacesCard'; export * from './CreateWorkspaceLink'; export * from './HeaderRow'; -export * from './RefreshButton'; +export * from './ExtraActionsButton'; export * from './Root'; export * from './SearchBox'; export * from './WorkspacesList'; diff --git a/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.test.ts b/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.test.ts index aeebb6d1..e58a3922 100644 --- a/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useId } from './hookPolyfills'; describe(`${useId.name}`, () => { diff --git a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts b/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts index 1d45d6a4..d245e5d3 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useBackstageEndpoints.test.ts @@ -12,8 +12,8 @@ import { } from '../testHelpers/mockBackstageData'; describe(`${useBackstageEndpoints.name}`, () => { - it('Should provide pre-formatted URLs for interacting with Backstage endpoints', () => { - const { result } = renderHookAsCoderEntity(useBackstageEndpoints); + it('Should provide pre-formatted URLs for interacting with Backstage endpoints', async () => { + const { result } = await renderHookAsCoderEntity(useBackstageEndpoints); expect(result.current).toEqual( expect.objectContaining({ diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.test.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.test.ts index 293f4b02..ffc52e57 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderEntityConfig.test.ts @@ -108,8 +108,8 @@ describe(`${compileCoderConfig.name}`, () => { }); describe(`${useCoderEntityConfig.name}`, () => { - it('Reads relevant data from CoderProvider, entity, and source control API', () => { - const { result } = renderHookAsCoderEntity(useCoderEntityConfig); + it('Reads relevant data from CoderProvider, entity, and source control API', async () => { + const { result } = await renderHookAsCoderEntity(useCoderEntityConfig); expect(result.current).toEqual( expect.objectContaining>({ diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspaces.test.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspaces.test.ts index e6a1806e..eb4674e1 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspaces.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspaces.test.ts @@ -14,7 +14,7 @@ afterAll(() => { describe(`${useCoderWorkspaces.name}`, () => { it('Will make a request when provided correct inputs', async () => { - const { result } = renderHookAsCoderEntity(() => { + const { result } = await renderHookAsCoderEntity(() => { return useCoderWorkspaces('owner:me'); }); @@ -22,7 +22,7 @@ describe(`${useCoderWorkspaces.name}`, () => { }); it('Will not be enabled if auth token is missing', async () => { - const { result } = renderHookAsCoderEntity( + const { result } = await renderHookAsCoderEntity( () => useCoderWorkspaces('owner:me'), { authStatus: 'invalid' }, ); @@ -47,7 +47,7 @@ describe(`${useCoderWorkspaces.name}`, () => { }); it('Will only return workspaces for a given repo when a repoConfig is provided', async () => { - const { result } = renderHookAsCoderEntity(() => { + const { result } = await renderHookAsCoderEntity(() => { return useCoderWorkspaces('owner:me', { repoConfig: mockCoderEntityConfig, }); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 4f94ae38..04e1e7c0 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -110,7 +110,8 @@ export const mockAppConfig = { const authedState = { token: mockCoderAuthToken, error: undefined, - isAuthed: true, + tokenLoadedOnMount: true, + isAuthenticated: true, registerNewToken: jest.fn(), ejectToken: jest.fn(), } as const satisfies Partial; @@ -118,7 +119,8 @@ const authedState = { const notAuthedState = { token: undefined, error: undefined, - isAuthed: false, + tokenLoadedOnMount: false, + isAuthenticated: false, registerNewToken: jest.fn(), ejectToken: jest.fn(), } as const satisfies Partial; @@ -139,9 +141,9 @@ export const mockAuthStates = { status: 'invalid', }, - reauthenticating: { + authenticating: { ...notAuthedState, - status: 'reauthenticating', + status: 'authenticating', }, distrusted: { @@ -163,6 +165,11 @@ export const mockAuthStates = { ...notAuthedState, status: 'tokenMissing', }, + + deploymentUnavailable: { + ...notAuthedState, + status: 'deploymentUnavailable', + }, } as const satisfies Record; export function getMockConfigApi() { diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 49dabdb5..e8018694 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -1,19 +1,27 @@ /* eslint-disable @backstage/no-undeclared-imports -- For test helpers only */ -import { render } from '@testing-library/react'; -import { MockErrorApi, TestApiProvider } from '@backstage/test-utils'; import { - RenderHookOptions, - RenderHookResult, + MockErrorApi, + TestApiProvider, + wrapInTestApp, +} from '@backstage/test-utils'; +import { + type RenderHookOptions, + type RenderHookResult, + render, renderHook, -} from '@testing-library/react-hooks'; + waitFor, +} from '@testing-library/react'; /* eslint-enable @backstage/no-undeclared-imports */ -import React, { ReactElement, type PropsWithChildren } from 'react'; +import React, { ReactElement } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; import { configApiRef, errorApiRef } from '@backstage/core-plugin-api'; +import { + type EntityProviderProps, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { type CoderProviderProps, @@ -29,9 +37,23 @@ import { getMockConfigApi, mockAuthStates, } from './mockBackstageData'; - import { CoderErrorBoundary } from '../plugin'; +const initialAbortSignalTimeout = AbortSignal.timeout; +beforeAll(() => { + if (!AbortSignal.timeout) { + AbortSignal.timeout = ms => { + const controller = new AbortController(); + setTimeout(() => controller.abort(new DOMException('TimeoutError')), ms); + return controller.signal; + }; + } +}); + +afterAll(() => { + AbortSignal.timeout = initialAbortSignalTimeout; +}); + const afterEachCleanupFunctions: (() => void)[] = []; export function cleanUpAfterEachHelpers() { @@ -116,7 +138,7 @@ export const CoderProviderWithMockAuth = ({ ); }; -type ChildProps = Readonly>; +type ChildProps = EntityProviderProps; type RenderResultWithErrorApi = ReturnType & { errorApi: MockErrorApi; }; @@ -193,37 +215,43 @@ type RenderHookAsCoderEntityOptions> = Omit< authStatus?: CoderAuthStatus; }; -export const renderHookAsCoderEntity = < - TProps extends NonNullable = NonNullable, +export const renderHookAsCoderEntity = async < TReturn = unknown, + TProps extends NonNullable = NonNullable, >( hook: (props: TProps) => TReturn, options?: RenderHookAsCoderEntityOptions, -): RenderHookResult => { +): Promise> => { const { authStatus, ...delegatedOptions } = options ?? {}; const mockErrorApi = getMockErrorApi(); const mockSourceControl = getMockSourceControl(); const mockConfigApi = getMockConfigApi(); const mockQueryClient = getMockQueryClient(); - return renderHook(hook, { + const renderHookValue = renderHook(hook, { ...delegatedOptions, - wrapper: ({ children }) => ( - - + wrapInTestApp( + - {children} - - - ), + + + <>{children} + + + , + ), }); + + await waitFor(() => expect(renderHookValue.result.current).not.toBe(null)); + return renderHookValue; }; diff --git a/yarn.lock b/yarn.lock index 746c7686..d3e8e486 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2208,6 +2208,16 @@ cross-fetch "^4.0.0" uri-template "^2.0.0" +"@backstage/catalog-client@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@backstage/catalog-client/-/catalog-client-1.6.0.tgz#d4ba505f84a58f03177d0998becc6eb8ed54f40e" + integrity sha512-O6yoBX/BcKy89AwXmXVxNPlk0mX7jbgqYUCeIxGZr7n10A9oJx1iRj1XMub+V67yuqdfILPmh8WW+jd0N98+JA== + dependencies: + "@backstage/catalog-model" "^1.4.4" + "@backstage/errors" "^1.2.3" + cross-fetch "^4.0.0" + uri-template "^2.0.0" + "@backstage/catalog-model@^1.4.3": version "1.4.3" resolved "https://registry.yarnpkg.com/@backstage/catalog-model/-/catalog-model-1.4.3.tgz#64abf34071d1cad6372f905b92e1d831e480750c" @@ -2218,6 +2228,16 @@ ajv "^8.10.0" lodash "^4.17.21" +"@backstage/catalog-model@^1.4.4": + version "1.4.4" + resolved "https://registry.yarnpkg.com/@backstage/catalog-model/-/catalog-model-1.4.4.tgz#53ebbe754c72a0e01bb7ea025af0358dc459db9c" + integrity sha512-JiCeAgUdRMQTjO0+34QeKDxYh/UQrXtDUvVic5z11uf8WuX3L9N7LiPOqJG+3t9TAyc5side21nDD7REdHoVFA== + dependencies: + "@backstage/errors" "^1.2.3" + "@backstage/types" "^1.1.1" + ajv "^8.10.0" + lodash "^4.17.21" + "@backstage/cli-common@^0.1.13": version "0.1.13" resolved "https://registry.yarnpkg.com/@backstage/cli-common/-/cli-common-0.1.13.tgz#cbeda6a359ca4437fc782f0ac51bb957e8d49e73" @@ -2458,6 +2478,51 @@ zen-observable "^0.10.0" zod "^3.22.4" +"@backstage/core-components@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@backstage/core-components/-/core-components-0.14.0.tgz#f9208617f569badd4dbf3bf270179e1d6dd41e26" + integrity sha512-uIoQJFOghQX9kNk/RjWKYzqc/euq6p6HLYU01ptrCwY81dIChXUU/XulxuT0l1LQq8oAzQPbg6v9l4nU7EJ1yg== + dependencies: + "@backstage/config" "^1.1.1" + "@backstage/core-plugin-api" "^1.9.0" + "@backstage/errors" "^1.2.3" + "@backstage/theme" "^0.5.1" + "@backstage/version-bridge" "^1.0.7" + "@date-io/core" "^1.3.13" + "@material-table/core" "^3.1.0" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.61" + "@react-hookz/web" "^24.0.0" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + "@types/react-sparklines" "^1.7.0" + "@types/react-text-truncate" "^0.14.0" + ansi-regex "^6.0.1" + classnames "^2.2.6" + d3-selection "^3.0.0" + d3-shape "^3.0.0" + d3-zoom "^3.0.0" + dagre "^0.8.5" + linkify-react "4.1.3" + linkifyjs "4.1.3" + lodash "^4.17.21" + pluralize "^8.0.0" + qs "^6.9.4" + rc-progress "3.5.1" + react-helmet "6.1.0" + react-hook-form "^7.12.2" + react-idle-timer "5.6.2" + react-markdown "^8.0.0" + react-sparklines "^1.7.0" + react-syntax-highlighter "^15.4.5" + react-text-truncate "^0.19.0" + react-use "^17.3.2" + react-virtualized-auto-sizer "^1.0.11" + react-window "^1.8.6" + remark-gfm "^3.0.1" + zen-observable "^0.10.0" + zod "^3.22.4" + "@backstage/core-plugin-api@^1.8.2": version "1.8.2" resolved "https://registry.yarnpkg.com/@backstage/core-plugin-api/-/core-plugin-api-1.8.2.tgz#1e6f54f0ef1669ffeff56490fbde92c766312230" @@ -2469,6 +2534,18 @@ "@types/react" "^16.13.1 || ^17.0.0" history "^5.0.0" +"@backstage/core-plugin-api@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@backstage/core-plugin-api/-/core-plugin-api-1.9.0.tgz#49cda87ab82b968c9c7439da99549a4c34c4f720" + integrity sha512-k+w9TfJCFv/5YyiATuZfnlg/8KkJEL0fo9MHGFcOTOeqX0rcb0eecEWmb2kiA4NfPzLmEeNSSc4Nv8zdRQwCQA== + dependencies: + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@backstage/types" "^1.1.1" + "@backstage/version-bridge" "^1.0.7" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + history "^5.0.0" + "@backstage/dev-utils@^1.0.26": version "1.0.26" resolved "https://registry.yarnpkg.com/@backstage/dev-utils/-/dev-utils-1.0.26.tgz#b3ca44a6900cd575cc0f2546b10f9865fa436176" @@ -2526,6 +2603,21 @@ zod "^3.22.4" zod-to-json-schema "^3.21.4" +"@backstage/frontend-plugin-api@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@backstage/frontend-plugin-api/-/frontend-plugin-api-0.6.0.tgz#849ced607fbf503daed29f4c1ea1e4381e3e0c01" + integrity sha512-09M3ftyZGljxTiCURGSHyPaO/ACBAQEL7iH0Kfq20i3c5ReyUjL/eZ/pgk/MGX7AhPheR98XTeHPD9OACfj+JQ== + dependencies: + "@backstage/core-components" "^0.14.0" + "@backstage/core-plugin-api" "^1.9.0" + "@backstage/types" "^1.1.1" + "@backstage/version-bridge" "^1.0.7" + "@material-ui/core" "^4.12.4" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + lodash "^4.17.21" + zod "^3.22.4" + zod-to-json-schema "^3.21.4" + "@backstage/integration-aws-node@^0.1.8": version "0.1.8" resolved "https://registry.yarnpkg.com/@backstage/integration-aws-node/-/integration-aws-node-0.1.8.tgz#c0582a63e2348a42bbe172bdcd4609f024cc0051" @@ -2551,6 +2643,18 @@ "@material-ui/icons" "^4.9.1" "@types/react" "^16.13.1 || ^17.0.0" +"@backstage/integration-react@^1.1.24": + version "1.1.24" + resolved "https://registry.yarnpkg.com/@backstage/integration-react/-/integration-react-1.1.24.tgz#2ae41ca6ad73cf5064bbe988229f0c942ba39198" + integrity sha512-C7aIYFCU14drZx9k0knDZeY4uq4oN5gbI4OVYJtQFVdZlgWwUuycxtw8ar9XAEzIl+UgPcpIpIWsbvOLBb8Qaw== + dependencies: + "@backstage/config" "^1.1.1" + "@backstage/core-plugin-api" "^1.9.0" + "@backstage/integration" "^1.9.0" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.9.1" + "@types/react" "^16.13.1 || ^17.0.0" + "@backstage/integration@^1.8.0": version "1.8.0" resolved "https://registry.yarnpkg.com/@backstage/integration/-/integration-1.8.0.tgz#affc54e1c12c5a4e68a92de4e42c6cf001bdf6ec" @@ -2565,6 +2669,21 @@ lodash "^4.17.21" luxon "^3.0.0" +"@backstage/integration@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@backstage/integration/-/integration-1.9.0.tgz#c60b33a7ec9b3970ccd4e8d54662b686b7ad27bf" + integrity sha512-lqZcjcfLeDyHxDdmTKxiko3GX+vQCyhoNM/lgPFLJFih9TiE3V+hTc9isEfkpQqRE9dCEy1w7rgUrNHXlz0pTA== + dependencies: + "@azure/identity" "^4.0.0" + "@backstage/config" "^1.1.1" + "@backstage/errors" "^1.2.3" + "@octokit/auth-app" "^4.0.0" + "@octokit/rest" "^19.0.3" + cross-fetch "^4.0.0" + git-url-parse "^14.0.0" + lodash "^4.17.21" + luxon "^3.0.0" + "@backstage/plugin-api-docs@^0.10.3": version "0.10.3" resolved "https://registry.yarnpkg.com/@backstage/plugin-api-docs/-/plugin-api-docs-0.10.3.tgz#e2dedad4d8630a1bf8297521d2d3b0bf872718e5" @@ -2874,6 +2993,15 @@ "@backstage/plugin-permission-common" "^0.7.12" "@backstage/plugin-search-common" "^1.2.10" +"@backstage/plugin-catalog-common@^1.0.21": + version "1.0.21" + resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-common/-/plugin-catalog-common-1.0.21.tgz#1dba78e151079cab0137158b71427276799d4104" + integrity sha512-7VA76TRzeVkfyefDVR01lAfTQnaHw2ZtlvOjIo+tSlteivZ+wEzJVq9af/ekHYlOGuDsYzDzGgc/P/eRwY67Ag== + dependencies: + "@backstage/catalog-model" "^1.4.4" + "@backstage/plugin-permission-common" "^0.7.12" + "@backstage/plugin-search-common" "^1.2.10" + "@backstage/plugin-catalog-graph@^0.3.3": version "0.3.3" resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-graph/-/plugin-catalog-graph-0.3.3.tgz#fc35db8f9c3ea67560a5ffaff71f9cfe459ed611" @@ -2938,6 +3066,36 @@ "@backstage/plugin-permission-node" "^0.7.20" "@backstage/types" "^1.1.1" +"@backstage/plugin-catalog-react@^1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-react/-/plugin-catalog-react-1.10.0.tgz#5c0bab60bd2bf854f4778c111e1f06e2db8ae881" + integrity sha512-xeejxqVp20NCtQIlWrOfvI/scWOefu7PsfQ0Eovqn0dULDVKAJTSgULpdm/AwgJ4E3F46voGw4FE/k5Rlf8Glg== + dependencies: + "@backstage/catalog-client" "^1.6.0" + "@backstage/catalog-model" "^1.4.4" + "@backstage/core-components" "^0.14.0" + "@backstage/core-plugin-api" "^1.9.0" + "@backstage/errors" "^1.2.3" + "@backstage/frontend-plugin-api" "^0.6.0" + "@backstage/integration-react" "^1.1.24" + "@backstage/plugin-catalog-common" "^1.0.21" + "@backstage/plugin-permission-common" "^0.7.12" + "@backstage/plugin-permission-react" "^0.4.20" + "@backstage/types" "^1.1.1" + "@backstage/version-bridge" "^1.0.7" + "@material-ui/core" "^4.12.2" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.61" + "@react-hookz/web" "^24.0.0" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + classnames "^2.2.6" + lodash "^4.17.21" + material-ui-popup-state "^1.9.3" + qs "^6.9.4" + react-use "^17.2.4" + yaml "^2.0.0" + zen-observable "^0.10.0" + "@backstage/plugin-catalog-react@^1.9.3": version "1.9.3" resolved "https://registry.yarnpkg.com/@backstage/plugin-catalog-react/-/plugin-catalog-react-1.9.3.tgz#d5910989bc62e1827be00bc4e9650985f2ea338e" @@ -3088,6 +3246,17 @@ "@types/react" "^16.13.1 || ^17.0.0" swr "^2.0.0" +"@backstage/plugin-permission-react@^0.4.20": + version "0.4.20" + resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-react/-/plugin-permission-react-0.4.20.tgz#508bb6bfadaa89a32e891c06bc68b168f10b88bf" + integrity sha512-kP1lmtEtN5XFgPJhnHO5xb++60XyMUmbSjfrT6p+77my3w0qvg8NwGwtg7fingrYJ3pcFGvEgcmL4j7JUfgH7g== + dependencies: + "@backstage/config" "^1.1.1" + "@backstage/core-plugin-api" "^1.9.0" + "@backstage/plugin-permission-common" "^0.7.12" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + swr "^2.0.0" + "@backstage/plugin-proxy-backend@^0.4.7": version "0.4.7" resolved "https://registry.yarnpkg.com/@backstage/plugin-proxy-backend/-/plugin-proxy-backend-0.4.7.tgz#4bc5f7f9118ce253063bc6132170340c7a1a4795" @@ -3688,6 +3857,15 @@ "@emotion/styled" "^11.10.5" "@mui/material" "^5.12.2" +"@backstage/theme@^0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@backstage/theme/-/theme-0.5.1.tgz#3134874f464990a043127c3fdbc634ea770a911b" + integrity sha512-dVX4xVx9TkNUkubgZqmRjIFTjJeOPRVM9U1aG8S2TRVSUzv9pNK0jDHDN2kyfdSUb9prrC9iEi3+g2lvCwjgKQ== + dependencies: + "@emotion/react" "^11.10.5" + "@emotion/styled" "^11.10.5" + "@mui/material" "^5.12.2" + "@backstage/types@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@backstage/types/-/types-1.1.1.tgz#c9ccb30357005e7fb5fa2ac140198059976eb076" @@ -6429,6 +6607,13 @@ dependencies: "@react-hookz/deep-equal" "^1.0.4" +"@react-hookz/web@^24.0.0": + version "24.0.4" + resolved "https://registry.yarnpkg.com/@react-hookz/web/-/web-24.0.4.tgz#7a13d4c2cc65861b926ef6c4452fba00408c8778" + integrity sha512-DcIM6JiZklDyHF6CRD1FTXzuggAkQ+3Ncq2Wln7Kdih8GV6ZIeN9JfS6ZaQxpQUxan8/4n0J2V/R7nMeiSrb2Q== + dependencies: + "@react-hookz/deep-equal" "^1.0.4" + "@remix-run/router@1.15.0": version "1.15.0" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.15.0.tgz#461a952c2872dd82c8b2e9b74c4dfaff569123e2" @@ -7928,20 +8113,6 @@ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.0.0.tgz#637bee36f0cabf96a1d436887c90f138a7e9378b" integrity sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg== -"@testing-library/dom@^8.0.0": - version "8.20.1" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f" - integrity sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/runtime" "^7.12.5" - "@types/aria-query" "^5.0.1" - aria-query "5.1.3" - chalk "^4.1.0" - dom-accessibility-api "^0.5.9" - lz-string "^1.5.0" - pretty-format "^27.0.2" - "@testing-library/dom@^9.0.0": version "9.3.4" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce" @@ -7985,16 +8156,7 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react@^12.1.3": - version "12.1.5" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" - integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg== - dependencies: - "@babel/runtime" "^7.12.5" - "@testing-library/dom" "^8.0.0" - "@types/react-dom" "<18.0.0" - -"@testing-library/react@^14.0.0": +"@testing-library/react@^14.0.0", "@testing-library/react@^14.2.1": version "14.2.1" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-14.2.1.tgz#bf69aa3f71c36133349976a4a2da3687561d8310" integrity sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A== @@ -8557,20 +8719,13 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react-dom@*", "@types/react-dom@^18.0.0": +"@types/react-dom@*", "@types/react-dom@^18", "@types/react-dom@^18.0.0": version "18.2.19" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.19.tgz#b84b7c30c635a6c26c6a6dfbb599b2da9788be58" integrity sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA== dependencies: "@types/react" "*" -"@types/react-dom@<18.0.0": - version "17.0.25" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.25.tgz#e0e5b3571e1069625b3a3da2b279379aa33a0cb5" - integrity sha512-urx7A7UxkZQmThYA4So0NelOVjx3V4rNFVJwp0WZlbIK5eM4rNJDiN3R/E9ix0MBh6kAEojk/9YL+Te6D9zHNA== - dependencies: - "@types/react" "^17" - "@types/react-redux@^7.1.20": version "7.1.33" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.33.tgz#53c5564f03f1ded90904e3c90f77e4bd4dc20b15" @@ -8602,19 +8757,10 @@ dependencies: "@types/react" "*" -"@types/react@*": - version "18.2.55" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.55.tgz#38141821b7084404b5013742bc4ae08e44da7a67" - integrity sha512-Y2Tz5P4yz23brwm2d7jNon39qoAtMMmalOQv6+fEFt1mT+FcM3D841wDpoUvFXhaYenuROCy3FZYqdTjM7qVyA== - dependencies: - "@types/prop-types" "*" - "@types/scheduler" "*" - csstype "^3.0.2" - -"@types/react@^16.13.1 || ^17.0.0", "@types/react@^17": - version "17.0.75" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.75.tgz#cffbc76840a12fcadaf5a3cf14878bb06efcf73d" - integrity sha512-MSA+NzEzXnQKrqpO63CYqNstFjsESgvJAdAyyJ1n6ZQq/GLgf6nOfIKwk+Twuz0L1N6xPe+qz5xRCJrbhMaLsw== +"@types/react@*", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0", "@types/react@^18": + version "18.2.57" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.57.tgz#147b516d8bdb2900219acbfc6f939bdeecca7691" + integrity sha512-ZvQsktJgSYrQiMirAN60y4O/LRevIV8hUzSOSNB6gfR3/o3wCBFQx3sPwIYtuDMeiVgsSS3UzCV26tEzgnfvQw== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -13767,6 +13913,13 @@ git-url-parse@^13.0.0: dependencies: git-up "^7.0.0" +git-url-parse@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/git-url-parse/-/git-url-parse-14.0.0.tgz#18ce834726d5fbca0c25a4555101aa277017418f" + integrity sha512-NnLweV+2A4nCvn4U/m2AoYu0pPKlsmhK9cknG7IMwsjFY1S2jxM+mAhsDxyxfCIGfGaD+dozsyX4b6vkYc83yQ== + dependencies: + git-up "^7.0.0" + gitconfiglocal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz#41d045f3851a5ea88f03f24ca1c6178114464b9b"