diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 84f1e68a..6c4f9df1 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -137,8 +137,8 @@ const coderAppConfig: CoderAppConfig = { }, workspaces: { - templateName: 'devcontainers', - mode: 'manual', + defaultTemplateName: 'devcontainers', + defaultMode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { repo: 'custom', diff --git a/plugins/backstage-plugin-coder/README.md b/plugins/backstage-plugin-coder/README.md index 93f3bdc2..eb53cb29 100644 --- a/plugins/backstage-plugin-coder/README.md +++ b/plugins/backstage-plugin-coder/README.md @@ -28,22 +28,23 @@ the Dev Container. yarn --cwd packages/app add @coder/backstage-plugin-coder ``` -1. Add the proxy key to your `app-config.yaml`: +2. Add the proxy key to your `app-config.yaml`: ```yaml proxy: endpoints: '/coder': - # Replace with your Coder deployment access URL and a trailing / + # Replace with your Coder deployment access URL (add a trailing slash) target: 'https://coder.example.com/' + changeOrigin: true - allowedMethods: ['GET'] + allowedMethods: ['GET'] # Additional methods will be supported soon! allowedHeaders: ['Authorization', 'Coder-Session-Token'] headers: X-Custom-Source: backstage ``` -1. Add the `CoderProvider` to the application: +3. Add the `CoderProvider` to the application: ```tsx // In packages/app/src/App.tsx @@ -58,14 +59,16 @@ the Dev Container. }, // Set the default template (and parameters) for - // catalog items. This can be overridden in the - // catalog-info.yaml for specific items. + // catalog items. Individual properties can be overridden + // by a repo's catalog-info.yaml file workspaces: { - templateName: 'devcontainers', - mode: 'manual', - // This parameter is used to filter Coder workspaces - // by a repo URL parameter. + defaultTemplateName: 'devcontainers', + defaultMode: 'manual', + + // This property defines which parameters in your Coder + // workspace templates are used to store repository links repoUrlParamKeys: ['custom_repo', 'repo_url'], + params: { repo: 'custom', region: 'eu-helsinki', @@ -88,7 +91,7 @@ the Dev Container. **Note:** You can also wrap a single page or component with `CoderProvider` if you only need Coder in a specific part of your app. See our [API reference](./docs/README.md) (particularly the section on [the `CoderProvider` component](./docs/components.md#coderprovider)) for more details. -1. Add the `CoderWorkspacesCard` card to the entity page in your app: +4. Add the `CoderWorkspacesCard` card to the entity page in your app: ```tsx // In packages/app/src/components/catalog/EntityPage.tsx @@ -101,6 +104,33 @@ the Dev Container. ; ``` +### `app-config.yaml` files + +In addition to the above, you can define additional properties on your specific repo's `catalog-info.yaml` file. + +Example: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: python-project +spec: + type: other + lifecycle: unknown + owner: pms + + # Properties for the Coder plugin are placed here + coder: + templateName: 'devcontainers' + mode: 'auto' + params: + repo: 'custom' + region: 'us-pittsburgh' +``` + +You can find more information about what properties are available (and how they're applied) in our [`catalog-info.yaml` file documentation](./docs/catalog-info.md). + ## Roadmap This plugin is in active development. The following features are planned: diff --git a/plugins/backstage-plugin-coder/dev/DevPage.tsx b/plugins/backstage-plugin-coder/dev/DevPage.tsx index 2d82cc6d..abc24008 100644 --- a/plugins/backstage-plugin-coder/dev/DevPage.tsx +++ b/plugins/backstage-plugin-coder/dev/DevPage.tsx @@ -24,8 +24,8 @@ const appConfig: CoderAppConfig = { }, workspaces: { - templateName: 'devcontainers', - mode: 'manual', + defaultTemplateName: 'devcontainers', + defaultMode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { repo: 'custom', diff --git a/plugins/backstage-plugin-coder/docs/catalog-info.md b/plugins/backstage-plugin-coder/docs/catalog-info.md new file mode 100644 index 00000000..34fd72b3 --- /dev/null +++ b/plugins/backstage-plugin-coder/docs/catalog-info.md @@ -0,0 +1,59 @@ +# `catalog-info.yaml` files + +This file provides documentation for all properties that the Coder plugin recognizes from Backstage's [`catalog-info.yaml` files](https://backstage.io/docs/features/software-catalog/descriptor-format/). + +## Example file + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: python-project +spec: + type: other + lifecycle: unknown + owner: pms + + # Properties for the Coder plugin are placed here + coder: + templateName: 'devcontainers' + mode: 'auto' + params: + repo: 'custom' + region: 'us-pittsburgh' +``` + +All config properties are placed under the `spec.coder` property. + +## Where these properties are used + +At present, there are two main areas where these values are used: + +- [`CoderWorkspacesCard`](./components.md#coderworkspacescard) (and all sub-components) +- [`useCoderWorkspacesConfig`](./hooks.md#usecoderworkspacesconfig) + +## Property listing + +### `templateName` + +**Type:** Optional `string` + +This defines the name of the Coder template you would like to use when creating new workspaces from Backstage. + +**Note:** This value has overlap with the `defaultTemplateName` property defined in [`CoderAppConfig`](types.md#coderappconfig). In the event that both values are present, the YAML file's `templateName` property will always be used instead. + +### `templateName` + +**Type:** Optional union of `manual` or `auto` + +This defines the workspace creation mode that will be embedded as a URL parameter in any outgoing links to make new workspaces in your Coder deployment. (e.g.,`useCoderWorkspacesConfig`'s `creationUrl` property) + +**Note:** This value has overlap with the `defaultMode` property defined in [`CoderAppConfig`](types.md#coderappconfig). In the event that both values are present, the YAML file's `mode` property will always be used instead. + +### `params` + +**Type:** Optional JSON object of string values (equivalent to TypeScript's `Record`) + +This allows you to define additional Coder workspace parameter values that should be passed along to any outgoing URLs for making new workspaces in your Coder deployment. These values are fully dynamic, and unfortunately, cannot have much type safety. + +**Note:** The properties from the `params` property are automatically merged with the properties defined via `CoderAppConfig`'s `params` property. In the event of any key conflicts, the params from `catalog-info.yaml` will always win. diff --git a/plugins/backstage-plugin-coder/docs/components.md b/plugins/backstage-plugin-coder/docs/components.md index 5b555915..9241a11b 100644 --- a/plugins/backstage-plugin-coder/docs/components.md +++ b/plugins/backstage-plugin-coder/docs/components.md @@ -26,7 +26,7 @@ This component is designed to simplify authentication checks for other component ```tsx type Props = Readonly< PropsWithChildren<{ - type: 'card'; + type: 'card'; // More types to be added soon! }> >; @@ -86,7 +86,7 @@ function YourComponent() { return

Will never reach this code

; } - +Something broke. Sorry!

}>
; ``` @@ -133,7 +133,7 @@ function YourComponent() { return ( ); @@ -145,8 +145,8 @@ const appConfig: CoderAppConfig = { }, workspaces: { - templateName: 'devcontainers', - mode: 'manual', + defaultTemplateName: 'devcontainers', + defaultMode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { repo: 'custom', @@ -162,12 +162,12 @@ const appConfig: CoderAppConfig = { ### Throws -- Does not throw +- Only throws if `appConfig` is not provided (but this is also caught at the type level) ### Notes - This component was deliberately designed to be agnostic of as many Backstage APIs as possible - it can be placed as high as the top of the app, or treated as a wrapper around a specific plugin component. - - That said, it is recommended that only have one instance of `CoderProvider` per Backstage deployment. Multiple `CoderProvider` component instances could interfere with each other and accidentally fragment caching state + - That said, it is recommended that you only have one instance of `CoderProvider` per Backstage deployment. Multiple `CoderProvider` component instances could interfere with each other and accidentally fragment caching state - If you are already using TanStack Query in your deployment, you can provide your own `QueryClient` value via the `queryClient` prop. - If not specified, `CoderProvider` will use its own client - Even if you aren't using TanStack Query anywhere else, you could consider adding your own client to configure it with more specific settings @@ -176,11 +176,11 @@ const appConfig: CoderAppConfig = { ## `CoderWorkspacesCard` -Allows you to search for and display Coder workspaces that the currently-authenticated user has access to. The component handles all data-fetching, caching +Allows you to search for and display Coder workspaces that the currently-authenticated user has access to. The component handles all data-fetching, caching, and displaying of workspaces. Has two "modes" – one where the component has access to all Coder workspaces for the user, and one where the component is aware of entity data and filters workspaces to those that match the currently-open repo page. See sample usage for examples. -All "pieces" of the component are also available as modular sub-components that can be imported and composed together individually. +All "pieces" of the component are also available as modular sub-components that can be imported and composed together individually. `CoderWorkspacesCard` represents a pre-configured version that is plug-and-play. ### Type signature @@ -216,7 +216,7 @@ const appConfig: CoderAppConfig = { ``` In "aware mode" – the component only displays workspaces that -match the repo data for the currently-open entity page: +match the repo data for the currently-open entity page, but in exchange, it must always be placed inside a Backstage component that has access to entity data (e.g., `EntityLayout`): ```tsx const appConfig: CoderAppConfig = { @@ -270,13 +270,15 @@ function YourComponent() { ## `CoderWorkspacesCard.CreateWorkspacesLink` -A link-button for creating new workspaces. Clicking this link will take you to "create workspace page" in your Coder deployment, with as many fields filled out as possible. +A link-button for creating new workspaces. Clicking this link will take you to "create workspace page" in your Coder deployment, with as many fields filled out as possible (see notes for exceptions). ### Type definition ```tsx +// All Tooltip-based props come from the type definitions for +// the MUI `Tooltip` component type Props = { - tooltipText?: string; + tooltipText?: string | ReactElement; tooltipProps?: Omit; tooltipRef?: ForwardedRef; @@ -290,14 +292,13 @@ declare function CreateWorkspacesLink( ): JSX.Element; ``` -All Tooltip-based props come from the type definitions for the MUI `Tooltip` component. - ### Throws - Will throw a render error if called outside of either a `CoderProvider` or `CoderWorkspacesCard.Root` ### Notes +- If no workspace creation URL could be generated, this component will not let you create a new workspace. This can happen when the `CoderAppConfig` does not have a `defaultTemplateName` property, and the `catalog-info.yaml` file also does not have a `templateName` - If `readEntityData` is `true` in `CoderWorkspacesCard.Root`: this component will include YAML properties parsed from the current page's entity data. ## `CoderWorkspacesCard.ExtraActionsButton` @@ -305,11 +306,13 @@ All Tooltip-based props come from the type definitions for the MUI `Tooltip` com A contextual menu of additional tertiary actions that can be performed for workspaces. Current actions: - Refresh workspaces list -- Eject token +- Unlinking the current Coder session token ### Type definition ```tsx +// All Tooltip- and Menu-based props come from the type definitions +// for the MUI Tooltip and Menu components. type ExtraActionsButtonProps = Omit< ButtonHTMLAttributes, 'id' | 'aria-controls' @@ -342,8 +345,6 @@ declare function ExtraActionsButton( ): JSX.Element; ``` -All Tooltip- and Menu-based props come from the type definitions for the MUI `Tooltip` and `Menu` components. - ### Throws - Will throw a render error if called outside of either a `CoderProvider` or `CoderWorkspacesCard.Root` @@ -351,7 +352,7 @@ All Tooltip- and Menu-based props come from the type definitions for the MUI `To ### Notes - When the menu opens, the first item of the list will auto-focus -- While the menu is open, you can navigate through items with the Up and Down arrow keys on the keyboard. These instructions are available for screen readers to announce +- While the menu is open, you can navigate through items with the Up and Down arrow keys on the keyboard. Reminder instructions are also available for screen readers to announce ## `CoderWorkspacesCard.HeaderRow` @@ -389,36 +390,35 @@ declare function HeaderGroup( - If `headerLevel` is not specified, the component will default to `h2` - If `fullBleedLayout` is `true`, the component will exert negative horizontal margins to fill out its parent -- If `activeRepoFilteringText` will only display if the value of `readEntityData` in `CoderWorkspacesCard.Root` is `true` +- `activeRepoFilteringText` will only display if the value of `readEntityData` in `CoderWorkspacesCard.Root` is `true`. The component automatically uses its own text if the prop is not specified. ## `CoderWorkspacesCard.Root` -Wrapper that acts as a context provider for all other sub-components in `CoderWorkspacesCard` – does not define any components that will render to HTML. +Wrapper that acts as a context provider for all other sub-components in `CoderWorkspacesCard` – defines a very minimal set of unstyled HTML components that are necessary only for screen reader support. ### Type definition ```tsx -type WorkspacesCardContext = { - queryFilter: string; - onFilterChange: (newFilter: string) => void; - workspacesQuery: UseQueryResult; - workspacesConfig: CoderWorkspacesConfig; - headerId: string; -}; +type Props = Readonly<{ + queryFilter?: string; + defaultQueryFilter?: string; + onFilterChange?: (newFilter: string) => void; + readEntityData?: boolean; + + // Also supports all props from the native HTMLDivElement + // component, except "id" and "aria-controls" +}>; declare function Root(props: Props): JSX.Element; ``` -All props mirror those returned by [`useWorkspacesCardContext`](./hooks.md#useworkspacescardcontext) - ### Throws - Will throw a render error if called outside of a `CoderProvider` ### Notes -- If `entityConfig` is defined, the Root will auto-filter all workspaces down to those that match the repo for the currently-opened entity page -- The key for `entityConfig` is not optional – even if it isn't defined, it must be explicitly passed an `undefined` value +- The value of `readEntityData` will cause the component to flip between the two modes mentioned in the documentation for [`CoderWorkspacesCard`](#coderworkspacescard). ## `CoderWorkspacesCard.SearchBox` @@ -448,7 +448,7 @@ declare function SearchBox(props: Props): JSX.Element; ### Notes -- The logic for processing user input into a new workspaces query is automatically debounced to wait 400ms. +- The logic for processing user input into a new workspaces query is automatically debounced. ## `CoderWorkspacesCard.WorkspacesList` @@ -544,3 +544,26 @@ declare function WorkspaceListItem(props: Props): JSX.Element; ### Notes - Supports full link-like functionality (right-clicking and middle-clicking to open in a new tab, etc.) + +## `CoderWorkspacesCard.ReminderAccordion` + +An accordion that will conditionally display additional help information in the event of a likely setup error. + +### Type definition + +```tsx +type ReminderAccordionProps = Readonly<{ + canShowEntityReminder?: boolean; + canShowTemplateNameReminder?: boolean; +}>; + +declare function ReminderAccordion(props: ReminderAccordionProps): JSX.Element; +``` + +### Throws + +- Will throw a render error if mounted outside of `CoderWorkspacesCard.Root` or `CoderProvider`. + +### Notes + +- All `canShow` props allow you to disable specific help messages. If any are set to `false`, their corresponding info block will **never** render. If set to `true` (and all will default to `true` if not specified), they will only appear when a likely setup error has been detected. diff --git a/plugins/backstage-plugin-coder/docs/hooks.md b/plugins/backstage-plugin-coder/docs/hooks.md index 282fba6f..c02ba4c0 100644 --- a/plugins/backstage-plugin-coder/docs/hooks.md +++ b/plugins/backstage-plugin-coder/docs/hooks.md @@ -30,7 +30,7 @@ declare function useCoderWorkspacesConfig( ```tsx function YourComponent() { - const config = useCoderWorkspacesConfig(); + const config = useCoderWorkspacesConfig({ readEntityData: true }); return

