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

View File

@ -67,14 +67,14 @@ test.describe("IdpOrgSyncPage", () => {
const syncField = page.getByRole("textbox", { const syncField = page.getByRole("textbox", {
name: "Organization sync field", 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 expect(saveButton).toBeDisabled();
await syncField.fill("test-field"); await syncField.fill("test-field");
await expect(saveButton).toBeEnabled(); await expect(saveButton).toBeEnabled();
await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: /save/i }).click();
await expect( await expect(
page.getByText("Organization sync settings updated."), 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("Name", { exact: true }).fill(groupValues.name);
await page.getByLabel("Display Name").fill(groupValues.displayName); await page.getByLabel("Display Name").fill(groupValues.displayName);
await page.getByLabel("Avatar URL").fill(groupValues.avatarURL); 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).toHaveTitle(`${groupValues.displayName} - Coder`);
await expect(page.getByText(groupValues.displayName)).toBeVisible(); await expect(page.getByText(groupValues.displayName)).toBeVisible();

View File

@ -34,7 +34,7 @@ test("create group", async ({ page }) => {
const displayName = `Group ${name}`; const displayName = `Group ${name}`;
await page.getByLabel("Display Name").fill(displayName); await page.getByLabel("Display Name").fill(displayName);
await page.getByLabel("Avatar URL").fill("/emojis/1f60d.png"); 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( await expectUrl(page).toHavePathName(
`/organizations/${org.name}/groups/${name}`, `/organizations/${org.name}/groups/${name}`,
@ -91,7 +91,7 @@ test("change quota settings", async ({ page }) => {
// Update Quota // Update Quota
await page.getByLabel("Quota Allowance").fill("100"); 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 // We should get sent back to the group page afterwards
expectUrl(page).toHavePathName( 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("Display name").fill(`Org ${name}`);
await page.getByLabel("Description").fill(`Org description ${name}`); await page.getByLabel("Description").fill(`Org description ${name}`);
await page.getByLabel("Icon", { exact: true }).fill("/emojis/1f957.png"); 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 // Expect to be redirected to the new organization
await expectUrl(page).toHavePathName(`/organizations/${name}`); await expectUrl(page).toHavePathName(`/organizations/${name}`);
@ -32,7 +32,7 @@ test("create and delete organization", async ({ page }) => {
const newName = randomName(); const newName = randomName();
await page.getByLabel("Slug").fill(newName); await page.getByLabel("Slug").fill(newName);
await page.getByLabel("Description").fill(`Org description ${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 // Expect to be redirected when renaming the organization
await expectUrl(page).toHavePathName(`/organizations/${newName}`); await expectUrl(page).toHavePathName(`/organizations/${newName}`);

View File

@ -87,7 +87,7 @@ test.describe("CustomRolesPage", () => {
await expect(organizationMemberCheckbox).toBeVisible(); await expect(organizationMemberCheckbox).toBeVisible();
await organizationMemberCheckbox.click(); 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 expect(saveButton).toBeVisible();
await saveButton.click(); await saveButton.click();

View File

@ -36,7 +36,7 @@ test("update template schedule settings without override other settings", async
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
}); });
await page.getByLabel("Default autostop (hours)").fill("48"); 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(); await expect(page.getByText("Template updated successfully")).toBeVisible();
const updatedTemplate = await API.getTemplate(template.id); 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`); await expectUrl(page).toHavePathName(`/templates/${templateName}/settings`);
let checkbox = await page.waitForSelector("#require_active_version"); let checkbox = await page.waitForSelector("#require_active_version");
await checkbox.click(); await checkbox.click();
await page.getByTestId("form-submit").click(); await page.getByRole("button", { name: /save/i }).click();
await page.goto(`/templates/${templateName}/settings`, { await page.goto(`/templates/${templateName}/settings`, {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",

View File

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { Trash } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { Button } from "./Button"; import { Button } from "./Button";
const meta: Meta<typeof Button> = { const meta: Meta<typeof Button> = {
@ -8,7 +8,7 @@ const meta: Meta<typeof Button> = {
args: { args: {
children: ( children: (
<> <>
<Trash /> <PlusIcon />
Button Button
</> </>
), ),
@ -20,30 +20,24 @@ type Story = StoryObj<typeof Button>;
export const Default: Story = {}; 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 = { export const DefaultDisabled: Story = {
args: { args: {
disabled: true, disabled: true,
}, },
}; };
export const DefaultSmall: Story = {
args: {
size: "sm",
},
};
export const Outline: Story = {
args: {
variant: "outline",
},
};
export const OutlineDisabled: Story = { export const OutlineDisabled: Story = {
args: { args: {
variant: "outline", 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 = { export const SubtleDisabled: Story = {
args: { args: {
variant: "subtle", 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 = { export const IconButtonDefault: Story = {
args: { args: {
variant: "default", variant: "default",
children: <Trash />, children: <PlusIcon />,
}, },
}; };
export const IconButtonOutline: Story = { export const IconButtonOutline: Story = {
args: { args: {
variant: "outline", variant: "outline",
children: <Trash />, children: <PlusIcon />,
}, },
}; };
export const IconButtonSubtle: Story = { export const IconButtonSubtle: Story = {
args: { args: {
variant: "subtle", variant: "subtle",
children: <Trash />, children: <PlusIcon />,
}, },
}; };

View File

@ -8,38 +8,33 @@ import { forwardRef } from "react";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
export const buttonVariants = cva( 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 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:pointer-events-none disabled:text-content-disabled disabled:pointer-events-none disabled:text-content-disabled
[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-[2px]`,
px-3 py-2`,
{ {
variants: { variants: {
variant: { variant: {
default: 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: outline:
"border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary", "border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary",
subtle: subtle:
"border-none bg-transparent text-content-secondary hover:text-content-primary", "border-none bg-transparent text-content-secondary hover:text-content-primary",
warning: destructive:
"border border-border-error text-content-primary bg-surface-error hover:bg-transparent", "border border-border-destructive text-content-primary bg-surface-destructive hover:bg-transparent disabled:bg-transparent disabled:text-content-disabled font-semibold",
ghost:
"text-content-primary bg-transparent border-0 hover:bg-surface-secondary",
}, },
size: { size: {
lg: "h-10", lg: "h-10 px-3 py-2 [&_svg]:size-icon-lg",
default: "h-9", sm: "h-[30px] px-2 py-1.5 text-xs [&_svg]:size-icon-sm",
sm: "h-8 px-2 py-1.5 text-xs",
icon: "h-10 w-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "lg",
}, },
}, },
); );

View File

@ -1,5 +1,7 @@
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 { userEvent } from "@storybook/test";
import { within } from "@testing-library/react";
import { DeleteDialog } from "./DeleteDialog"; import { DeleteDialog } from "./DeleteDialog";
const meta: Meta<typeof DeleteDialog> = { const meta: Meta<typeof DeleteDialog> = {
@ -19,12 +21,28 @@ export default meta;
type Story = StoryObj<typeof DeleteDialog>; 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 = { export const Loading: Story = {
args: { args: {
confirmLoading: true, confirmLoading: true,
}, },
}; };
export { Example as DeleteDialog };

View File

@ -1,8 +1,6 @@
import type { Interpolation, Theme } from "@emotion/react"; import MuiDialog, { type DialogProps } from "@mui/material/Dialog";
import LoadingButton, { type LoadingButtonProps } from "@mui/lab/LoadingButton"; import { Button } from "components/Button/Button";
import MuiDialog, { import { Spinner } from "components/Spinner/Spinner";
type DialogProps as MuiDialogProps,
} from "@mui/material/Dialog";
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import type { ConfirmDialogType } from "./types"; import type { ConfirmDialogType } from "./types";
@ -22,13 +20,6 @@ export interface DialogActionButtonsProps {
type?: ConfirmDialogType; 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 * Quickly handles most modals actions, some combination of a cancel and confirm button
*/ */
@ -44,124 +35,29 @@ export const DialogActionButtons: FC<DialogActionButtonsProps> = ({
return ( return (
<> <>
{onCancel && ( {onCancel && (
<LoadingButton disabled={confirmLoading} onClick={onCancel} fullWidth> <Button disabled={confirmLoading} onClick={onCancel} variant="outline">
{cancelText} {cancelText}
</LoadingButton> </Button>
)} )}
{onConfirm && ( {onConfirm && (
<LoadingButton <Button
fullWidth variant={type === "delete" ? "destructive" : undefined}
data-testid="confirm-button" disabled={confirmLoading || disabled}
variant="contained"
onClick={onConfirm} onClick={onConfirm}
color={typeToColor(type)} data-testid="confirm-button"
loading={confirmLoading}
disabled={disabled}
type="submit" type="submit"
css={[
type === "delete" && styles.dangerButton,
type === "success" && styles.successButton,
]}
> >
{confirmLoading && <Spinner loading />}
{confirmText} {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. * Re-export of MUI's Dialog component, for convenience.
* @link See original documentation here: https://mui.com/material-ui/react-dialog/ * @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, forwardRef,
useContext, useContext,
} from "react"; } from "react";
import { import { cn } from "utils/cn";
FormFooter as BaseFormFooter,
type FormFooterProps,
type FormFooterStyles,
} from "../FormFooter/FormFooter";
type FormContextValue = { direction?: "horizontal" | "vertical" }; type FormContextValue = { direction?: "horizontal" | "vertical" };
@ -191,29 +187,12 @@ const styles = {
}, },
} satisfies Record<string, Interpolation<Theme>>; } satisfies Record<string, Interpolation<Theme>>;
export const FormFooter: FC<Exclude<FormFooterProps, "styles">> = (props) => ( export const FormFooter: FC<HTMLProps<HTMLDivElement>> = ({
<BaseFormFooter {...props} styles={footerStyles} /> 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 TextField from "@mui/material/TextField";
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react"; 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 type { FC } from "react";
import { FormFooter } from "../FormFooter/FormFooter";
import { Stack } from "../Stack/Stack"; import { Stack } from "../Stack/Stack";
import { FullPageForm, type FullPageFormProps } from "./FullPageForm"; import { FullPageForm, type FullPageFormProps } from "./FullPageForm";
@ -16,7 +16,10 @@ const Template: FC<FullPageFormProps> = (props) => (
<Stack> <Stack>
<TextField fullWidth label="Field 1" name="field1" /> <TextField fullWidth label="Field 1" name="field1" />
<TextField fullWidth label="Field 2" name="field2" /> <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> </Stack>
</form> </form>
</FullPageForm> </FullPageForm>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { type CSSObject, useTheme } from "@emotion/react"; 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"; import type { FC, FormEventHandler, ReactNode } from "react";
interface FieldsetProps { interface FieldsetProps {

View File

@ -26,7 +26,7 @@ export const OrganizationPills: FC<OrganizationPillsProps> = ({
{orgs.length > 0 ? ( {orgs.length > 0 ? (
<Pill <Pill
className={cn("border-none w-fit", { className={cn("border-none w-fit", {
"bg-surface-error": orgs[0].isUUID, "bg-surface-destructive": orgs[0].isUUID,
"bg-surface-secondary": !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"> <li key={organization.name} className="mb-2 last:mb-0">
<Pill <Pill
className={cn("border-none w-fit", { className={cn("border-none w-fit", {
"bg-surface-error": organization.isUUID, "bg-surface-destructive": organization.isUUID,
"bg-surface-secondary": !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 { isApiValidationError } from "api/errors";
import type { CreateGroupRequest } from "api/typesGenerated"; import type { CreateGroupRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert"; 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 { FullPageForm } from "components/FullPageForm/FullPageForm";
import { IconField } from "components/IconField/IconField"; import { IconField } from "components/IconField/IconField";
import { Margins } from "components/Margins/Margins"; import { Margins } from "components/Margins/Margins";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
import { type FormikTouched, useFormik } from "formik"; import { type FormikTouched, useFormik } from "formik";
import type { FC } from "react"; import type { FC } from "react";
@ -76,7 +78,17 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
onPickEmoji={(value) => form.setFieldValue("avatar_url", value)} onPickEmoji={(value) => form.setFieldValue("avatar_url", value)}
/> />
</Stack> </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> </form>
</FullPageForm> </FullPageForm>
</Margins> </Margins>

View File

@ -1,10 +1,12 @@
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import type { Group } from "api/typesGenerated"; 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 { FullPageForm } from "components/FullPageForm/FullPageForm";
import { IconField } from "components/IconField/IconField"; import { IconField } from "components/IconField/IconField";
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins"; import { Margins } from "components/Margins/Margins";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
import { useFormik } from "formik"; import { useFormik } from "formik";
import type { FC } from "react"; import type { FC } from "react";
@ -105,7 +107,16 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
/> />
</Stack> </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> </form>
</FullPageForm> </FullPageForm>
); );

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import type { Group } from "api/typesGenerated"; import type { Group } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { import {
FormFields, FormFields,
FormFooter, FormFooter,
@ -9,6 +10,7 @@ import {
import { IconField } from "components/IconField/IconField"; import { IconField } from "components/IconField/IconField";
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { ResourcePageHeader } from "components/PageHeader/PageHeader"; import { ResourcePageHeader } from "components/PageHeader/PageHeader";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik"; import { useFormik } from "formik";
import type { FC } from "react"; import type { FC } from "react";
import { import {
@ -116,7 +118,16 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
</FormFields> </FormFields>
</FormSection> </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> </HorizontalForm>
); );
}; };

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import {
WorkspaceAppSharingLevels, WorkspaceAppSharingLevels,
} from "api/typesGenerated"; } from "api/typesGenerated";
import { PremiumBadge } from "components/Badges/Badges"; import { PremiumBadge } from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import { import {
FormFields, FormFields,
FormFooter, FormFooter,
@ -16,6 +17,7 @@ import {
HorizontalForm, HorizontalForm,
} from "components/Form/Form"; } from "components/Form/Form";
import { IconField } from "components/IconField/IconField"; import { IconField } from "components/IconField/IconField";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
import { import {
StackLabel, StackLabel,
@ -290,7 +292,16 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
</FormFields> </FormFields>
</FormSection> </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> </HorizontalForm>
); );
}; };

View File

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

View File

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

View File

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

View File

@ -4,12 +4,14 @@ import type {
TemplateVersionVariable, TemplateVersionVariable,
VariableValue, VariableValue,
} from "api/typesGenerated"; } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { import {
FormFields, FormFields,
FormFooter, FormFooter,
FormSection, FormSection,
HorizontalForm, HorizontalForm,
} from "components/Form/Form"; } from "components/Form/Form";
import { Spinner } from "components/Spinner/Spinner";
import { type FormikContextType, type FormikTouched, useFormik } from "formik"; import { type FormikContextType, type FormikTouched, useFormik } from "formik";
import type { FC } from "react"; import type { FC } from "react";
import { type FormHelpers, getFormHelpers } from "utils/formUtils"; 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> </HorizontalForm>
); );
}; };

View File

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

View File

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

View File

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

View File

@ -312,7 +312,9 @@ describe("WorkspacePage", () => {
); );
await user.clear(secondParameterInput); await user.clear(secondParameterInput);
await user.type(secondParameterInput, "2"); 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 // Check if the update was called using the values from the form
await waitFor(() => { await waitFor(() => {

View File

@ -4,6 +4,7 @@ import type {
WorkspaceBuildParameter, WorkspaceBuildParameter,
} from "api/typesGenerated"; } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert"; import { Alert } from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import { import {
FormFields, FormFields,
FormFooter, FormFooter,
@ -11,6 +12,7 @@ import {
HorizontalForm, HorizontalForm,
} from "components/Form/Form"; } from "components/Form/Form";
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"; import { RichParameterInput } from "components/RichParameterInput/RichParameterInput";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik"; import { useFormik } from "formik";
import type { FC } from "react"; import type { FC } from "react";
import { getFormHelpers } from "utils/formUtils"; import { getFormHelpers } from "utils/formUtils";
@ -154,12 +156,19 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
</FormSection> </FormSection>
)} )}
<FormFooter <FormFooter>
onCancel={onCancel} <Button onClick={onCancel} variant="outline">
isLoading={isSubmitting} Cancel
submitLabel="Submit and restart" </Button>
submitDisabled={disabled || !form.dirty}
/> <Button
type="submit"
disabled={isSubmitting || disabled || !form.dirty}
>
{isSubmitting && <Spinner />}
Submit and restart
</Button>
</FormFooter>
</HorizontalForm> </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(); 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); jest.spyOn(API, "getTemplateByName").mockResolvedValue(MockTemplate);
render(<WorkspaceScheduleForm {...props} />); render(<WorkspaceScheduleForm {...props} />);
const submitButton = await screen.findByRole("button", { name: "Submit" }); const submitButton = await screen.findByRole("button", {
name: /save/i,
});
expect(submitButton).toBeDisabled(); expect(submitButton).toBeDisabled();
}); });

View File

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

View File

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

View File

@ -6,12 +6,14 @@ import {
AutomaticUpdateses, AutomaticUpdateses,
type Workspace, type Workspace,
} from "api/typesGenerated"; } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { import {
FormFields, FormFields,
FormFooter, FormFooter,
FormSection, FormSection,
HorizontalForm, HorizontalForm,
} from "components/Form/Form"; } from "components/Form/Form";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik"; import { useFormik } from "formik";
import upperFirst from "lodash/upperFirst"; import upperFirst from "lodash/upperFirst";
import type { FC } from "react"; import type { FC } from "react";
@ -115,7 +117,16 @@ export const WorkspaceSettingsForm: FC<WorkspaceSettingsFormProps> = ({
</FormFields> </FormFields>
</FormSection> </FormSection>
{formEnabled && ( {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> </HorizontalForm>
); );

View File

@ -31,7 +31,7 @@ test("Submit the workspace settings page successfully", async () => {
const name = within(form).getByLabelText("Name"); const name = within(form).getByLabelText("Name");
await user.clear(name); await user.clear(name);
await user.type(within(form).getByLabelText("Name"), "new-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 // Assert that the API calls were made with the correct data
await waitFor(() => { await waitFor(() => {
expect(patchWorkspaceSpy).toHaveBeenCalledWith(MockWorkspace.id, { 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 OpenIcon from "@mui/icons-material/OpenInNewOutlined";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link"; import Link from "@mui/material/Link";
import type { Template } from "api/typesGenerated"; import type { Template } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar"; import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { MenuSearch } from "components/Menu/MenuSearch"; import { MenuSearch } from "components/Menu/MenuSearch";
import { OverflowY } from "components/OverflowY/OverflowY"; import { OverflowY } from "components/OverflowY/OverflowY";
@ -13,6 +12,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "components/deprecated/Popover/Popover"; } from "components/deprecated/Popover/Popover";
import { ChevronDownIcon } from "lucide-react";
import { linkToTemplate, useLinks } from "modules/navigation"; import { linkToTemplate, useLinks } from "modules/navigation";
import { type FC, type ReactNode, useState } from "react"; import { type FC, type ReactNode, useState } from "react";
import type { UseQueryResult } from "react-query"; import type { UseQueryResult } from "react-query";
@ -56,8 +56,9 @@ export const WorkspacesButton: FC<WorkspacesButtonProps> = ({
return ( return (
<Popover> <Popover>
<PopoverTrigger> <PopoverTrigger>
<Button startIcon={<AddIcon />} variant="contained"> <Button size="lg">
{children} {children}
<ChevronDownIcon />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent

View File

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

View File

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