From bf7b2f32c4cca9eb4df4a0cdb01cabfce68ab771 Mon Sep 17 00:00:00 2001 From: kira-pilot Date: Thu, 14 Jul 2022 16:51:40 +0000 Subject: [PATCH 1/4] added workspace cta dropdown resolves #2748 --- .../WorkspaceActions/ActionCtas.tsx | 115 ++++++++++ .../WorkspaceActions/WorkspaceActions.tsx | 210 ++++++++++-------- .../components/WorkspaceActions/constants.ts | 90 ++++++++ 3 files changed, 325 insertions(+), 90 deletions(-) create mode 100644 site/src/components/WorkspaceActions/ActionCtas.tsx create mode 100644 site/src/components/WorkspaceActions/constants.ts diff --git a/site/src/components/WorkspaceActions/ActionCtas.tsx b/site/src/components/WorkspaceActions/ActionCtas.tsx new file mode 100644 index 0000000000000..8501877201a12 --- /dev/null +++ b/site/src/components/WorkspaceActions/ActionCtas.tsx @@ -0,0 +1,115 @@ +import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" +import CloudQueueIcon from "@material-ui/icons/CloudQueue" +import CropSquareIcon from "@material-ui/icons/CropSquare" +import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline" +import HighlightOffIcon from "@material-ui/icons/HighlightOff" +import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline" +import { FC } from "react" +import { Workspace } from "../../api/typesGenerated" +import { WorkspaceStatus } from "../../util/workspace" +import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton" + +const Language = { + start: "Start", + stop: "Stop", + delete: "Delete", + cancel: "Cancel", + update: "Update", +} + +interface WorkspaceAction { + handleAction: () => void +} + +export const StartButton: FC = ({ handleAction }) => { + const styles = useStyles() + + return ( + } + onClick={handleAction} + label={Language.start} + /> + ) +} + +export const StopButton: FC = ({ handleAction }) => { + const styles = useStyles() + + return ( + } + onClick={handleAction} + label={Language.stop} + /> + ) +} + +export const DeleteButton: FC = ({ handleAction }) => { + const styles = useStyles() + + return ( + } + onClick={handleAction} + label={Language.delete} + /> + ) +} + +type UpdateAction = WorkspaceAction & { + workspace: Workspace + workspaceStatus: WorkspaceStatus +} + +export const UpdateButton: FC = ({ handleAction, workspace, workspaceStatus }) => { + const styles = useStyles() + + /** + * Jobs submitted while another job is in progress will be discarded, + * so check whether workspace job status has reached completion (whether successful or not). + */ + const canAcceptJobs = (workspaceStatus: WorkspaceStatus) => + ["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus) + + return ( + <> + {workspace.outdated && canAcceptJobs(workspaceStatus) && ( + + )} + + ) +} + +export const CancelButton: FC = ({ handleAction }) => { + const styles = useStyles() + + return ( + } + onClick={handleAction} + label={Language.cancel} + /> + ) +} + +const useStyles = makeStyles((theme) => ({ + actionButton: { + // Set fixed width for the action buttons so they will not change the size + // during the transitions + width: theme.spacing(16), + border: "none", + borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, + }, +})) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 4a60c15c5ccef..37c790dbc9e04 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -1,50 +1,12 @@ import Button from "@material-ui/core/Button" +import Popover from "@material-ui/core/Popover" import { makeStyles } from "@material-ui/core/styles" -import CancelIcon from "@material-ui/icons/Cancel" -import CloudDownloadIcon from "@material-ui/icons/CloudDownload" -import DeleteIcon from "@material-ui/icons/Delete" -import PlayArrowRoundedIcon from "@material-ui/icons/PlayArrowRounded" -import StopIcon from "@material-ui/icons/Stop" -import { FC } from "react" +import { FC, ReactNode, useEffect, useRef, useState } from "react" import { Workspace } from "../../api/typesGenerated" -import { getWorkspaceStatus, WorkspaceStatus } from "../../util/workspace" -import { Stack } from "../Stack/Stack" -import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton" - -export const Language = { - stop: "Stop", - stopping: "Stopping", - start: "Start", - starting: "Starting", - delete: "Delete", - deleting: "Deleting", - cancel: "Cancel action", - update: "Update", -} - -/** - * Jobs submitted while another job is in progress will be discarded, - * so check whether workspace job status has reached completion (whether successful or not). - */ -const canAcceptJobs = (workspaceStatus: WorkspaceStatus) => - ["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus) - -/** - * Jobs that are in progress (queued or pending) can be canceled. - * @param workspaceStatus WorkspaceStatus - * @returns boolean - */ -const canCancelJobs = (workspaceStatus: WorkspaceStatus) => - ["starting", "stopping", "deleting"].includes(workspaceStatus) - -const canStart = (workspaceStatus: WorkspaceStatus) => - ["stopped", "canceled", "error"].includes(workspaceStatus) - -const canStop = (workspaceStatus: WorkspaceStatus) => - ["started", "canceled", "error"].includes(workspaceStatus) - -const canDelete = (workspaceStatus: WorkspaceStatus) => - ["started", "stopped", "canceled", "error"].includes(workspaceStatus) +import { getWorkspaceStatus } from "../../util/workspace" +import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows" +import { CancelButton, DeleteButton, StartButton, StopButton, UpdateButton } from "./ActionCtas" +import { ButtonTypesEnum, WorkspaceStateActions, WorkspaceStateEnum } from "./constants" export interface WorkspaceActionsProps { workspace: Workspace @@ -64,62 +26,130 @@ export const WorkspaceActions: FC = ({ handleCancel, }) => { const styles = useStyles() - const workspaceStatus = getWorkspaceStatus(workspace.latest_build) + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "action-popover" : undefined + + const workspaceStatus: keyof typeof WorkspaceStateEnum = getWorkspaceStatus( + workspace.latest_build, + ) + const workspaceState = WorkspaceStateEnum[workspaceStatus] + const actions = WorkspaceStateActions[workspaceState] + + /** + * Ensures we close the popover before calling any action handler + */ + useEffect(() => { + setIsOpen(false) + return () => { + setIsOpen(false) + } + }, [workspaceStatus]) + + const disabledButton = ( + + ) + + type ButtonMapping = { + [key in ButtonTypesEnum]: ReactNode + } + + // A mapping of button type to the corresponding React component + const buttonMapping: ButtonMapping = { + [ButtonTypesEnum.start]: , + [ButtonTypesEnum.stop]: , + [ButtonTypesEnum.delete]: , + [ButtonTypesEnum.update]: ( + + ), + [ButtonTypesEnum.cancel]: , + [ButtonTypesEnum.canceling]: disabledButton, + [ButtonTypesEnum.disabled]: disabledButton, + [ButtonTypesEnum.queued]: disabledButton, + [ButtonTypesEnum.error]: disabledButton, + [ButtonTypesEnum.loading]: disabledButton, + } return ( - - {canStart(workspaceStatus) && ( - } - onClick={handleStart} - label={Language.start} - /> - )} - {canStop(workspaceStatus) && ( - } - onClick={handleStop} - label={Language.stop} - /> - )} - {canDelete(workspaceStatus) && ( - } - onClick={handleDelete} - label={Language.delete} - /> - )} - {canCancelJobs(workspaceStatus) && ( - } - onClick={handleCancel} - label={Language.cancel} - /> - )} - {workspace.outdated && canAcceptJobs(workspaceStatus) && ( - - )} - +
+ {/* primary workspace CTA */} + {buttonMapping[actions.primary]} + {/* popover toggle button */} + + + setIsOpen(false)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + {/* secondary workspace CTAs */} + {actions.secondary.map((action) => ( +
+ {buttonMapping[action]} +
+ ))} +
+
) } const useStyles = makeStyles((theme) => ({ + buttonContainer: { + border: `1px solid ${theme.palette.divider}`, + borderRadius: `${theme.shape.borderRadius}px`, + }, + dropdownButton: { + border: "none", + borderLeft: `1px solid ${theme.palette.divider}`, + borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`, + minWidth: "unset", + width: "35px", + "& .MuiButton-label": { + marginRight: "8px", + }, + }, actionButton: { // Set fixed width for the action buttons so they will not change the size // during the transitions width: theme.spacing(16), + border: "none", + borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, + }, + popoverActionButton: { + "& .MuiButtonBase-root": { + backgroundColor: "unset", + justifyContent: "start", + padding: "0px", + }, }, - cancelActionButton: { - width: theme.spacing(27), + popoverPaper: { + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing(3)}px`, }, })) diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts new file mode 100644 index 0000000000000..88531fefedab6 --- /dev/null +++ b/site/src/components/WorkspaceActions/constants.ts @@ -0,0 +1,90 @@ +// all the possible states returned by the API +export enum WorkspaceStateEnum { + starting = "Starting", + started = "Started", + stopping = "Stopping", + stopped = "Stopped", + canceling = "Canceling", + canceled = "Canceled", + deleting = "Deleting", + deleted = "Deleted", + queued = "Queued", + error = "Error", + loading = "Loading", +} + +// the button types we have +export enum ButtonTypesEnum { + start, + stop, + delete, + update, + cancel, + error, + // disabled buttons + canceling, + disabled, + queued, + loading, +} + +type StateActionsType = { + [key in WorkspaceStateEnum]: { + primary: ButtonTypesEnum + secondary: ButtonTypesEnum[] + } +} + +// A mapping of workspace state to button type +// 'Primary' actions are the main ctas +// 'Secondary' actions are ctas housed within the popover +export const WorkspaceStateActions: StateActionsType = { + [WorkspaceStateEnum.starting]: { + primary: ButtonTypesEnum.cancel, + secondary: [], + }, + [WorkspaceStateEnum.started]: { + primary: ButtonTypesEnum.stop, + secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update], + }, + [WorkspaceStateEnum.stopping]: { + primary: ButtonTypesEnum.cancel, + secondary: [], + }, + [WorkspaceStateEnum.stopped]: { + primary: ButtonTypesEnum.start, + secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update], + }, + [WorkspaceStateEnum.canceled]: { + primary: ButtonTypesEnum.start, + secondary: [ButtonTypesEnum.stop, ButtonTypesEnum.delete, ButtonTypesEnum.update], + }, + // in the case of an error + [WorkspaceStateEnum.error]: { + primary: ButtonTypesEnum.start, // give the user the ability to start a workspace again + secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update], // allows the user to delete or update + }, + /** + * disabled states + */ + [WorkspaceStateEnum.canceling]: { + primary: ButtonTypesEnum.canceling, + secondary: [], + }, + [WorkspaceStateEnum.deleting]: { + primary: ButtonTypesEnum.cancel, + secondary: [], + }, + [WorkspaceStateEnum.deleted]: { + primary: ButtonTypesEnum.disabled, + secondary: [], + }, + [WorkspaceStateEnum.queued]: { + primary: ButtonTypesEnum.queued, + secondary: [], + }, + [WorkspaceStateEnum.loading]: { + primary: ButtonTypesEnum.loading, + secondary: [], + }, +} From a9b7688bd861281b82ee2e27568fa247e80f3a11 Mon Sep 17 00:00:00 2001 From: kira-pilot Date: Thu, 14 Jul 2022 18:24:08 +0000 Subject: [PATCH 2/4] added tests --- .../WorkspaceActions/ActionCtas.tsx | 2 +- .../WorkspaceActions.stories.tsx | 79 ++++++++++++++++ .../WorkspaceActions.test.tsx | 89 +++++++++++++++++++ .../WorkspaceActions/WorkspaceActions.tsx | 16 ++-- .../WorkspacePage/WorkspacePage.test.tsx | 2 +- 5 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx create mode 100644 site/src/components/WorkspaceActions/WorkspaceActions.test.tsx diff --git a/site/src/components/WorkspaceActions/ActionCtas.tsx b/site/src/components/WorkspaceActions/ActionCtas.tsx index 8501877201a12..b99807d1050ae 100644 --- a/site/src/components/WorkspaceActions/ActionCtas.tsx +++ b/site/src/components/WorkspaceActions/ActionCtas.tsx @@ -10,7 +10,7 @@ import { Workspace } from "../../api/typesGenerated" import { WorkspaceStatus } from "../../util/workspace" import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton" -const Language = { +export const Language = { start: "Start", stop: "Stop", delete: "Delete", diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx new file mode 100644 index 0000000000000..4b48a214ace08 --- /dev/null +++ b/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx @@ -0,0 +1,79 @@ +import { action } from "@storybook/addon-actions" +import { Story } from "@storybook/react" +import * as Mocks from "../../testHelpers/entities" +import { WorkspaceActions, WorkspaceActionsProps } from "./WorkspaceActions" + +export default { + title: "components/WorkspaceActions", + component: WorkspaceActions, +} + +const Template: Story = (args) => + +const defaultArgs = { + handleStart: action("start"), + handleStop: action("stop"), + handleDelete: action("delete"), + handleUpdate: action("update"), + handleCancel: action("cancel"), +} + +export const Starting = Template.bind({}) +Starting.args = { + ...defaultArgs, + workspace: Mocks.MockStartingWorkspace, +} + +export const Started = Template.bind({}) +Started.args = { + ...defaultArgs, + workspace: Mocks.MockWorkspace, +} + +export const Stopping = Template.bind({}) +Stopping.args = { + ...defaultArgs, + workspace: Mocks.MockStoppingWorkspace, +} + +export const Stopped = Template.bind({}) +Stopped.args = { + ...defaultArgs, + workspace: Mocks.MockStoppedWorkspace, +} + +export const Canceling = Template.bind({}) +Canceling.args = { + ...defaultArgs, + workspace: Mocks.MockCancelingWorkspace, +} + +export const Canceled = Template.bind({}) +Canceled.args = { + ...defaultArgs, + workspace: Mocks.MockCanceledWorkspace, +} + +export const Deleting = Template.bind({}) +Deleting.args = { + ...defaultArgs, + workspace: Mocks.MockDeletingWorkspace, +} + +export const Deleted = Template.bind({}) +Deleted.args = { + ...defaultArgs, + workspace: Mocks.MockDeletedWorkspace, +} + +export const Outdated = Template.bind({}) +Outdated.args = { + ...defaultArgs, + workspace: Mocks.MockOutdatedWorkspace, +} + +export const Errored = Template.bind({}) +Errored.args = { + ...defaultArgs, + workspace: Mocks.MockFailedWorkspace, +} diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx new file mode 100644 index 0000000000000..d916393f70026 --- /dev/null +++ b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx @@ -0,0 +1,89 @@ +import { screen } from "@testing-library/react" +import * as Mocks from "../../testHelpers/entities" +import { render } from "../../testHelpers/renderHelpers" +import { Language } from "./ActionCtas" +import { WorkspaceStateEnum } from "./constants" +import { WorkspaceActions, WorkspaceActionsProps } from "./WorkspaceActions" + +const renderAndClick = async (props: Partial = {}) => { + render( + , + ) + const trigger = await screen.findByTestId("workspace-actions-button") + trigger.click() +} + +describe("WorkspaceActions", () => { + describe("when the workspace is starting", () => { + it("primary is cancel; no secondary", async () => { + await renderAndClick({ workspace: Mocks.MockStartingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.cancel) + expect(screen.queryByTestId("secondary-ctas")).toBeNull() + }) + }) + describe("when the workspace is started", () => { + it("primary is stop; secondary is delete", async () => { + await renderAndClick({ workspace: Mocks.MockWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.stop) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + }) + }) + describe("when the workspace is stopping", () => { + it("primary is cancel; no secondary", async () => { + await renderAndClick({ workspace: Mocks.MockStoppingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.cancel) + expect(screen.queryByTestId("secondary-ctas")).toBeNull() + }) + }) + describe("when the workspace is canceling", () => { + it("primary is canceling; no secondary", async () => { + await renderAndClick({ workspace: Mocks.MockCancelingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(WorkspaceStateEnum.canceling) + expect(screen.queryByTestId("secondary-ctas")).toBeNull() + }) + }) + describe("when the workspace is canceled", () => { + it("primary is start; secondary are stop, delete", async () => { + await renderAndClick({ workspace: Mocks.MockCanceledWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.start) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.stop) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + }) + }) + describe("when the workspace is errored", () => { + it("primary is start; secondary is delete", async () => { + await renderAndClick({ workspace: Mocks.MockFailedWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.start) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + }) + }) + describe("when the workspace is deleting", () => { + it("primary is cancel; no secondary", async () => { + await renderAndClick({ workspace: Mocks.MockDeletingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.cancel) + expect(screen.queryByTestId("secondary-ctas")).toBeNull() + }) + }) + describe("when the workspace is deleted", () => { + it("primary is deleted; no secondary", async () => { + await renderAndClick({ workspace: Mocks.MockDeletedWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(WorkspaceStateEnum.deleted) + expect(screen.queryByTestId("secondary-ctas")).toBeNull() + }) + }) + describe("when the workspace is outdated", () => { + it("primary is start; secondary are delete, update", async () => { + await renderAndClick({ workspace: Mocks.MockOutdatedWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.start) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.update) + }) + }) +}) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 37c790dbc9e04..47adf920b1cc8 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -79,9 +79,11 @@ export const WorkspaceActions: FC = ({ return (
{/* primary workspace CTA */} - {buttonMapping[actions.primary]} + {buttonMapping[actions.primary]} + {/* popover toggle button */}
) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 88cb09c6591fa..3665a83eb0b83 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react" import { rest } from "msw" import * as api from "../../api/api" import { Workspace } from "../../api/typesGenerated" -import { Language } from "../../components/WorkspaceActions/WorkspaceActions" +import { Language } from "../../components/WorkspaceActions/ActionCtas" import { MockBuilds, MockCanceledWorkspace, From 9d503d82c964f6a4549e12cfb059af90a1edcd0c Mon Sep 17 00:00:00 2001 From: kira-pilot Date: Thu, 14 Jul 2022 20:05:47 +0000 Subject: [PATCH 3/4] fixed failing tests --- site/src/pages/WorkspacePage/WorkspacePage.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 3665a83eb0b83..35b9d2243ac5e 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -43,6 +43,9 @@ const renderWorkspacePage = async () => { const testButton = async (label: string, actionMock: jest.SpyInstance) => { await renderWorkspacePage() + // open the workspace action popover so we have access to all available ctas + const trigger = await screen.findByTestId("workspace-actions-button") + trigger.click() // REMARK: exact here because the "Start" button and "START" label for // workspace schedule could otherwise conflict. const button = await screen.findByText(label, { exact: true }) @@ -87,6 +90,11 @@ describe("Workspace Page", () => { .spyOn(api, "deleteWorkspace") .mockResolvedValueOnce(MockWorkspaceBuild) await renderWorkspacePage() + + // open the workspace action popover so we have access to all available ctas + const trigger = await screen.findByTestId("workspace-actions-button") + trigger.click() + const button = await screen.findByText(Language.delete) await waitFor(() => fireEvent.click(button)) const confirmDialog = await screen.findByRole("dialog") From 045401d5cd335ef87f1dab8c14ec1a8d7654c85f Mon Sep 17 00:00:00 2001 From: kira-pilot Date: Thu, 14 Jul 2022 20:32:27 +0000 Subject: [PATCH 4/4] clean up snapshots --- site/src/components/WorkspaceActions/WorkspaceActions.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 47adf920b1cc8..10cc02a486270 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -77,7 +77,7 @@ export const WorkspaceActions: FC = ({ } return ( -
+ {/* primary workspace CTA */} {buttonMapping[actions.primary]} @@ -120,7 +120,7 @@ export const WorkspaceActions: FC = ({ ))} -
+ ) } @@ -128,6 +128,7 @@ const useStyles = makeStyles((theme) => ({ buttonContainer: { border: `1px solid ${theme.palette.divider}`, borderRadius: `${theme.shape.borderRadius}px`, + display: "inline-block", }, dropdownButton: { border: "none", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy