From 9db4173e2f39b8e43f189e4103d1eca97ab29fec Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 14 May 2024 20:27:09 +0000 Subject: [PATCH 01/57] wip: commit progress on fallback UI --- .../CoderProvider/CoderAuthProvider.tsx | 262 ++++++++++++++---- 1 file changed, 201 insertions(+), 61 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 852abce1..cf9f8077 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -1,11 +1,14 @@ import React, { type PropsWithChildren, createContext, + useCallback, useContext, useEffect, useState, + useRef, + useLayoutEffect, } from 'react'; - +import { createPortal } from 'react-dom'; import { type UseQueryResult, useQuery, @@ -18,6 +21,8 @@ import { } from '../../api/queryOptions'; import { coderClientApiRef } from '../../api/CoderClient'; import { useApi } from '@backstage/core-plugin-api'; +import { useId } from '../../hooks/hookPolyfills'; +import { makeStyles } from '@material-ui/core'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -55,52 +60,24 @@ export type CoderAuthStatus = AuthState['status']; export type CoderAuth = Readonly< AuthState & { isAuthenticated: boolean; - tokenLoadedOnMount: boolean; registerNewToken: (newToken: string) => void; ejectToken: () => void; } >; -function isAuthValid(state: AuthState): boolean { - return ( - state.status === 'authenticated' || - state.status === 'distrustedWithGracePeriod' - ); -} - -type ValidCoderAuth = Extract< - CoderAuth, - { status: 'authenticated' | 'distrustedWithGracePeriod' } ->; - -export function assertValidCoderAuth( - auth: CoderAuth, -): asserts auth is ValidCoderAuth { - if (!isAuthValid(auth)) { - throw new Error('Coder auth is not valid'); - } -} - -export const AuthContext = createContext(null); +type TrackComponent = (componentInstanceId: string) => () => void; -export function useCoderAuth(): CoderAuth { - const contextValue = useContext(AuthContext); - if (contextValue === null) { - throw new Error( - `Hook ${useCoderAuth.name} is being called outside of CoderProvider`, - ); - } +export const AuthStateContext = createContext(null); +const AuthTrackingContext = createContext(null); - return contextValue; -} - -type CoderAuthProviderProps = Readonly>; - -export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { +function useAuthState(): CoderAuth { // 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 [authToken, setAuthToken] = useState( + () => window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? '', + ); + const [readonlyInitialAuthToken] = useState(authToken); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); @@ -174,28 +151,111 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { return unsubscribe; }, [queryClient]); - return ( - { - if (newToken !== '') { - setAuthToken(newToken); - } - }, - ejectToken: () => { - window.localStorage.removeItem(TOKEN_STORAGE_KEY); - queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); - setAuthToken(''); - }, - }} - > - {children} - - ); -}; + const validAuthStatuses: readonly CoderAuthStatus[] = [ + 'authenticated', + 'distrustedWithGracePeriod', + ]; + + return { + ...authState, + isAuthenticated: validAuthStatuses.includes(authState.status), + registerNewToken: newToken => { + if (newToken !== '') { + setAuthToken(newToken); + } + }, + ejectToken: () => { + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); + setAuthToken(''); + }, + }; +} + +type AuthFallbackState = Readonly<{ + trackComponent: TrackComponent; + hasNoAuthInputs: boolean; +}>; + +function useAuthFallbackState(): AuthFallbackState { + // Can't do state syncs or anything else that would normally minimize + // re-renders here because we have to wait for the entire application to + // complete its initial render before we can decide if we need a fallback + const [isMounted, setIsMounted] = useState(false); + useEffect(() => { + setIsMounted(true); + }, []); + + // Not the biggest fan of needing to keep the two pieces of state in sync, but + // setting the render state to a simple boolean rather than the whole Set + // means that we re-render only when we go from 0 trackers to 1+, or + // vice-versa, versus re-rendering when we get a new tracker at all. If we + // have 1+ trackers, we don't care about the actual number past that + const [hasTrackers, setHasTrackers] = useState(false); + const trackedComponentsRef = useRef>(null!); + if (trackedComponentsRef.current === null) { + trackedComponentsRef.current = new Set(); + } + + const trackComponent = useCallback((componentId: string) => { + // React will bail out of re-renders if you dispatch the same state value + // that it already has. Calling this function too often should cause no + // problems and should be a no-op 95% of the time + const syncTrackerToUi = () => { + setHasTrackers(trackedComponentsRef.current.size !== 0); + }; + + trackedComponentsRef.current.add(componentId); + syncTrackerToUi(); + + return () => { + trackedComponentsRef.current.delete(componentId); + syncTrackerToUi(); + }; + }, []); + + return { + trackComponent, + hasNoAuthInputs: isMounted && !hasTrackers, + }; +} + +/** + * This is a lightweight hook for grabbing the Coder auth and doing nothing + * else. + * + * This is deemed "unsafe" for most of the UI, because getting the auth value + * this way does not interact with the component tracking logic at all. + */ +function useUnsafeCoderAuth(): CoderAuth { + const contextValue = useContext(AuthStateContext); + if (contextValue === null) { + throw new Error('Cannot retrieve auth information from CoderProvider'); + } + + return contextValue; +} + +export function useCoderAuth(): CoderAuth { + const trackComponent = useContext(AuthTrackingContext); + if (trackComponent === null) { + throw new Error('Unable to retrieve state for displaying fallback auth UI'); + } + + // Assuming subscribe is set up properly, the values of instanceId and + // subscribe should both be stable until whatever component is using this hook + // unmounts. Values only added to dependency array to satisfy ESLint + const instanceId = useId(); + useEffect(() => { + const cleanupTracking = trackComponent(instanceId); + return cleanupTracking; + }, [instanceId, trackComponent]); + + // Getting the auth value is now safe, since we can guarantee that if another + // component calls this hook, the fallback auth UI won't ever need to be + // displayed + return useUnsafeCoderAuth(); +} type GenerateAuthStateInputs = Readonly<{ authToken: string; @@ -331,6 +391,86 @@ function generateAuthState({ }; } -function readAuthToken(): string { - return window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''; +// Have to get the root of the React application to adjust its dimensions when +// we display the fallback UI. Sadly, we can't assert that the root is always +// defined from outside a UI component, because throwing any errors here would +// blow up the entire Backstage application, and wreck all the other plugins +const mainAppRoot = document.querySelector('#root'); + +const useFallbackStyles = makeStyles(theme => ({ + root: { + width: '100%', + padding: theme.spacing(1), + backgroundColor: 'green', + }, + + modalTrigger: { + backgroundColor: 'inherit', + border: 'none', + display: 'block', + width: '100%', + maxWidth: '60%', + minWidth: 'fit-content', + color: 'blue', + marginLeft: 'auto', + marginRight: 'auto', + }, +})); + +function FallbackAuthUi() { + const styles = useFallbackStyles(); + + /** + * Would've been nice to be able to use ref callbacks here, but we need to + * make sure that we have a cleanup step available. Ref callbacks don't get + * cleanup support until React 19 + * + * @see {@link https://github.com/facebook/react/pull/25686} + */ + const fallbackRef = useRef(null); + useLayoutEffect(() => { + const fallback = fallbackRef.current; + if (fallback === null || mainAppRoot === null) { + return undefined; + } + + const originalRootHeight = mainAppRoot.offsetHeight; + const fallbackHeight = fallback.offsetHeight; + mainAppRoot.style.height = `calc(${originalRootHeight}px - ${fallbackHeight}px)`; + + return () => { + mainAppRoot.style.height = `${originalRootHeight}px`; + }; + }, []); + + const fallbackUi = ( +
+ +
+ ); + + return createPortal(fallbackUi, document.body); } + +export const CoderAuthProvider = ({ + children, +}: Readonly>) => { + const authState = useAuthState(); + const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); + const needFallbackUi = true; // hasNoAuthInputs && !authState.isAuthenticated; + + return ( + + + {children} + + + {needFallbackUi && } + + ); +}; From 50d90081a318a71d47c8e6a3c24342177983b22f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 14 May 2024 20:32:18 +0000 Subject: [PATCH 02/57] chore: move dep to peer dependencies --- plugins/backstage-plugin-coder/package.json | 3 +- yarn.lock | 61 +++++++++------------ 2 files changed, 27 insertions(+), 37 deletions(-) diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index 6dcc24a8..84987984 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -46,7 +46,8 @@ "valibot": "^0.28.1" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^18.3.1" }, "devDependencies": { "@backstage/cli": "^0.25.1", diff --git a/yarn.lock b/yarn.lock index b13b38c9..e7553d7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8719,9 +8719,9 @@ integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== "@types/react-dom@*", "@types/react-dom@^18.0.0": - version "18.2.21" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.21.tgz#b8c81715cebdebb2994378616a8d54ace54f043a" - integrity sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw== + version "18.3.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" + integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== dependencies: "@types/react" "*" @@ -8757,12 +8757,11 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0": - version "18.2.64" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.64.tgz#3700fbb6b2fa60a6868ec1323ae4cbd446a2197d" - integrity sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg== + version "18.3.2" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.2.tgz#462ae4904973bc212fa910424d901e3d137dbfcd" + integrity sha512-Btgg89dAnqD4vV7R3hlwOxgqobUQKgx3MmrQRi0yYbs/P0ym8XozIAlkqVilPqHQwXs4e9Tf63rrCgl58BcO4w== dependencies: "@types/prop-types" "*" - "@types/scheduler" "*" csstype "^3.0.2" "@types/react@^16.13.1 || ^17.0.0": @@ -8801,7 +8800,7 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== -"@types/scheduler@*", "@types/scheduler@^0.16": +"@types/scheduler@^0.16": version "0.16.8" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== @@ -20249,6 +20248,14 @@ react-dom@^18.0.2: loose-envify "^1.1.0" scheduler "^0.23.0" +react-dom@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + react-double-scrollbar@0.0.15: version "0.0.15" resolved "https://registry.yarnpkg.com/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz#e915ab8cb3b959877075f49436debfdb04288fe4" @@ -21181,6 +21188,13 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + schema-utils@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" @@ -21918,16 +21932,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -22001,7 +22006,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22015,13 +22020,6 @@ strip-ansi@5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23830,7 +23828,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23848,15 +23846,6 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From c57d4c956694011109cccd049bd5c0e1e5ac5106 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 May 2024 15:05:22 +0000 Subject: [PATCH 03/57] wip: commit more progress --- .../CoderProvider/CoderAuthProvider.tsx | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index cf9f8077..2a3cccc1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -399,21 +399,28 @@ const mainAppRoot = document.querySelector('#root'); const useFallbackStyles = makeStyles(theme => ({ root: { + position: 'relative', + zIndex: 9999, width: '100%', - padding: theme.spacing(1), - backgroundColor: 'green', + backgroundColor: theme.palette.background.default, + borderTop: `1px solid ${theme.palette.background.default}`, }, modalTrigger: { + cursor: 'pointer', + color: theme.palette.text.primary, backgroundColor: 'inherit', + transition: '10s color ease-in-out', + padding: theme.spacing(1), border: 'none', display: 'block', width: '100%', - maxWidth: '60%', - minWidth: 'fit-content', - color: 'blue', marginLeft: 'auto', marginRight: 'auto', + + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, }, })); @@ -421,25 +428,35 @@ function FallbackAuthUi() { const styles = useFallbackStyles(); /** + * Adjust the heights of the original UI components so that the fallback UI + * can fit directly under them + * * Would've been nice to be able to use ref callbacks here, but we need to * make sure that we have a cleanup step available. Ref callbacks don't get * cleanup support until React 19 - * * @see {@link https://github.com/facebook/react/pull/25686} */ const fallbackRef = useRef(null); useLayoutEffect(() => { const fallback = fallbackRef.current; - if (fallback === null || mainAppRoot === null) { + const drawer = document.querySelector( + "div[class*='BackstageSidebar-drawer']", + ); + + if (fallback === null || mainAppRoot === null || drawer === null) { return undefined; } - const originalRootHeight = mainAppRoot.offsetHeight; - const fallbackHeight = fallback.offsetHeight; - mainAppRoot.style.height = `calc(${originalRootHeight}px - ${fallbackHeight}px)`; + // Need to adjust the drawer separately because it has fixed positioning, so + // changing the root height doesn't affect it + const adjustedHeight = `calc(100vh - ${fallback.offsetHeight}px)`; + mainAppRoot.style.height = adjustedHeight; + drawer.style.height = adjustedHeight; return () => { - mainAppRoot.style.height = `${originalRootHeight}px`; + const resetHeight = '100vh'; + mainAppRoot.style.height = resetHeight; + drawer.style.height = resetHeight; }; }, []); @@ -449,7 +466,7 @@ function FallbackAuthUi() { className={styles.modalTrigger} onClick={() => window.alert("I'm active!")} > - Please add authentication + Please authenticate with Coder ); From e567260952bf9246c4bc69cf6fe6b3f21e57ca5b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 May 2024 15:36:05 +0000 Subject: [PATCH 04/57] wip: more progress --- .../src/components/CoderProvider/CoderAuthProvider.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 2a3cccc1..80dd91a2 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -68,7 +68,7 @@ export type CoderAuth = Readonly< type TrackComponent = (componentInstanceId: string) => () => void; export const AuthStateContext = createContext(null); -const AuthTrackingContext = createContext(null); +export const AuthTrackingContext = createContext(null); function useAuthState(): CoderAuth { // Need to split hairs, because the query object can be disabled. Only want to @@ -202,7 +202,7 @@ function useAuthFallbackState(): AuthFallbackState { // that it already has. Calling this function too often should cause no // problems and should be a no-op 95% of the time const syncTrackerToUi = () => { - setHasTrackers(trackedComponentsRef.current.size !== 0); + setHasTrackers(trackedComponentsRef.current.size > 0); }; trackedComponentsRef.current.add(componentId); @@ -402,6 +402,7 @@ const useFallbackStyles = makeStyles(theme => ({ position: 'relative', zIndex: 9999, width: '100%', + bottom: 0, backgroundColor: theme.palette.background.default, borderTop: `1px solid ${theme.palette.background.default}`, }, @@ -479,7 +480,7 @@ export const CoderAuthProvider = ({ }: Readonly>) => { const authState = useAuthState(); const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); - const needFallbackUi = true; // hasNoAuthInputs && !authState.isAuthenticated; + const needFallbackUi = hasNoAuthInputs && !authState.isAuthenticated; return ( From 61ee528fe15d1c120f719477dc554ac8d49005ea Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 May 2024 15:42:54 +0000 Subject: [PATCH 05/57] refactor: consolidate card logic --- .../src/components/Card/Card.tsx | 27 ---------------- .../src/components/Card/index.ts | 1 - .../components/CoderWorkspacesCard/Root.tsx | 32 +++++++++++++++++-- 3 files changed, 30 insertions(+), 30 deletions(-) delete mode 100644 plugins/backstage-plugin-coder/src/components/Card/Card.tsx delete mode 100644 plugins/backstage-plugin-coder/src/components/Card/index.ts diff --git a/plugins/backstage-plugin-coder/src/components/Card/Card.tsx b/plugins/backstage-plugin-coder/src/components/Card/Card.tsx deleted file mode 100644 index 995b8e5c..00000000 --- a/plugins/backstage-plugin-coder/src/components/Card/Card.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { type HTMLAttributes, forwardRef } from 'react'; -import { makeStyles } from '@material-ui/core'; - -const useStyles = makeStyles(theme => ({ - root: { - color: theme.palette.type, - backgroundColor: theme.palette.background.paper, - padding: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - boxShadow: theme.shadows[1], - }, -})); - -type CardProps = HTMLAttributes; - -export const Card = forwardRef((props, ref) => { - const { className, ...delegatedProps } = props; - const styles = useStyles(); - - return ( -
- ); -}); diff --git a/plugins/backstage-plugin-coder/src/components/Card/index.ts b/plugins/backstage-plugin-coder/src/components/Card/index.ts deleted file mode 100644 index ca0b0604..00000000 --- a/plugins/backstage-plugin-coder/src/components/Card/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Card'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 9a2d118f..c65d145a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -5,6 +5,7 @@ import React, { type HTMLAttributes, createContext, + forwardRef, useContext, useState, } from 'react'; @@ -14,12 +15,39 @@ import { useCoderWorkspacesConfig, type CoderWorkspacesConfig, } from '../../hooks/useCoderWorkspacesConfig'; - import type { Workspace } from '../../typesConstants'; import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; -import { Card } from '../Card'; +import { makeStyles } from '@material-ui/core'; import { CoderAuthWrapper } from '../CoderAuthWrapper'; +const useCardStyles = makeStyles(theme => ({ + root: { + color: theme.palette.type, + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[1], + }, +})); + +// Card should be treated as equivalent to Backstage's official InfoCard +// component; had to make custom version so that it could forward properties for +// accessibility/screen reader support +const Card = forwardRef>( + (props, ref) => { + const { className, ...delegatedProps } = props; + const styles = useCardStyles(); + + return ( +
+ ); + }, +); + export type WorkspacesQuery = UseQueryResult; export type WorkspacesCardContext = Readonly<{ From 9b16cc282c30dcf9b7e87c3b556e7be22aab016c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 May 2024 19:09:00 +0000 Subject: [PATCH 06/57] fix: update component tracking hooks --- .../src/components/CoderProvider/CoderAuthProvider.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 80dd91a2..7f51831a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -236,7 +236,7 @@ function useUnsafeCoderAuth(): CoderAuth { return contextValue; } -export function useCoderAuth(): CoderAuth { +export function useAuthComponentTracking(): void { const trackComponent = useContext(AuthTrackingContext); if (trackComponent === null) { throw new Error('Unable to retrieve state for displaying fallback auth UI'); @@ -250,10 +250,13 @@ export function useCoderAuth(): CoderAuth { const cleanupTracking = trackComponent(instanceId); return cleanupTracking; }, [instanceId, trackComponent]); +} +export function useCoderAuth(): CoderAuth { // Getting the auth value is now safe, since we can guarantee that if another // component calls this hook, the fallback auth UI won't ever need to be // displayed + useAuthComponentTracking(); return useUnsafeCoderAuth(); } From 344593d110c28addd16b8561d3b520a8797ed454 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 May 2024 19:38:42 +0000 Subject: [PATCH 07/57] fix: add a11y landmark to auth fallback --- .../CoderProvider/CoderAuthProvider.tsx | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 7f51831a..001a91f5 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -401,34 +401,38 @@ function generateAuthState({ const mainAppRoot = document.querySelector('#root'); const useFallbackStyles = makeStyles(theme => ({ - root: { - position: 'relative', + landmarkWrapper: { zIndex: 9999, + position: 'absolute', + bottom: theme.spacing(2), width: '100%', - bottom: 0, - backgroundColor: theme.palette.background.default, - borderTop: `1px solid ${theme.palette.background.default}`, + maxWidth: 'fit-content', + left: '50%', + transform: 'translateX(-50%)', }, modalTrigger: { cursor: 'pointer', - color: theme.palette.text.primary, - backgroundColor: 'inherit', - transition: '10s color ease-in-out', - padding: theme.spacing(1), - border: 'none', + color: theme.palette.primary.contrastText, + backgroundColor: theme.palette.primary.main, display: 'block', - width: '100%', - marginLeft: 'auto', - marginRight: 'auto', + width: 'fit-content', + border: 'none', + fontWeight: 600, + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[1], + transition: '10s color ease-in-out', + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, '&:hover': { - backgroundColor: theme.palette.action.hover, + backgroundColor: theme.palette.primary.dark, + boxShadow: theme.shadows[2], }, }, })); function FallbackAuthUi() { + const hookId = useId(); const styles = useFallbackStyles(); /** @@ -464,15 +468,20 @@ function FallbackAuthUi() { }; }, []); + const landmarkId = `${hookId}-landmark`; const fallbackUi = ( -
+
+ + -
+ ); return createPortal(fallbackUi, document.body); From 2041f17298f94668997c2f18254297c95452e24c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 May 2024 20:13:57 +0000 Subject: [PATCH 08/57] wip: commit more style progress --- .../CoderProvider/CoderAuthProvider.tsx | 67 +++++++++++-------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 001a91f5..26d5db18 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -23,6 +23,7 @@ import { coderClientApiRef } from '../../api/CoderClient'; import { useApi } from '@backstage/core-plugin-api'; import { useId } from '../../hooks/hookPolyfills'; import { makeStyles } from '@material-ui/core'; +import { CoderLogo } from '../CoderLogo'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -408,69 +409,80 @@ const useFallbackStyles = makeStyles(theme => ({ width: '100%', maxWidth: 'fit-content', left: '50%', - transform: 'translateX(-50%)', + + // Not using translateX(50%) for optical balance reasons. If the button is + // perfectly centered, the larger Coder logo on the left side makes the + // look left-heavy, and like it's not fully balanced + transform: 'translateX(-42%)', }, modalTrigger: { + display: 'flex', + flexFlow: 'row nowrap', + columnGap: theme.spacing(1), + alignItems: 'center', + cursor: 'pointer', color: theme.palette.primary.contrastText, backgroundColor: theme.palette.primary.main, - display: 'block', width: 'fit-content', border: 'none', fontWeight: 600, borderRadius: theme.shape.borderRadius, boxShadow: theme.shadows[1], transition: '10s color ease-in-out', - padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, '&:hover': { backgroundColor: theme.palette.primary.dark, boxShadow: theme.shadows[2], }, }, + + logo: { + fill: theme.palette.primary.contrastText, + width: theme.spacing(3), + }, })); function FallbackAuthUi() { const hookId = useId(); const styles = useFallbackStyles(); - /** - * Adjust the heights of the original UI components so that the fallback UI - * can fit directly under them - * - * Would've been nice to be able to use ref callbacks here, but we need to - * make sure that we have a cleanup step available. Ref callbacks don't get - * cleanup support until React 19 - * @see {@link https://github.com/facebook/react/pull/25686} - */ - const fallbackRef = useRef(null); + // Have to add additional padding to the bottom of the main app to make sure + // that the user is still able to see all the content as long as they scroll + // down far enough + const fallbackRef = useRef(null); useLayoutEffect(() => { const fallback = fallbackRef.current; - const drawer = document.querySelector( - "div[class*='BackstageSidebar-drawer']", - ); - - if (fallback === null || mainAppRoot === null || drawer === null) { + if (fallback === null || mainAppRoot === null) { return undefined; } - // Need to adjust the drawer separately because it has fixed positioning, so - // changing the root height doesn't affect it - const adjustedHeight = `calc(100vh - ${fallback.offsetHeight}px)`; - mainAppRoot.style.height = adjustedHeight; - drawer.style.height = adjustedHeight; + // Can't access style properties directly from fallback because most of the + // styling goes through MUI, which is CSS class-based + const fallbackStyles = getComputedStyle(fallback); + const paddingToAdd = + fallback.offsetHeight + parseInt(fallbackStyles.bottom || '0', 10); + + const prevPadding = mainAppRoot.style.paddingBottom || '0px'; + mainAppRoot.style.paddingBottom = `calc(${prevPadding} + ${paddingToAdd}px)`; return () => { - const resetHeight = '100vh'; - mainAppRoot.style.height = resetHeight; - drawer.style.height = resetHeight; + mainAppRoot.style.paddingBottom = prevPadding; }; }, []); + // Wrapping fallback button in landmark so that screen reader users can jump + // straight to the button from a screen reader directory rotor, and don't have + // to go through every single other element first const landmarkId = `${hookId}-landmark`; const fallbackUi = ( -
+
@@ -479,6 +491,7 @@ function FallbackAuthUi() { className={styles.modalTrigger} onClick={() => window.alert("I'm active!")} > + Authenticate with Coder
From 43cfd52bc7110119d3a86e9b0a6e3daf96972d3d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 May 2024 20:30:11 +0000 Subject: [PATCH 09/57] wip: commit more progress --- .../CoderProvider/CoderAuthProvider.tsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 26d5db18..edff2e78 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -404,7 +404,7 @@ const mainAppRoot = document.querySelector('#root'); const useFallbackStyles = makeStyles(theme => ({ landmarkWrapper: { zIndex: 9999, - position: 'absolute', + position: 'fixed', bottom: theme.spacing(2), width: '100%', maxWidth: 'fit-content', @@ -459,17 +459,33 @@ function FallbackAuthUi() { return undefined; } + const overrideClassName = 'backstage-root-override'; + // Can't access style properties directly from fallback because most of the // styling goes through MUI, which is CSS class-based + const rootStyles = getComputedStyle(mainAppRoot); const fallbackStyles = getComputedStyle(fallback); + + const prevPadding = rootStyles.paddingBottom || '0px'; const paddingToAdd = fallback.offsetHeight + parseInt(fallbackStyles.bottom || '0', 10); - const prevPadding = mainAppRoot.style.paddingBottom || '0px'; - mainAppRoot.style.paddingBottom = `calc(${prevPadding} + ${paddingToAdd}px)`; + // Adding extra class to the root rather than using styles to help ensure + // that there aren't any weird conflicts with MUI's makeStyles + const overrideStyleNode = document.createElement('style'); + overrideStyleNode.type = 'text/css'; + overrideStyleNode.innerHTML = ` + .${overrideClassName} { + padding-bottom: calc(${prevPadding} + ${paddingToAdd}px) + } + `; + + document.head.append(overrideStyleNode); + mainAppRoot.classList.add(overrideClassName); return () => { - mainAppRoot.style.paddingBottom = prevPadding; + overrideStyleNode.remove(); + mainAppRoot.classList.remove(overrideClassName); }; }, []); From 560d009d9e910a6fbca2390b6c2ce5ca8f0cbca4 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 17 May 2024 13:03:05 +0000 Subject: [PATCH 10/57] wip: more progress --- .../CoderProvider/CoderAuthProvider.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index edff2e78..2d924882 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -189,9 +189,9 @@ function useAuthFallbackState(): AuthFallbackState { // Not the biggest fan of needing to keep the two pieces of state in sync, but // setting the render state to a simple boolean rather than the whole Set - // means that we re-render only when we go from 0 trackers to 1+, or - // vice-versa, versus re-rendering when we get a new tracker at all. If we - // have 1+ trackers, we don't care about the actual number past that + // means that we re-render only when we go from 0 trackers to 1+, or from 1+ + // trackers to 0. We don't care about the exact number of components being + // tracked, just whether we have any at all const [hasTrackers, setHasTrackers] = useState(false); const trackedComponentsRef = useRef>(null!); if (trackedComponentsRef.current === null) { @@ -459,24 +459,25 @@ function FallbackAuthUi() { return undefined; } - const overrideClassName = 'backstage-root-override'; - // Can't access style properties directly from fallback because most of the // styling goes through MUI, which is CSS class-based const rootStyles = getComputedStyle(mainAppRoot); const fallbackStyles = getComputedStyle(fallback); - const prevPadding = rootStyles.paddingBottom || '0px'; - const paddingToAdd = - fallback.offsetHeight + parseInt(fallbackStyles.bottom || '0', 10); + const paddingBeforeOverride = rootStyles.paddingBottom || '0px'; + const parsedBottom = parseInt(fallbackStyles.bottom || '0', 10); + const normalized = Number.isNaN(parsedBottom) ? 0 : parsedBottom; + const paddingToAdd = fallback.offsetHeight + normalized; + + const overrideClassName = 'backstage-root-override'; - // Adding extra class to the root rather than using styles to help ensure - // that there aren't any weird conflicts with MUI's makeStyles + // Adding extra class to the root rather than applying styling via inline + // styles to ensure no weird conflicts with MUI's makeStyles const overrideStyleNode = document.createElement('style'); overrideStyleNode.type = 'text/css'; overrideStyleNode.innerHTML = ` .${overrideClassName} { - padding-bottom: calc(${prevPadding} + ${paddingToAdd}px) + padding-bottom: calc(${paddingBeforeOverride} + ${paddingToAdd}px) } `; @@ -491,7 +492,7 @@ function FallbackAuthUi() { // Wrapping fallback button in landmark so that screen reader users can jump // straight to the button from a screen reader directory rotor, and don't have - // to go through every single other element first + // to navigate through every single other element first const landmarkId = `${hookId}-landmark`; const fallbackUi = (
Date: Fri, 17 May 2024 13:13:47 +0000 Subject: [PATCH 11/57] wip: cleanup current approach --- .../CoderProvider/CoderAuthProvider.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 2d924882..6a888993 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -25,6 +25,7 @@ import { useId } from '../../hooks/hookPolyfills'; import { makeStyles } from '@material-ui/core'; import { CoderLogo } from '../CoderLogo'; +const FALLBACK_UI_OVERRIDE_CLASS_NAME = 'backstage-root-override'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; // Handles auth edge case where a previously-valid token can't be verified. Not @@ -469,24 +470,25 @@ function FallbackAuthUi() { const normalized = Number.isNaN(parsedBottom) ? 0 : parsedBottom; const paddingToAdd = fallback.offsetHeight + normalized; - const overrideClassName = 'backstage-root-override'; - - // Adding extra class to the root rather than applying styling via inline - // styles to ensure no weird conflicts with MUI's makeStyles + // Adding a new style node lets us override the existing styles without + // directly touching them, minimizing the risks of breaking anything. If we + // were to modify the styles and try resetting them with the cleanup + // function, there's a risk the cleanup function would have closure over + // stale values and try "resetting" things to a value that is no longer used const overrideStyleNode = document.createElement('style'); overrideStyleNode.type = 'text/css'; overrideStyleNode.innerHTML = ` - .${overrideClassName} { - padding-bottom: calc(${paddingBeforeOverride} + ${paddingToAdd}px) + .${FALLBACK_UI_OVERRIDE_CLASS_NAME} { + padding-bottom: calc(${paddingBeforeOverride} + ${paddingToAdd}px) !important; } `; document.head.append(overrideStyleNode); - mainAppRoot.classList.add(overrideClassName); + mainAppRoot.classList.add(FALLBACK_UI_OVERRIDE_CLASS_NAME); return () => { overrideStyleNode.remove(); - mainAppRoot.classList.remove(overrideClassName); + mainAppRoot.classList.remove(FALLBACK_UI_OVERRIDE_CLASS_NAME); }; }, []); From a73b1b1ed7e9d839b6d32ba33dbf76491b9adf8f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 17 May 2024 14:09:03 +0000 Subject: [PATCH 12/57] wip: commit progress on observer approach --- .../CoderProvider/CoderAuthProvider.tsx | 73 +++++++++++++------ 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 6a888993..da353340 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -447,12 +447,15 @@ const useFallbackStyles = makeStyles(theme => ({ })); function FallbackAuthUi() { - const hookId = useId(); - const styles = useFallbackStyles(); - - // Have to add additional padding to the bottom of the main app to make sure - // that the user is still able to see all the content as long as they scroll - // down far enough + /** + * Add additional padding to the bottom of the main app to make sure that the + * user is still able to see all the content as long as they scroll down far + * enough. + * + * But because we don't own the full application, we have to jump through a + * bunch of hoops to minimize risks of breaking existing Backstage code or + * other plugins + */ const fallbackRef = useRef(null); useLayoutEffect(() => { const fallback = fallbackRef.current; @@ -460,16 +463,6 @@ function FallbackAuthUi() { return undefined; } - // Can't access style properties directly from fallback because most of the - // styling goes through MUI, which is CSS class-based - const rootStyles = getComputedStyle(mainAppRoot); - const fallbackStyles = getComputedStyle(fallback); - - const paddingBeforeOverride = rootStyles.paddingBottom || '0px'; - const parsedBottom = parseInt(fallbackStyles.bottom || '0', 10); - const normalized = Number.isNaN(parsedBottom) ? 0 : parsedBottom; - const paddingToAdd = fallback.offsetHeight + normalized; - // Adding a new style node lets us override the existing styles without // directly touching them, minimizing the risks of breaking anything. If we // were to modify the styles and try resetting them with the cleanup @@ -477,21 +470,57 @@ function FallbackAuthUi() { // stale values and try "resetting" things to a value that is no longer used const overrideStyleNode = document.createElement('style'); overrideStyleNode.type = 'text/css'; - overrideStyleNode.innerHTML = ` - .${FALLBACK_UI_OVERRIDE_CLASS_NAME} { - padding-bottom: calc(${paddingBeforeOverride} + ${paddingToAdd}px) !important; - } - `; + // Need to make sure that we apply custom mutations before observing document.head.append(overrideStyleNode); mainAppRoot.classList.add(FALLBACK_UI_OVERRIDE_CLASS_NAME); + // Using ComputedStyle objects because they maintain live links to computed + // properties. Plus, since most styling goes through MUI's makeStyles (which + // is based on CSS classes), trying to access properties directly off the + // nodes won't always work + const liveRootStyles = getComputedStyle(mainAppRoot); + const liveFallbackStyles = getComputedStyle(fallback); + + let prevPaddingBottom: string | undefined = undefined; + const onMutation: MutationCallback = () => { + const newPaddingBottom = liveRootStyles.paddingBottom || '0px'; + if (newPaddingBottom === prevPaddingBottom) { + return; + } + + const parsedBottom = parseInt(liveFallbackStyles.bottom || '0', 10); + const normalized = Number.isNaN(parsedBottom) ? 0 : parsedBottom; + const paddingToAdd = fallback.offsetHeight + normalized; + + overrideStyleNode.innerHTML = ` + .${FALLBACK_UI_OVERRIDE_CLASS_NAME} { + padding-bottom: calc(${newPaddingBottom} + ${paddingToAdd}px) !important; + } + `; + + // Only update prev padding after state changes have definitely succeeded + prevPaddingBottom = newPaddingBottom; + }; + + const observer = new MutationObserver(onMutation); + observer.observe(document.head, { subtree: true }); + observer.observe(mainAppRoot, { + subtree: false, + attributes: true, + attributeFilter: ['class', 'style'], + }); + return () => { + observer.disconnect(); overrideStyleNode.remove(); mainAppRoot.classList.remove(FALLBACK_UI_OVERRIDE_CLASS_NAME); }; }, []); + const hookId = useId(); + const styles = useFallbackStyles(); + // Wrapping fallback button in landmark so that screen reader users can jump // straight to the button from a screen reader directory rotor, and don't have // to navigate through every single other element first @@ -503,7 +532,7 @@ function FallbackAuthUi() { aria-labelledby={landmarkId} > From ae85984af497e45a2605f746a9d25a7fae2359a9 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 17 May 2024 16:55:06 +0000 Subject: [PATCH 17/57] fix: tidy up types --- .../CoderProvider/CoderAuthProvider.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 4e46dd30..e8c864ad 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -410,11 +410,7 @@ const useFallbackStyles = makeStyles(theme => ({ width: '100%', maxWidth: 'fit-content', left: '50%', - - // Not using translateX(50%) for optical balance reasons. If the button is - // perfectly centered, the larger Coder logo on the left side makes the - // look left-heavy, and like it's not fully balanced - transform: 'translateX(-40%)', + transform: 'translateX(-50%)', }, modalTrigger: { @@ -430,13 +426,13 @@ const useFallbackStyles = makeStyles(theme => ({ border: 'none', fontWeight: 600, borderRadius: theme.shape.borderRadius, - boxShadow: theme.shadows[1], transition: '10s color ease-in-out', padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, + boxShadow: theme.shadows[10], '&:hover': { backgroundColor: theme.palette.primary.dark, - boxShadow: theme.shadows[2], + boxShadow: theme.shadows[15], }, }, @@ -459,9 +455,10 @@ function FallbackAuthUi() { const fallbackRef = useRef(null); useLayoutEffect(() => { const fallback = fallbackRef.current; - const mainAppContainer = mainAppRoot?.querySelector('main'); + const mainAppContainer = + mainAppRoot?.querySelector('main') ?? null; - if (fallback === null || !mainAppContainer) { + if (fallback === null || mainAppContainer === null) { return undefined; } @@ -509,14 +506,15 @@ function FallbackAuthUi() { const observer = new MutationObserver(updatePaddingForFallbackUi); observer.observe(document.head, { childList: true }); observer.observe(mainAppContainer, { + childList: false, subtree: false, attributes: true, attributeFilter: ['class', 'style'], }); - // Applying mutations here after observing will trigger callback, but as - // long as the callback is set up properly, the user shouldn't notice. Also - // serves a way to ensure the mutation callback runs at least once + // Applying mutations after we've started observing will trigger the + // callback, but as long as it's set up properly, the user shouldn't notice. + // Also serves a way to ensure the mutation callback runs at least once document.head.append(overrideStyleNode); mainAppContainer.classList.add(FALLBACK_UI_OVERRIDE_CLASS_NAME); From 6e1820425d9c27ca50c67c0a7a0189bf9146fd6c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 17 May 2024 17:43:36 +0000 Subject: [PATCH 18/57] wip: create initial version of dialog form --- .../CoderAuthFormDialog.tsx | 74 +++++++++++++++++++ .../CoderProvider/CoderAuthProvider.tsx | 47 ++++++------ 2 files changed, 95 insertions(+), 26 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx new file mode 100644 index 00000000..c1a109ef --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx @@ -0,0 +1,74 @@ +import { Dialog, makeStyles } from '@material-ui/core'; +import React, { type PropsWithChildren, useState } from 'react'; + +const useStyles = makeStyles(theme => ({ + trigger: { + cursor: 'pointer', + color: theme.palette.primary.contrastText, + backgroundColor: theme.palette.primary.main, + width: 'fit-content', + border: 'none', + fontWeight: 600, + borderRadius: theme.shape.borderRadius, + transition: '10s color ease-in-out', + padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, + boxShadow: theme.shadows[10], + + '&:hover': { + backgroundColor: theme.palette.primary.dark, + boxShadow: theme.shadows[15], + }, + }, + + dialog: { + zIndex: 9999, + }, +})); + +type DialogProps = Readonly< + PropsWithChildren<{ + open?: boolean; + onOpen?: () => void; + onClose?: () => void; + triggerClassName?: string; + dialogClassName?: string; + }> +>; + +export function CoderAuthFormDialog({ + children, + onOpen, + onClose, + triggerClassName, + dialogClassName, + open: outerIsOpen, +}: DialogProps) { + const styles = useStyles(); + const [innerIsOpen, setInnerIsOpen] = useState(false); + const isOpen = outerIsOpen ?? innerIsOpen; + + return ( + <> + + + { + setInnerIsOpen(false); + onClose?.(); + }} + > + Blah + + + ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index e8c864ad..2ad3a37a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -22,8 +22,9 @@ import { import { coderClientApiRef } from '../../api/CoderClient'; import { useApi } from '@backstage/core-plugin-api'; import { useId } from '../../hooks/hookPolyfills'; -import { makeStyles } from '@material-ui/core'; +import { Theme, makeStyles } from '@material-ui/core'; import { CoderLogo } from '../CoderLogo'; +import { CoderAuthFormDialog } from '../CoderAuthFormDialog/CoderAuthFormDialog'; const FALLBACK_UI_OVERRIDE_CLASS_NAME = 'backstage-root-override'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -402,38 +403,27 @@ function generateAuthState({ // blow up the entire Backstage application, and wreck all the other plugins const mainAppRoot = document.querySelector('#root'); -const useFallbackStyles = makeStyles(theme => ({ - landmarkWrapper: { - zIndex: 9999, +type StyleKey = 'landmarkWrapper' | 'dialogButton' | 'logo'; +type StyleProps = Readonly<{ + isDialogOpen: boolean; +}>; + +const useFallbackStyles = makeStyles(theme => ({ + landmarkWrapper: ({ isDialogOpen }) => ({ + zIndex: isDialogOpen ? 0 : 9999, position: 'fixed', bottom: theme.spacing(2), width: '100%', maxWidth: 'fit-content', left: '50%', transform: 'translateX(-50%)', - }, + }), - modalTrigger: { + dialogButton: { display: 'flex', flexFlow: 'row nowrap', columnGap: theme.spacing(1), alignItems: 'center', - - cursor: 'pointer', - color: theme.palette.primary.contrastText, - backgroundColor: theme.palette.primary.main, - width: 'fit-content', - border: 'none', - fontWeight: 600, - borderRadius: theme.shape.borderRadius, - transition: '10s color ease-in-out', - padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, - boxShadow: theme.shadows[10], - - '&:hover': { - backgroundColor: theme.palette.primary.dark, - boxShadow: theme.shadows[15], - }, }, logo: { @@ -527,7 +517,8 @@ function FallbackAuthUi() { }, []); const hookId = useId(); - const styles = useFallbackStyles(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const styles = useFallbackStyles({ isDialogOpen }); // Wrapping fallback button in landmark so that screen reader users can jump // straight to the button from a screen reader directory rotor, and don't have @@ -543,11 +534,15 @@ function FallbackAuthUi() { Authenticate with Coder to enable Coder plugin features - {/** @todo Update placeholder button to have full modal functionality */} - +
); From f097f9b1b3f78fe32d6e8e0c2803260d496bb3ba Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 May 2024 17:03:19 +0000 Subject: [PATCH 19/57] wip: commit progress on modal --- .../CoderAuthFormDialog.tsx | 79 ++++++++++++++++--- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx index c1a109ef..7242646d 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx @@ -1,5 +1,11 @@ -import { Dialog, makeStyles } from '@material-ui/core'; -import React, { type PropsWithChildren, useState } from 'react'; +import React, { type HTMLAttributes, useState } from 'react'; +import { useId } from '../../hooks/hookPolyfills'; +import { makeStyles } from '@material-ui/core'; +import { LinkButton } from '@backstage/core-components'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogActions from '@material-ui/core/DialogActions'; const useStyles = makeStyles(theme => ({ trigger: { @@ -20,19 +26,43 @@ const useStyles = makeStyles(theme => ({ }, }, - dialog: { - zIndex: 9999, + dialogContainer: { + width: '100%', + height: '100%', + display: 'flex', + flexFlow: 'column nowrap', + justifyContent: 'center', + alignItems: 'center', + }, + + dialogPaper: { + width: '100%', + }, + + contentContainer: { + padding: `0 ${theme.spacing(3)}px ${theme.spacing(4)}px`, + }, + + actionsRow: { + display: 'flex', + flexFlow: 'row nowrap', + justifyContent: 'center', + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + }, + + closeButton: { + padding: `${theme.spacing(0.5)}px ${theme.spacing(1)}px`, + color: theme.palette.primary.main, }, })); type DialogProps = Readonly< - PropsWithChildren<{ + Omit, 'onClick' | 'className'> & { open?: boolean; onOpen?: () => void; onClose?: () => void; triggerClassName?: string; - dialogClassName?: string; - }> + } >; export function CoderAuthFormDialog({ @@ -40,12 +70,20 @@ export function CoderAuthFormDialog({ onOpen, onClose, triggerClassName, - dialogClassName, open: outerIsOpen, }: DialogProps) { + const hookId = useId(); const styles = useStyles(); const [innerIsOpen, setInnerIsOpen] = useState(false); + + const handleClose = () => { + setInnerIsOpen(false); + onClose?.(); + }; + const isOpen = outerIsOpen ?? innerIsOpen; + const titleId = `${hookId}-dialog-title`; + const descriptionId = `${hookId}-dialog-description`; return ( <> @@ -61,13 +99,28 @@ export function CoderAuthFormDialog({ { - setInnerIsOpen(false); - onClose?.(); + onClose={handleClose} + aria-labelledby={titleId} + aria-describedby={descriptionId} + classes={{ + container: styles.dialogContainer, + paper: styles.dialogPaper, }} > - Blah + Authenticate with Coder + + Here's some other content + + + + + Close + + ); From e1a70dd083940c1b1e597897b428d87f99f21f3f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 May 2024 19:15:06 +0000 Subject: [PATCH 20/57] chore: finish styling for modal wrapper --- .../CoderAuthFormDialog.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx index 7242646d..05075951 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx @@ -39,6 +39,11 @@ const useStyles = makeStyles(theme => ({ width: '100%', }, + dialogTitle: { + borderBottom: `${theme.palette.divider} 1px solid`, + padding: `${theme.spacing(1)}px ${theme.spacing(3)}px`, + }, + contentContainer: { padding: `0 ${theme.spacing(3)}px ${theme.spacing(4)}px`, }, @@ -51,8 +56,13 @@ const useStyles = makeStyles(theme => ({ }, closeButton: { + letterSpacing: '0.05em', padding: `${theme.spacing(0.5)}px ${theme.spacing(1)}px`, color: theme.palette.primary.main, + + '&:hover': { + textDecoration: 'none', + }, }, })); @@ -107,7 +117,10 @@ export function CoderAuthFormDialog({ paper: styles.dialogPaper, }} > - Authenticate with Coder + + Authenticate with Coder + + Here's some other content @@ -117,8 +130,9 @@ export function CoderAuthFormDialog({ to="" onClick={handleClose} className={styles.closeButton} + disableRipple > - Close + Close From c4101f631dd70f1c41b7546b30643d866f165a0a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 May 2024 21:01:59 +0000 Subject: [PATCH 21/57] fix: update padding for FormDialog --- .../src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx index 05075951..e5e5bcde 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx @@ -45,7 +45,7 @@ const useStyles = makeStyles(theme => ({ }, contentContainer: { - padding: `0 ${theme.spacing(3)}px ${theme.spacing(4)}px`, + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px`, }, actionsRow: { From f8bb8525815485738d5012b06cb5add3bfa736d6 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 May 2024 21:30:22 +0000 Subject: [PATCH 22/57] wip: start extracting out auth form --- .../CoderAuthForm/CoderAuthDistrustedForm.tsx | 60 +++++ .../CoderAuthForm/CoderAuthForm.tsx | 64 +++++ .../CoderAuthForm/CoderAuthInputForm.tsx | 247 ++++++++++++++++++ .../CoderAuthForm/CoderAuthLoadingState.tsx | 62 +++++ .../src/components/CoderAuthForm/index.ts | 1 + 5 files changed, 434 insertions(+) create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthLoadingState.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthForm/index.ts diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx new file mode 100644 index 00000000..1a63a24a --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/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/CoderAuthForm/CoderAuthForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx new file mode 100644 index 00000000..f36f0add --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx @@ -0,0 +1,64 @@ +import React, { type PropsWithChildren } from 'react'; +import { useCoderAuth } from '../CoderProvider'; +import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; +import { CoderAuthLoadingState } from './CoderAuthLoadingState'; +import { CoderAuthInputForm } from './CoderAuthInputForm'; + +export const CoderAuthForm = ({ + children, +}: Readonly>) => { + const auth = useCoderAuth(); + if (auth.isAuthenticated) { + return <>{children}; + } + + // Slightly awkward syntax with the IIFE, but need something switch-like + // to make sure that all status cases are handled exhaustively + return ( + <> + {(() => { + 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( + 'Tried to process authenticated user after main content should already be shown', + ); + } + + default: { + return assertExhaustion(auth); + } + } + })()} + + ); +}; + +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/CoderAuthForm/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx new file mode 100644 index 00000000..f7e926b2 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx @@ -0,0 +1,247 @@ +import React, { type FormEvent, useState } from 'react'; +import { useId } from '../../hooks/hookPolyfills'; +import { + type CoderAuthStatus, + useCoderAppConfig, + useCoderAuth, +} from '../CoderProvider'; + +import { CoderLogo } from '../CoderLogo'; +import { Link, LinkButton } from '@backstage/core-components'; +import { VisuallyHidden } from '../VisuallyHidden'; +import { makeStyles } from '@material-ui/core'; +import TextField from '@material-ui/core/TextField'; +import ErrorIcon from '@material-ui/icons/ErrorOutline'; +import SyncIcon from '@material-ui/icons/Sync'; + +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', + }, +})); + +export const CoderAuthInputForm = () => { + const hookId = useId(); + const styles = useStyles(); + const appConfig = useCoderAppConfig(); + const { status, registerNewToken } = useCoderAuth(); + + 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 formHeaderId = `${hookId}-form-header`; + const legendId = `${hookId}-legend`; + const authTokenInputId = `${hookId}-auth-token`; + const warningBannerId = `${hookId}-warning-banner`; + + return ( +
+ + +
+ +

+ Link your Coder account to create remote workspaces. Please enter a + new token from your{' '} + + Coder deployment's token page + (link opens in new tab) + + . +

+
+ +
+ + + + + + Authenticate + +
+ + {(status === 'invalid' || status === 'authenticating') && ( + + )} + + ); +}; + +const useInvalidStatusStyles = makeStyles(theme => ({ + warningBannerSpacer: { + paddingTop: theme.spacing(2), + }, + + warningBanner: { + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + color: theme.palette.text.primary, + backgroundColor: theme.palette.background.default, + borderRadius: theme.shape.borderRadius, + border: `1.5px solid ${theme.palette.background.default}`, + padding: 0, + }, + + errorContent: { + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + columnGap: theme.spacing(1), + marginRight: 'auto', + + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + paddingLeft: theme.spacing(2), + paddingRight: 0, + }, + + icon: { + fontSize: '16px', + }, + + syncIcon: { + color: theme.palette.text.primary, + opacity: 0.6, + }, + + errorIcon: { + color: theme.palette.error.main, + fontSize: '16px', + }, + + dismissButton: { + border: 'none', + alignSelf: 'stretch', + padding: `0 ${theme.spacing(1.5)}px 0 ${theme.spacing(2)}px`, + color: theme.palette.text.primary, + backgroundColor: 'inherit', + lineHeight: 1, + cursor: 'pointer', + + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + }, + + '@keyframes spin': { + '100%': { + transform: 'rotate(360deg)', + }, + }, +})); + +type InvalidStatusProps = Readonly<{ + authStatus: CoderAuthStatus; + bannerId: string; +}>; + +function InvalidStatusNotifier({ authStatus, bannerId }: InvalidStatusProps) { + const [showNotification, setShowNotification] = useState(true); + const styles = useInvalidStatusStyles(); + + if (!showNotification) { + return null; + } + + return ( +
+
+ + {authStatus === 'authenticating' && ( + <> + + Authenticating… + + )} + + {authStatus === 'invalid' && ( + <> + + Invalid token + + )} + + + +
+
+ ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthLoadingState.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthLoadingState.tsx new file mode 100644 index 00000000..1ed9749a --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/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/CoderAuthForm/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/index.ts new file mode 100644 index 00000000..752873c4 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/index.ts @@ -0,0 +1 @@ +export * from './CoderAuthForm'; From 98c96afe1e566a7fb9375fd715ff493cf118c13e Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 May 2024 21:32:20 +0000 Subject: [PATCH 23/57] fix: add missing barrel export file --- .../src/components/CoderAuthFormDialog/index.ts | 1 + .../src/components/CoderProvider/CoderAuthProvider.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts new file mode 100644 index 00000000..3b1069e3 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts @@ -0,0 +1 @@ +export * from './CoderAuthFormDialog'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 2ad3a37a..0fd0a3e5 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -24,7 +24,7 @@ import { useApi } from '@backstage/core-plugin-api'; import { useId } from '../../hooks/hookPolyfills'; import { Theme, makeStyles } from '@material-ui/core'; import { CoderLogo } from '../CoderLogo'; -import { CoderAuthFormDialog } from '../CoderAuthFormDialog/CoderAuthFormDialog'; +import { CoderAuthFormDialog } from '../CoderAuthFormDialog'; const FALLBACK_UI_OVERRIDE_CLASS_NAME = 'backstage-root-override'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; From a404ddb9a1e298a0516e337008107adf52d6273e Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 May 2024 22:41:02 +0000 Subject: [PATCH 24/57] fix: make sure that auth form isn't dismissed early --- .../CoderAuthForm/CoderAuthForm.tsx | 18 +++-- .../CoderAuthFormDialog.tsx | 3 +- .../CoderProvider/CoderAuthProvider.tsx | 67 ++++++++++++------- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx index f36f0add..369e26ad 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx @@ -4,18 +4,26 @@ import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; import { CoderAuthLoadingState } from './CoderAuthLoadingState'; import { CoderAuthInputForm } from './CoderAuthInputForm'; -export const CoderAuthForm = ({ - children, -}: Readonly>) => { +type Props = Readonly< + PropsWithChildren<{ + descriptionId?: string; + }> +>; + +export const CoderAuthForm = ({ descriptionId, children }: Props) => { const auth = useCoderAuth(); if (auth.isAuthenticated) { return <>{children}; } - // Slightly awkward syntax with the IIFE, but need something switch-like - // to make sure that all status cases are handled exhaustively return ( <> + + + {/* 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': { diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx index e5e5bcde..17919b35 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx @@ -6,6 +6,7 @@ import Dialog from '@material-ui/core/Dialog'; import DialogContent from '@material-ui/core/DialogContent'; import DialogTitle from '@material-ui/core/DialogTitle'; import DialogActions from '@material-ui/core/DialogActions'; +import { CoderAuthForm } from '../CoderAuthForm/CoderAuthForm'; const useStyles = makeStyles(theme => ({ trigger: { @@ -122,7 +123,7 @@ export function CoderAuthFormDialog({ - Here's some other content + diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 0fd0a3e5..c48031f5 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -69,9 +69,8 @@ export type CoderAuth = Readonly< >; type TrackComponent = (componentInstanceId: string) => () => void; - -export const AuthStateContext = createContext(null); export const AuthTrackingContext = createContext(null); +export const AuthStateContext = createContext(null); function useAuthState(): CoderAuth { // Need to split hairs, because the query object can be disabled. Only want to @@ -223,23 +222,12 @@ function useAuthFallbackState(): AuthFallbackState { }; } -/** - * This is a lightweight hook for grabbing the Coder auth and doing nothing - * else. - * - * This is deemed "unsafe" for most of the UI, because getting the auth value - * this way does not interact with the component tracking logic at all. - */ -function useUnsafeCoderAuth(): CoderAuth { - const contextValue = useContext(AuthStateContext); - if (contextValue === null) { +export function useCoderAuth(): CoderAuth { + const authContextValue = useContext(AuthStateContext); + if (authContextValue === null) { throw new Error('Cannot retrieve auth information from CoderProvider'); } - return contextValue; -} - -export function useAuthComponentTracking(): void { const trackComponent = useContext(AuthTrackingContext); if (trackComponent === null) { throw new Error('Unable to retrieve state for displaying fallback auth UI'); @@ -253,14 +241,8 @@ export function useAuthComponentTracking(): void { const cleanupTracking = trackComponent(instanceId); return cleanupTracking; }, [instanceId, trackComponent]); -} -export function useCoderAuth(): CoderAuth { - // Getting the auth value is now safe, since we can guarantee that if another - // component calls this hook, the fallback auth UI won't ever need to be - // displayed - useAuthComponentTracking(); - return useUnsafeCoderAuth(); + return authContextValue; } type GenerateAuthStateInputs = Readonly<{ @@ -549,12 +531,43 @@ function FallbackAuthUi() { return createPortal(fallbackUi, document.body); } +/** + * This is very wacky, and I'm sorry for how cursed it is. We're definitely + * abusing React Context here, but this setup should simplify the code literally + * everywhere else in the app. + * + * The setup is that we have two versions of the tracking context: one that has + * the live trackComponent function, and one that has the dummy. The main parts + * of the UI get the live version, and the parts of the UI that deal with the + * fallback auth UI get the dummy version. + * + * By having two contexts, we can dynamically expose or hide the tracking + * state for different parts of the app without any other components needing to + * be rewritten at all. + * + * Any other component that uses useCoderAuth will reach up the component tree + * until it can grab *some* kind of tracking function. The hook only cares about + * whether it got a function at all; it doesn't care about what it does. It'll + * call the function the same way, but only the components in the "live" region + * will actually influence whether the fallback UI should be displayed. + * + * Function also defined outside the component to prevent risk of needless + * re-renders through Context. + */ +const dummyTrackComponent: TrackComponent = () => { + // Deliberately perform a no-op on initial call + + return () => { + // And deliberately perform a no-op on cleanup + }; +}; + export const CoderAuthProvider = ({ children, }: Readonly>) => { const authState = useAuthState(); const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); - const needFallbackUi = hasNoAuthInputs && !authState.isAuthenticated; + const needFallbackUi = !authState.isAuthenticated && hasNoAuthInputs; return ( @@ -562,7 +575,11 @@ export const CoderAuthProvider = ({ {children} - {needFallbackUi && } + {needFallbackUi && ( + + + + )} ); }; From af2f383c2b4c659d3123266ab0efc88b54754852 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 May 2024 22:42:38 +0000 Subject: [PATCH 25/57] fix: update auth imports --- .../src/components/CoderProvider/CoderAuthProvider.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index c48031f5..6d89a395 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -4,9 +4,9 @@ import React, { useCallback, useContext, useEffect, - useState, - useRef, useLayoutEffect, + useRef, + useState, } from 'react'; import { createPortal } from 'react-dom'; import { @@ -14,15 +14,15 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; +import { useApi } from '@backstage/core-plugin-api'; +import { type Theme, makeStyles } from '@material-ui/core'; +import { useId } from '../../hooks/hookPolyfills'; import { BackstageHttpError } from '../../api/errors'; import { CODER_QUERY_KEY_PREFIX, sharedAuthQueryKey, } from '../../api/queryOptions'; import { coderClientApiRef } from '../../api/CoderClient'; -import { useApi } from '@backstage/core-plugin-api'; -import { useId } from '../../hooks/hookPolyfills'; -import { Theme, makeStyles } from '@material-ui/core'; import { CoderLogo } from '../CoderLogo'; import { CoderAuthFormDialog } from '../CoderAuthFormDialog'; From 5b046282f5a2dcba2c8f973af56f4e750f8d6cf0 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 13:09:26 +0000 Subject: [PATCH 26/57] fix: update spacing for auth modal --- .../CoderAuthFormDialog/CoderAuthFormDialog.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx index 17919b35..8dec0930 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx @@ -46,14 +46,18 @@ const useStyles = makeStyles(theme => ({ }, contentContainer: { - padding: `${theme.spacing(2)}px ${theme.spacing(3)}px`, + padding: `${theme.spacing(6)}px ${theme.spacing(3)}px ${theme.spacing( + 3, + )}px`, }, actionsRow: { display: 'flex', flexFlow: 'row nowrap', justifyContent: 'center', - padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px ${theme.spacing( + 2, + )}px`, }, closeButton: { From 8ae3d14c3002edd92720abf49eb28e27e23d378d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 13:51:52 +0000 Subject: [PATCH 27/57] refactor: clean up auth provider for clarity --- .../CoderProvider/CoderAuthProvider.tsx | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 6d89a395..d8c0cb9b 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -26,6 +26,7 @@ import { coderClientApiRef } from '../../api/CoderClient'; import { CoderLogo } from '../CoderLogo'; import { CoderAuthFormDialog } from '../CoderAuthFormDialog'; +const BACKSTAGE_APP_ROOT_ID = '#root'; const FALLBACK_UI_OVERRIDE_CLASS_NAME = 'backstage-root-override'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; @@ -72,14 +73,19 @@ type TrackComponent = (componentInstanceId: string) => () => void; export const AuthTrackingContext = createContext(null); export const AuthStateContext = createContext(null); +const validAuthStatuses: readonly CoderAuthStatus[] = [ + 'authenticated', + 'distrustedWithGracePeriod', +]; + function useAuthState(): CoderAuth { - // 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( () => window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? '', ); + // Need to differentiate the current token from the token loaded on mount + // 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 [readonlyInitialAuthToken] = useState(authToken); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); @@ -91,6 +97,8 @@ function useAuthState(): CoderAuth { queryFn: () => coderClient.syncToken(authToken), enabled: queryIsEnabled, keepPreviousData: queryIsEnabled, + + // Can't use !query.state.data because we want to refetch on undefined cases refetchOnWindowFocus: query => query.state.data !== false, }); @@ -102,8 +110,8 @@ function useAuthState(): CoderAuth { }); // 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 - // someone's arbitrarily-large Backstage deployment + // introduce. We don't know how costly re-renders could be in someone's + // arbitrarily-large Backstage deployment, so erring on the side of caution if (!isInsideGracePeriod && authState.status === 'authenticated') { setIsInsideGracePeriod(true); } @@ -153,11 +161,6 @@ function useAuthState(): CoderAuth { return unsubscribe; }, [queryClient]); - const validAuthStatuses: readonly CoderAuthStatus[] = [ - 'authenticated', - 'distrustedWithGracePeriod', - ]; - return { ...authState, isAuthenticated: validAuthStatuses.includes(authState.status), @@ -167,9 +170,9 @@ function useAuthState(): CoderAuth { } }, ejectToken: () => { + setAuthToken(''); window.localStorage.removeItem(TOKEN_STORAGE_KEY); queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); - setAuthToken(''); }, }; } @@ -182,7 +185,7 @@ type AuthFallbackState = Readonly<{ function useAuthFallbackState(): AuthFallbackState { // Can't do state syncs or anything else that would normally minimize // re-renders here because we have to wait for the entire application to - // complete its initial render before we can decide if we need a fallback + // complete its initial render before we can decide if we need a fallback UI const [isMounted, setIsMounted] = useState(false); useEffect(() => { setIsMounted(true); @@ -192,7 +195,7 @@ function useAuthFallbackState(): AuthFallbackState { // setting the render state to a simple boolean rather than the whole Set // means that we re-render only when we go from 0 trackers to 1+, or from 1+ // trackers to 0. We don't care about the exact number of components being - // tracked, just whether we have any at all + // tracked - just whether we have any at all const [hasTrackers, setHasTrackers] = useState(false); const trackedComponentsRef = useRef>(null!); if (trackedComponentsRef.current === null) { @@ -201,8 +204,9 @@ function useAuthFallbackState(): AuthFallbackState { const trackComponent = useCallback((componentId: string) => { // React will bail out of re-renders if you dispatch the same state value - // that it already has. Calling this function too often should cause no - // problems and should be a no-op 95% of the time + // that it already has, and that's easier to guarantee since the UI state + // only has a primitive. Calling this function too often should cause no + // problems, and most calls should be a no-op const syncTrackerToUi = () => { setHasTrackers(trackedComponentsRef.current.size > 0); }; @@ -233,9 +237,9 @@ export function useCoderAuth(): CoderAuth { throw new Error('Unable to retrieve state for displaying fallback auth UI'); } - // Assuming subscribe is set up properly, the values of instanceId and - // subscribe should both be stable until whatever component is using this hook - // unmounts. Values only added to dependency array to satisfy ESLint + // Assuming trackComponent is set up properly, the values of it and instanceId + // should both be stable until whatever component is using this hook unmounts. + // Values only added to dependency array to satisfy ESLint const instanceId = useId(); useEffect(() => { const cleanupTracking = trackComponent(instanceId); @@ -383,12 +387,10 @@ function generateAuthState({ // we display the fallback UI. Sadly, we can't assert that the root is always // defined from outside a UI component, because throwing any errors here would // blow up the entire Backstage application, and wreck all the other plugins -const mainAppRoot = document.querySelector('#root'); +const mainAppRoot = document.querySelector(BACKSTAGE_APP_ROOT_ID); type StyleKey = 'landmarkWrapper' | 'dialogButton' | 'logo'; -type StyleProps = Readonly<{ - isDialogOpen: boolean; -}>; +type StyleProps = Readonly<{ isDialogOpen: boolean }>; const useFallbackStyles = makeStyles(theme => ({ landmarkWrapper: ({ isDialogOpen }) => ({ @@ -434,11 +436,12 @@ function FallbackAuthUi() { return undefined; } - // Adding a new style node lets us override the existing styles without - // directly touching them, minimizing the risks of breaking anything. If we - // were to modify the styles and try resetting them with the cleanup - // function, there's a risk the cleanup function would have closure over - // stale values and try "resetting" things to a value that is no longer used + // Adding a new style node lets us override the existing styles via the CSS + // cascade rather than directly modifying them, which minimizes the risks of + // breaking anything. If we were to modify the styles and try resetting them + // with the cleanup function, there's a risk the cleanup function would have + // closure over stale values and try "resetting" things to a value that is + // no longer used const overrideStyleNode = document.createElement('style'); overrideStyleNode.type = 'text/css'; @@ -532,9 +535,8 @@ function FallbackAuthUi() { } /** - * This is very wacky, and I'm sorry for how cursed it is. We're definitely - * abusing React Context here, but this setup should simplify the code literally - * everywhere else in the app. + * Sorry about how wacky this approach is, but this setup should simplify the + * code literally everywhere else in the plugin. * * The setup is that we have two versions of the tracking context: one that has * the live trackComponent function, and one that has the dummy. The main parts @@ -542,21 +544,20 @@ function FallbackAuthUi() { * fallback auth UI get the dummy version. * * By having two contexts, we can dynamically expose or hide the tracking - * state for different parts of the app without any other components needing to - * be rewritten at all. + * state for different parts of the plugin without any other components needing + * to be rewritten at all. * * Any other component that uses useCoderAuth will reach up the component tree * until it can grab *some* kind of tracking function. The hook only cares about - * whether it got a function at all; it doesn't care about what it does. It'll - * call the function the same way, but only the components in the "live" region - * will actually influence whether the fallback UI should be displayed. + * whether it got a function at all; it doesn't care about what it does. The + * hook will call the function either way, but only the components in the "live" + * region will actually influence whether the fallback UI should be displayed. * - * Function also defined outside the component to prevent risk of needless + * Dummy function defined outside the component to prevent risk of needless * re-renders through Context. */ const dummyTrackComponent: TrackComponent = () => { // Deliberately perform a no-op on initial call - return () => { // And deliberately perform a no-op on cleanup }; From 1b1f8c4e11e8f2aee40a23d6cab62f706363a22f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 13:56:11 +0000 Subject: [PATCH 28/57] docs: rewrite comment for clarity --- .../src/components/CoderProvider/CoderAuthProvider.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index d8c0cb9b..cd2492a4 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -544,14 +544,16 @@ function FallbackAuthUi() { * fallback auth UI get the dummy version. * * By having two contexts, we can dynamically expose or hide the tracking - * state for different parts of the plugin without any other components needing - * to be rewritten at all. + * state for any components that use useCoderAuth. All other components can + * use the same hook without being aware of where they're being mounted. That + * means you can use the exact same components in either region without needing + * to rewrite anything outside this file. * * Any other component that uses useCoderAuth will reach up the component tree * until it can grab *some* kind of tracking function. The hook only cares about * whether it got a function at all; it doesn't care about what it does. The * hook will call the function either way, but only the components in the "live" - * region will actually influence whether the fallback UI should be displayed. + * region will influence whether the fallback UI should be displayed. * * Dummy function defined outside the component to prevent risk of needless * re-renders through Context. From 1ea39e0ea8e798b7ad109414d7bc2e2b18cd8ad1 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 14:27:47 +0000 Subject: [PATCH 29/57] fix: improve granularity between official Coder components and user components --- .../CoderProvider/CoderAuthProvider.tsx | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index cd2492a4..9b36044a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -226,12 +226,20 @@ function useAuthFallbackState(): AuthFallbackState { }; } -export function useCoderAuth(): CoderAuth { - const authContextValue = useContext(AuthStateContext); - if (authContextValue === null) { - throw new Error('Cannot retrieve auth information from CoderProvider'); - } - +/** + * Behaves almost exactly like useCoderAuth, but has additional logic for + * spying on consumers of this hook. + * + * A fallback UI for letting the user input auth information will appear if + * there are no official Coder components that are able to give the user a way + * to do that through normal user flows. + * + * Caveats: + * 1. This hook should *NEVER* be exposed to the end user + * 2. All official Coder plugin components should favor this hook over + * useCoderAuth when possible + */ +export function useCoderAuthWithTracking(): CoderAuth { const trackComponent = useContext(AuthTrackingContext); if (trackComponent === null) { throw new Error('Unable to retrieve state for displaying fallback auth UI'); @@ -246,6 +254,18 @@ export function useCoderAuth(): CoderAuth { return cleanupTracking; }, [instanceId, trackComponent]); + return useCoderAuth(); +} + +/** + * Exposes Coder auth state to the rest of the UI. + */ +export function useCoderAuth(): CoderAuth { + const authContextValue = useContext(AuthStateContext); + if (authContextValue === null) { + throw new Error('Cannot retrieve auth information from CoderProvider'); + } + return authContextValue; } From 9236ca4ae1796fb30561fa99d0541500ad4a40c4 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 14:41:27 +0000 Subject: [PATCH 30/57] fix: update all internal consumers of useCoderAuth --- .../src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx | 4 ++-- .../src/components/CoderAuthForm/CoderAuthForm.tsx | 4 ++-- .../components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx | 4 ++-- .../src/components/CoderAuthWrapper/CoderAuthWrapper.tsx | 4 ++-- .../src/components/CoderWorkspacesCard/ExtraActionsButton.tsx | 4 ++-- .../src/hooks/useCoderWorkspacesQuery.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx index 1a63a24a..cf3dacc2 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CoderLogo } from '../CoderLogo'; import { LinkButton } from '@backstage/core-components'; import { makeStyles } from '@material-ui/core'; -import { useCoderAuth } from '../CoderProvider'; +import { useCoderAuthWithTracking } from '../CoderProvider'; const useStyles = makeStyles(theme => ({ root: { @@ -31,7 +31,7 @@ const useStyles = makeStyles(theme => ({ export const CoderAuthDistrustedForm = () => { const styles = useStyles(); - const { ejectToken } = useCoderAuth(); + const { ejectToken } = useCoderAuthWithTracking(); return (
diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx index 369e26ad..af7f7ec2 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx @@ -1,5 +1,5 @@ import React, { type PropsWithChildren } from 'react'; -import { useCoderAuth } from '../CoderProvider'; +import { useCoderAuthWithTracking } from '../CoderProvider'; import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; import { CoderAuthLoadingState } from './CoderAuthLoadingState'; import { CoderAuthInputForm } from './CoderAuthInputForm'; @@ -11,7 +11,7 @@ type Props = Readonly< >; export const CoderAuthForm = ({ descriptionId, children }: Props) => { - const auth = useCoderAuth(); + const auth = useCoderAuthWithTracking(); if (auth.isAuthenticated) { return <>{children}; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx index 1a63a24a..cf3dacc2 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CoderLogo } from '../CoderLogo'; import { LinkButton } from '@backstage/core-components'; import { makeStyles } from '@material-ui/core'; -import { useCoderAuth } from '../CoderProvider'; +import { useCoderAuthWithTracking } from '../CoderProvider'; const useStyles = makeStyles(theme => ({ root: { @@ -31,7 +31,7 @@ const useStyles = makeStyles(theme => ({ export const CoderAuthDistrustedForm = () => { const styles = useStyles(); - const { ejectToken } = useCoderAuth(); + const { ejectToken } = useCoderAuthWithTracking(); return (
diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx index b0e6ee22..98f5c9d5 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx @@ -1,5 +1,5 @@ import React, { type FC, type PropsWithChildren } from 'react'; -import { useCoderAuth } from '../CoderProvider'; +import { useCoderAuthWithTracking } from '../CoderProvider'; import { InfoCard } from '@backstage/core-components'; import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; import { makeStyles } from '@material-ui/core'; @@ -29,7 +29,7 @@ type WrapperProps = Readonly< >; export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { - const auth = useCoderAuth(); + const auth = useCoderAuthWithTracking(); if (auth.isAuthenticated) { return <>{children}; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx index 57a41922..7c375e9a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx @@ -7,7 +7,7 @@ import React, { } from 'react'; import { useId } from '../../hooks/hookPolyfills'; -import { useCoderAuth } from '../CoderProvider'; +import { useCoderAuthWithTracking } from '../CoderProvider'; import { useWorkspacesCardContext } from './Root'; import { VisuallyHidden } from '../VisuallyHidden'; @@ -102,7 +102,7 @@ export const ExtraActionsButton = ({ const hookId = useId(); const [loadedAnchor, setLoadedAnchor] = useState(); const refreshWorkspaces = useRefreshWorkspaces(); - const { ejectToken } = useCoderAuth(); + const { ejectToken } = useCoderAuthWithTracking(); const styles = useStyles(); const closeMenu = () => setLoadedAnchor(undefined); diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index a3b22d3d..66bf966b 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { workspaces, workspacesByRepo } from '../api/queryOptions'; import type { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; import { useCoderSdk } from './useCoderSdk'; -import { useCoderAuth } from '../components/CoderProvider'; +import { useCoderAuthWithTracking } from '../components/CoderProvider'; type QueryInput = Readonly<{ coderQuery: string; @@ -13,7 +13,7 @@ export function useCoderWorkspacesQuery({ coderQuery, workspacesConfig, }: QueryInput) { - const auth = useCoderAuth(); + const auth = useCoderAuthWithTracking(); const coderSdk = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; From b90ebd0785d2d86b4ca86817149c65677d09928f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 16:11:50 +0000 Subject: [PATCH 31/57] wip: commit initial version of useCoderQuery helper hook --- .../src/hooks/useCoderQuery.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts new file mode 100644 index 00000000..028c9310 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts @@ -0,0 +1,84 @@ +/** + * @file Provides a convenience wrapper for end users trying to cache data from + * the Coder SDK. Removes the need to manually bring in useCoderAuth + */ +import { + type QueryKey, + type UseQueryOptions, + type UseQueryResult, + useQuery, +} from '@tanstack/react-query'; +import { useCoderAuth } from '../components/CoderProvider'; + +/** + * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to + * retrying a failed API request 3 times before exposing an error to the UI + */ +const DEFAULT_RETRY_COUNT = 3; + +export function useCoderQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + queryOptions: UseQueryOptions, +): UseQueryResult { + // This hook is intended for the end user only; don't need internal version of + // auth hook + const { isAuthenticated } = useCoderAuth(); + + const patchedOptions: typeof queryOptions = { + ...queryOptions, + enabled: isAuthenticated && (queryOptions.enabled ?? true), + keepPreviousData: + isAuthenticated && (queryOptions.keepPreviousData ?? false), + refetchIntervalInBackground: + isAuthenticated && (queryOptions.refetchIntervalInBackground ?? false), + + refetchInterval: (data, query) => { + if (!isAuthenticated) { + return false; + } + + const externalRefetchInterval = queryOptions.refetchInterval; + if (typeof externalRefetchInterval !== 'function') { + return externalRefetchInterval ?? false; + } + + return externalRefetchInterval(data, query); + }, + + refetchOnMount: query => { + if (!isAuthenticated) { + return false; + } + + const externalRefetchOnMount = queryOptions.refetchOnMount; + if (typeof externalRefetchOnMount !== 'function') { + return externalRefetchOnMount ?? true; + } + + return externalRefetchOnMount(query); + }, + + retry: (failureCount, error) => { + if (!isAuthenticated) { + return false; + } + + const externalRetry = queryOptions.retry; + if (typeof externalRetry === 'number') { + return failureCount < (externalRetry ?? DEFAULT_RETRY_COUNT); + } + + if (typeof externalRetry !== 'function') { + return externalRetry ?? true; + } + + return externalRetry(failureCount, error); + }, + }; + + return useQuery(patchedOptions); +} From 12a7b3ed106c33851e08a4f57ed17dad88908945 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 16:23:04 +0000 Subject: [PATCH 32/57] refactor: rename hooks to avoid confusion --- .../CoderAuthForm/CoderAuthDistrustedForm.tsx | 4 ++-- .../src/components/CoderAuthForm/CoderAuthForm.tsx | 4 ++-- .../components/CoderAuthForm/CoderAuthInputForm.tsx | 4 ++-- .../CoderAuthWrapper/CoderAuthDistrustedForm.tsx | 4 ++-- .../components/CoderAuthWrapper/CoderAuthInputForm.tsx | 4 ++-- .../components/CoderAuthWrapper/CoderAuthWrapper.tsx | 4 ++-- .../src/components/CoderProvider/CoderAuthProvider.tsx | 10 ++++++---- .../components/CoderProvider/CoderProvider.test.tsx | 4 ++-- .../CoderWorkspacesCard/ExtraActionsButton.tsx | 4 ++-- .../backstage-plugin-coder/src/hooks/useCoderQuery.ts | 9 ++++++--- .../src/hooks/useCoderWorkspacesQuery.ts | 4 ++-- 11 files changed, 30 insertions(+), 25 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx index cf3dacc2..36d72d49 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CoderLogo } from '../CoderLogo'; import { LinkButton } from '@backstage/core-components'; import { makeStyles } from '@material-ui/core'; -import { useCoderAuthWithTracking } from '../CoderProvider'; +import { useInternalCoderAuth } from '../CoderProvider'; const useStyles = makeStyles(theme => ({ root: { @@ -31,7 +31,7 @@ const useStyles = makeStyles(theme => ({ export const CoderAuthDistrustedForm = () => { const styles = useStyles(); - const { ejectToken } = useCoderAuthWithTracking(); + const { ejectToken } = useInternalCoderAuth(); return (
diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx index af7f7ec2..48490895 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx @@ -1,5 +1,5 @@ import React, { type PropsWithChildren } from 'react'; -import { useCoderAuthWithTracking } from '../CoderProvider'; +import { useInternalCoderAuth } from '../CoderProvider'; import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; import { CoderAuthLoadingState } from './CoderAuthLoadingState'; import { CoderAuthInputForm } from './CoderAuthInputForm'; @@ -11,7 +11,7 @@ type Props = Readonly< >; export const CoderAuthForm = ({ descriptionId, children }: Props) => { - const auth = useCoderAuthWithTracking(); + const auth = useInternalCoderAuth(); if (auth.isAuthenticated) { return <>{children}; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx index f7e926b2..ae527e28 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx @@ -3,7 +3,7 @@ import { useId } from '../../hooks/hookPolyfills'; import { type CoderAuthStatus, useCoderAppConfig, - useCoderAuth, + useInternalCoderAuth, } from '../CoderProvider'; import { CoderLogo } from '../CoderLogo'; @@ -49,7 +49,7 @@ export const CoderAuthInputForm = () => { const hookId = useId(); const styles = useStyles(); const appConfig = useCoderAppConfig(); - const { status, registerNewToken } = useCoderAuth(); + const { status, registerNewToken } = useInternalCoderAuth(); const onSubmit = (event: FormEvent) => { event.preventDefault(); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx index cf3dacc2..36d72d49 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CoderLogo } from '../CoderLogo'; import { LinkButton } from '@backstage/core-components'; import { makeStyles } from '@material-ui/core'; -import { useCoderAuthWithTracking } from '../CoderProvider'; +import { useInternalCoderAuth } from '../CoderProvider'; const useStyles = makeStyles(theme => ({ root: { @@ -31,7 +31,7 @@ const useStyles = makeStyles(theme => ({ export const CoderAuthDistrustedForm = () => { const styles = useStyles(); - const { ejectToken } = useCoderAuthWithTracking(); + const { ejectToken } = useInternalCoderAuth(); return (
diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx index f7e926b2..ae527e28 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx @@ -3,7 +3,7 @@ import { useId } from '../../hooks/hookPolyfills'; import { type CoderAuthStatus, useCoderAppConfig, - useCoderAuth, + useInternalCoderAuth, } from '../CoderProvider'; import { CoderLogo } from '../CoderLogo'; @@ -49,7 +49,7 @@ export const CoderAuthInputForm = () => { const hookId = useId(); const styles = useStyles(); const appConfig = useCoderAppConfig(); - const { status, registerNewToken } = useCoderAuth(); + const { status, registerNewToken } = useInternalCoderAuth(); const onSubmit = (event: FormEvent) => { event.preventDefault(); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx index 98f5c9d5..a1680070 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx @@ -1,5 +1,5 @@ import React, { type FC, type PropsWithChildren } from 'react'; -import { useCoderAuthWithTracking } from '../CoderProvider'; +import { useInternalCoderAuth } from '../CoderProvider'; import { InfoCard } from '@backstage/core-components'; import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; import { makeStyles } from '@material-ui/core'; @@ -29,7 +29,7 @@ type WrapperProps = Readonly< >; export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { - const auth = useCoderAuthWithTracking(); + const auth = useInternalCoderAuth(); if (auth.isAuthenticated) { return <>{children}; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 9b36044a..5d288200 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -237,9 +237,9 @@ function useAuthFallbackState(): AuthFallbackState { * Caveats: * 1. This hook should *NEVER* be exposed to the end user * 2. All official Coder plugin components should favor this hook over - * useCoderAuth when possible + * useEndUserCoderAuth when possible */ -export function useCoderAuthWithTracking(): CoderAuth { +export function useInternalCoderAuth(): CoderAuth { const trackComponent = useContext(AuthTrackingContext); if (trackComponent === null) { throw new Error('Unable to retrieve state for displaying fallback auth UI'); @@ -254,13 +254,15 @@ export function useCoderAuthWithTracking(): CoderAuth { return cleanupTracking; }, [instanceId, trackComponent]); - return useCoderAuth(); + return useEndUserCoderAuth(); } /** * Exposes Coder auth state to the rest of the UI. */ -export function useCoderAuth(): CoderAuth { +// This hook should only be used by end users trying to use the Coder SDK inside +// Backstage. The hook is renamed on final export to avoid confusion +export function useEndUserCoderAuth(): CoderAuth { const authContextValue = useContext(AuthStateContext); if (authContextValue === null) { throw new Error('Cannot retrieve auth information from CoderProvider'); 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 955aae28..223ea727 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -12,7 +12,7 @@ import { import { CoderProvider } from './CoderProvider'; import { useCoderAppConfig } from './CoderAppConfigProvider'; -import { type CoderAuth, useCoderAuth } from './CoderAuthProvider'; +import { type CoderAuth, useEndUserCoderAuth } from './CoderAuthProvider'; import { getMockConfigApi, @@ -70,7 +70,7 @@ describe(`${CoderProvider.name}`, () => { apis: { urlSync, identityApi }, }); - return renderHook(useCoderAuth, { + return renderHook(useEndUserCoderAuth, { wrapper: ({ children }) => ( (); const refreshWorkspaces = useRefreshWorkspaces(); - const { ejectToken } = useCoderAuthWithTracking(); + const { ejectToken } = useInternalCoderAuth(); const styles = useStyles(); const closeMenu = () => setLoadedAnchor(undefined); diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts index 028c9310..0081e57f 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts @@ -1,6 +1,9 @@ /** * @file Provides a convenience wrapper for end users trying to cache data from - * the Coder SDK. Removes the need to manually bring in useCoderAuth + * the Coder SDK. Removes the need to manually bring in useCoderAuth and wire + * it up to all of useQuery's config properties + * + * This has a little more overhead, but the hook is a lot more fire-and-forget */ import { type QueryKey, @@ -8,7 +11,7 @@ import { type UseQueryResult, useQuery, } from '@tanstack/react-query'; -import { useCoderAuth } from '../components/CoderProvider'; +import { useEndUserCoderAuth } from '../components/CoderProvider'; /** * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to @@ -26,7 +29,7 @@ export function useCoderQuery< ): UseQueryResult { // This hook is intended for the end user only; don't need internal version of // auth hook - const { isAuthenticated } = useCoderAuth(); + const { isAuthenticated } = useEndUserCoderAuth(); const patchedOptions: typeof queryOptions = { ...queryOptions, diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index 66bf966b..4e41ef86 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { workspaces, workspacesByRepo } from '../api/queryOptions'; import type { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; import { useCoderSdk } from './useCoderSdk'; -import { useCoderAuthWithTracking } from '../components/CoderProvider'; +import { useInternalCoderAuth } from '../components/CoderProvider'; type QueryInput = Readonly<{ coderQuery: string; @@ -13,7 +13,7 @@ export function useCoderWorkspacesQuery({ coderQuery, workspacesConfig, }: QueryInput) { - const auth = useCoderAuthWithTracking(); + const auth = useInternalCoderAuth(); const coderSdk = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; From c1467883f64526615db4b415de9c99e7c4d6a014 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 16:37:38 +0000 Subject: [PATCH 33/57] fix: update exports for plugin --- .../CoderProvider/CoderAuthProvider.tsx | 31 ++++++++++--------- plugins/backstage-plugin-coder/src/plugin.ts | 12 +++++-- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 5d288200..6e5e40b6 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -227,17 +227,17 @@ function useAuthFallbackState(): AuthFallbackState { } /** - * Behaves almost exactly like useCoderAuth, but has additional logic for - * spying on consumers of this hook. - * - * A fallback UI for letting the user input auth information will appear if - * there are no official Coder components that are able to give the user a way - * to do that through normal user flows. + * Exposes auth state for other components, but has additional logic for spying + * on consumers of the hook. * * Caveats: * 1. This hook should *NEVER* be exposed to the end user * 2. All official Coder plugin components should favor this hook over * useEndUserCoderAuth when possible + * + * A fallback UI for letting the user input auth information will appear if + * there are no official Coder components that are able to give the user a way + * to do that through normal user flows. */ export function useInternalCoderAuth(): CoderAuth { const trackComponent = useContext(AuthTrackingContext); @@ -566,16 +566,17 @@ function FallbackAuthUi() { * fallback auth UI get the dummy version. * * By having two contexts, we can dynamically expose or hide the tracking - * state for any components that use useCoderAuth. All other components can - * use the same hook without being aware of where they're being mounted. That - * means you can use the exact same components in either region without needing - * to rewrite anything outside this file. + * state for any components that use any version of the Coder auth state. All + * other components can use the same hook without being aware of where they're + * being mounted. That means you can use the exact same components in either + * region without needing to rewrite anything outside this file. * - * Any other component that uses useCoderAuth will reach up the component tree - * until it can grab *some* kind of tracking function. The hook only cares about - * whether it got a function at all; it doesn't care about what it does. The - * hook will call the function either way, but only the components in the "live" - * region will influence whether the fallback UI should be displayed. + * Any other component that uses useInternalCoderAuth will reach up the + * component tree until it can grab *some* kind of tracking function. The hook + * only cares about whether it got a function at all; it doesn't care about what + * it does. The hook will call the function either way, but only the components + * in the "live" region will influence whether the fallback UI should be + * displayed. * * Dummy function defined outside the component to prevent risk of needless * re-renders through Context. diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 5dad65dc..b9f9c5d7 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -192,12 +192,18 @@ export const CoderWorkspacesReminderAccordion = coderPlugin.provide( ); /** - * All custom hooks exposed by the plugin. + * Custom hooks needed throughout */ -export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; -export { useCoderWorkspacesQuery } from './hooks/useCoderWorkspacesQuery'; export { useWorkspacesCardContext } from './components/CoderWorkspacesCard/Root'; +/** + * General custom hooks that can be used in various places. + */ +export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; +export { useCoderSdk } from './hooks/useCoderSdk'; +export { useCoderQuery } from './hooks/useCoderQuery'; +export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; + /** * All custom types */ From e7e77553b3cf6382120854d7d732d8621f499229 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 16:38:13 +0000 Subject: [PATCH 34/57] docs: fill in incomplete sentence --- plugins/backstage-plugin-coder/src/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index b9f9c5d7..f8c9180e 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -192,7 +192,7 @@ export const CoderWorkspacesReminderAccordion = coderPlugin.provide( ); /** - * Custom hooks needed throughout + * Custom hooks needed for some of the custom Coder components */ export { useWorkspacesCardContext } from './components/CoderWorkspacesCard/Root'; From 84930801bb7b5abbdf5c884f4eb4323aa50be419 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 17:03:56 +0000 Subject: [PATCH 35/57] wip: commit initial version of useMutation wrapper --- .../src/hooks/useCoderMutation.ts | 90 +++++++++++++++++++ .../src/hooks/useCoderQuery.ts | 11 +-- .../src/typesConstants.ts | 6 ++ 3 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts new file mode 100644 index 00000000..7f08054a --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts @@ -0,0 +1,90 @@ +import { + type UseMutationOptions, + useQueryClient, + useMutation, +} from '@tanstack/react-query'; +import { useEndUserCoderAuth } from '../components/CoderProvider'; +import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; + +export function useCoderMutation< + TData = unknown, + TError = unknown, + TVariables = void, + TContext = unknown, +>(mutationOptions: UseMutationOptions) { + // This hook is intended for the end user only; don't need internal version of + // auth hook + const { isAuthenticated } = useEndUserCoderAuth(); + const queryClient = useQueryClient(); + + const patchedOptions: typeof mutationOptions = { + ...mutationOptions, + mutationFn: variables => { + // useMutation doesn't expose an enabled property, so the best we can do + // is immediately throw an error if the user isn't authenticated + if (!isAuthenticated) { + throw new Error( + 'Cannot perform Coder mutations without being authenticated', + ); + } + + const defaultMutationOptions = queryClient.getMutationDefaults(); + const externalMutationFn = + mutationOptions.mutationFn ?? defaultMutationOptions?.mutationFn; + + if (externalMutationFn === undefined) { + throw new Error('No mutation function has been provided'); + } + + return externalMutationFn(variables); + }, + + retry: (failureCount, error) => { + if (!isAuthenticated) { + return false; + } + + const externalRetry = mutationOptions.retry; + if (typeof externalRetry === 'number') { + return ( + failureCount < (externalRetry ?? DEFAULT_TANSTACK_QUERY_RETRY_COUNT) + ); + } + + if (typeof externalRetry !== 'function') { + return externalRetry ?? true; + } + + return externalRetry(failureCount, error); + }, + + retryDelay: (failureCount, error) => { + /** + * Formula is one of the examples of exponential backoff taken straight + * from the React Query docs + * @see {@link https://tanstack.com/query/v4/docs/framework/react/reference/useMutation} + */ + const exponentialDelay = Math.min( + failureCount > 1 ? 2 ** failureCount * 1000 : 1000, + 30 * 1000, + ); + + // Doesn't matter what value we return out as long as the retry property + // consistently returns false when not authenticated. Considered using + // Infinity, but didn't have time to look up whether that would break + // anything in the React Query internals + if (!isAuthenticated) { + return exponentialDelay; + } + + const externalRetryDelay = mutationOptions.retryDelay; + if (typeof externalRetryDelay !== 'function') { + return externalRetryDelay ?? exponentialDelay; + } + + return externalRetryDelay(failureCount, error); + }, + }; + + return useMutation(patchedOptions); +} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts index 0081e57f..b3c7e6f6 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts @@ -12,12 +12,7 @@ import { useQuery, } from '@tanstack/react-query'; import { useEndUserCoderAuth } from '../components/CoderProvider'; - -/** - * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to - * retrying a failed API request 3 times before exposing an error to the UI - */ -const DEFAULT_RETRY_COUNT = 3; +import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; export function useCoderQuery< TQueryFnData = unknown, @@ -72,7 +67,9 @@ export function useCoderQuery< const externalRetry = queryOptions.retry; if (typeof externalRetry === 'number') { - return failureCount < (externalRetry ?? DEFAULT_RETRY_COUNT); + return ( + failureCount < (externalRetry ?? DEFAULT_TANSTACK_QUERY_RETRY_COUNT) + ); } if (typeof externalRetry !== 'function') { diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index d9922920..484e39c9 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -103,3 +103,9 @@ export type User = Readonly<{ username: string; avatar_url: string; }>; + +/** + * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to + * retrying a failed API request 3 times before exposing an error to the UI + */ +export const DEFAULT_REACT_QUERY_RETRY_COUNT = 3; From 8087e19450ca969af90732a0c21a2e35ce10bf77 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 18:18:25 +0000 Subject: [PATCH 36/57] refactor: extract retry factor into global constant --- .../src/hooks/useCoderMutation.ts | 90 +++++++++++++++++++ .../src/hooks/useCoderQuery.ts | 11 +-- .../src/typesConstants.ts | 6 ++ 3 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts new file mode 100644 index 00000000..7f08054a --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts @@ -0,0 +1,90 @@ +import { + type UseMutationOptions, + useQueryClient, + useMutation, +} from '@tanstack/react-query'; +import { useEndUserCoderAuth } from '../components/CoderProvider'; +import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; + +export function useCoderMutation< + TData = unknown, + TError = unknown, + TVariables = void, + TContext = unknown, +>(mutationOptions: UseMutationOptions) { + // This hook is intended for the end user only; don't need internal version of + // auth hook + const { isAuthenticated } = useEndUserCoderAuth(); + const queryClient = useQueryClient(); + + const patchedOptions: typeof mutationOptions = { + ...mutationOptions, + mutationFn: variables => { + // useMutation doesn't expose an enabled property, so the best we can do + // is immediately throw an error if the user isn't authenticated + if (!isAuthenticated) { + throw new Error( + 'Cannot perform Coder mutations without being authenticated', + ); + } + + const defaultMutationOptions = queryClient.getMutationDefaults(); + const externalMutationFn = + mutationOptions.mutationFn ?? defaultMutationOptions?.mutationFn; + + if (externalMutationFn === undefined) { + throw new Error('No mutation function has been provided'); + } + + return externalMutationFn(variables); + }, + + retry: (failureCount, error) => { + if (!isAuthenticated) { + return false; + } + + const externalRetry = mutationOptions.retry; + if (typeof externalRetry === 'number') { + return ( + failureCount < (externalRetry ?? DEFAULT_TANSTACK_QUERY_RETRY_COUNT) + ); + } + + if (typeof externalRetry !== 'function') { + return externalRetry ?? true; + } + + return externalRetry(failureCount, error); + }, + + retryDelay: (failureCount, error) => { + /** + * Formula is one of the examples of exponential backoff taken straight + * from the React Query docs + * @see {@link https://tanstack.com/query/v4/docs/framework/react/reference/useMutation} + */ + const exponentialDelay = Math.min( + failureCount > 1 ? 2 ** failureCount * 1000 : 1000, + 30 * 1000, + ); + + // Doesn't matter what value we return out as long as the retry property + // consistently returns false when not authenticated. Considered using + // Infinity, but didn't have time to look up whether that would break + // anything in the React Query internals + if (!isAuthenticated) { + return exponentialDelay; + } + + const externalRetryDelay = mutationOptions.retryDelay; + if (typeof externalRetryDelay !== 'function') { + return externalRetryDelay ?? exponentialDelay; + } + + return externalRetryDelay(failureCount, error); + }, + }; + + return useMutation(patchedOptions); +} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts index 0081e57f..b3c7e6f6 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts @@ -12,12 +12,7 @@ import { useQuery, } from '@tanstack/react-query'; import { useEndUserCoderAuth } from '../components/CoderProvider'; - -/** - * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to - * retrying a failed API request 3 times before exposing an error to the UI - */ -const DEFAULT_RETRY_COUNT = 3; +import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; export function useCoderQuery< TQueryFnData = unknown, @@ -72,7 +67,9 @@ export function useCoderQuery< const externalRetry = queryOptions.retry; if (typeof externalRetry === 'number') { - return failureCount < (externalRetry ?? DEFAULT_RETRY_COUNT); + return ( + failureCount < (externalRetry ?? DEFAULT_TANSTACK_QUERY_RETRY_COUNT) + ); } if (typeof externalRetry !== 'function') { diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index d9922920..ecf02cdc 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -103,3 +103,9 @@ export type User = Readonly<{ username: string; avatar_url: string; }>; + +/** + * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to + * retrying a failed API request 3 times before exposing an error to the UI + */ +export const DEFAULT_TANSTACK_QUERY_RETRY_COUNT = 3; From ca257aefae41e490de7ba5ff1c52911bce62c029 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 18:33:26 +0000 Subject: [PATCH 37/57] fix: add explicit return type to useCoderMutation --- plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts index 7f08054a..d48ca60c 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts @@ -1,5 +1,6 @@ import { type UseMutationOptions, + type UseMutationResult, useQueryClient, useMutation, } from '@tanstack/react-query'; @@ -11,7 +12,9 @@ export function useCoderMutation< TError = unknown, TVariables = void, TContext = unknown, ->(mutationOptions: UseMutationOptions) { +>( + mutationOptions: UseMutationOptions, +): UseMutationResult { // This hook is intended for the end user only; don't need internal version of // auth hook const { isAuthenticated } = useEndUserCoderAuth(); From a92ec05383776727c3a9b803444e00302fadfe3c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 19:52:41 +0000 Subject: [PATCH 38/57] wip: start extracting auth logic into better reusable components --- .../components/A11yInfoCard/A11yInfoCard.tsx | 48 +++++++++++++++++++ .../src/components/A11yInfoCard/index.ts | 1 + .../CoderAuthForm/CoderAuthForm.tsx | 7 ++- .../CoderAuthFormCardWrapper.tsx | 35 ++++++++++++++ .../CoderAuthFormCardWrapper/index.ts | 1 + 5 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts diff --git a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx new file mode 100644 index 00000000..d1e2c733 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx @@ -0,0 +1,48 @@ +/** + * @file A slightly different take on Backstage's official InfoCard component, + * with better support for accessibility. + */ +import React, { type HTMLAttributes, type ReactNode, forwardRef } from 'react'; +import { makeStyles } from '@material-ui/core'; + +export type A11yInfoCardProps = Readonly< + HTMLAttributes & { + headerContent?: ReactNode; + } +>; + +const useCardStyles = makeStyles(theme => ({ + root: { + color: theme.palette.type, + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[1], + }, + + headerContent: {}, +})); + +// Card should be treated as equivalent to Backstage's official InfoCard +// component; had to make custom version so that it could forward properties for +// accessibility/screen reader support +export const A11yInfoCard = forwardRef( + (props, ref) => { + const { className, children, headerContent, ...delegatedProps } = props; + const styles = useCardStyles(); + + return ( +
+ {headerContent !== undefined && ( +
{headerContent}
+ )} + + {children} +
+ ); + }, +); diff --git a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts new file mode 100644 index 00000000..5ef69f03 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts @@ -0,0 +1 @@ +export * from './A11yInfoCard'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx index 48490895..57344f1c 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx @@ -4,13 +4,16 @@ import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; import { CoderAuthLoadingState } from './CoderAuthLoadingState'; import { CoderAuthInputForm } from './CoderAuthInputForm'; -type Props = Readonly< +export type CoderAuthFormProps = Readonly< PropsWithChildren<{ descriptionId?: string; }> >; -export const CoderAuthForm = ({ descriptionId, children }: Props) => { +export const CoderAuthForm = ({ + children, + descriptionId, +}: CoderAuthFormProps) => { const auth = useInternalCoderAuth(); if (auth.isAuthenticated) { return <>{children}; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx new file mode 100644 index 00000000..f27096cf --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { A11yInfoCard, A11yInfoCardProps } from '../A11yInfoCard'; +import { useInternalCoderAuth } from '../CoderProvider'; +import { + type CoderAuthFormProps, + CoderAuthForm, +} from '../CoderAuthForm/CoderAuthForm'; + +type Props = A11yInfoCardProps & CoderAuthFormProps; + +export function CoderAuthFormCardWrapper({ + children, + headerContent, + descriptionId, + ...delegatedCardProps +}: Props) { + const { isAuthenticated } = useInternalCoderAuth(); + + return ( + Authenticate with Coder + } + {...delegatedCardProps} + > + {isAuthenticated ? ( + <>{children} + ) : ( + + )} + + ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts new file mode 100644 index 00000000..e59d2626 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts @@ -0,0 +1 @@ +export * from './CoderAuthFormCardWrapper'; From 028c6b7ec58783962bc27e1e6a3cc13850c20467 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 21:03:24 +0000 Subject: [PATCH 39/57] fix: update card to have better styling for body --- .../components/A11yInfoCard/A11yInfoCard.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx index d1e2c733..0edd690d 100644 --- a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx +++ b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx @@ -1,6 +1,8 @@ /** * @file A slightly different take on Backstage's official InfoCard component, * with better support for accessibility. + * + * Does not support all of InfoCard's properties just yet. */ import React, { type HTMLAttributes, type ReactNode, forwardRef } from 'react'; import { makeStyles } from '@material-ui/core'; @@ -20,7 +22,25 @@ const useCardStyles = makeStyles(theme => ({ boxShadow: theme.shadows[1], }, - headerContent: {}, + headerContent: { + // Ideally wouldn't be using hard-coded font sizes, but couldn't figure out + // how to use the theme.typography property, especially since not all + // sub-properties have font sizes defined + fontSize: '24px', + color: theme.palette.text.primary, + fontWeight: 700, + borderBottom: `1px solid ${theme.palette.divider}`, + + // Margins and padding are a bit wonky to support full-bleed layouts + marginLeft: `-${theme.spacing(2)}px`, + marginRight: `-${theme.spacing(2)}px`, + padding: `0 ${theme.spacing(2)}px ${theme.spacing(2)}px`, + }, + + bodyContent: { + paddingTop: theme.spacing(6), + paddingBottom: theme.spacing(6), + }, })); // Card should be treated as equivalent to Backstage's official InfoCard @@ -41,7 +61,7 @@ export const A11yInfoCard = forwardRef(
{headerContent}
)} - {children} +
{children}
); }, From aea9345ffe807091fb577831f28ad1f3ddefa6fd Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 21:32:20 +0000 Subject: [PATCH 40/57] wip: commit progress on style refactoring --- .../components/A11yInfoCard/A11yInfoCard.tsx | 7 +- .../CoderWorkspacesCard.tsx | 25 +++-- .../CoderWorkspacesCard/HeaderRow.tsx | 30 ++--- .../components/CoderWorkspacesCard/Root.tsx | 106 +++++++----------- .../src/typesConstants.ts | 2 + 5 files changed, 67 insertions(+), 103 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx index 0edd690d..27b123d7 100644 --- a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx +++ b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx @@ -36,11 +36,6 @@ const useCardStyles = makeStyles(theme => ({ marginRight: `-${theme.spacing(2)}px`, padding: `0 ${theme.spacing(2)}px ${theme.spacing(2)}px`, }, - - bodyContent: { - paddingTop: theme.spacing(6), - paddingBottom: theme.spacing(6), - }, })); // Card should be treated as equivalent to Backstage's official InfoCard @@ -61,7 +56,7 @@ export const A11yInfoCard = forwardRef(
{headerContent}
)} -
{children}
+ {children}
); }, diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx index ac53b0f0..626b8122 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx @@ -22,17 +22,20 @@ export const CoderWorkspacesCard = (props: Props) => { const styles = useStyles(); return ( - - - - - - } - /> - + + + + + } + /> + } + {...props} + >
diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx index 8c67d5e5..4c1728e8 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx @@ -1,31 +1,17 @@ import React, { HTMLAttributes, ReactNode } from 'react'; -import { Theme, makeStyles } from '@material-ui/core'; +import { type Theme, makeStyles } from '@material-ui/core'; import { useWorkspacesCardContext } from './Root'; +import type { HtmlHeader } from '../../typesConstants'; type StyleKey = 'root' | 'header' | 'hgroup' | 'subheader'; - -type MakeStylesInputs = Readonly<{ - fullBleedLayout: boolean; -}>; - -const useStyles = makeStyles(theme => ({ - root: ({ fullBleedLayout }) => ({ +const useStyles = makeStyles(theme => ({ + root: { color: theme.palette.text.primary, display: 'flex', flexFlow: 'row nowrap', alignItems: 'center', gap: theme.spacing(1), - - // Have to jump through some hoops for the border; have to extend out the - // root to make sure that the border stretches all the way across the - // parent, and then add padding back to just the main content - borderBottom: `1px solid ${theme.palette.divider}`, - marginLeft: fullBleedLayout ? `-${theme.spacing(2)}px` : 0, - marginRight: fullBleedLayout ? `-${theme.spacing(2)}px` : 0, - padding: `0 ${theme.spacing(2)}px ${theme.spacing(2)}px ${theme.spacing( - 2.5, - )}px`, - }), + }, hgroup: { marginRight: 'auto', @@ -39,12 +25,13 @@ const useStyles = makeStyles(theme => ({ subheader: { margin: '0', + fontSize: '14px', + fontWeight: 400, color: theme.palette.text.secondary, paddingTop: theme.spacing(0.5), }, })); -type HtmlHeader = `h${1 | 2 | 3 | 4 | 5 | 6}`; type ClassName = `${Exclude}ClassName`; type HeaderProps = Readonly< @@ -67,11 +54,10 @@ export const HeaderRow = ({ subheaderClassName, activeRepoFilteringText, headerText = 'Coder Workspaces', - fullBleedLayout = true, ...delegatedProps }: HeaderProps) => { const { headerId, workspacesConfig } = useWorkspacesCardContext(); - const styles = useStyles({ fullBleedLayout }); + const styles = useStyles(); const HeadingComponent = headerLevel ?? 'h2'; const { repoUrl } = workspacesConfig; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index c65d145a..0866d95a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -4,8 +4,8 @@ */ import React, { type HTMLAttributes, + type ReactNode, createContext, - forwardRef, useContext, useState, } from 'react'; @@ -17,36 +17,7 @@ import { } from '../../hooks/useCoderWorkspacesConfig'; import type { Workspace } from '../../typesConstants'; import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; -import { makeStyles } from '@material-ui/core'; -import { CoderAuthWrapper } from '../CoderAuthWrapper'; - -const useCardStyles = makeStyles(theme => ({ - root: { - color: theme.palette.type, - backgroundColor: theme.palette.background.paper, - padding: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - boxShadow: theme.shadows[1], - }, -})); - -// Card should be treated as equivalent to Backstage's official InfoCard -// component; had to make custom version so that it could forward properties for -// accessibility/screen reader support -const Card = forwardRef>( - (props, ref) => { - const { className, ...delegatedProps } = props; - const styles = useCardStyles(); - - return ( -
- ); - }, -); +import { CoderAuthFormCardWrapper } from '../CoderAuthFormCardWrapper'; export type WorkspacesQuery = UseQueryResult; @@ -68,12 +39,14 @@ export type WorkspacesCardProps = Readonly< defaultQueryFilter?: string; onFilterChange?: (newFilter: string) => void; readEntityData?: boolean; + headerContent?: ReactNode; } >; const InnerRoot = ({ children, className, + headerContent, queryFilter: outerFilter, onFilterChange: onOuterFilterChange, defaultQueryFilter = 'owner:me', @@ -93,44 +66,49 @@ const InnerRoot = ({ const headerId = `${hookId}-header`; return ( - - { - setInnerFilter(newFilter); - onOuterFilterChange?.(newFilter); - }, - }} + { + setInnerFilter(newFilter); + onOuterFilterChange?.(newFilter); + }, + }} + > + - {/* - * 2024-01-31: This output is a
, but that should be changed to a - * once that element is supported by more browsers. Setting up - * accessibility markup and landmark behavior manually in the meantime - */} - - {/* 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), it - felt better to use a
with a role override to side-step edge - cases around keyboard input and button children that native
- elements automatically introduce */} -
{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), it + felt better to use a
with a role override to side-step edge + cases around keyboard input and button children that native + elements automatically introduce */} +
{children}
+ + ); }; export function Root(props: WorkspacesCardProps) { - // Doing this to insulate the user from needing to worry about accidentally - // flipping the value of readEntityData between renders. If this value - // changes, it will cause the component to unmount and remount, but that - // should be painless/maybe invisible compared to having the component throw - // a full error and triggering an error boundary + /** + * Binding the value of readEntityData as a render key to make using the + * component less painful to use overall for end users. + * + * Without this, the component will throw an error anytime the user flips the + * value of readEntityData from false to true, or vice-versa. + * + * With a render key, whenever the key changes, the whole component will + * unmount and then remount. This isn't a problem because all its important + * state is stored outside React via React Query, so on the remount, it can + * reuse the existing state and just has rebuild itself via the new props. + */ const renderKey = String(props.readEntityData ?? false); return ; } diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index ecf02cdc..76551f89 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -109,3 +109,5 @@ export type User = Readonly<{ * retrying a failed API request 3 times before exposing an error to the UI */ export const DEFAULT_TANSTACK_QUERY_RETRY_COUNT = 3; + +export type HtmlHeader = `h${1 | 2 | 3 | 4 | 5 | 6}`; From ceb65e11193f26c7646d00f5a0587dcf90782925 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 21:42:21 +0000 Subject: [PATCH 41/57] fix: update vertical padding for card wrapper --- .../CoderAuthFormCardWrapper.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx index f27096cf..1fa0f9fc 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx @@ -5,9 +5,17 @@ import { type CoderAuthFormProps, CoderAuthForm, } from '../CoderAuthForm/CoderAuthForm'; +import { makeStyles } from '@material-ui/core'; type Props = A11yInfoCardProps & CoderAuthFormProps; +const useStyles = makeStyles(theme => ({ + root: { + paddingTop: theme.spacing(6), + paddingBottom: theme.spacing(6), + }, +})); + export function CoderAuthFormCardWrapper({ children, headerContent, @@ -15,6 +23,7 @@ export function CoderAuthFormCardWrapper({ ...delegatedCardProps }: Props) { const { isAuthenticated } = useInternalCoderAuth(); + const styles = useStyles(); return ( {children} ) : ( - +
+ +
)}
); From 8d1343f4ddd778593df08cf6e56ad2a0d60d23ff Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 21:47:30 +0000 Subject: [PATCH 42/57] chore: delete CoderAuthWrapper component --- .../CoderAuthForm.test.tsx} | 0 .../CoderAuthDistrustedForm.tsx | 60 ----- .../CoderAuthWrapper/CoderAuthInputForm.tsx | 247 ------------------ .../CoderAuthLoadingState.tsx | 62 ----- .../CoderAuthWrapper/CoderAuthWrapper.tsx | 97 ------- .../src/components/CoderAuthWrapper/index.ts | 1 - plugins/backstage-plugin-coder/src/plugin.ts | 10 - 7 files changed, 477 deletions(-) rename plugins/backstage-plugin-coder/src/components/{CoderAuthWrapper/CoderAuthWrapper.test.tsx => CoderAuthForm/CoderAuthForm.test.tsx} (100%) delete mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx delete mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx delete mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx delete mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx delete mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/index.ts diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx similarity index 100% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx deleted file mode 100644 index 36d72d49..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { CoderLogo } from '../CoderLogo'; -import { LinkButton } from '@backstage/core-components'; -import { makeStyles } from '@material-ui/core'; -import { useInternalCoderAuth } 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 } = useInternalCoderAuth(); - - 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 deleted file mode 100644 index ae527e28..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import React, { type FormEvent, useState } from 'react'; -import { useId } from '../../hooks/hookPolyfills'; -import { - type CoderAuthStatus, - useCoderAppConfig, - useInternalCoderAuth, -} from '../CoderProvider'; - -import { CoderLogo } from '../CoderLogo'; -import { Link, LinkButton } from '@backstage/core-components'; -import { VisuallyHidden } from '../VisuallyHidden'; -import { makeStyles } from '@material-ui/core'; -import TextField from '@material-ui/core/TextField'; -import ErrorIcon from '@material-ui/icons/ErrorOutline'; -import SyncIcon from '@material-ui/icons/Sync'; - -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', - }, -})); - -export const CoderAuthInputForm = () => { - const hookId = useId(); - const styles = useStyles(); - const appConfig = useCoderAppConfig(); - const { status, registerNewToken } = useInternalCoderAuth(); - - 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 formHeaderId = `${hookId}-form-header`; - const legendId = `${hookId}-legend`; - const authTokenInputId = `${hookId}-auth-token`; - const warningBannerId = `${hookId}-warning-banner`; - - return ( - - - -
- -

- Link your Coder account to create remote workspaces. Please enter a - new token from your{' '} - - Coder deployment's token page - (link opens in new tab) - - . -

-
- -
- - - - - - Authenticate - -
- - {(status === 'invalid' || status === 'authenticating') && ( - - )} - - ); -}; - -const useInvalidStatusStyles = makeStyles(theme => ({ - warningBannerSpacer: { - paddingTop: theme.spacing(2), - }, - - warningBanner: { - display: 'flex', - flexFlow: 'row nowrap', - alignItems: 'center', - color: theme.palette.text.primary, - backgroundColor: theme.palette.background.default, - borderRadius: theme.shape.borderRadius, - border: `1.5px solid ${theme.palette.background.default}`, - padding: 0, - }, - - errorContent: { - display: 'flex', - flexFlow: 'row nowrap', - alignItems: 'center', - columnGap: theme.spacing(1), - marginRight: 'auto', - - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - paddingLeft: theme.spacing(2), - paddingRight: 0, - }, - - icon: { - fontSize: '16px', - }, - - syncIcon: { - color: theme.palette.text.primary, - opacity: 0.6, - }, - - errorIcon: { - color: theme.palette.error.main, - fontSize: '16px', - }, - - dismissButton: { - border: 'none', - alignSelf: 'stretch', - padding: `0 ${theme.spacing(1.5)}px 0 ${theme.spacing(2)}px`, - color: theme.palette.text.primary, - backgroundColor: 'inherit', - lineHeight: 1, - cursor: 'pointer', - - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, - }, - - '@keyframes spin': { - '100%': { - transform: 'rotate(360deg)', - }, - }, -})); - -type InvalidStatusProps = Readonly<{ - authStatus: CoderAuthStatus; - bannerId: string; -}>; - -function InvalidStatusNotifier({ authStatus, bannerId }: InvalidStatusProps) { - const [showNotification, setShowNotification] = useState(true); - const styles = useInvalidStatusStyles(); - - if (!showNotification) { - return null; - } - - return ( -
-
- - {authStatus === 'authenticating' && ( - <> - - Authenticating… - - )} - - {authStatus === 'invalid' && ( - <> - - Invalid token - - )} - - - -
-
- ); -} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx deleted file mode 100644 index 1ed9749a..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index a1680070..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { type FC, type PropsWithChildren } from 'react'; -import { useInternalCoderAuth } 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 WrapperProps = Readonly< - PropsWithChildren<{ - type: 'card'; - }> ->; - -export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { - const auth = useInternalCoderAuth(); - if (auth.isAuthenticated) { - return <>{children}; - } - - let Wrapper: FC>; - switch (type) { - case 'card': { - Wrapper = CoderAuthCard; - break; - } - default: { - assertExhaustion(type); - } - } - - return ( - - {/* 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( - 'Tried to process authenticated user after main content should already be shown', - ); - } - - default: { - return assertExhaustion(auth); - } - } - })()} - - ); -}; - -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/CoderAuthWrapper/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/index.ts deleted file mode 100644 index 3d0896b5..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './CoderAuthWrapper'; diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index f8c9180e..1fcbd6e8 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -58,16 +58,6 @@ export const CoderProvider = coderPlugin.provide( }), ); -export const CoderAuthWrapper = coderPlugin.provide( - createComponentExtension({ - name: 'CoderAuthWrapper', - component: { - lazy: () => - import('./components/CoderAuthWrapper').then(m => m.CoderAuthWrapper), - }, - }), -); - export const CoderErrorBoundary = coderPlugin.provide( createComponentExtension({ name: 'CoderErrorBoundary', From 91e0702586284cd0f115ce88af5e93b3797bb245 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 22:03:25 +0000 Subject: [PATCH 43/57] fix: update styling for auth fallback --- .../components/CoderAuthFormDialog/CoderAuthFormDialog.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx index 8dec0930..7c39fc95 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx @@ -41,14 +41,13 @@ const useStyles = makeStyles(theme => ({ }, dialogTitle: { + fontSize: '24px', borderBottom: `${theme.palette.divider} 1px solid`, padding: `${theme.spacing(1)}px ${theme.spacing(3)}px`, }, contentContainer: { - padding: `${theme.spacing(6)}px ${theme.spacing(3)}px ${theme.spacing( - 3, - )}px`, + padding: `${theme.spacing(6)}px ${theme.spacing(3)}px 0`, }, actionsRow: { @@ -56,7 +55,7 @@ const useStyles = makeStyles(theme => ({ flexFlow: 'row nowrap', justifyContent: 'center', padding: `${theme.spacing(1)}px ${theme.spacing(2)}px ${theme.spacing( - 2, + 6, )}px`, }, From b644eecbc693be706860804e26add599a068c82c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 22:07:27 +0000 Subject: [PATCH 44/57] chore: shrink size of PR --- .../src/hooks/useCoderMutation.ts | 93 ------------------- .../src/hooks/useCoderQuery.ts | 84 ----------------- 2 files changed, 177 deletions(-) delete mode 100644 plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts delete mode 100644 plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts deleted file mode 100644 index d48ca60c..00000000 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderMutation.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - type UseMutationOptions, - type UseMutationResult, - useQueryClient, - useMutation, -} from '@tanstack/react-query'; -import { useEndUserCoderAuth } from '../components/CoderProvider'; -import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; - -export function useCoderMutation< - TData = unknown, - TError = unknown, - TVariables = void, - TContext = unknown, ->( - mutationOptions: UseMutationOptions, -): UseMutationResult { - // This hook is intended for the end user only; don't need internal version of - // auth hook - const { isAuthenticated } = useEndUserCoderAuth(); - const queryClient = useQueryClient(); - - const patchedOptions: typeof mutationOptions = { - ...mutationOptions, - mutationFn: variables => { - // useMutation doesn't expose an enabled property, so the best we can do - // is immediately throw an error if the user isn't authenticated - if (!isAuthenticated) { - throw new Error( - 'Cannot perform Coder mutations without being authenticated', - ); - } - - const defaultMutationOptions = queryClient.getMutationDefaults(); - const externalMutationFn = - mutationOptions.mutationFn ?? defaultMutationOptions?.mutationFn; - - if (externalMutationFn === undefined) { - throw new Error('No mutation function has been provided'); - } - - return externalMutationFn(variables); - }, - - retry: (failureCount, error) => { - if (!isAuthenticated) { - return false; - } - - const externalRetry = mutationOptions.retry; - if (typeof externalRetry === 'number') { - return ( - failureCount < (externalRetry ?? DEFAULT_TANSTACK_QUERY_RETRY_COUNT) - ); - } - - if (typeof externalRetry !== 'function') { - return externalRetry ?? true; - } - - return externalRetry(failureCount, error); - }, - - retryDelay: (failureCount, error) => { - /** - * Formula is one of the examples of exponential backoff taken straight - * from the React Query docs - * @see {@link https://tanstack.com/query/v4/docs/framework/react/reference/useMutation} - */ - const exponentialDelay = Math.min( - failureCount > 1 ? 2 ** failureCount * 1000 : 1000, - 30 * 1000, - ); - - // Doesn't matter what value we return out as long as the retry property - // consistently returns false when not authenticated. Considered using - // Infinity, but didn't have time to look up whether that would break - // anything in the React Query internals - if (!isAuthenticated) { - return exponentialDelay; - } - - const externalRetryDelay = mutationOptions.retryDelay; - if (typeof externalRetryDelay !== 'function') { - return externalRetryDelay ?? exponentialDelay; - } - - return externalRetryDelay(failureCount, error); - }, - }; - - return useMutation(patchedOptions); -} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts deleted file mode 100644 index b3c7e6f6..00000000 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderQuery.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * @file Provides a convenience wrapper for end users trying to cache data from - * the Coder SDK. Removes the need to manually bring in useCoderAuth and wire - * it up to all of useQuery's config properties - * - * This has a little more overhead, but the hook is a lot more fire-and-forget - */ -import { - type QueryKey, - type UseQueryOptions, - type UseQueryResult, - useQuery, -} from '@tanstack/react-query'; -import { useEndUserCoderAuth } from '../components/CoderProvider'; -import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; - -export function useCoderQuery< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, ->( - queryOptions: UseQueryOptions, -): UseQueryResult { - // This hook is intended for the end user only; don't need internal version of - // auth hook - const { isAuthenticated } = useEndUserCoderAuth(); - - const patchedOptions: typeof queryOptions = { - ...queryOptions, - enabled: isAuthenticated && (queryOptions.enabled ?? true), - keepPreviousData: - isAuthenticated && (queryOptions.keepPreviousData ?? false), - refetchIntervalInBackground: - isAuthenticated && (queryOptions.refetchIntervalInBackground ?? false), - - refetchInterval: (data, query) => { - if (!isAuthenticated) { - return false; - } - - const externalRefetchInterval = queryOptions.refetchInterval; - if (typeof externalRefetchInterval !== 'function') { - return externalRefetchInterval ?? false; - } - - return externalRefetchInterval(data, query); - }, - - refetchOnMount: query => { - if (!isAuthenticated) { - return false; - } - - const externalRefetchOnMount = queryOptions.refetchOnMount; - if (typeof externalRefetchOnMount !== 'function') { - return externalRefetchOnMount ?? true; - } - - return externalRefetchOnMount(query); - }, - - retry: (failureCount, error) => { - if (!isAuthenticated) { - return false; - } - - const externalRetry = queryOptions.retry; - if (typeof externalRetry === 'number') { - return ( - failureCount < (externalRetry ?? DEFAULT_TANSTACK_QUERY_RETRY_COUNT) - ); - } - - if (typeof externalRetry !== 'function') { - return externalRetry ?? true; - } - - return externalRetry(failureCount, error); - }, - }; - - return useQuery(patchedOptions); -} From b127195f35ecd09818e62017fdf17401210500af Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 22:15:52 +0000 Subject: [PATCH 45/57] fix: update imports --- plugins/backstage-plugin-coder/src/plugin.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 1fcbd6e8..2aaaab89 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -191,7 +191,6 @@ export { useWorkspacesCardContext } from './components/CoderWorkspacesCard/Root' */ export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; export { useCoderSdk } from './hooks/useCoderSdk'; -export { useCoderQuery } from './hooks/useCoderQuery'; export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; /** From 7118896bca2255decb728c25df947fed22dfbe0c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 May 2024 22:37:16 +0000 Subject: [PATCH 46/57] docs: add comment about description setup --- .../src/components/CoderAuthForm/CoderAuthForm.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx index 57344f1c..6af655b6 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx @@ -21,6 +21,13 @@ export const CoderAuthForm = ({ return ( <> + {/* + * By default this text will be inert, and not be exposed anywhere + * (Sighted and blind users won't be able to interact with it). To enable + * it for screen readers, a consuming component will need bind an ID to + * another component via aria-describedby and then pass the same ID down + * as props. + */} From b364e0cac52fa6f998f562accc411b8ff81c1f49 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 13:34:11 +0000 Subject: [PATCH 47/57] fix: remove risk of runtime render errors in auth form --- .../CoderAuthForm/CoderAuthDistrustedForm.tsx | 17 +----- .../CoderAuthForm/CoderAuthForm.tsx | 23 +++---- .../CoderAuthForm/CoderAuthSuccessStatus.tsx | 61 +++++++++++++++++++ .../CoderAuthForm/UnlinkAccountButton.tsx | 42 +++++++++++++ 4 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx index 36d72d49..ebe97725 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { CoderLogo } from '../CoderLogo'; -import { LinkButton } from '@backstage/core-components'; import { makeStyles } from '@material-ui/core'; import { useInternalCoderAuth } from '../CoderProvider'; +import { UnlinkAccountButton } from './UnlinkAccountButton'; const useStyles = makeStyles(theme => ({ root: { @@ -31,8 +31,6 @@ const useStyles = makeStyles(theme => ({ export const CoderAuthDistrustedForm = () => { const styles = useStyles(); - const { ejectToken } = useInternalCoderAuth(); - return (
@@ -43,18 +41,7 @@ export const CoderAuthDistrustedForm = () => {

- - Eject token - +
); }; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx index 6af655b6..638a1a75 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx @@ -1,23 +1,16 @@ -import React, { type PropsWithChildren } from 'react'; +import React from 'react'; import { useInternalCoderAuth } from '../CoderProvider'; import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; import { CoderAuthLoadingState } from './CoderAuthLoadingState'; import { CoderAuthInputForm } from './CoderAuthInputForm'; +import { CoderAuthSuccessStatus } from './CoderAuthSuccessStatus'; -export type CoderAuthFormProps = Readonly< - PropsWithChildren<{ - descriptionId?: string; - }> ->; +export type CoderAuthFormProps = Readonly<{ + descriptionId?: string; +}>; -export const CoderAuthForm = ({ - children, - descriptionId, -}: CoderAuthFormProps) => { +export const CoderAuthForm = ({ descriptionId }: CoderAuthFormProps) => { const auth = useInternalCoderAuth(); - if (auth.isAuthenticated) { - return <>{children}; - } return ( <> @@ -54,9 +47,7 @@ export const CoderAuthForm = ({ case 'authenticated': case 'distrustedWithGracePeriod': { - throw new Error( - 'Tried to process authenticated user after main content should already be shown', - ); + return ; } default: { diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx new file mode 100644 index 00000000..d2c71513 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx @@ -0,0 +1,61 @@ +/** + * @file In practice, this is a component that ideally shouldn't ever be seen by + * the end user. Any component rendering out CoderAuthForm should ideally be set + * up so that when a user is authenticated, the entire component will be + * unmounted before CoderAuthForm has a chance to handle successful states. + * + * But just for the sake of completion (and to remove the risk of runtime render + * errors), this component has been added to provide a form of double + * book-keeping for the auth status switch checks in the parent component. Don't + * want the entire plugin to blow up if an auth conditional in a different + * component is accidentally set up wrong. + */ +import React from 'react'; +import { makeStyles } from '@material-ui/core'; +import { CoderLogo } from '../CoderLogo'; +import { UnlinkAccountButton } from './UnlinkAccountButton'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'center', + rowGap: theme.spacing(1), + + maxWidth: '30em', + marginLeft: 'auto', + marginRight: 'auto', + color: theme.palette.text.primary, + fontSize: '1rem', + }, + + statusArea: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'center', + }, + + logo: { + // + }, + + text: { + textAlign: 'center', + lineHeight: '1rem', + }, +})); + +export function CoderAuthSuccessStatus() { + const styles = useStyles(); + + return ( +
+
+ +

You are fully authenticated with Coder!

+
+ + +
+ ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx new file mode 100644 index 00000000..63b9fdd0 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx @@ -0,0 +1,42 @@ +import React, { type ComponentProps } from 'react'; +import { LinkButton } from '@backstage/core-components'; +import { makeStyles } from '@material-ui/core'; +import { useInternalCoderAuth } from '../CoderProvider'; + +type Props = Readonly, 'to'>>; + +const useStyles = makeStyles(() => ({ + root: { + display: 'block', + maxWidth: 'fit-content', + }, +})); + +export function UnlinkAccountButton({ + className, + onClick, + type = 'button', + ...delegatedProps +}: Props) { + const styles = useStyles(); + const { ejectToken } = useInternalCoderAuth(); + + return ( + { + ejectToken(); + onClick?.(event); + }} + {...delegatedProps} + > + Unlink Coder account + + ); +} From 39f9a06267b57399d611500b2bf26863be163c0a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 13:34:48 +0000 Subject: [PATCH 48/57] fix: update imports --- .../src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx index ebe97725..a37c1916 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { CoderLogo } from '../CoderLogo'; import { makeStyles } from '@material-ui/core'; -import { useInternalCoderAuth } from '../CoderProvider'; import { UnlinkAccountButton } from './UnlinkAccountButton'; const useStyles = makeStyles(theme => ({ From 1148eae66093b417e01c424a0b93e4c19f1c5780 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 13:45:12 +0000 Subject: [PATCH 49/57] fix: update font sizes to use relative units --- .../src/components/A11yInfoCard/A11yInfoCard.tsx | 6 +++--- .../src/components/CoderWorkspacesCard/HeaderRow.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx index 27b123d7..4c5959b9 100644 --- a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx +++ b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx @@ -13,7 +13,7 @@ export type A11yInfoCardProps = Readonly< } >; -const useCardStyles = makeStyles(theme => ({ +const useStyles = makeStyles(theme => ({ root: { color: theme.palette.type, backgroundColor: theme.palette.background.paper, @@ -26,7 +26,7 @@ const useCardStyles = makeStyles(theme => ({ // Ideally wouldn't be using hard-coded font sizes, but couldn't figure out // how to use the theme.typography property, especially since not all // sub-properties have font sizes defined - fontSize: '24px', + fontSize: '1.5rem', color: theme.palette.text.primary, fontWeight: 700, borderBottom: `1px solid ${theme.palette.divider}`, @@ -44,7 +44,7 @@ const useCardStyles = makeStyles(theme => ({ export const A11yInfoCard = forwardRef( (props, ref) => { const { className, children, headerContent, ...delegatedProps } = props; - const styles = useCardStyles(); + const styles = useStyles(); return (
(theme => ({ }, header: { - fontSize: '24px', + fontSize: '1.5rem', lineHeight: 1, margin: 0, }, subheader: { margin: '0', - fontSize: '14px', + fontSize: '0.875rem', fontWeight: 400, color: theme.palette.text.secondary, paddingTop: theme.spacing(0.5), From 5aca4f75190c6433e08af6175f2e6d06a702a7b0 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 13:47:51 +0000 Subject: [PATCH 50/57] fix: update peer dependencies for react-dom --- plugins/backstage-plugin-coder/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index 84987984..e21caf74 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -47,7 +47,7 @@ }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0 || ^18.0.0", - "react-dom": "^18.3.1" + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0" }, "devDependencies": { "@backstage/cli": "^0.25.1", From b04482e0b5b57ac65ec16647a12f93178bd59d01 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 14:00:25 +0000 Subject: [PATCH 51/57] refactor: clean up auth revalidation logic --- .../CoderProvider/CoderAuthProvider.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 6e5e40b6..8462de0c 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -10,6 +10,7 @@ import React, { } from 'react'; import { createPortal } from 'react-dom'; import { + type QueryCacheNotifyEvent, type UseQueryResult, useQuery, useQueryClient, @@ -139,13 +140,14 @@ function useAuthState(): CoderAuth { // outside React because we let the user connect their own queryClient const queryClient = useQueryClient(); useEffect(() => { - let isRefetchingTokenQuery = false; - const queryCache = queryClient.getQueryCache(); + // Pseudo-mutex; makes sure that if we get a bunch of errors, only one + // revalidation will be processed at a time + let isRevalidatingToken = false; - const unsubscribe = queryCache.subscribe(async event => { + const revalidateTokenOnError = async (event: QueryCacheNotifyEvent) => { const queryError = event.query.state.error; const shouldRevalidate = - !isRefetchingTokenQuery && + !isRevalidatingToken && BackstageHttpError.isInstance(queryError) && queryError.status === 401; @@ -153,11 +155,13 @@ function useAuthState(): CoderAuth { return; } - isRefetchingTokenQuery = true; + isRevalidatingToken = true; await queryClient.refetchQueries({ queryKey: sharedAuthQueryKey }); - isRefetchingTokenQuery = false; - }); + isRevalidatingToken = false; + }; + const queryCache = queryClient.getQueryCache(); + const unsubscribe = queryCache.subscribe(revalidateTokenOnError); return unsubscribe; }, [queryClient]); From 063006cbe3010aee990fa67c804ffe4ab1712bb8 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 16:20:47 +0000 Subject: [PATCH 52/57] wip: start updating tests for new code changes --- .../CoderAuthFormCardWrapper.test.tsx | 90 +++++++++++++++++++ .../CoderProvider/CoderAuthProvider.tsx | 19 +++- .../src/testHelpers/setup.tsx | 16 ++-- 3 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx new file mode 100644 index 00000000..244c310d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { CoderProviderWithMockAuth } from '../../testHelpers/setup'; +import type { CoderAuthStatus } from '../CoderProvider'; +import { + mockAppConfig, + mockAuthStates, +} from '../../testHelpers/mockBackstageData'; +import { CoderAuthFormCardWrapper } from './CoderAuthFormCardWrapper'; +import { renderInTestApp } from '@backstage/test-utils'; + +type RenderInputs = Readonly<{ + authStatus: CoderAuthStatus; + childButtonText: string; +}>; + +async function renderAuthWrapper({ + authStatus, + childButtonText, +}: RenderInputs) { + /** + * @todo RTL complains about the current environment not being configured to + * support act. Luckily, it doesn't seem to be making any of our main test + * cases to kick up false positives. + * + * This may not be an issue with our code, and might be a bug from Backstage's + * migration to React 18. Need to figure out where this issue is coming from, + * and open an issue upstream if necessary + */ + return renderInTestApp( + + + + + , + ); +} + +describe(`${CoderAuthFormCardWrapper.name}`, () => { + it('Displays the main children when the user is authenticated', async () => { + const childButtonText = 'I have secret Coder content!'; + const validStatuses: readonly CoderAuthStatus[] = [ + 'authenticated', + 'distrustedWithGracePeriod', + ]; + + for (const authStatus of validStatuses) { + const { unmount } = await renderAuthWrapper({ + authStatus, + childButtonText, + }); + + const button = await screen.findByRole('button', { + name: childButtonText, + }); + + // This assertion isn't necessary because findByRole will throw an error + // if the button can't be found within the expected period of time. Doing + // this purely to make the Backstage linter happy + expect(button).toBeInTheDocument(); + unmount(); + } + }); + + it('Hides the main children for any invalid/untrustworthy auth status', async () => { + const childButtonText = 'I should never be visible on the screen!'; + const invalidStatuses: readonly CoderAuthStatus[] = [ + 'deploymentUnavailable', + 'distrusted', + 'initializing', + 'invalid', + 'noInternetConnection', + 'tokenMissing', + ]; + + for (const authStatus of invalidStatuses) { + const { unmount } = await renderAuthWrapper({ + authStatus, + childButtonText, + }); + + const button = screen.queryByRole('button', { name: childButtonText }); + expect(button).not.toBeInTheDocument(); + unmount(); + } + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 8462de0c..c9b6fbb1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -585,16 +585,27 @@ function FallbackAuthUi() { * Dummy function defined outside the component to prevent risk of needless * re-renders through Context. */ -const dummyTrackComponent: TrackComponent = () => { + +/** + * A dummy version of the component tracker function. + * + * In production, this is used to define a dummy version of the context + * dependency for the "fallback auth UI" portion of the app. + * + * In testing, this is used for the vast majority of component tests to provide + * the tracker dependency and make sure that the components can properly render + * without having to be wired up to the entire plugin. + */ +export const dummyTrackComponent: TrackComponent = () => { // Deliberately perform a no-op on initial call return () => { // And deliberately perform a no-op on cleanup }; }; -export const CoderAuthProvider = ({ +export function CoderAuthProvider({ children, -}: Readonly>) => { +}: Readonly>) { const authState = useAuthState(); const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); const needFallbackUi = !authState.isAuthenticated && hasNoAuthInputs; @@ -612,4 +623,4 @@ export const CoderAuthProvider = ({ )} ); -}; +} diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 0cef032f..86ceedcb 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -17,8 +17,10 @@ import { type CoderAuthStatus, type CoderAppConfig, type CoderProviderProps, - AuthContext, + AuthStateContext, + AuthTrackingContext, CoderAppConfigProvider, + dummyTrackComponent, } from '../components/CoderProvider'; import { mockAppConfig, @@ -128,9 +130,11 @@ export const CoderProviderWithMockAuth = ({ - - {children} - + + + {children} + + @@ -164,7 +168,9 @@ export const renderHookAsCoderEntity = async < queryClient={mockQueryClient} authStatus={authStatus} > - {children} + + <>{children} + ); From 17027552e5fc055d9e265cfc2e7fef354b9db22a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 17:26:39 +0000 Subject: [PATCH 53/57] fix: adding missing test case for auth card --- .../CoderAuthFormCardWrapper.test.tsx | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx index 244c310d..2a0c7cb1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx @@ -18,15 +18,6 @@ async function renderAuthWrapper({ authStatus, childButtonText, }: RenderInputs) { - /** - * @todo RTL complains about the current environment not being configured to - * support act. Luckily, it doesn't seem to be making any of our main test - * cases to kick up false positives. - * - * This may not be an issue with our code, and might be a bug from Backstage's - * migration to React 18. Need to figure out where this issue is coming from, - * and open an issue upstream if necessary - */ return renderInTestApp( { unmount(); } }); + + it('Will go back to hiding content if auth state becomes invalid after re-renders', async () => { + const buttonText = "Now you see me, now you don't"; + const { rerender } = await renderAuthWrapper({ + authStatus: 'authenticated', + childButtonText: buttonText, + }); + + // Capture button after it first appears on the screen; findBy will throw if + // the button is not actually visible + const button = await screen.findByRole('button', { name: buttonText }); + + rerender( + + + + + , + ); + + // Assert that the button is gone after the re-render flushes + expect(button).not.toBeInTheDocument(); + }); }); From 1d928bb4a0586731f87fad12be40bd49ff6fed93 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 17:27:40 +0000 Subject: [PATCH 54/57] wip: commit progress on auth form test updates --- .../CoderAuthForm/CoderAuthForm.test.tsx | 101 ++++-------------- 1 file changed, 19 insertions(+), 82 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx index 43199c04..c33db9c9 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx @@ -8,18 +8,14 @@ import { mockAuthStates, mockCoderAuthToken, } from '../../testHelpers/mockBackstageData'; -import { CoderAuthWrapper } from './CoderAuthWrapper'; +import { CoderAuthForm } from './CoderAuthForm'; import { renderInTestApp } from '@backstage/test-utils'; type RenderInputs = Readonly<{ authStatus: CoderAuthStatus; - childButtonText?: string; }>; -async function renderAuthWrapper({ - authStatus, - childButtonText = 'Default button text', -}: RenderInputs) { +async function renderAuthWrapper({ authStatus }: RenderInputs) { const ejectToken = jest.fn(); const registerNewToken = jest.fn(); @@ -40,50 +36,24 @@ async function renderAuthWrapper({ */ const renderOutput = await renderInTestApp( - - - + , ); return { ...renderOutput, ejectToken, registerNewToken }; } -describe(`${CoderAuthWrapper.name}`, () => { - describe('Displaying main content', () => { - it('Displays the main children when the user is authenticated', async () => { - const buttonText = 'I have secret Coder content!'; - renderAuthWrapper({ - authStatus: 'authenticated', - childButtonText: buttonText, - }); - - const button = await screen.findByRole('button', { name: buttonText }); - - // This assertion isn't necessary because findByRole will throw an error - // if the button can't be found within the expected period of time. Doing - // this purely to make the Backstage linter happy - expect(button).toBeInTheDocument(); - }); - }); - +describe(`${CoderAuthForm.name}`, () => { describe('Loading UI', () => { it('Is displayed while the auth is initializing', async () => { - const buttonText = "You shouldn't be able to see me!"; - renderAuthWrapper({ - authStatus: 'initializing', - childButtonText: buttonText, - }); - - await screen.findByText(/Loading/); - const button = screen.queryByRole('button', { name: buttonText }); - expect(button).not.toBeInTheDocument(); + renderAuthWrapper({ authStatus: 'initializing' }); + const loadingIndicator = await screen.findByText(/Loading/); + expect(loadingIndicator).toBeInTheDocument(); }); }); describe('Token distrusted form', () => { it("Is displayed when the user's auth status cannot be verified", async () => { - const buttonText = 'Not sure if you should be able to see me'; const distrustedTextMatcher = /Unable to verify token authenticity/; const distrustedStatuses: readonly CoderAuthStatus[] = [ 'distrusted', @@ -91,16 +61,11 @@ describe(`${CoderAuthWrapper.name}`, () => { 'deploymentUnavailable', ]; - for (const status of distrustedStatuses) { - const { unmount } = await renderAuthWrapper({ - authStatus: status, - childButtonText: buttonText, - }); - - await screen.findByText(distrustedTextMatcher); - const button = screen.queryByRole('button', { name: buttonText }); - expect(button).not.toBeInTheDocument(); + for (const authStatus of distrustedStatuses) { + const { unmount } = await renderAuthWrapper({ authStatus }); + const message = await screen.findByText(distrustedTextMatcher); + expect(message).toBeInTheDocument(); unmount(); } }); @@ -112,58 +77,29 @@ describe(`${CoderAuthWrapper.name}`, () => { const user = userEvent.setup(); const ejectButton = await screen.findByRole('button', { - name: 'Eject token', + name: /Eject token/, }); await user.click(ejectButton); expect(ejectToken).toHaveBeenCalled(); }); - - it('Will appear if auth status changes during re-renders', async () => { - const buttonText = "Now you see me, now you don't"; - const { rerender } = await renderAuthWrapper({ - authStatus: 'authenticated', - childButtonText: buttonText, - }); - - // Capture button after it first appears on the screen - const button = await screen.findByRole('button', { name: buttonText }); - - rerender( - - - - - , - ); - - // Assert that the button is now gone - expect(button).not.toBeInTheDocument(); - }); }); describe('Token submission form', () => { it("Is displayed when the token either doesn't exist or is definitely not valid", async () => { - const buttonText = "You're not allowed to gaze upon my visage"; const tokenFormMatcher = /Please enter a new token/; const statusesForInvalidUser: readonly CoderAuthStatus[] = [ 'invalid', 'tokenMissing', ]; - for (const status of statusesForInvalidUser) { - const { unmount } = await renderAuthWrapper({ - authStatus: status, - childButtonText: buttonText, + for (const authStatus of statusesForInvalidUser) { + const { unmount } = await renderAuthWrapper({ authStatus }); + const form = screen.getByRole('form', { + name: tokenFormMatcher, }); - await screen.findByText(tokenFormMatcher); - const button = screen.queryByRole('button', { name: buttonText }); - expect(button).not.toBeInTheDocument(); - + expect(form).toBeInTheDocument(); unmount(); } }); @@ -178,7 +114,8 @@ describe(`${CoderAuthWrapper.name}`, () => { * 1. The auth input is of type password, which does not have a role * compatible with Testing Library; can't use getByRole to select it * 2. MUI adds a star to its labels that are required, meaning that any - * attempts at trying to match the string "Auth token" will fail + * attempts at trying to match string literal "Auth token" will fail; + * have to use a regex selector */ const inputField = screen.getByLabelText(/Auth token/); const submitButton = screen.getByRole('button', { name: 'Authenticate' }); From 34f027f3a9f6f0ce7f10f90fa1986b8b272a5175 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 17:38:01 +0000 Subject: [PATCH 55/57] fix: removal vetigal properties --- .../backstage-plugin-coder/src/testHelpers/mockBackstageData.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 28e258f5..34f11218 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -159,7 +159,6 @@ export const mockCoderWorkspacesConfig = (() => { const authedState = { token: mockCoderAuthToken, error: undefined, - tokenLoadedOnMount: true, isAuthenticated: true, registerNewToken: jest.fn(), ejectToken: jest.fn(), @@ -168,7 +167,6 @@ const authedState = { const notAuthedState = { token: undefined, error: undefined, - tokenLoadedOnMount: false, isAuthenticated: false, registerNewToken: jest.fn(), ejectToken: jest.fn(), From e0a47606095ad0d4be8ebdb76f09dbc5ee2fe47f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 17:40:31 +0000 Subject: [PATCH 56/57] fix: get all CoderAuthForm tests passing --- .../src/components/CoderAuthForm/CoderAuthForm.test.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx index c33db9c9..95ce2993 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx @@ -77,7 +77,7 @@ describe(`${CoderAuthForm.name}`, () => { const user = userEvent.setup(); const ejectButton = await screen.findByRole('button', { - name: /Eject token/, + name: /Unlink Coder account/, }); await user.click(ejectButton); @@ -87,7 +87,6 @@ describe(`${CoderAuthForm.name}`, () => { describe('Token submission form', () => { it("Is displayed when the token either doesn't exist or is definitely not valid", async () => { - const tokenFormMatcher = /Please enter a new token/; const statusesForInvalidUser: readonly CoderAuthStatus[] = [ 'invalid', 'tokenMissing', @@ -96,7 +95,7 @@ describe(`${CoderAuthForm.name}`, () => { for (const authStatus of statusesForInvalidUser) { const { unmount } = await renderAuthWrapper({ authStatus }); const form = screen.getByRole('form', { - name: tokenFormMatcher, + name: /Authenticate with Coder/, }); expect(form).toBeInTheDocument(); From 15a4e2218c55621746e1ff557995842fdda3f91a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 23 May 2024 19:20:55 +0000 Subject: [PATCH 57/57] fix: update import for auth hook in test --- .../src/components/CoderProvider/CoderProvider.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 223ea727..73acc13c 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -12,7 +12,7 @@ import { import { CoderProvider } from './CoderProvider'; import { useCoderAppConfig } from './CoderAppConfigProvider'; -import { type CoderAuth, useEndUserCoderAuth } from './CoderAuthProvider'; +import { type CoderAuth, useInternalCoderAuth } from './CoderAuthProvider'; import { getMockConfigApi, @@ -56,7 +56,7 @@ describe(`${CoderProvider.name}`, () => { describe('Auth', () => { // Can't use the render helpers because they all assume that the auth isn't // core to the functionality. In this case, you do need to bring in the full - // CoderProvider + // CoderProvider to make sure that it's working properly const renderUseCoderAuth = () => { const discoveryApi = getMockDiscoveryApi(); const configApi = getMockConfigApi(); @@ -70,7 +70,7 @@ describe(`${CoderProvider.name}`, () => { apis: { urlSync, identityApi }, }); - return renderHook(useEndUserCoderAuth, { + return renderHook(useInternalCoderAuth, { wrapper: ({ children }) => (