feat: show a warning when an organization has no provisioners (#14136)

This commit is contained in:
Kayla Washburn-Love
2024-08-05 10:44:39 -06:00
committed by GitHub
parent efbd6257e4
commit dfeafa8f5a
5 changed files with 113 additions and 11 deletions

View File

@ -67,6 +67,13 @@ There are two exceptions:
**Organization-scoped Provisioners** can pick up build jobs created by any user. **Organization-scoped Provisioners** can pick up build jobs created by any user.
These provisioners always have the implicit tags `scope=organization owner=""`. These provisioners always have the implicit tags `scope=organization owner=""`.
```shell
coder provisionerd start --org <organization_name>
```
If you omit the `--org` argument, the provisioner will be assigned to the
default organization.
```shell ```shell
coder provisionerd start coder provisionerd start
``` ```

View File

@ -627,6 +627,18 @@ class ApiMethods {
return response.data; return response.data;
}; };
/**
* @param organization Can be the organization's ID or name
*/
getProvisionerDaemonsByOrganization = async (
organization: string,
): Promise<TypesGen.ProvisionerDaemon[]> => {
const response = await this.axios.get<TypesGen.ProvisionerDaemon[]>(
`/api/v2/organizations/${organization}/provisionerdaemons`,
);
return response.data;
};
getTemplate = async (templateId: string): Promise<TypesGen.Template> => { getTemplate = async (templateId: string): Promise<TypesGen.Template> => {
const response = await this.axios.get<TypesGen.Template>( const response = await this.axios.get<TypesGen.Template>(
`/api/v2/templates/${templateId}`, `/api/v2/templates/${templateId}`,

View File

@ -107,3 +107,16 @@ export const organizations = () => {
queryFn: () => API.getOrganizations(), queryFn: () => API.getOrganizations(),
}; };
}; };
export const getProvisionerDaemonsKey = (organization: string) => [
"organization",
organization,
"provisionerDaemons",
];
export const provisionerDaemons = (organization: string) => {
return {
queryKey: getProvisionerDaemonsKey(organization),
queryFn: () => API.getProvisionerDaemonsByOrganization(organization),
};
};

View File

@ -1,6 +1,13 @@
import { action } from "@storybook/addon-actions"; import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { screen, userEvent } from "@storybook/test";
import { import {
getProvisionerDaemonsKey,
organizationsKey,
} from "api/queries/organizations";
import {
MockDefaultOrganization,
MockOrganization2,
MockTemplate, MockTemplate,
MockTemplateExample, MockTemplateExample,
MockTemplateVersionVariable1, MockTemplateVersionVariable1,
@ -54,6 +61,31 @@ export const StarterTemplateWithOrgPicker: Story = {
}, },
}; };
export const StarterTemplateWithProvisionerWarning: Story = {
parameters: {
queries: [
{
key: organizationsKey,
data: [MockDefaultOrganization, MockOrganization2],
},
{
key: getProvisionerDaemonsKey(MockOrganization2.id),
data: [],
},
],
},
args: {
...StarterTemplate.args,
showOrganizationPicker: true,
},
play: async () => {
const organizationPicker = screen.getByPlaceholderText("Organization name");
await userEvent.click(organizationPicker);
const org2 = await screen.findByText(MockOrganization2.display_name);
await userEvent.click(org2);
},
};
export const DuplicateTemplateWithVariables: Story = { export const DuplicateTemplateWithVariables: Story = {
args: { args: {
copiedTemplate: MockTemplate, copiedTemplate: MockTemplate,

View File

@ -1,10 +1,13 @@
import Link from "@mui/material/Link";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useFormik } from "formik"; import { useFormik } from "formik";
import camelCase from "lodash/camelCase"; import camelCase from "lodash/camelCase";
import capitalize from "lodash/capitalize"; import capitalize from "lodash/capitalize";
import { useState, type FC } from "react"; import { useState, type FC } from "react";
import { useQuery } from "react-query";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import * as Yup from "yup"; import * as Yup from "yup";
import { provisionerDaemons } from "api/queries/organizations";
import type { import type {
Organization, Organization,
ProvisionerJobLog, ProvisionerJobLog,
@ -14,6 +17,7 @@ import type {
TemplateVersionVariable, TemplateVersionVariable,
VariableValue, VariableValue,
} from "api/typesGenerated"; } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { import {
HorizontalForm, HorizontalForm,
FormSection, FormSection,
@ -23,6 +27,7 @@ import {
import { IconField } from "components/IconField/IconField"; import { IconField } from "components/IconField/IconField";
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete"; import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate"; import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate";
import { docs } from "utils/docs";
import { import {
nameValidator, nameValidator,
getFormHelpers, getFormHelpers,
@ -210,6 +215,24 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
}); });
const getFieldHelpers = getFormHelpers<CreateTemplateFormData>(form, error); const getFieldHelpers = getFormHelpers<CreateTemplateFormData>(form, error);
const provisionerDaemonsQuery = useQuery(
selectedOrg
? {
...provisionerDaemons(selectedOrg.id),
enabled: showOrganizationPicker,
select: (provisioners) => provisioners.length < 1,
}
: { enabled: false },
);
// TODO: Ideally, we would have a backend endpoint that could notify the
// frontend that a provisioner has been connected, so that we could hide
// this warning. In the meantime, **do not use this variable to disable
// form submission**!! A user could easily see this warning, connect a
// provisioner, and then not refresh the page. Even if they submit without
// a provisioner, it'll just sit in the job queue until they connect one.
const showProvisionerWarning = provisionerDaemonsQuery.data;
return ( return (
<HorizontalForm onSubmit={form.handleSubmit}> <HorizontalForm onSubmit={form.handleSubmit}>
{/* General info */} {/* General info */}
@ -232,17 +255,20 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
)} )}
{showOrganizationPicker && ( {showOrganizationPicker && (
<OrganizationAutocomplete <>
{...getFieldHelpers("organization")} {showProvisionerWarning && <ProvisionerWarning />}
required <OrganizationAutocomplete
label="Belongs to" {...getFieldHelpers("organization")}
value={selectedOrg} required
onChange={(newValue) => { label="Belongs to"
setSelectedOrg(newValue); value={selectedOrg}
void form.setFieldValue("organization", newValue?.name || ""); onChange={(newValue) => {
}} setSelectedOrg(newValue);
size="medium" void form.setFieldValue("organization", newValue?.name || "");
/> }}
size="medium"
/>
</>
)} )}
{"copiedTemplate" in props && ( {"copiedTemplate" in props && (
@ -369,3 +395,15 @@ const fillNameAndDisplayWithFilename = async (
form.setFieldValue("display_name", capitalize(name)), form.setFieldValue("display_name", capitalize(name)),
]); ]);
}; };
const ProvisionerWarning: FC = () => {
return (
<Alert severity="warning" css={{ marginBottom: 16 }}>
This organization does not have any provisioners. Before you create a
template, you&apos;ll need to configure a provisioner.{" "}
<Link href={docs("/admin/provisioners#organization-scoped-provisioners")}>
See our documentation.
</Link>
</Alert>
);
};