refactor: use the new button component on forms and dialogs (#16050)

This is a significant PR that will impact many parts of the UI, so I’d
like to ask you, @jaaydenh, for a very thorough review of the Storybook
stories on Chromatic. I know it’s a bit of a hassle with around 180
stories affected, but it’s all for a good cause 💪

Main changes:
- Update the `Button` component to match the [new buttons
design](https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=3-1756&p=f&m=dev).
- Update forms and dialogs to use the new `Button` component.

Related to https://github.com/coder/coder/issues/14978
This commit is contained in:
Bruno Quaresma
2025-01-07 14:28:58 -03:00
committed by GitHub
parent 289338f19e
commit cb6facb53a
54 changed files with 443 additions and 500 deletions

View File

@ -147,7 +147,7 @@ export const createWorkspace = async (
await popup.waitForSelector("text=You are now authenticated.");
}
await page.getByTestId("form-submit").click();
await page.getByRole("button", { name: /create workspace/i }).click();
const user = currentUser(page);
@ -276,7 +276,7 @@ export const createTemplate = async (
const name = randomName();
await page.getByLabel("Name *").fill(name);
await page.getByTestId("form-submit").click();
await page.getByRole("button", { name: /save/i }).click();
await expectUrl(page).toHavePathName(
organizationsEnabled
? `/templates/${orgName}/${name}/files`
@ -298,7 +298,7 @@ export const createGroup = async (page: Page): Promise<string> => {
const name = randomName();
await page.getByLabel("Name", { exact: true }).fill(name);
await page.getByTestId("form-submit").click();
await page.getByRole("button", { name: /save/i }).click();
await expectUrl(page).toHavePathName(`/groups/${name}`);
return name;
};
@ -982,7 +982,7 @@ export const updateTemplateSettings = async (
await page.getByLabel(labelText, { exact: true }).fill(value);
}
await page.getByTestId("form-submit").click();
await page.getByRole("button", { name: /save/i }).click();
const name = templateSettingValues.name ?? templateName;
await expectUrl(page).toHavePathNameEndingWith(`/${name}`);
@ -1003,7 +1003,7 @@ export const updateWorkspace = async (
await page.getByTestId("confirm-button").click();
await fillParameters(page, richParameters, buildParameters);
await page.getByTestId("form-submit").click();
await page.getByRole("button", { name: /update parameters/i }).click();
await page.waitForSelector("*[data-testid='build-status'] >> text=Running", {
state: "visible",
@ -1024,7 +1024,7 @@ export const updateWorkspaceParameters = async (
);
await fillParameters(page, richParameters, buildParameters);
await page.getByTestId("form-submit").click();
await page.getByRole("button", { name: /submit and restart/i }).click();
await page.waitForSelector("*[data-testid='build-status'] >> text=Running", {
state: "visible",
@ -1091,7 +1091,7 @@ export async function createUser(
// as the label for the currently active option.
const passwordField = page.locator("input[name=password]");
await passwordField.fill(password);
await page.getByRole("button", { name: "Create user" }).click();
await page.getByRole("button", { name: /save/i }).click();
await expect(page.getByText("Successfully created user.")).toBeVisible();
await expect(page).toHaveTitle("Users - Coder");
@ -1123,7 +1123,7 @@ export async function createOrganization(page: Page): Promise<{
const description = `Org description ${name}`;
await page.getByLabel("Description").fill(description);
await page.getByLabel("Icon", { exact: true }).fill("/emojis/1f957.png");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: /save/i }).click();
await expectUrl(page).toHavePathName(`/organizations/${name}`);
await expect(page.getByText("Organization created.")).toBeVisible();

View File

@ -67,14 +67,14 @@ test.describe("IdpOrgSyncPage", () => {
const syncField = page.getByRole("textbox", {
name: "Organization sync field",
});
const saveButton = page.getByRole("button", { name: "Save" }).first();
const saveButton = page.getByRole("button", { name: /save/i }).first();
await expect(saveButton).toBeDisabled();
await syncField.fill("test-field");
await expect(saveButton).toBeEnabled();
await page.getByRole("button", { name: "Save" }).click();
await page.getByRole("button", { name: /save/i }).click();
await expect(
page.getByText("Organization sync settings updated."),

View File

@ -27,7 +27,7 @@ test("create group", async ({ page, baseURL }) => {
await page.getByLabel("Name", { exact: true }).fill(groupValues.name);
await page.getByLabel("Display Name").fill(groupValues.displayName);
await page.getByLabel("Avatar URL").fill(groupValues.avatarURL);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: /save/i }).click();
await expect(page).toHaveTitle(`${groupValues.displayName} - Coder`);
await expect(page.getByText(groupValues.displayName)).toBeVisible();

View File

@ -34,7 +34,7 @@ test("create group", async ({ page }) => {
const displayName = `Group ${name}`;
await page.getByLabel("Display Name").fill(displayName);
await page.getByLabel("Avatar URL").fill("/emojis/1f60d.png");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: /save/i }).click();
await expectUrl(page).toHavePathName(
`/organizations/${org.name}/groups/${name}`,
@ -91,7 +91,7 @@ test("change quota settings", async ({ page }) => {
// Update Quota
await page.getByLabel("Quota Allowance").fill("100");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: /save/i }).click();
// We should get sent back to the group page afterwards
expectUrl(page).toHavePathName(

View File

@ -23,7 +23,7 @@ test("create and delete organization", async ({ page }) => {
await page.getByLabel("Display name").fill(`Org ${name}`);
await page.getByLabel("Description").fill(`Org description ${name}`);
await page.getByLabel("Icon", { exact: true }).fill("/emojis/1f957.png");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: /save/i }).click();
// Expect to be redirected to the new organization
await expectUrl(page).toHavePathName(`/organizations/${name}`);
@ -32,7 +32,7 @@ test("create and delete organization", async ({ page }) => {
const newName = randomName();
await page.getByLabel("Slug").fill(newName);
await page.getByLabel("Description").fill(`Org description ${newName}`);
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: /save/i }).click();
// Expect to be redirected when renaming the organization
await expectUrl(page).toHavePathName(`/organizations/${newName}`);

View File

@ -87,7 +87,7 @@ test.describe("CustomRolesPage", () => {
await expect(organizationMemberCheckbox).toBeVisible();
await organizationMemberCheckbox.click();
const saveButton = page.getByRole("button", { name: "Save" }).first();
const saveButton = page.getByRole("button", { name: /save/i }).first();
await expect(saveButton).toBeVisible();
await saveButton.click();

View File

@ -36,7 +36,7 @@ test("update template schedule settings without override other settings", async
waitUntil: "domcontentloaded",
});
await page.getByLabel("Default autostop (hours)").fill("48");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: /save/i }).click();
await expect(page.getByText("Template updated successfully")).toBeVisible();
const updatedTemplate = await API.getTemplate(template.id);

View File

@ -67,7 +67,7 @@ test("require latest version", async ({ page }) => {
await expectUrl(page).toHavePathName(`/templates/${templateName}/settings`);
let checkbox = await page.waitForSelector("#require_active_version");
await checkbox.click();
await page.getByTestId("form-submit").click();
await page.getByRole("button", { name: /save/i }).click();
await page.goto(`/templates/${templateName}/settings`, {
waitUntil: "domcontentloaded",

View File

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Trash } from "lucide-react";
import { PlusIcon } from "lucide-react";
import { Button } from "./Button";
const meta: Meta<typeof Button> = {
@ -8,7 +8,7 @@ const meta: Meta<typeof Button> = {
args: {
children: (
<>
<Trash />
<PlusIcon />
Button
</>
),
@ -20,30 +20,24 @@ type Story = StoryObj<typeof Button>;
export const Default: Story = {};
export const Outline: Story = {
args: {
variant: "outline",
},
};
export const Subtle: Story = {
args: {
variant: "subtle",
},
};
export const Warning: Story = {
args: {
variant: "warning",
},
};
export const DefaultDisabled: Story = {
args: {
disabled: true,
},
};
export const DefaultSmall: Story = {
args: {
size: "sm",
},
};
export const Outline: Story = {
args: {
variant: "outline",
},
};
export const OutlineDisabled: Story = {
args: {
variant: "outline",
@ -51,6 +45,19 @@ export const OutlineDisabled: Story = {
},
};
export const OutlineSmall: Story = {
args: {
variant: "outline",
size: "sm",
},
};
export const Subtle: Story = {
args: {
variant: "subtle",
},
};
export const SubtleDisabled: Story = {
args: {
variant: "subtle",
@ -58,23 +65,51 @@ export const SubtleDisabled: Story = {
},
};
export const SubtleSmall: Story = {
args: {
variant: "subtle",
size: "sm",
},
};
export const Destructive: Story = {
args: {
variant: "destructive",
children: "Delete",
},
};
export const DestructiveDisabled: Story = {
args: {
...Destructive.args,
disabled: true,
},
};
export const DestructiveSmall: Story = {
args: {
...Destructive.args,
size: "sm",
},
};
export const IconButtonDefault: Story = {
args: {
variant: "default",
children: <Trash />,
children: <PlusIcon />,
},
};
export const IconButtonOutline: Story = {
args: {
variant: "outline",
children: <Trash />,
children: <PlusIcon />,
},
};
export const IconButtonSubtle: Story = {
args: {
variant: "subtle",
children: <Trash />,
children: <PlusIcon />,
},
};

View File

@ -8,38 +8,33 @@ import { forwardRef } from "react";
import { cn } from "utils/cn";
export const buttonVariants = cva(
`inline-flex items-center justify-center gap-2 whitespace-nowrap
`inline-flex items-center justify-center gap-1 whitespace-nowrap
border-solid rounded-md transition-colors
text-sm font-semibold font-medium cursor-pointer
text-sm font-semibold font-medium cursor-pointer no-underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:pointer-events-none disabled:text-content-disabled
[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0
px-3 py-2`,
[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-[2px]`,
{
variants: {
variant: {
default:
"bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary",
"bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary font-semibold",
outline:
"border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary",
subtle:
"border-none bg-transparent text-content-secondary hover:text-content-primary",
warning:
"border border-border-error text-content-primary bg-surface-error hover:bg-transparent",
ghost:
"text-content-primary bg-transparent border-0 hover:bg-surface-secondary",
destructive:
"border border-border-destructive text-content-primary bg-surface-destructive hover:bg-transparent disabled:bg-transparent disabled:text-content-disabled font-semibold",
},
size: {
lg: "h-10",
default: "h-9",
sm: "h-8 px-2 py-1.5 text-xs",
icon: "h-10 w-10",
lg: "h-10 px-3 py-2 [&_svg]:size-icon-lg",
sm: "h-[30px] px-2 py-1.5 text-xs [&_svg]:size-icon-sm",
},
},
defaultVariants: {
variant: "default",
size: "default",
size: "lg",
},
},
);

View File

@ -1,5 +1,7 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent } from "@storybook/test";
import { within } from "@testing-library/react";
import { DeleteDialog } from "./DeleteDialog";
const meta: Meta<typeof DeleteDialog> = {
@ -19,12 +21,28 @@ export default meta;
type Story = StoryObj<typeof DeleteDialog>;
const Example: Story = {};
export const Idle: Story = {};
export const FilledSuccessfully: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const input = await body.findByLabelText("Name of the foo to delete");
await user.type(input, "MyFoo");
},
};
export const FilledWrong: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const input = await body.findByLabelText("Name of the foo to delete");
await user.type(input, "InvalidFooName");
},
};
export const Loading: Story = {
args: {
confirmLoading: true,
},
};
export { Example as DeleteDialog };

View File

@ -1,8 +1,6 @@
import type { Interpolation, Theme } from "@emotion/react";
import LoadingButton, { type LoadingButtonProps } from "@mui/lab/LoadingButton";
import MuiDialog, {
type DialogProps as MuiDialogProps,
} from "@mui/material/Dialog";
import MuiDialog, { type DialogProps } from "@mui/material/Dialog";
import { Button } from "components/Button/Button";
import { Spinner } from "components/Spinner/Spinner";
import type { FC, ReactNode } from "react";
import type { ConfirmDialogType } from "./types";
@ -22,13 +20,6 @@ export interface DialogActionButtonsProps {
type?: ConfirmDialogType;
}
const typeToColor = (type: ConfirmDialogType): LoadingButtonProps["color"] => {
if (type === "delete") {
return "secondary";
}
return "primary";
};
/**
* Quickly handles most modals actions, some combination of a cancel and confirm button
*/
@ -44,124 +35,29 @@ export const DialogActionButtons: FC<DialogActionButtonsProps> = ({
return (
<>
{onCancel && (
<LoadingButton disabled={confirmLoading} onClick={onCancel} fullWidth>
<Button disabled={confirmLoading} onClick={onCancel} variant="outline">
{cancelText}
</LoadingButton>
</Button>
)}
{onConfirm && (
<LoadingButton
fullWidth
data-testid="confirm-button"
variant="contained"
<Button
variant={type === "delete" ? "destructive" : undefined}
disabled={confirmLoading || disabled}
onClick={onConfirm}
color={typeToColor(type)}
loading={confirmLoading}
disabled={disabled}
data-testid="confirm-button"
type="submit"
css={[
type === "delete" && styles.dangerButton,
type === "success" && styles.successButton,
]}
>
{confirmLoading && <Spinner loading />}
{confirmText}
</LoadingButton>
</Button>
)}
</>
);
};
const styles = {
dangerButton: (theme) => ({
"&.MuiButton-contained": {
backgroundColor: theme.roles.danger.fill.solid,
borderColor: theme.roles.danger.fill.outline,
"&:not(.MuiLoadingButton-loading)": {
color: theme.roles.danger.fill.text,
},
"&:hover:not(:disabled)": {
backgroundColor: theme.roles.danger.hover.fill.solid,
borderColor: theme.roles.danger.hover.fill.outline,
},
"&.Mui-disabled": {
backgroundColor: theme.roles.danger.disabled.background,
borderColor: theme.roles.danger.disabled.outline,
"&:not(.MuiLoadingButton-loading)": {
color: theme.roles.danger.disabled.fill.text,
},
},
},
}),
successButton: (theme) => ({
"&.MuiButton-contained": {
backgroundColor: theme.palette.success.dark,
"&:not(.MuiLoadingButton-loading)": {
color: theme.palette.primary.contrastText,
},
"&:hover": {
backgroundColor: theme.palette.success.main,
"@media (hover: none)": {
backgroundColor: "transparent",
},
"&.Mui-disabled": {
backgroundColor: "transparent",
},
},
"&.Mui-disabled": {
backgroundColor: theme.palette.success.dark,
"&:not(.MuiLoadingButton-loading)": {
color: theme.palette.text.secondary,
},
},
},
"&.MuiButton-outlined": {
color: theme.palette.success.main,
borderColor: theme.palette.success.main,
"&:hover": {
backgroundColor: theme.palette.success.dark,
"@media (hover: none)": {
backgroundColor: "transparent",
},
"&.Mui-disabled": {
backgroundColor: "transparent",
},
},
"&.Mui-disabled": {
color: theme.palette.text.secondary,
borderColor: theme.palette.action.disabled,
},
},
"&.MuiButton-text": {
color: theme.palette.success.main,
"&:hover": {
backgroundColor: theme.palette.success.dark,
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
"&.Mui-disabled": {
color: theme.palette.text.secondary,
},
},
}),
} satisfies Record<string, Interpolation<Theme>>;
export type DialogProps = MuiDialogProps;
/**
* Re-export of MUI's Dialog component, for convenience.
* @link See original documentation here: https://mui.com/material-ui/react-dialog/
*/
export { MuiDialog as Dialog };
export { MuiDialog as Dialog, type DialogProps };

View File

@ -10,11 +10,7 @@ import {
forwardRef,
useContext,
} from "react";
import {
FormFooter as BaseFormFooter,
type FormFooterProps,
type FormFooterStyles,
} from "../FormFooter/FormFooter";
import { cn } from "utils/cn";
type FormContextValue = { direction?: "horizontal" | "vertical" };
@ -191,29 +187,12 @@ const styles = {
},
} satisfies Record<string, Interpolation<Theme>>;
export const FormFooter: FC<Exclude<FormFooterProps, "styles">> = (props) => (
<BaseFormFooter {...props} styles={footerStyles} />
export const FormFooter: FC<HTMLProps<HTMLDivElement>> = ({
className,
...props
}) => (
<footer
className={cn("flex items-center justify-end space-x-2 mt-2", className)}
{...props}
/>
);
const footerStyles = {
button: (theme) => ({
minWidth: 184,
[theme.breakpoints.down("md")]: {
width: "100%",
},
}),
footer: (theme) => ({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
flexDirection: "row-reverse",
gap: 16,
[theme.breakpoints.down("md")]: {
flexDirection: "column",
gap: 8,
},
}),
} satisfies FormFooterStyles;

View File

@ -1,37 +0,0 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { FormFooter } from "./FormFooter";
const meta: Meta<typeof FormFooter> = {
title: "components/FormFooter",
component: FormFooter,
args: {
isLoading: false,
onCancel: action("onCancel"),
},
};
export default meta;
type Story = StoryObj<typeof FormFooter>;
export const Ready: Story = {
args: {},
};
export const NoCancel: Story = {
args: {
onCancel: undefined,
},
};
export const Custom: Story = {
args: {
submitLabel: "Create",
},
};
export const Loading: Story = {
args: {
isLoading: true,
},
};

View File

@ -1,78 +0,0 @@
import type { Interpolation, Theme } from "@emotion/react";
import LoadingButton from "@mui/lab/LoadingButton";
import Button from "@mui/material/Button";
import type { FC } from "react";
export const Language = {
cancelLabel: "Cancel",
defaultSubmitLabel: "Submit",
};
export interface FormFooterStyles {
footer: Interpolation<Theme>;
button: Interpolation<Theme>;
}
export interface FormFooterProps {
onCancel?: () => void;
isLoading: boolean;
styles?: FormFooterStyles;
submitLabel?: string;
submitDisabled?: boolean;
extraActions?: React.ReactNode;
}
export const FormFooter: FC<FormFooterProps> = ({
onCancel,
extraActions,
isLoading,
submitDisabled,
submitLabel = Language.defaultSubmitLabel,
styles = defaultStyles,
}) => {
return (
<div css={styles.footer}>
<LoadingButton
size="large"
tabIndex={0}
loading={isLoading}
css={styles.button}
variant="contained"
color="primary"
type="submit"
disabled={submitDisabled}
data-testid="form-submit"
>
{submitLabel}
</LoadingButton>
{onCancel && (
<Button
size="large"
type="button"
css={styles.button}
onClick={onCancel}
tabIndex={0}
>
{Language.cancelLabel}
</Button>
)}
{extraActions}
</div>
);
};
const defaultStyles = {
footer: {
display: "flex",
flex: "0",
// The first button is the submit so it is the first element to be focused
// on tab so we use row-reverse to display it on the right
flexDirection: "row-reverse",
gap: 12,
alignItems: "center",
marginTop: 24,
},
button: {
width: "100%",
},
} satisfies FormFooterStyles;

View File

@ -1,8 +1,8 @@
import TextField from "@mui/material/TextField";
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "components/Button/Button";
import { FormFooter } from "components/Form/Form";
import type { FC } from "react";
import { FormFooter } from "../FormFooter/FormFooter";
import { Stack } from "../Stack/Stack";
import { FullPageForm, type FullPageFormProps } from "./FullPageForm";
@ -16,7 +16,10 @@ const Template: FC<FullPageFormProps> = (props) => (
<Stack>
<TextField fullWidth label="Field 1" name="field1" />
<TextField fullWidth label="Field 2" name="field2" />
<FormFooter isLoading={false} onCancel={action("cancel")} />
<FormFooter>
<Button variant="outline">Cancel</Button>
<Button type="submit">Save</Button>
</FormFooter>
</Stack>
</form>
</FullPageForm>

View File

@ -18,10 +18,10 @@
--surface-quaternary: 240, 5%, 84%;
--surface-invert-primary: 240, 4%, 16%;
--surface-invert-secondary: 240, 5%, 26%;
--surface-error: 0, 100%, 14%;
--surface-destructive: 0, 93%, 94%;
--border-default: 240, 6%, 90%;
--border-success: 142, 76%, 36%;
--border-error: 0, 84%, 60%;
--border-destructive: 0, 84%, 60%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
@ -31,7 +31,6 @@
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--avatar-lg: 2.5rem;
--avatar-default: 1.5rem;
--avatar-sm: 1.125rem;
@ -51,10 +50,10 @@
--surface-quaternary: 240, 5%, 26%;
--surface-invert-primary: 240, 6%, 90%;
--surface-invert-secondary: 240, 5%, 65%;
--surface-error: 0, 100%, 14%;
--surface-destructive: 0, 75%, 15%;
--border-default: 240, 4%, 16%;
--border-success: 142, 76%, 36%;
--border-error: 0, 91%, 71%;
--border-destructive: 0, 91%, 71%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;

View File

@ -70,15 +70,11 @@ export const MobileMenu: FC<MobileMenuProps> = ({
<DropdownMenuTrigger asChild>
<Button
aria-label={open ? "Close menu" : "Open menu"}
size="icon"
variant="ghost"
className="ml-auto md:hidden [&_svg]:size-6"
size="lg"
variant="subtle"
className="ml-auto md:hidden"
>
{open ? (
<XIcon className="size-icon-lg" />
) : (
<MenuIcon className="size-icon-lg" />
)}
{open ? <XIcon /> : <MenuIcon />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent

View File

@ -11,6 +11,7 @@ import type {
VariableValue,
} from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
@ -19,6 +20,7 @@ import {
} from "components/Form/Form";
import { IconField } from "components/IconField/IconField";
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import camelCase from "lodash/camelCase";
import capitalize from "lodash/capitalize";
@ -350,37 +352,37 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
</FormSection>
)}
<div className="flex items-center">
<FormFooter
extraActions={
logs && (
<button
type="button"
onClick={onOpenBuildLogsDrawer}
css={(theme) => ({
backgroundColor: "transparent",
border: 0,
fontWeight: 500,
fontSize: 14,
cursor: "pointer",
color: theme.palette.text.secondary,
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Spinner />}
{jobError ? "Retry" : "Save"}
</Button>
{logs && (
<button
type="button"
onClick={onOpenBuildLogsDrawer}
css={(theme) => ({
backgroundColor: "transparent",
border: 0,
fontWeight: 500,
fontSize: 14,
cursor: "pointer",
color: theme.palette.text.secondary,
"&:hover": {
textDecoration: "underline",
textUnderlineOffset: 4,
color: theme.palette.text.primary,
},
})}
>
Show build logs
</button>
)
}
onCancel={onCancel}
isLoading={isSubmitting}
submitLabel={jobError ? "Retry" : "Create template"}
/>
</div>
"&:hover": {
textDecoration: "underline",
textUnderlineOffset: 4,
color: theme.palette.text.primary,
},
})}
>
Show build logs
</button>
)}
</FormFooter>
</HorizontalForm>
);
};

View File

@ -61,9 +61,7 @@ test("Create template from starter template", async () => {
MockTemplateVersionVariable3,
]);
await userEvent.type(screen.getByLabelText(/Name/), "my-template");
await userEvent.click(
within(form).getByRole("button", { name: /create template/i }),
);
await userEvent.click(within(form).getByRole("button", { name: /save/i }));
// Wait for the drawer error to be rendered
await screen.findByRole("heading", { name: /missing variables/i });
@ -92,9 +90,7 @@ test("Create template from starter template", async () => {
.mockResolvedValue(MockTemplateVersion);
jest.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion);
jest.spyOn(API, "createTemplate").mockResolvedValue(MockTemplate);
await userEvent.click(
within(form).getByRole("button", { name: /create template/i }),
);
await userEvent.click(within(form).getByRole("button", { name: /save/i }));
await waitFor(() => expect(API.createTemplate).toBeCalledTimes(1));
expect(router.state.location.pathname).toEqual(
`/templates/${MockTemplate.name}/files`,
@ -142,9 +138,7 @@ test("Create template from duplicating a template", async () => {
.mockResolvedValue(MockTemplateVersion);
jest.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion);
jest.spyOn(API, "createTemplate").mockResolvedValue(MockTemplate);
await userEvent.click(
screen.getByRole("button", { name: /create template/i }),
);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => {
expect(router.state.location.pathname).toEqual(
`/templates/${MockTemplate.name}/files`,

View File

@ -1,12 +1,14 @@
import { css } from "@emotion/css";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
FormSection,
HorizontalForm,
} from "components/Form/Form";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
@ -144,11 +146,16 @@ export const CreateTokenForm: FC<CreateTokenFormProps> = ({
</Stack>
</FormFields>
</FormSection>
<FormFooter
onCancel={() => navigate("/settings/tokens")}
isLoading={isCreating}
submitLabel={creationFailed ? "Retry" : "Create token"}
/>
<FormFooter>
<Button onClick={() => navigate("/settings/tokens")} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating && <Spinner />}
{creationFailed ? "Retry" : "Create token"}
</Button>
</FormFooter>
</HorizontalForm>
);
};

View File

@ -4,12 +4,14 @@ import TextField from "@mui/material/TextField";
import { hasApiFieldErrors, isApiError } from "api/errors";
import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { FormFooter } from "components/FormFooter/FormFooter";
import { Button } from "components/Button/Button";
import { FormFooter } from "components/Form/Form";
import { FullPageForm } from "components/FullPageForm/FullPageForm";
import { PasswordField } from "components/PasswordField/PasswordField";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import { type FormikContextType, useFormik } from "formik";
import { type FC, useEffect } from "react";
import type { FC } from "react";
import {
displayNameValidator,
getFormHelpers,
@ -202,11 +204,16 @@ export const CreateUserForm: FC<
label={Language.passwordLabel}
/>
</Stack>
<FormFooter
submitLabel="Create user"
onCancel={onCancel}
isLoading={isLoading}
/>
<FormFooter className="mt-8">
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Spinner />}
Save
</Button>
</FormFooter>
</form>
</FullPageForm>
);

View File

@ -35,7 +35,7 @@ const fillForm = async ({
await userEvent.type(loginTypeField, "password");
await userEvent.type(passwordField as HTMLElement, password);
const submitButton = screen.getByRole("button", {
name: "Create user",
name: /save/i,
});
fireEvent.click(submitButton);
};

View File

@ -22,7 +22,7 @@ import CreateWorkspacePage from "./CreateWorkspacePage";
import { Language } from "./CreateWorkspacePageView";
const nameLabelText = "Workspace Name";
const createWorkspaceText = "Create Workspace";
const createWorkspaceText = "Create workspace";
const validationNumberNotInRangeText = "Value must be between 1 and 3.";
const renderCreateWorkspacePage = () => {
@ -93,7 +93,7 @@ describe("CreateWorkspacePage", () => {
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
const element = await screen.findByText("Create Workspace");
const element = await screen.findByText(createWorkspaceText);
expect(element).toBeDefined();
const secondParameter = await screen.findByText(
MockTemplateVersionParameter2.description,

View File

@ -1,11 +1,11 @@
import type { Interpolation, Theme } from "@emotion/react";
import Button from "@mui/material/Button";
import FormHelperText from "@mui/material/FormHelperText";
import TextField from "@mui/material/TextField";
import type * as TypesGen from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
@ -20,6 +20,7 @@ import {
} from "components/PageHeader/PageHeader";
import { Pill } from "components/Pill/Pill";
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { type FormikContextType, useFormik } from "formik";
@ -146,7 +147,13 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
return (
<Margins size="medium">
<PageHeader actions={<Button onClick={onCancel}>Cancel</Button>}>
<PageHeader
actions={
<Button size="sm" variant="outline" onClick={onCancel}>
Cancel
</Button>
}
>
<Stack direction="row">
<Avatar
variant="icon"
@ -218,7 +225,8 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
<FormHelperText data-chromatic="ignore">
Need a suggestion?{" "}
<Button
variant="text"
variant="subtle"
size="sm"
css={styles.nameSuggestion}
onClick={async () => {
await form.setFieldValue("name", suggestedName);
@ -306,12 +314,18 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
</FormSection>
)}
<FormFooter
onCancel={onCancel}
isLoading={creatingWorkspace}
submitDisabled={!hasAllRequiredExternalAuth}
submitLabel="Create Workspace"
/>
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button
type="submit"
disabled={creatingWorkspace || !hasAllRequiredExternalAuth}
>
{creatingWorkspace && <Spinner />}
Create workspace
</Button>
</FormFooter>
</HorizontalForm>
</Margins>
);

View File

@ -1,4 +1,3 @@
import Button from "@mui/material/Button";
import InputAdornment from "@mui/material/InputAdornment";
import TextField from "@mui/material/TextField";
import type { UpdateAppearanceConfig } from "api/typesGenerated";
@ -7,6 +6,7 @@ import {
EnterpriseBadge,
PremiumBadge,
} from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import { PopoverPaywall } from "components/Paywall/PopoverPaywall";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import {

View File

@ -1,5 +1,5 @@
import { type CSSObject, useTheme } from "@emotion/react";
import Button from "@mui/material/Button";
import { Button } from "components/Button/Button";
import type { FC, FormEventHandler, ReactNode } from "react";
interface FieldsetProps {

View File

@ -26,7 +26,7 @@ export const OrganizationPills: FC<OrganizationPillsProps> = ({
{orgs.length > 0 ? (
<Pill
className={cn("border-none w-fit", {
"bg-surface-error": orgs[0].isUUID,
"bg-surface-destructive": orgs[0].isUUID,
"bg-surface-secondary": !orgs[0].isUUID,
})}
>
@ -88,7 +88,7 @@ const OverflowPillList: FC<OverflowPillProps> = ({ organizations }) => {
<li key={organization.name} className="mb-2 last:mb-0">
<Pill
className={cn("border-none w-fit", {
"bg-surface-error": organization.isUUID,
"bg-surface-destructive": organization.isUUID,
"bg-surface-secondary": !organization.isUUID,
})}
>

View File

@ -2,10 +2,12 @@ import TextField from "@mui/material/TextField";
import { isApiValidationError } from "api/errors";
import type { CreateGroupRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { FormFooter } from "components/FormFooter/FormFooter";
import { Button } from "components/Button/Button";
import { FormFooter } from "components/Form/Form";
import { FullPageForm } from "components/FullPageForm/FullPageForm";
import { IconField } from "components/IconField/IconField";
import { Margins } from "components/Margins/Margins";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import { type FormikTouched, useFormik } from "formik";
import type { FC } from "react";
@ -76,7 +78,17 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
onPickEmoji={(value) => form.setFieldValue("avatar_url", value)}
/>
</Stack>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
<FormFooter className="mt-8">
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Spinner />}
Save
</Button>
</FormFooter>
</form>
</FullPageForm>
</Margins>

View File

@ -1,10 +1,12 @@
import TextField from "@mui/material/TextField";
import type { Group } from "api/typesGenerated";
import { FormFooter } from "components/FormFooter/FormFooter";
import { Button } from "components/Button/Button";
import { FormFooter } from "components/Form/Form";
import { FullPageForm } from "components/FullPageForm/FullPageForm";
import { IconField } from "components/IconField/IconField";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import { useFormik } from "formik";
import type { FC } from "react";
@ -105,7 +107,16 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
/>
</Stack>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Spinner />}
Save
</Button>
</FormFooter>
</form>
</FullPageForm>
);

View File

@ -3,6 +3,7 @@ import { isApiValidationError } from "api/errors";
import type { CreateOrganizationRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Badges, PremiumBadge } from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import {
FormFields,
@ -14,6 +15,7 @@ import { IconField } from "components/IconField/IconField";
import { Paywall } from "components/Paywall/Paywall";
import { PopoverPaywall } from "components/Paywall/PopoverPaywall";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import {
Popover,
@ -154,7 +156,13 @@ export const CreateOrganizationPageView: FC<
</FormFields>
</fieldset>
</FormSection>
<FormFooter isLoading={form.isSubmitting} />
<FormFooter>
<Button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting && <Spinner />}
Save
</Button>
</FormFooter>
</HorizontalForm>
</Cond>
</ChooseOne>

View File

@ -1,7 +1,6 @@
import type { Interpolation, Theme } from "@emotion/react";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import Button from "@mui/material/Button";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import Table from "@mui/material/Table";
@ -23,8 +22,10 @@ import type {
Role,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { FormFields, FormFooter, VerticalForm } from "components/Form/Form";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import { useFormik } from "formik";
import { type ChangeEvent, type FC, useState } from "react";
@ -84,8 +85,9 @@ export const CreateEditRolePageView: FC<CreateEditRolePageViewProps> = ({
description="Set a name and permissions for this role."
/>
{canAssignOrgRole && (
<Stack direction="row" spacing={2}>
<div className="flex space-x-2 items-center">
<Button
variant="outline"
onClick={() => {
navigate(`/organizations/${organizationName}/roles`);
}}
@ -93,15 +95,13 @@ export const CreateEditRolePageView: FC<CreateEditRolePageViewProps> = ({
Cancel
</Button>
<Button
variant="contained"
color="primary"
onClick={() => {
form.handleSubmit();
}}
>
{role !== undefined ? "Save" : "Create Role"}
</Button>
</Stack>
</div>
)}
</Stack>
@ -135,11 +135,16 @@ export const CreateEditRolePageView: FC<CreateEditRolePageViewProps> = ({
/>
</FormFields>
{canAssignOrgRole && (
<FormFooter
onCancel={onCancel}
isLoading={isLoading}
submitLabel={role !== undefined ? "Save" : "Create Role"}
/>
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Spinner />}
{role ? "Save role" : "Create Role"}
</Button>
</FormFooter>
)}
</VerticalForm>
</>

View File

@ -2,6 +2,7 @@ import TextField from "@mui/material/TextField";
import { isApiValidationError } from "api/errors";
import type { CreateGroupRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
@ -10,6 +11,7 @@ import {
} from "components/Form/Form";
import { IconField } from "components/IconField/IconField";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import type { FC } from "react";
import { useNavigate } from "react-router-dom";
@ -84,7 +86,17 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
/>
</FormFields>
</FormSection>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Spinner />}
Save
</Button>
</FormFooter>
</HorizontalForm>
</>
);

View File

@ -1,5 +1,6 @@
import TextField from "@mui/material/TextField";
import type { Group } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
@ -9,6 +10,7 @@ import {
import { IconField } from "components/IconField/IconField";
import { Loader } from "components/Loader/Loader";
import { ResourcePageHeader } from "components/PageHeader/PageHeader";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import type { FC } from "react";
import {
@ -116,7 +118,16 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
</FormFields>
</FormSection>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
<FormFooter className="mt-8">
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Spinner />}
Save
</Button>
</FormFooter>
</HorizontalForm>
);
};

View File

@ -1,12 +1,12 @@
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
import Button from "@mui/material/Button";
import type {
GroupSyncSettings,
Organization,
RoleSyncSettings,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { displayError } from "components/GlobalSnackbar/utils";
import { saveAs } from "file-saver";
import { DownloadIcon } from "lucide-react";
import { type FC, useState } from "react";
interface DownloadPolicyButtonProps {
@ -29,7 +29,6 @@ export const ExportPolicyButton: FC<DownloadPolicyButtonProps> = ({
return (
<Button
startIcon={<DownloadOutlined />}
disabled={!canCreatePolicyJson || isDownloading}
onClick={async () => {
if (canCreatePolicyJson) {
@ -48,6 +47,7 @@ export const ExportPolicyButton: FC<DownloadPolicyButtonProps> = ({
}
}}
>
<DownloadIcon />
Export Policy
</Button>
);

View File

@ -16,6 +16,7 @@ import {
} from "components/Form/Form";
import { IconField } from "components/IconField/IconField";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import { type FC, useState } from "react";
import {
@ -115,7 +116,13 @@ export const OrganizationSettingsPageView: FC<
</FormFields>
</fieldset>
</FormSection>
<FormFooter isLoading={form.isSubmitting} />
<FormFooter>
<Button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting && <Spinner />}
Save
</Button>
</FormFooter>
</HorizontalForm>
{!organization.is_default && (

View File

@ -9,6 +9,7 @@ import {
WorkspaceAppSharingLevels,
} from "api/typesGenerated";
import { PremiumBadge } from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
@ -16,6 +17,7 @@ import {
HorizontalForm,
} from "components/Form/Form";
import { IconField } from "components/IconField/IconField";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import {
StackLabel,
@ -290,7 +292,16 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
</FormFields>
</FormSection>
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Spinner />}
Save
</Button>
</FormFooter>
</HorizontalForm>
);
};

View File

@ -2,7 +2,6 @@ import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API, withDefaultFeatures } from "api/api";
import type { Template, UpdateTemplateMeta } from "api/typesGenerated";
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter";
import { http, HttpResponse } from "msw";
import {
MockEntitlements,
@ -99,9 +98,7 @@ const fillAndSubmitForm = async ({
await userEvent.click(allowCancelJobsField);
}
const submitButton = await screen.findByText(
FooterFormLanguage.defaultSubmitLabel,
);
const submitButton = await screen.findByText(/save/i);
await userEvent.click(submitButton);
};
@ -174,7 +171,7 @@ describe("TemplateSettingsPage", () => {
const deprecationMessage = "This template is deprecated";
await renderTemplateSettingsPage();
await deprecateTemplate(MockTemplate, deprecationMessage);
await deprecateTemplate(deprecationMessage);
const [templateId, data] = updateTemplateMetaSpy.mock.calls[0];
@ -198,10 +195,7 @@ describe("TemplateSettingsPage", () => {
const updateTemplateMetaSpy = jest.spyOn(API, "updateTemplateMeta");
await renderTemplateSettingsPage();
await deprecateTemplate(
MockTemplate,
"This template should not be able to deprecate",
);
await deprecateTemplate("This template should not be able to deprecate");
const [templateId, data] = updateTemplateMetaSpy.mock.calls[0];
@ -213,12 +207,9 @@ describe("TemplateSettingsPage", () => {
});
});
async function deprecateTemplate(template: Template, message: string) {
async function deprecateTemplate(message: string) {
const deprecationField = screen.getByLabelText("Deprecation Message");
await userEvent.type(deprecationField, message);
const submitButton = await screen.findByText(
FooterFormLanguage.defaultSubmitLabel,
);
const submitButton = await screen.findByRole("button", { name: /save/i });
await userEvent.click(submitButton);
}

View File

@ -4,6 +4,7 @@ import MenuItem from "@mui/material/MenuItem";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import type { Template, UpdateTemplateMeta } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { DurationField } from "components/DurationField/DurationField";
import {
FormFields,
@ -11,6 +12,7 @@ import {
FormSection,
HorizontalForm,
} from "components/Form/Form";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import {
StackLabel,
@ -628,11 +630,19 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
/>
)}
<FormFooter
onCancel={onCancel}
isLoading={isSubmitting}
submitDisabled={!form.isValid || !form.dirty}
/>
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !form.isValid || !form.dirty}
>
{isSubmitting && <Spinner />}
Save
</Button>
</FormFooter>
</HorizontalForm>
);
};

View File

@ -1,7 +1,6 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter";
import {
MockEntitlementsWithScheduling,
MockTemplate,
@ -100,7 +99,7 @@ const fillAndSubmitForm = async ({
}
const submitButton = screen.getByRole("button", {
name: FooterFormLanguage.defaultSubmitLabel,
name: /save/i,
});
expect(submitButton).not.toBeDisabled();

View File

@ -4,12 +4,14 @@ import type {
TemplateVersionVariable,
VariableValue,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
FormSection,
HorizontalForm,
} from "components/Form/Form";
import { Spinner } from "components/Spinner/Spinner";
import { type FormikContextType, type FormikTouched, useFormik } from "formik";
import type { FC } from "react";
import { type FormHelpers, getFormHelpers } from "utils/formUtils";
@ -106,7 +108,16 @@ export const TemplateVariablesForm: FC<TemplateVariablesForm> = ({
);
})}
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Spinner />}
Save
</Button>
</FormFooter>
</HorizontalForm>
);
};

View File

@ -1,7 +1,6 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter";
import {
MockTemplate,
MockTemplateVersion,
@ -100,9 +99,7 @@ describe("TemplateVariablesPage", () => {
await userEvent.type(secondVariableField, validFormValues.second_variable);
// Submit the form
const submitButton = await screen.findByText(
FooterFormLanguage.defaultSubmitLabel,
);
const submitButton = await screen.findByText(/save/i);
await userEvent.click(submitButton);
// Wait for the success message
await delay(1500);

View File

@ -1,7 +1,6 @@
import type { Interpolation, Theme } from "@emotion/react";
import AddIcon from "@mui/icons-material/AddOutlined";
import ArrowForwardOutlined from "@mui/icons-material/ArrowForwardOutlined";
import Button from "@mui/material/Button";
import MuiButton from "@mui/material/Button";
import Skeleton from "@mui/material/Skeleton";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
@ -16,6 +15,7 @@ import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
import { DeprecatedBadge } from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import type { useFilter } from "components/Filter/Filter";
import {
HelpTooltip,
@ -38,9 +38,10 @@ import {
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import { useClickableTableRow } from "hooks/useClickableTableRow";
import { PlusIcon } from "lucide-react";
import { linkToTemplate, useLinks } from "modules/navigation";
import type { FC } from "react";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { createDayString } from "utils/createDayString";
import { docs } from "utils/docs";
import {
@ -154,7 +155,7 @@ const TemplateRow: FC<TemplateRowProps> = ({ showOrganizations, template }) => {
{template.deprecated ? (
<DeprecatedBadge />
) : (
<Button
<MuiButton
size="small"
css={styles.actionButton}
className="actionButton"
@ -166,7 +167,7 @@ const TemplateRow: FC<TemplateRowProps> = ({ showOrganizations, template }) => {
}}
>
Create Workspace
</Button>
</MuiButton>
)}
</TableCell>
</TableRow>
@ -195,14 +196,11 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
const navigate = useNavigate();
const createTemplateAction = showOrganizations ? (
<Button
startIcon={<AddIcon />}
variant="contained"
onClick={() => {
navigate("/starter-templates");
}}
>
Create Template
<Button asChild size="lg">
<Link to="/starter-templates">
<PlusIcon />
New template
</Link>
</Button>
) : (
<CreateTemplateButton onNavigate={navigate} />

View File

@ -100,14 +100,8 @@ export const UpdateBuildParametersDialog: FC<
<Button fullWidth type="button" onClick={dialogProps.onClose}>
Cancel
</Button>
<Button
color="primary"
fullWidth
type="submit"
form="updateParameters"
data-testid="form-submit"
>
Update
<Button color="primary" fullWidth type="submit" form="updateParameters">
Update parameters
</Button>
</DialogActions>
</Dialog>

View File

@ -312,7 +312,9 @@ describe("WorkspacePage", () => {
);
await user.clear(secondParameterInput);
await user.type(secondParameterInput, "2");
await user.click(within(dialog).getByRole("button", { name: "Update" }));
await user.click(
within(dialog).getByRole("button", { name: /update parameters/i }),
);
// Check if the update was called using the values from the form
await waitFor(() => {

View File

@ -4,6 +4,7 @@ import type {
WorkspaceBuildParameter,
} from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
@ -11,6 +12,7 @@ import {
HorizontalForm,
} from "components/Form/Form";
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import type { FC } from "react";
import { getFormHelpers } from "utils/formUtils";
@ -154,12 +156,19 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
</FormSection>
)}
<FormFooter
onCancel={onCancel}
isLoading={isSubmitting}
submitLabel="Submit and restart"
submitDisabled={disabled || !form.dirty}
/>
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || disabled || !form.dirty}
>
{isSubmitting && <Spinner />}
Submit and restart
</Button>
</FormFooter>
</HorizontalForm>
</>
);

View File

@ -416,7 +416,9 @@ test("form should be enabled when both auto stop and auto start features are dis
/>,
);
const submitButton = await screen.findByRole("button", { name: "Submit" });
const submitButton = await screen.findByRole("button", {
name: /save/i,
});
expect(submitButton).toBeEnabled();
});
@ -432,6 +434,8 @@ test("form should be disabled when both auto stop and auto start features are di
jest.spyOn(API, "getTemplateByName").mockResolvedValue(MockTemplate);
render(<WorkspaceScheduleForm {...props} />);
const submitButton = await screen.findByRole("button", { name: "Submit" });
const submitButton = await screen.findByRole("button", {
name: /save/i,
});
expect(submitButton).toBeDisabled();
});

View File

@ -8,12 +8,14 @@ import MenuItem from "@mui/material/MenuItem";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import type { Template } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
FormSection,
HorizontalForm,
} from "components/Form/Form";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import {
StackLabel,
@ -441,15 +443,23 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
/>
</FormFields>
</FormSection>
<FormFooter
onCancel={onCancel}
isLoading={isLoading}
// If both options, autostart and autostop, are disabled at the template
// level, the form is disabled.
submitDisabled={
!template.allow_user_autostart && !template.allow_user_autostop
}
/>
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button
type="submit"
disabled={
isLoading ||
(!template.allow_user_autostart && !template.allow_user_autostop)
}
>
{isLoading && <Spinner />}
Save
</Button>
</FormFooter>
</HorizontalForm>
);
};

View File

@ -288,7 +288,7 @@ describe("WorkspaceSchedulePage", () => {
);
await user.click(autostopToggle);
const submitButton = await screen.findByRole("button", {
name: /submit/i,
name: /save/i,
});
await user.click(submitButton);
const dialog = await screen.findByText("Restart workspace?");
@ -309,7 +309,7 @@ describe("WorkspaceSchedulePage", () => {
);
await user.click(autostartToggle);
const submitButton = await screen.findByRole("button", {
name: /submit/i,
name: /save/i,
});
await user.click(submitButton);
const dialog = screen.queryByText("Restart workspace?");

View File

@ -6,12 +6,14 @@ import {
AutomaticUpdateses,
type Workspace,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
FormFields,
FormFooter,
FormSection,
HorizontalForm,
} from "components/Form/Form";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import upperFirst from "lodash/upperFirst";
import type { FC } from "react";
@ -115,7 +117,16 @@ export const WorkspaceSettingsForm: FC<WorkspaceSettingsFormProps> = ({
</FormFields>
</FormSection>
{formEnabled && (
<FormFooter onCancel={onCancel} isLoading={form.isSubmitting} />
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting && <Spinner />}
Save
</Button>
</FormFooter>
)}
</HorizontalForm>
);

View File

@ -31,7 +31,7 @@ test("Submit the workspace settings page successfully", async () => {
const name = within(form).getByLabelText("Name");
await user.clear(name);
await user.type(within(form).getByLabelText("Name"), "new-name");
await user.click(within(form).getByRole("button", { name: "Submit" }));
await user.click(within(form).getByRole("button", { name: /save/i }));
// Assert that the API calls were made with the correct data
await waitFor(() => {
expect(patchWorkspaceSpy).toHaveBeenCalledWith(MockWorkspace.id, {

View File

@ -1,9 +1,8 @@
import AddIcon from "@mui/icons-material/AddOutlined";
import OpenIcon from "@mui/icons-material/OpenInNewOutlined";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import type { Template } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import { Loader } from "components/Loader/Loader";
import { MenuSearch } from "components/Menu/MenuSearch";
import { OverflowY } from "components/OverflowY/OverflowY";
@ -13,6 +12,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { ChevronDownIcon } from "lucide-react";
import { linkToTemplate, useLinks } from "modules/navigation";
import { type FC, type ReactNode, useState } from "react";
import type { UseQueryResult } from "react-query";
@ -56,8 +56,9 @@ export const WorkspacesButton: FC<WorkspacesButtonProps> = ({
return (
<Popover>
<PopoverTrigger>
<Button startIcon={<AddIcon />} variant="contained">
<Button size="lg">
{children}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent

View File

@ -38,7 +38,6 @@ export const Language = {
yourWorkspacesButton: "Your workspaces",
allWorkspacesButton: "All workspaces",
runningWorkspacesButton: "Running workspaces",
createWorkspace: <>Create Workspace&hellip;</>,
seeAllTemplates: "See all templates",
template: "Template",
};
@ -103,7 +102,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
templates={templates}
templatesFetchStatus={templatesFetchStatus}
>
{Language.createWorkspace}
New workspace
</WorkspacesButton>
}
>

View File

@ -42,11 +42,11 @@ module.exports = {
primary: "hsl(var(--surface-invert-primary))",
secondary: "hsl(var(--surface-invert-secondary))",
},
error: "hsl(var(--surface-error))",
destructive: "hsl(var(--surface-destructive))",
},
border: {
DEFAULT: "hsl(var(--border-default))",
error: "hsl(var(--border-error))",
destructive: "hsl(var(--border-destructive))",
},
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",