diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 38a00134800f4..9feca1a84c909 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -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 => { 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(); diff --git a/site/e2e/tests/deployment/idpOrgSync.spec.ts b/site/e2e/tests/deployment/idpOrgSync.spec.ts index 5743fd83f49e9..231d15dab15c5 100644 --- a/site/e2e/tests/deployment/idpOrgSync.spec.ts +++ b/site/e2e/tests/deployment/idpOrgSync.spec.ts @@ -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."), diff --git a/site/e2e/tests/groups/createGroup.spec.ts b/site/e2e/tests/groups/createGroup.spec.ts index fbfb775c5b950..3ae7bbe2a317e 100644 --- a/site/e2e/tests/groups/createGroup.spec.ts +++ b/site/e2e/tests/groups/createGroup.spec.ts @@ -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(); diff --git a/site/e2e/tests/organizationGroups.spec.ts b/site/e2e/tests/organizationGroups.spec.ts index 2f419dd7ee4ac..e774de2a7491f 100644 --- a/site/e2e/tests/organizationGroups.spec.ts +++ b/site/e2e/tests/organizationGroups.spec.ts @@ -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( diff --git a/site/e2e/tests/organizations.spec.ts b/site/e2e/tests/organizations.spec.ts index c7ea60e64eb14..268640f28ff29 100644 --- a/site/e2e/tests/organizations.spec.ts +++ b/site/e2e/tests/organizations.spec.ts @@ -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}`); diff --git a/site/e2e/tests/organizations/customRoles/customRoles.spec.ts b/site/e2e/tests/organizations/customRoles/customRoles.spec.ts index a9e49c0921740..1e1e518e96399 100644 --- a/site/e2e/tests/organizations/customRoles/customRoles.spec.ts +++ b/site/e2e/tests/organizations/customRoles/customRoles.spec.ts @@ -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(); diff --git a/site/e2e/tests/templates/updateTemplateSchedule.spec.ts b/site/e2e/tests/templates/updateTemplateSchedule.spec.ts index 3c4d7e2341611..8c1f6a87dc2fe 100644 --- a/site/e2e/tests/templates/updateTemplateSchedule.spec.ts +++ b/site/e2e/tests/templates/updateTemplateSchedule.spec.ts @@ -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); diff --git a/site/e2e/tests/updateTemplate.spec.ts b/site/e2e/tests/updateTemplate.spec.ts index 87fc7e5a104cf..b8f1192b461b5 100644 --- a/site/e2e/tests/updateTemplate.spec.ts +++ b/site/e2e/tests/updateTemplate.spec.ts @@ -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", diff --git a/site/src/components/Button/Button.stories.tsx b/site/src/components/Button/Button.stories.tsx index 90d37ee9af832..3dc5001064f44 100644 --- a/site/src/components/Button/Button.stories.tsx +++ b/site/src/components/Button/Button.stories.tsx @@ -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 = { @@ -8,7 +8,7 @@ const meta: Meta = { args: { children: ( <> - + Button ), @@ -20,34 +20,41 @@ type Story = StoryObj; export const Default: Story = {}; -export const Outline: Story = { +export const DefaultDisabled: Story = { args: { - variant: "outline", + disabled: true, }, }; -export const Subtle: Story = { +export const DefaultSmall: Story = { args: { - variant: "subtle", + size: "sm", }, }; -export const Warning: Story = { +export const Outline: Story = { args: { - variant: "warning", + variant: "outline", }, }; -export const DefaultDisabled: Story = { +export const OutlineDisabled: Story = { args: { + variant: "outline", disabled: true, }, }; -export const OutlineDisabled: Story = { +export const OutlineSmall: Story = { args: { variant: "outline", - disabled: true, + size: "sm", + }, +}; + +export const Subtle: 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: , + children: , }, }; export const IconButtonOutline: Story = { args: { variant: "outline", - children: , + children: , }, }; export const IconButtonSubtle: Story = { args: { variant: "subtle", - children: , + children: , }, }; diff --git a/site/src/components/Button/Button.tsx b/site/src/components/Button/Button.tsx index f01a7017e87e1..a519ce22cc846 100644 --- a/site/src/components/Button/Button.tsx +++ b/site/src/components/Button/Button.tsx @@ -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", }, }, ); diff --git a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.stories.tsx b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.stories.tsx index 3162e6403c6a6..68f00eaa5c7e0 100644 --- a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.stories.tsx +++ b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.stories.tsx @@ -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 = { @@ -19,12 +21,28 @@ export default meta; type Story = StoryObj; -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 }; diff --git a/site/src/components/Dialogs/Dialog.tsx b/site/src/components/Dialogs/Dialog.tsx index 2c2411df12b88..f53274cd62999 100644 --- a/site/src/components/Dialogs/Dialog.tsx +++ b/site/src/components/Dialogs/Dialog.tsx @@ -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 = ({ return ( <> {onCancel && ( - + )} {onConfirm && ( - + {confirmLoading && } {confirmText} - + )} ); }; -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>; - -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 }; diff --git a/site/src/components/Form/Form.tsx b/site/src/components/Form/Form.tsx index 7286e0df1e700..faf900fb4f344 100644 --- a/site/src/components/Form/Form.tsx +++ b/site/src/components/Form/Form.tsx @@ -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>; -export const FormFooter: FC> = (props) => ( - +export const FormFooter: FC> = ({ + className, + ...props +}) => ( +