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
);
@@ -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 (
+