Your repo URL is {config.repoUrl}

; } @@ -62,14 +62,14 @@ const serviceEntityPage = ( ### Notes -- The type definition for `CoderWorkspacesConfig` [can be found here](./types.md#coderworkspacesconfig). That section also includes info on the heuristic used for compiling the data +- The type definition for `CoderWorkspacesConfig` [can be found here](./types.md#coderworkspacesconfig). That section also includes info on the heuristic used for compiling the data. - The value of `readEntityData` determines the "mode" that the workspace operates in. If the value is `false`/`undefined`, the component will act as a general list of workspaces that isn't aware of Backstage APIs. If the value is `true`, the hook will also read Backstage data during the compilation step. - The hook tries to ensure that the returned value maintains a stable memory reference as much as possible, if you ever need to use that value in other React hooks that use dependency arrays (e.g., `useEffect`, `useCallback`) ## `useCoderWorkspacesQuery` This hook gives you access to all workspaces that match a given query string. If -[`workspacesConfig`](#usecoderworkspacesconfig) is defined via `options`, and that config has a defined `repoUrl`, the workspaces returned will be filtered down further to only those that match the the repo. +[`workspacesConfig`](#usecoderworkspacesconfig) is defined via `options`, and that config has a defined `repoUrl` property, the workspaces returned will be filtered down further to only those that match the the repo. ### Type signature @@ -88,9 +88,9 @@ declare function useCoderWorkspacesConfig( ```tsx function YourComponent() { - const [filter, setFilter] = useState('owner:me'); + const [coderQuery, setCoderQuery] = useState('owner:me'); const workspacesConfig = useCoderWorkspacesConfig({ readEntityData: true }); - const queryState = useCoderWorkspacesQuery({ filter, workspacesConfig }); + const queryState = useCoderWorkspacesQuery({ coderQuery, workspacesConfig }); return ( <> @@ -130,7 +130,7 @@ const coderAppConfig: CoderAppConfig = { 1. The user is not currently authenticated (We recommend wrapping your component inside [`CoderAuthWrapper`](./components.md#coderauthwrapper) to make these checks easier) 2. If `repoConfig` is passed in via `options`: when the value of `coderQuery` is an empty string - The `workspacesConfig` property is the return type of [`useCoderWorkspacesConfig`](#usecoderworkspacesconfig) - - The only way to get automatically-filtered results is by (1) passing in a workspaces config value, and (2) ensuring that config has a `repoUrl` property of type string (it can sometimes be `undefined`, depending on built-in Backstage APIs). + - The only way to get workspace results that are automatically filtered by repo URL is by (1) passing in a workspaces config value, and (2) ensuring that config has a `repoUrl` property of type string (it can sometimes be `undefined`, depending on built-in Backstage APIs). ## `useWorkspacesCardContext` diff --git a/plugins/backstage-plugin-coder/docs/types.md b/plugins/backstage-plugin-coder/docs/types.md index 6caf7cd9..263f9872 100644 --- a/plugins/backstage-plugin-coder/docs/types.md +++ b/plugins/backstage-plugin-coder/docs/types.md @@ -2,7 +2,7 @@ ## General notes -- All type definitions for the Coder plugin are defined as type aliases and not interfaces, to prevent the risk of accidental interface merging. If you need to extend from one of our types, you can do it in one of two ways: +- All exported type definitions for the Coder plugin are defined as type aliases and not interfaces, to prevent the risk of accidental interface merging. If you need to extend from one of our types, you can do it in one of two ways: ```tsx // Type intersection @@ -28,15 +28,15 @@ Defines a set of configuration options for integrating Backstage with Coder. Primarily has two main uses: 1. Defining a centralized source of truth for certain Coder configuration options (such as which workspace parameters should be used for injecting repo URL values) -2. Defining "fallback" workspace parameters when a repository entity either doesn't have a `catalog-info.yaml` file at all, or only specifies a handful of properties. +2. Defining "fallback" workspace parameters when a repository entity either doesn't have a [`catalog-info.yaml` file](./catalog-info.md) at all, or only specifies a handful of properties. ### Type definition ```tsx type CoderAppConfig = Readonly<{ workspaces: Readonly<{ - templateName: string; - mode?: 'auto' | 'manual' | undefined; + defaultTemplateName?: string; + defaultMode?: 'auto' | 'manual' | undefined; params?: Record; repoUrlParamKeys: readonly [string, ...string[]]; }>; @@ -54,10 +54,10 @@ See example for [`CoderProvider`](./components.md#coderprovider) ### Notes - `accessUrl` is the URL pointing at your specific Coder deployment -- `templateName` refers to the name of the Coder template that you wish to use as default for creating workspaces -- If `mode` is not specified, the plugin will default to a value of `manual` +- `defaultTemplateName` refers to the name of the Coder template that you wish to use as default for creating workspaces. If this is not provided (and there is no `templateName` available from the `catalog-info.yaml` file, you will not be able to create new workspaces from Backstage) +- If `defaultMode` is not specified, the plugin will default to a value of `manual` - `repoUrlParamKeys` is defined as a non-empty array – there must be at least one element inside it. -- For more info on how this type is used within the plugin, see [`CoderWorkspacesConfig`](./types.md#coderworkspacesconfig) and [`useCoderWorkspacesConfig`](./hooks.md#usecoderworkspacesconfig) +- For more info on how this type is used within the plugin, see [`CoderWorkspacesConfig`](#coderworkspacesconfig) and [`useCoderWorkspacesConfig`](./hooks.md#usecoderworkspacesconfig) ## `CoderWorkspacesConfig` @@ -72,11 +72,11 @@ Represents the result of compiling Coder plugin configuration data. The main sou ```tsx type CoderWorkspacesConfig = Readonly<{ mode: 'manual' | 'auto'; + templateName: string | undefined; params: Record; creationUrl: string; repoUrl: string | undefined; repoUrlParamKeys: [string, ...string[]][]; - templateName: string; }>; ``` @@ -91,8 +91,8 @@ const appConfig: CoderAppConfig = { }, workspaces: { - templateName: 'devcontainers-a', - mode: 'manual', + defaultTemplateName: 'devcontainers-config', + defaultMode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { repo: 'custom', @@ -113,7 +113,7 @@ spec: lifecycle: unknown owner: pms coder: - templateName: 'devcontainers-b' + templateName: 'devcontainers-yaml' mode: 'auto' params: repo: 'custom' @@ -132,13 +132,13 @@ const config: CoderWorkspacesConfig = { repo_url: 'https://github.com/Parkreiner/python-project/', }, repoUrlParamKeys: ['custom_repo', 'repo_url'], - templateName: 'devcontainers', + templateName: 'devcontainers-yaml', repoUrl: 'https://github.com/Parkreiner/python-project/', // Other URL parameters will be included in real code // but were stripped out for this example creationUrl: - 'https://dev.coder.com/templates/devcontainers-b/workspace?mode=auto', + 'https://dev.coder.com/templates/devcontainers-yaml/workspace?mode=auto', }; ``` @@ -148,7 +148,7 @@ const config: CoderWorkspacesConfig = { - The value of the `repoUrl` property is derived from [Backstage's `getEntitySourceLocation`](https://backstage.io/docs/reference/plugin-catalog-react.getentitysourcelocation/), which does not guarantee that a URL will always be defined. - This is the current order of operations used to reconcile param data between `CoderAppConfig`, `catalog-info.yaml`, and the entity location data: 1. Start with an empty `Record` value - 2. Populate the record with the data from `CoderAppConfig` + 2. Populate the record with the data from `CoderAppConfig`. If there are any property names that start with `default`, those will be stripped out (e.g., `defaultTemplateName` will be injected as `templateName`) 3. Go through all properties parsed from `catalog-info.yaml` and inject those. If the properties are already defined, overwrite them 4. Grab the repo URL from the entity's location fields. 5. For each key in `CoderAppConfig`'s `workspaces.repoUrlParamKeys` property, take that key, and inject it as a key-value pair, using the URL as the value. If the key already exists, always override it with the URL diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx index bf27a634..43199c04 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx @@ -166,8 +166,6 @@ describe(`${CoderAuthWrapper.name}`, () => { unmount(); } - - expect.hasAssertions(); }); it('Lets the user submit a new token', async () => { diff --git a/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.test.tsx index 5245cc4c..734defb0 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.test.tsx @@ -47,8 +47,8 @@ function setupBoundaryTest(component: ReactElement) { describe(`${CoderErrorBoundary.name}`, () => { it('Displays a fallback UI when a rendering error is encountered', () => { setupBoundaryTest(); - screen.getByText(fallbackText); - expect.hasAssertions(); + const fallbackUi = screen.getByText(fallbackText); + expect(fallbackUi).toBeInTheDocument(); }); it('Exposes rendering errors to Backstage Error API', () => { diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx index 5d383be6..e3422292 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAppConfigProvider.tsx @@ -3,23 +3,24 @@ import React, { createContext, useContext, } from 'react'; - -import type { YamlConfig } from '../../hooks/useCoderWorkspacesConfig'; +import type { WorkspaceCreationMode } from '../../hooks/useCoderWorkspacesConfig'; export type CoderAppConfig = Readonly<{ deployment: Readonly<{ accessUrl: string; }>; - workspaces: Readonly< - Exclude & { - // Only specified explicitly to make templateName required - templateName: string; + // Type is meant to be used with YamlConfig from useCoderWorkspacesConfig; + // not using a mapped type because there's just enough differences that + // maintaining a relationship that way would be a nightmare of ternaries + workspaces: Readonly<{ + defaultMode?: WorkspaceCreationMode; + defaultTemplateName?: string; + params?: Record; - // Defined like this to ensure array always has at least one element - repoUrlParamKeys: readonly [string, ...string[]]; - } - >; + // Defined like this to ensure array always has at least one element + repoUrlParamKeys: readonly [string, ...string[]]; + }>; }>; const AppConfigContext = createContext(null); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx index 64bff808..ac53b0f0 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.tsx @@ -7,6 +7,7 @@ import { SearchBox } from './SearchBox'; import { WorkspacesList } from './WorkspacesList'; import { CreateWorkspaceLink } from './CreateWorkspaceLink'; import { ExtraActionsButton } from './ExtraActionsButton'; +import { ReminderAccordion } from './ReminderAccordion'; const useStyles = makeStyles(theme => ({ searchWrapper: { @@ -15,9 +16,9 @@ const useStyles = makeStyles(theme => ({ }, })); -export const CoderWorkspacesCard = ( - props: Omit, -) => { +type Props = Omit; + +export const CoderWorkspacesCard = (props: Props) => { const styles = useStyles(); return ( @@ -37,6 +38,7 @@ export const CoderWorkspacesCard = ( + ); }; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx index b26c86f1..6c219531 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.test.tsx @@ -1,17 +1,38 @@ import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { mockAppConfig } from '../../testHelpers/mockBackstageData'; +import { + mockAppConfig, + mockCoderWorkspacesConfig, +} from '../../testHelpers/mockBackstageData'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; -import { Root } from './Root'; +import { CardContext, WorkspacesCardContext } from './Root'; import { CreateWorkspaceLink } from './CreateWorkspaceLink'; +import type { CoderWorkspacesConfig } from '../../hooks/useCoderWorkspacesConfig'; + +type RenderInputs = Readonly<{ + hasTemplateName?: boolean; +}>; + +function render(inputs?: RenderInputs) { + const { hasTemplateName = true } = inputs ?? {}; + + const mockWorkspacesConfig: CoderWorkspacesConfig = { + ...mockCoderWorkspacesConfig, + creationUrl: hasTemplateName + ? mockCoderWorkspacesConfig.creationUrl + : undefined, + }; + + const mockContextValue: Partial = { + workspacesConfig: mockWorkspacesConfig, + }; -function render() { return renderInCoderEnvironment({ children: ( - + - + ), }); } @@ -37,4 +58,25 @@ describe(`${CreateWorkspaceLink.name}`, () => { const tooltip = await screen.findByText('Add a new workspace'); expect(tooltip).toBeInTheDocument(); }); + + it('Will be disabled and will indicate to the user when there is no usable templateName value', async () => { + await render({ hasTemplateName: false }); + const link = screen.getByRole('link'); + + // Check that the link is "disabled" properly (see main component file for + // a link to resource explaining edge cases). Can't assert toBeDisabled, + // because links don't support the disabled attribute; also can't check + // the .role and .ariaDisabled properties on the link variable, because even + // though they exist in the output, RTL doesn't correctly pass them through. + // This is a niche edge case - have to check properties on the raw HTML node + expect(link.href).toBe(''); + expect(link.getAttribute('role')).toBe('link'); + expect(link.getAttribute('aria-disabled')).toBe('true'); + + // Make sure tooltip is also updated + const user = userEvent.setup(); + await user.hover(link); + const tooltip = await screen.findByText(/Please add a template name value/); + expect(tooltip).toBeInTheDocument(); + }); }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx index 10c8fb86..a0a1ab84 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CreateWorkspaceLink.tsx @@ -1,36 +1,57 @@ -import React, { type AnchorHTMLAttributes, type ForwardedRef } from 'react'; -import { makeStyles } from '@material-ui/core'; +import React, { + type AnchorHTMLAttributes, + type ForwardedRef, + type ReactElement, +} from 'react'; +import { type Theme, makeStyles } from '@material-ui/core'; import { useWorkspacesCardContext } from './Root'; import { VisuallyHidden } from '../VisuallyHidden'; import AddIcon from '@material-ui/icons/AddCircleOutline'; import Tooltip, { type TooltipProps } from '@material-ui/core/Tooltip'; -const useStyles = makeStyles(theme => { +type StyleInput = Readonly<{ + canCreateWorkspace: boolean; +}>; + +type StyleKeys = 'root' | 'noLinkTooltipContainer'; + +const useStyles = makeStyles(theme => { const padding = theme.spacing(0.5); return { - root: { + root: ({ canCreateWorkspace }) => ({ padding, width: theme.spacing(4) + padding, height: theme.spacing(4) + padding, + cursor: 'pointer', display: 'flex', justifyContent: 'center', alignItems: 'center', backgroundColor: 'inherit', borderRadius: '9999px', lineHeight: 1, + color: canCreateWorkspace + ? theme.palette.text.primary + : theme.palette.text.disabled, '&:hover': { - backgroundColor: theme.palette.action.hover, + backgroundColor: canCreateWorkspace + ? theme.palette.action.hover + : 'inherit', }, + }), + + noLinkTooltipContainer: { + display: 'block', + maxWidth: '24em', }, }; }); type CreateButtonLinkProps = Readonly< - AnchorHTMLAttributes & { - tooltipText?: string; + Omit, 'aria-disabled'> & { + tooltipText?: string | ReactElement; tooltipProps?: Omit; tooltipRef?: ForwardedRef; } @@ -45,22 +66,58 @@ export const CreateWorkspaceLink = ({ tooltipProps = {}, ...delegatedProps }: CreateButtonLinkProps) => { - const styles = useStyles(); const { workspacesConfig } = useWorkspacesCardContext(); + const canCreateWorkspace = Boolean(workspacesConfig.creationUrl); + const styles = useStyles({ canCreateWorkspace }); return ( - + + Please add a template name value. More info available in the + accordion at the bottom of this widget. + + ) + } + {...tooltipProps} + > + {/* eslint-disable-next-line jsx-a11y/no-redundant-roles -- + Some browsers will render out elements as having no role when the + href value is undefined or an empty string. Need to make sure that the + link role is always defined, no matter what. The ESLint rule is wrong + here. */} {children ?? } - {tooltipText} - {target === '_blank' && <> (Link opens in new tab)} + {canCreateWorkspace ? ( + <> + {tooltipText} + {target === '_blank' && <> (Link opens in new tab)} + + ) : ( + <> + This component does not have a usable template name. Please see + the disclosure section in this widget for steps on adding this + information. + + )} diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx deleted file mode 100644 index 61536c72..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { renderInCoderEnvironment } from '../../testHelpers/setup'; -import { Root } from './Root'; -import { EntityDataReminder } from './EntityDataReminder'; - -function render() { - return renderInCoderEnvironment({ - children: ( - - - - ), - }); -} - -describe(`${EntityDataReminder.name}`, () => { - it('Will toggle between showing/hiding the disclosure info when the user clicks it', async () => { - await render(); - const user = userEvent.setup(); - const disclosureButton = screen.getByRole('button', { - name: /Why am I seeing all workspaces\?/, - }); - - await user.click(disclosureButton); - const disclosureInfo = await screen.findByText( - /This component displays all workspaces when the entity has no repo URL to filter by/, - ); - - await user.click(disclosureButton); - expect(disclosureInfo).not.toBeInTheDocument(); - }); -}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.tsx deleted file mode 100644 index c6335d85..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/EntityDataReminder.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useState } from 'react'; -import { useId } from '../../hooks/hookPolyfills'; -import { Theme, makeStyles } from '@material-ui/core'; -import { VisuallyHidden } from '../VisuallyHidden'; -import { useWorkspacesCardContext } from './Root'; - -type UseStyleProps = Readonly<{ - hasData: boolean; -}>; - -type UseStyleKeys = - | 'root' - | 'button' - | 'disclosureTriangle' - | 'disclosureBody' - | 'snippet'; - -const useStyles = makeStyles(theme => ({ - root: ({ hasData }) => ({ - paddingTop: theme.spacing(1), - borderTop: hasData ? 'none' : `1px solid ${theme.palette.divider}`, - }), - - button: { - width: '100%', - textAlign: 'left', - color: theme.palette.text.primary, - backgroundColor: theme.palette.background.paper, - padding: theme.spacing(1), - border: 'none', - borderRadius: theme.shape.borderRadius, - fontSize: theme.typography.body2.fontSize, - cursor: 'pointer', - - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, - }, - - disclosureTriangle: { - display: 'inline-block', - textAlign: 'right', - width: theme.spacing(2.25), - }, - - disclosureBody: { - margin: 0, - padding: `${theme.spacing(0.5)}px ${theme.spacing(3.5)}px 0 ${theme.spacing( - 3.75, - )}px`, - }, - - snippet: { - color: theme.palette.text.primary, - borderRadius: theme.spacing(0.5), - padding: `${theme.spacing(0.2)}px ${theme.spacing(1)}px`, - backgroundColor: () => { - const defaultBackgroundColor = theme.palette.background.default; - const isDefaultSpotifyLightTheme = - defaultBackgroundColor.toUpperCase() === '#F8F8F8'; - - return isDefaultSpotifyLightTheme - ? 'hsl(0deg,0%,93%)' - : defaultBackgroundColor; - }, - }, -})); - -export const EntityDataReminder = () => { - const [isExpanded, setIsExpanded] = useState(false); - const { workspacesQuery } = useWorkspacesCardContext(); - const styles = useStyles({ hasData: workspacesQuery.data !== undefined }); - - const hookId = useId(); - const disclosureBodyId = `${hookId}-disclosure-body`; - - // Might be worth revisiting the markup here to try implementing this - // functionality with and elements. Would likely clean up - // the component code a ton but might reduce control over screen reader output - return ( -
- - - {isExpanded && ( -

- This component displays all workspaces when the entity has no repo URL - to filter by. Consider disabling{' '} - readEntityData (details in our{' '} - - documentation - (link opens in new tab) - - ). -

- )} -
- ); -}; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx index 732a859d..008d931a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx @@ -39,21 +39,17 @@ async function renderButton({ buttonText }: RenderInputs) { * @todo Research how to test dependencies on useQuery */ const refetch = jest.fn(); - const mockWorkspacesQuery = { - refetch, - } as unknown as WorkspacesCardContext['workspacesQuery']; - const mockContext: WorkspacesCardContext = { - headerId: "Doesn't matter", - queryFilter: "Doesn't matter", - onFilterChange: jest.fn(), + const mockContext: Partial = { workspacesConfig: mockCoderWorkspacesConfig, - workspacesQuery: mockWorkspacesQuery, + workspacesQuery: { + refetch, + } as unknown as WorkspacesCardContext['workspacesQuery'], }; const renderOutput = await renderInCoderEnvironment({ auth, children: ( - + ), diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx new file mode 100644 index 00000000..0ae1d918 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx @@ -0,0 +1,233 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderInCoderEnvironment } from '../../testHelpers/setup'; +import type { Workspace } from '../../typesConstants'; +import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; +import { + type WorkspacesCardContext, + type WorkspacesQuery, + CardContext, +} from './Root'; +import { + type ReminderAccordionProps, + ReminderAccordion, +} from './ReminderAccordion'; + +type RenderInputs = Readonly< + ReminderAccordionProps & { + isReadingEntityData?: boolean; + repoUrl?: undefined | string; + creationUrl?: undefined | string; + queryData?: undefined | readonly Workspace[]; + } +>; + +function renderAccordion(inputs?: RenderInputs) { + const { + repoUrl, + creationUrl, + queryData = [], + isReadingEntityData = true, + canShowEntityReminder = true, + canShowTemplateNameReminder = true, + } = inputs ?? {}; + + const mockContext: Partial = { + workspacesConfig: { + ...mockCoderWorkspacesConfig, + repoUrl, + creationUrl, + isReadingEntityData, + }, + workspacesQuery: { + data: queryData, + } as WorkspacesQuery, + }; + + return renderInCoderEnvironment({ + children: ( + + + + ), + }); +} + +const matchers = { + toggles: { + entity: /Why am I not seeing any workspaces\?/i, + templateName: /Why can't I make a new workspace\?/, + }, + bodyText: { + entity: /^This component only displays all workspaces when/, + templateName: + /^This component cannot make a new workspace without a template name value/, + }, +} as const satisfies Record>; + +describe(`${ReminderAccordion.name}`, () => { + describe('General behavior', () => { + it('Lets the user open a single accordion item', async () => { + await renderAccordion(); + const entityToggle = await screen.findByRole('button', { + name: matchers.toggles.entity, + }); + + const user = userEvent.setup(); + await user.click(entityToggle); + + const entityText = await screen.findByText(matchers.bodyText.entity); + expect(entityText).toBeInTheDocument(); + }); + + it('Will close an open accordion item when that item is clicked', async () => { + await renderAccordion(); + const entityToggle = await screen.findByRole('button', { + name: matchers.toggles.entity, + }); + + const user = userEvent.setup(); + await user.click(entityToggle); + + const entityText = await screen.findByText(matchers.bodyText.entity); + await user.click(entityToggle); + expect(entityText).not.toBeInTheDocument(); + }); + + it('Only lets one accordion item be open at a time', async () => { + await renderAccordion(); + const entityToggle = await screen.findByRole('button', { + name: matchers.toggles.entity, + }); + const templateNameToggle = await screen.findByRole('button', { + name: matchers.toggles.templateName, + }); + + const user = userEvent.setup(); + await user.click(entityToggle); + + const entityText = await screen.findByText(matchers.bodyText.entity); + expect(entityText).toBeInTheDocument(); + + await user.click(templateNameToggle); + expect(entityText).not.toBeInTheDocument(); + + const templateText = await screen.findByText( + matchers.bodyText.templateName, + ); + expect(templateText).toBeInTheDocument(); + }); + }); + + describe('Conditionally displaying items', () => { + it('Lets the user conditionally hide accordion items based on props', async () => { + type Configuration = Readonly<{ + props: ReminderAccordionProps; + expectedItemCount: number; + }>; + + const configurations: readonly Configuration[] = [ + { + expectedItemCount: 0, + props: { + canShowEntityReminder: false, + canShowTemplateNameReminder: false, + }, + }, + { + expectedItemCount: 1, + props: { + canShowEntityReminder: false, + canShowTemplateNameReminder: true, + }, + }, + { + expectedItemCount: 1, + props: { + canShowEntityReminder: true, + canShowTemplateNameReminder: false, + }, + }, + ]; + + for (const config of configurations) { + const { unmount } = await renderAccordion(config.props); + const accordionItems = screen.queryAllByRole('button'); + + expect(accordionItems.length).toBe(config.expectedItemCount); + unmount(); + } + }); + + it('Will NOT display the template name reminder if there is a creation URL', async () => { + await renderAccordion({ + creationUrl: mockCoderWorkspacesConfig.creationUrl, + canShowTemplateNameReminder: true, + }); + + const templateToggle = screen.queryByRole('button', { + name: matchers.toggles.templateName, + }); + + expect(templateToggle).not.toBeInTheDocument(); + }); + + /** + * Assuming that the user hasn't disabled showing the reminder at all, it + * will only appear when both of these are true: + * 1. The component is set up to read entity data + * 2. There is no repo URL that could be parsed from the entity data + */ + it('Will only display the entity data reminder when appropriate', async () => { + type Config = Readonly<{ + isReadingEntityData: boolean; + repoUrl: string | undefined; + }>; + + const doNotDisplayConfigs: readonly Config[] = [ + { + isReadingEntityData: false, + repoUrl: mockCoderWorkspacesConfig.repoUrl, + }, + { + isReadingEntityData: false, + repoUrl: undefined, + }, + { + isReadingEntityData: true, + repoUrl: mockCoderWorkspacesConfig.repoUrl, + }, + ]; + + for (const config of doNotDisplayConfigs) { + const { unmount } = await renderAccordion({ + isReadingEntityData: config.isReadingEntityData, + repoUrl: config.repoUrl, + }); + + const entityToggle = screen.queryByRole('button', { + name: matchers.toggles.entity, + }); + + expect(entityToggle).not.toBeInTheDocument(); + unmount(); + } + + // Verify that toggle appears only this one time + await renderAccordion({ + isReadingEntityData: true, + repoUrl: undefined, + }); + + const entityToggle = await screen.findByRole('button', { + name: matchers.toggles.entity, + }); + + expect(entityToggle).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.tsx new file mode 100644 index 00000000..34666194 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.tsx @@ -0,0 +1,146 @@ +import React, { type ReactNode, Fragment, useState } from 'react'; +import { type Theme, makeStyles } from '@material-ui/core'; +import { VisuallyHidden } from '../VisuallyHidden'; +import { useWorkspacesCardContext } from './Root'; +import { Disclosure } from '../Disclosure/Disclosure'; +import { InlineCodeSnippet as Snippet } from '../InlineCodeSnippet/InlineCodeSnippet'; + +type AccordionItemInfo = Readonly<{ + id: string; + canDisplay: boolean; + headerText: ReactNode; + bodyText: ReactNode; +}>; + +type StyleKeys = 'root' | 'link' | 'innerPadding' | 'disclosure'; +type StyleInputs = Readonly<{ + hasData: boolean; +}>; + +const useStyles = makeStyles(theme => ({ + root: ({ hasData }) => ({ + paddingTop: theme.spacing(1), + marginLeft: `-${theme.spacing(2)}px`, + marginRight: `-${theme.spacing(2)}px`, + marginBottom: `-${theme.spacing(2)}px`, + borderTop: hasData ? 'none' : `1px solid ${theme.palette.divider}`, + maxHeight: '240px', + overflowX: 'hidden', + overflowY: 'auto', + }), + + innerPadding: { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + paddingBottom: theme.spacing(2), + }, + + link: { + color: theme.palette.link, + '&:hover': { + textDecoration: 'underline', + }, + }, + + disclosure: { + '&:not(:first-child)': { + paddingTop: theme.spacing(1), + }, + }, +})); + +export type ReminderAccordionProps = Readonly<{ + canShowEntityReminder?: boolean; + canShowTemplateNameReminder?: boolean; +}>; + +export function ReminderAccordion({ + canShowEntityReminder = true, + canShowTemplateNameReminder = true, +}: ReminderAccordionProps) { + const [activeItemId, setActiveItemId] = useState(); + const { workspacesConfig, workspacesQuery } = useWorkspacesCardContext(); + const styles = useStyles({ hasData: workspacesQuery.data !== undefined }); + + const accordionData: readonly AccordionItemInfo[] = [ + { + id: 'entity', + canDisplay: + canShowEntityReminder && + workspacesConfig.isReadingEntityData && + !workspacesConfig.repoUrl, + headerText: 'Why am I not seeing any workspaces?', + bodyText: ( + <> + This component only displays all workspaces when the value of the{' '} + readEntityData prop is false. + See{' '} + + our documentation + (link opens in new tab) + {' '} + for more info. + + ), + }, + { + id: 'templateName', + canDisplay: canShowTemplateNameReminder && !workspacesConfig.creationUrl, + headerText: <>Why can't I make a new workspace?, + bodyText: ( + <> + This component cannot make a new workspace without a template name + value. Values can be provided via{' '} + defaultTemplateName in{' '} + CoderAppConfig or the{' '} + templateName property in a repo's{' '} + catalog-info.yaml file. See{' '} + + our documentation + (link opens in new tab) + {' '} + for more info. + + ), + }, + ]; + + const toggleAccordionGroup = (newItemId: string) => { + if (newItemId === activeItemId) { + setActiveItemId(undefined); + } else { + setActiveItemId(newItemId); + } + }; + + return ( +
+
+ {accordionData.map(({ id, canDisplay, headerText, bodyText }) => ( + + {canDisplay && ( + toggleAccordionGroup(id)} + > + {bodyText} + + )} + + ))} +
+
+ ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 6829753a..9a2d118f 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -1,9 +1,6 @@ /** * @file Wires up all the core logic for passing values down to the * sub-components in the same directory. - * - * Does not need any tests – test functionality covered by integration tests in - * CoderWorkspacesCard */ import React, { type HTMLAttributes, @@ -22,7 +19,6 @@ import type { Workspace } from '../../typesConstants'; import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; import { Card } from '../Card'; import { CoderAuthWrapper } from '../CoderAuthWrapper'; -import { EntityDataReminder } from './EntityDataReminder'; export type WorkspacesQuery = UseQueryResult; @@ -47,7 +43,7 @@ export type WorkspacesCardProps = Readonly< } >; -export const Root = ({ +const InnerRoot = ({ children, className, queryFilter: outerFilter, @@ -56,7 +52,6 @@ export const Root = ({ readEntityData = false, ...delegatedProps }: WorkspacesCardProps) => { - const hookId = useId(); const [innerFilter, setInnerFilter] = useState(defaultQueryFilter); const activeFilter = outerFilter ?? innerFilter; @@ -66,11 +61,8 @@ export const Root = ({ coderQuery: activeFilter, }); + const hookId = useId(); const headerId = `${hookId}-header`; - const showEntityDataReminder = - readEntityData && - !workspacesConfig.repoUrl && - workspacesQuery.data !== undefined; return ( @@ -99,13 +91,22 @@ export const Root = ({ cases around keyboard input and button children that native
elements automatically introduce */}
{children}
- {showEntityDataReminder && } ); }; +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 + const renderKey = String(props.readEntityData ?? false); + return ; +} + export function useWorkspacesCardContext(): WorkspacesCardContext { const contextValue = useContext(CardContext); if (contextValue === null) { diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx index ecb31bb7..a0894946 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/SearchBox.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; import { CardContext, WorkspacesCardContext } from './Root'; import { SearchBox } from './SearchBox'; -import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -28,18 +27,14 @@ async function renderSearchBox(input?: RenderInputs) { const { queryFilter = 'owner:me' } = input ?? {}; const onFilterChange = jest.fn(); - const mockContext: WorkspacesCardContext = { + const mockContext: Partial = { onFilterChange, queryFilter, - headerId: "Doesn't matter", - workspacesConfig: mockCoderWorkspacesConfig, - workspacesQuery: - null as unknown as WorkspacesCardContext['workspacesQuery'], }; const renderOutput = await renderInCoderEnvironment({ children: ( - + ), diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx index f2033a82..50bc1de1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx @@ -10,22 +10,22 @@ import { screen } from '@testing-library/react'; type RenderInputs = Readonly<{ workspacesQuery: Partial; renderListItem?: WorkspacesListProps['renderListItem']; + repoUrl?: string; }>; function renderWorkspacesList(inputs?: RenderInputs) { - const { renderListItem, workspacesQuery } = inputs ?? {}; - - const mockContext: WorkspacesCardContext = { - headerId: "Doesn't matter", - queryFilter: "Also doesn't matter", - onFilterChange: jest.fn(), - workspacesConfig: mockCoderWorkspacesConfig, + const { renderListItem, workspacesQuery, repoUrl } = inputs ?? {}; + const mockContext: Partial = { workspacesQuery: workspacesQuery as WorkspacesQuery, + workspacesConfig: { + ...mockCoderWorkspacesConfig, + repoUrl, + }, }; return renderInCoderEnvironment({ children: ( - + ), @@ -38,8 +38,8 @@ function renderWorkspacesList(inputs?: RenderInputs) { describe(`${WorkspacesList.name}`, () => { it('Allows the user to provide their own callback for iterating through each item', async () => { const workspaceNames = ['dog', 'cat', 'bird']; - await renderWorkspacesList({ + repoUrl: mockCoderWorkspacesConfig.repoUrl, workspacesQuery: { data: workspaceNames.map((name, index) => ({ ...mockWorkspaceWithMatch, @@ -63,4 +63,24 @@ describe(`${WorkspacesList.name}`, () => { expect(listItem).toBeInstanceOf(HTMLLIElement); } }); + + it('Displays the call-to-action link for making new workspaces when nothing is loading, but there is no data', async () => { + await renderWorkspacesList({ + repoUrl: mockCoderWorkspacesConfig.repoUrl, + workspacesQuery: { data: [] }, + }); + + const ctaLink = screen.getByRole('link', { name: /Create workspace/ }); + expect(ctaLink).toBeInTheDocument(); + }); + + it('Does NOT display the call-to-action link for making new workspaces when there is no workspace creation URL', async () => { + await renderWorkspacesList({ + repoUrl: undefined, + workspacesQuery: { data: [] }, + }); + + const ctaLink = screen.queryByRole('link', { name: /Create workspace/ }); + expect(ctaLink).not.toBeInTheDocument(); + }); }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx index 03860201..1e47b08a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx @@ -97,12 +97,12 @@ export const WorkspacesList = ({ {workspacesQuery.data?.length === 0 && ( <> {emptyState ?? ( - + {repoUrl ? ( -
+ No workspaces found for repo {repoUrl} -
+ ) : ( <>No workspaces returned for your query )} diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts index 55b94206..deff6410 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/index.ts @@ -7,3 +7,4 @@ export * from './SearchBox'; export * from './WorkspacesList'; export * from './WorkspacesListIcon'; export * from './WorkspacesListItem'; +export * from './ReminderAccordion'; diff --git a/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.test.tsx b/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.test.tsx new file mode 100644 index 00000000..09894e48 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type DisclosureProps, Disclosure } from './Disclosure'; + +type RenderInputs = Partial; + +function renderDisclosure(inputs?: RenderInputs) { + const { headerText, children, isExpanded, onExpansionToggle } = inputs ?? {}; + + return render( + + {children} + , + ); +} + +describe(`${Disclosure.name}`, () => { + it('Will toggle between showing/hiding the disclosure info when the user clicks it', async () => { + const headerText = 'Blah'; + const children = 'Blah blah blah blah'; + renderDisclosure({ headerText, children }); + + const user = userEvent.setup(); + const disclosureButton = screen.getByRole('button', { name: headerText }); + await user.click(disclosureButton); + + const disclosureInfo = await screen.findByText(children); + await user.click(disclosureButton); + expect(disclosureInfo).not.toBeInTheDocument(); + }); + + it('Can flip from an uncontrolled input to a controlled one if additional props are passed in', async () => { + const headerText = 'Blah'; + const children = 'Blah blah blah blah'; + const onExpansionToggle = jest.fn(); + + const { rerender } = renderDisclosure({ + onExpansionToggle, + headerText, + children, + isExpanded: true, + }); + + const user = userEvent.setup(); + const disclosureInfo = await screen.findByText(children); + const disclosureButton = screen.getByRole('button', { name: headerText }); + + await user.click(disclosureButton); + expect(onExpansionToggle).toHaveBeenCalled(); + + rerender( + + {children} + , + ); + + expect(disclosureInfo).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.tsx b/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.tsx new file mode 100644 index 00000000..c53eca54 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/Disclosure/Disclosure.tsx @@ -0,0 +1,93 @@ +import React, { type HTMLAttributes, type ReactNode, useState } from 'react'; +import { useId } from '../../hooks/hookPolyfills'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + disclosureTriangle: { + display: 'inline-block', + textAlign: 'right', + width: theme.spacing(2.25), + fontSize: '0.7rem', + }, + + disclosureBody: { + margin: 0, + padding: `${theme.spacing(0.5)}px ${theme.spacing(3.5)}px 0 ${theme.spacing( + 4, + )}px`, + }, + + button: { + width: '100%', + textAlign: 'left', + color: theme.palette.text.primary, + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(1), + border: 'none', + borderRadius: theme.shape.borderRadius, + fontSize: theme.typography.body2.fontSize, + cursor: 'pointer', + + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + + '&:not(:first-child)': { + paddingTop: theme.spacing(6), + }, + }, +})); + +export type DisclosureProps = Readonly< + HTMLAttributes & { + isExpanded?: boolean; + onExpansionToggle?: () => void; + headerText: ReactNode; + } +>; + +export const Disclosure = ({ + isExpanded, + onExpansionToggle, + headerText, + children, + ...delegatedProps +}: DisclosureProps) => { + const hookId = useId(); + const styles = useStyles(); + const [internalIsExpanded, setInternalIsExpanded] = useState( + isExpanded ?? false, + ); + + const activeIsExpanded = isExpanded ?? internalIsExpanded; + const disclosureBodyId = `${hookId}-disclosure-body`; + + // Might be worth revisiting the markup here to try implementing this + // functionality with and elements. Would likely clean up + // the component code a bit but might reduce control over screen reader output + return ( +
+ + + {activeIsExpanded && ( +

+ {children} +

+ )} +
+ ); +}; diff --git a/plugins/backstage-plugin-coder/src/components/InlineCodeSnippet/InlineCodeSnippet.tsx b/plugins/backstage-plugin-coder/src/components/InlineCodeSnippet/InlineCodeSnippet.tsx new file mode 100644 index 00000000..7743bdc8 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/InlineCodeSnippet/InlineCodeSnippet.tsx @@ -0,0 +1,32 @@ +import React, { HTMLAttributes } from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + root: { + fontSize: theme.typography.body2.fontSize, + color: theme.palette.text.primary, + borderRadius: theme.spacing(0.5), + padding: `${theme.spacing(0.2)}px ${theme.spacing(1)}px`, + backgroundColor: () => { + const isLightTheme = theme.palette.type === 'light'; + return isLightTheme + ? 'hsl(0deg,0%,93%)' + : theme.palette.background.default; + }, + }, +})); + +type Props = Readonly< + Omit, 'children'> & { + children: string; + } +>; + +export function InlineCodeSnippet({ children, ...delegatedProps }: Props) { + const styles = useStyles(); + return ( + + {children} + + ); +} diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts index 8e189225..bfd079b5 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.test.ts @@ -111,6 +111,7 @@ describe(`${useCoderWorkspacesConfig.name}`, () => { ); expect(result.current).toEqual({ + isReadingEntityData: true, mode: mockYamlConfig.mode, repoUrl: cleanedRepoUrl, creationUrl: mockCoderWorkspacesConfig.creationUrl, diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts index 999a60b7..67bbb556 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesConfig.ts @@ -23,19 +23,22 @@ import { useCoderAppConfig, } from '../components/CoderProvider'; +const workspaceCreationModeSchema = optional( + union( + [literal('manual'), literal('auto')], + "If defined, createMode must be 'manual' or 'auto'", + ), +); + +export type WorkspaceCreationMode = Output; + // Very loose parsing requirements to make interfacing with various kinds of // YAML files as easy as possible const yamlConfigSchema = union([ undefined_(), object({ templateName: optional(string()), - mode: optional( - union( - [literal('manual'), literal('auto')], - "If defined, createMode must be 'manual' or 'auto'", - ), - ), - + mode: workspaceCreationModeSchema, params: optional( record( string(), @@ -49,6 +52,11 @@ const yamlConfigSchema = union([ }), ]); +/** + * The set of properties that the Coder plugin is configured to parse from a + * repo's catalog-info.yaml file. The entire value will be undefined if a repo + * does not have the file + */ export type YamlConfig = Output; /** @@ -56,11 +64,12 @@ export type YamlConfig = Output; * sourced from CoderAppConfig and any entity data. */ export type CoderWorkspacesConfig = - // Was originally defined in terms of fancy mapped types; ended up being a bad - // idea, because it increased coupling in a bad way + // Was originally defined in terms of fancy mapped types based on YamlConfig; + // ended up being a bad idea, because it increased coupling in a bad way Readonly<{ - creationUrl: string; - templateName: string; + isReadingEntityData: boolean; + creationUrl?: string; + templateName?: string; repoUrlParamKeys: readonly string[]; mode: 'manual' | 'auto'; params: Record; @@ -71,17 +80,19 @@ export type CoderWorkspacesConfig = export function compileCoderConfig( appConfig: CoderAppConfig, - rawYamlConfig: unknown, + rawYamlConfig: unknown, // Function parses this into more specific type repoUrl: string | undefined, ): CoderWorkspacesConfig { const { workspaces, deployment } = appConfig; const yamlConfig = parse(yamlConfigSchema, rawYamlConfig); - const mode = yamlConfig?.mode ?? workspaces.mode ?? 'manual'; + const mode = yamlConfig?.mode ?? workspaces.defaultMode ?? 'manual'; + const templateName = + yamlConfig?.templateName ?? workspaces.defaultTemplateName; const urlParams = new URLSearchParams({ mode }); const compiledParams: Record = {}; - // Can't replace this with destructuring, because that is all-or-nothing; + // Can't replace section with destructuring, because that's all-or-nothing; // there's no easy way to granularly check each property without a loop const paramsPrecedence = [workspaces.params, yamlConfig?.params ?? {}]; for (const params of paramsPrecedence) { @@ -112,21 +123,22 @@ export function compileCoderConfig( } } - const safeTemplate = encodeURIComponent( - yamlConfig?.templateName ?? workspaces.templateName, - ); - - const creationUrl = `${ - deployment.accessUrl - }/templates/${safeTemplate}/workspace?${urlParams.toString()}`; + let creationUrl: string | undefined = undefined; + if (templateName) { + const safeTemplate = encodeURIComponent(templateName); + creationUrl = `${ + deployment.accessUrl + }/templates/${safeTemplate}/workspace?${urlParams.toString()}`; + } return { + mode, creationUrl, + templateName, repoUrl: cleanedRepoUrl, + isReadingEntityData: yamlConfig !== undefined, repoUrlParamKeys: workspaces.repoUrlParamKeys, params: compiledParams, - templateName: yamlConfig?.templateName ?? workspaces.templateName, - mode: yamlConfig?.mode ?? workspaces.mode ?? 'manual', }; } diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 85ae7178..7de9929e 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -149,6 +149,18 @@ export const CoderWorkspacesCardWorkspacesListItem = coderPlugin.provide( }), ); +export const CoderWorkspacesReminderAccordion = coderPlugin.provide( + createComponentExtension({ + name: 'CoderWorkspacesCard.ReminderAccordion', + component: { + lazy: () => + import('./components/CoderWorkspacesCard').then( + m => m.ReminderAccordion, + ), + }, + }), +); + /** * All custom hooks exposed by the plugin. */ diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 10b8723e..049050cc 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -89,8 +89,8 @@ export const mockAppConfig = { }, workspaces: { - templateName: 'devcontainers', - mode: 'manual', + defaultTemplateName: 'devcontainers', + defaultMode: 'manual', repoUrlParamKeys: ['custom_repo', 'repo_url'], params: { repo: 'custom', @@ -99,7 +99,7 @@ export const mockAppConfig = { }, } as const satisfies CoderAppConfig; -export const mockCoderWorkspacesConfig: CoderWorkspacesConfig = (() => { +export const mockCoderWorkspacesConfig = (() => { const urlParams = new URLSearchParams({ mode: mockYamlConfig.mode, 'param.repo': mockAppConfig.workspaces.params.repo, @@ -110,6 +110,7 @@ export const mockCoderWorkspacesConfig: CoderWorkspacesConfig = (() => { return { mode: 'auto', + isReadingEntityData: true, templateName: mockYamlConfig.templateName, repoUrlParamKeys: ['custom_repo', 'repo_url'], repoUrl: cleanedRepoUrl, @@ -124,7 +125,7 @@ export const mockCoderWorkspacesConfig: CoderWorkspacesConfig = (() => { custom_repo: cleanedRepoUrl, repo_url: cleanedRepoUrl, }, - }; + } as const satisfies CoderWorkspacesConfig; })(); const authedState = {