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 (
+
+ );
+};
+
+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 (
-
+
+ {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 (
+ <>
+
+ {
+ setLoadedAnchor(event.currentTarget);
+ outerOnClick?.(event);
+ }}
+ {...delegatedButtonProps}
+ >
+ {children ?? }
+ {tooltipText}
+
+
+
+
+ Press the up and down arrow keys to navigate between list items. Press
+ Escape to close the menu.
+
+
+ {/* 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.) */}
+
+ >
+ );
+});
+
+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 (
-
- {
- refreshWorkspaces();
- outerOnClick?.(event);
- }}
- {...delegatedProps}
- >
- {children ?? }
- {tooltipText}
-
-
- );
- },
-);
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}
>
-
+ {/* 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"