mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): move history into sidebar (#11413)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { Interpolation, Theme, useTheme } from "@mui/material/styles";
|
||||
import { ComponentProps, HTMLAttributes } from "react";
|
||||
import { Link, LinkProps } from "react-router-dom";
|
||||
import { TopbarIconButton } from "./Topbar";
|
||||
|
||||
export const Sidebar = (props: HTMLAttributes<HTMLDivElement>) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
width: 260,
|
||||
borderRight: `1px solid ${theme.palette.divider}`,
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
flexShrink: 0,
|
||||
padding: "8px 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarLink = (props: LinkProps) => {
|
||||
return <Link css={styles.sidebarItem} {...props} />;
|
||||
};
|
||||
|
||||
export const SidebarItem = (props: HTMLAttributes<HTMLButtonElement>) => {
|
||||
return <button css={styles.sidebarItem} {...props} />;
|
||||
};
|
||||
|
||||
export const SidebarCaption = (props: HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
css={{
|
||||
fontSize: 10,
|
||||
lineHeight: 1.2,
|
||||
padding: "12px 16px",
|
||||
display: "block",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.1em",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarIconButton = (
|
||||
props: { isActive: boolean } & ComponentProps<typeof TopbarIconButton>,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<TopbarIconButton
|
||||
css={[
|
||||
{ opacity: 0.75, "&:hover": { opacity: 1 } },
|
||||
props.isActive && {
|
||||
opacity: 1,
|
||||
position: "relative",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 2,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
height: "100%",
|
||||
},
|
||||
},
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
sidebarItem: (theme) => ({
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
color: theme.palette.text.primary,
|
||||
textDecoration: "none",
|
||||
padding: "8px 16px",
|
||||
display: "block",
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: 0,
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
@@ -560,10 +560,11 @@ const styles = {
|
||||
}),
|
||||
|
||||
agentInfo: (theme) => ({
|
||||
padding: "16px 32px",
|
||||
padding: "24px 32px",
|
||||
display: "flex",
|
||||
gap: 16,
|
||||
alignItems: "center",
|
||||
gap: 48,
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
|
||||
@@ -586,9 +587,7 @@ const styles = {
|
||||
agentButtons: (theme) => ({
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
justifyContent: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
flex: 1,
|
||||
|
||||
[theme.breakpoints.down("md")]: {
|
||||
marginLeft: 0,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorkspaceAgent, WorkspaceResource } from "api/typesGenerated";
|
||||
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
||||
import { Stack } from "../Stack/Stack";
|
||||
import { ResourceCard } from "./ResourceCard";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
const countAgents = (resource: WorkspaceResource) => {
|
||||
return resource.agents ? resource.agents.length : 0;
|
||||
@@ -19,6 +20,7 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
resources,
|
||||
agentRow,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [shouldDisplayHideResources, setShouldDisplayHideResources] =
|
||||
useState(false);
|
||||
const displayResources = shouldDisplayHideResources
|
||||
@@ -30,7 +32,11 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
const hasHideResources = resources.some((r) => r.hide);
|
||||
|
||||
return (
|
||||
<Stack direction="column" spacing={0}>
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={0}
|
||||
css={{ background: theme.palette.background.default }}
|
||||
>
|
||||
{displayResources.map((resource) => (
|
||||
<ResourceCard
|
||||
key={resource.id}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Interpolation, Theme, useTheme } from "@emotion/react";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { WorkspaceBuild } from "api/typesGenerated";
|
||||
import { BuildIcon } from "components/BuildIcon/BuildIcon";
|
||||
import { createDayString } from "utils/createDayString";
|
||||
import {
|
||||
getDisplayWorkspaceBuildStatus,
|
||||
getDisplayWorkspaceBuildInitiatedBy,
|
||||
} from "utils/workspace";
|
||||
|
||||
export const WorkspaceBuildData = ({ build }: { build: WorkspaceBuild }) => {
|
||||
const theme = useTheme();
|
||||
const statusType = getDisplayWorkspaceBuildStatus(theme, build).type;
|
||||
|
||||
return (
|
||||
<div css={styles.root}>
|
||||
<BuildIcon
|
||||
transition={build.transition}
|
||||
css={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: theme.palette[statusType].light,
|
||||
}}
|
||||
/>
|
||||
<div css={{ overflow: "hidden" }}>
|
||||
<div
|
||||
css={{
|
||||
textTransform: "capitalize",
|
||||
color: theme.palette.text.primary,
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{build.transition} by{" "}
|
||||
<span css={{ fontWeight: 500 }}>
|
||||
{getDisplayWorkspaceBuildInitiatedBy(build)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
css={{
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{createDayString(build.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkspaceBuildDataSkeleton = () => {
|
||||
return (
|
||||
<div css={styles.root}>
|
||||
<Skeleton variant="circular" width={16} height={16} />
|
||||
<div>
|
||||
<Skeleton variant="text" width={94} height={16} />
|
||||
<Skeleton
|
||||
variant="text"
|
||||
width={60}
|
||||
height={14}
|
||||
css={{ marginTop: 2 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
lineHeight: "1.4",
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
TopbarDivider,
|
||||
TopbarIconButton,
|
||||
} from "components/FullPageLayout/Topbar";
|
||||
import { Sidebar } from "components/FullPageLayout/Sidebar";
|
||||
|
||||
type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab
|
||||
|
||||
@@ -301,13 +302,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
css={{
|
||||
width: 240,
|
||||
borderRight: `1px solid ${theme.palette.divider}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Sidebar>
|
||||
<div
|
||||
css={{
|
||||
height: 42,
|
||||
@@ -409,7 +404,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
onRename={(file) => setRenameFileOpen(file)}
|
||||
activePath={activePath}
|
||||
/>
|
||||
</div>
|
||||
</Sidebar>
|
||||
|
||||
<div
|
||||
css={{
|
||||
|
||||
@@ -12,16 +12,14 @@ import {
|
||||
} from "components/PageHeader/FullWidthPageHeader";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Stats, StatsItem } from "components/Stats/Stats";
|
||||
import {
|
||||
displayWorkspaceBuildDuration,
|
||||
getDisplayWorkspaceBuildInitiatedBy,
|
||||
getDisplayWorkspaceBuildStatus,
|
||||
} from "utils/workspace";
|
||||
import { displayWorkspaceBuildDuration } from "utils/workspace";
|
||||
import { Sidebar, SidebarCaption, SidebarItem } from "./Sidebar";
|
||||
import { BuildIcon } from "components/BuildIcon/BuildIcon";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { DashboardFullPage } from "components/Dashboard/DashboardLayout";
|
||||
import {
|
||||
WorkspaceBuildData,
|
||||
WorkspaceBuildDataSkeleton,
|
||||
} from "components/WorkspaceBuild/WorkspaceBuildData";
|
||||
|
||||
const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => {
|
||||
return [...logs].sort(
|
||||
@@ -112,15 +110,20 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
<SidebarCaption>Builds</SidebarCaption>
|
||||
{!builds &&
|
||||
Array.from({ length: 15 }, (_, i) => (
|
||||
<BuildSidebarItemSkeleton key={i} />
|
||||
<SidebarItem key={i}>
|
||||
<WorkspaceBuildDataSkeleton />
|
||||
</SidebarItem>
|
||||
))}
|
||||
|
||||
{builds?.map((build) => (
|
||||
<BuildSidebarItem
|
||||
<Link
|
||||
key={build.id}
|
||||
build={build}
|
||||
active={build.build_number === activeBuildNumber}
|
||||
/>
|
||||
to={`/@${build.workspace_owner_name}/${build.workspace_name}/builds/${build.build_number}`}
|
||||
>
|
||||
<SidebarItem active={build.build_number === activeBuildNumber}>
|
||||
<WorkspaceBuildData build={build} />
|
||||
</SidebarItem>
|
||||
</Link>
|
||||
))}
|
||||
</Sidebar>
|
||||
|
||||
@@ -167,78 +170,6 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface BuildSidebarItemProps {
|
||||
build: WorkspaceBuild;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const BuildSidebarItem: FC<BuildSidebarItemProps> = ({ build, active }) => {
|
||||
const theme = useTheme();
|
||||
const statusType = getDisplayWorkspaceBuildStatus(theme, build).type;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={build.id}
|
||||
to={`/@${build.workspace_owner_name}/${build.workspace_name}/builds/${build.build_number}`}
|
||||
>
|
||||
<SidebarItem active={active}>
|
||||
<div css={{ display: "flex", alignItems: "start", gap: 8 }}>
|
||||
<BuildIcon
|
||||
transition={build.transition}
|
||||
css={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: theme.palette[statusType].light,
|
||||
}}
|
||||
/>
|
||||
<div css={{ overflow: "hidden" }}>
|
||||
<div
|
||||
css={{
|
||||
textTransform: "capitalize",
|
||||
color: theme.palette.text.primary,
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{build.transition} by{" "}
|
||||
<strong>{getDisplayWorkspaceBuildInitiatedBy(build)}</strong>
|
||||
</div>
|
||||
<div
|
||||
css={{
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{displayWorkspaceBuildDuration(build)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarItem>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const BuildSidebarItemSkeleton: FC = () => {
|
||||
return (
|
||||
<SidebarItem>
|
||||
<div css={{ display: "flex", alignItems: "start", gap: 8 }}>
|
||||
<Skeleton variant="circular" width={16} height={16} />
|
||||
<div>
|
||||
<Skeleton variant="text" width={94} height={16} />
|
||||
<Skeleton
|
||||
variant="text"
|
||||
width={60}
|
||||
height={14}
|
||||
css={{ marginTop: 2 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarItem>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
stats: (theme) => ({
|
||||
padding: 0,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { MockBuilds } from "testHelpers/entities";
|
||||
import { BuildsTable } from "./BuildsTable";
|
||||
|
||||
const meta: Meta<typeof BuildsTable> = {
|
||||
title: "pages/WorkspacePage/BuildsTable",
|
||||
component: BuildsTable,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BuildsTable>;
|
||||
|
||||
export const Example: Story = {
|
||||
args: {
|
||||
builds: MockBuilds,
|
||||
hasMoreBuilds: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
builds: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoMoreBuilds: Story = {
|
||||
args: {
|
||||
builds: MockBuilds,
|
||||
hasMoreBuilds: false,
|
||||
},
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
||||
import { type FC, type ReactNode } from "react";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { TableLoader } from "components/TableLoader/TableLoader";
|
||||
import { Timeline } from "components/Timeline/Timeline";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { BuildRow } from "./BuildRow";
|
||||
|
||||
export const Language = {
|
||||
emptyMessage: "No builds found",
|
||||
};
|
||||
|
||||
export interface BuildsTableProps {
|
||||
children?: ReactNode;
|
||||
builds: TypesGen.WorkspaceBuild[] | undefined;
|
||||
onLoadMoreBuilds: () => void;
|
||||
isLoadingMoreBuilds: boolean;
|
||||
hasMoreBuilds: boolean;
|
||||
}
|
||||
|
||||
export const BuildsTable: FC<BuildsTableProps> = ({
|
||||
builds,
|
||||
onLoadMoreBuilds,
|
||||
isLoadingMoreBuilds,
|
||||
hasMoreBuilds,
|
||||
}) => {
|
||||
return (
|
||||
<Stack>
|
||||
<TableContainer>
|
||||
<Table data-testid="builds-table" aria-describedby="builds table">
|
||||
<TableBody>
|
||||
{builds ? (
|
||||
<Timeline
|
||||
items={builds}
|
||||
getDate={(build) => new Date(build.created_at)}
|
||||
row={(build) => <BuildRow key={build.id} build={build} />}
|
||||
/>
|
||||
) : (
|
||||
<TableLoader />
|
||||
)}
|
||||
|
||||
{builds && builds.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<div css={{ padding: 32 }}>
|
||||
<EmptyState message={Language.emptyMessage} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{hasMoreBuilds && (
|
||||
<LoadingButton
|
||||
onClick={onLoadMoreBuilds}
|
||||
loading={isLoadingMoreBuilds}
|
||||
loadingPosition="start"
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
startIcon={<ArrowDownwardOutlined />}
|
||||
css={{
|
||||
display: "inline-flex",
|
||||
margin: "auto",
|
||||
borderRadius: "9999px",
|
||||
}}
|
||||
>
|
||||
Load previous builds
|
||||
</LoadingButton>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
import { infiniteWorkspaceBuilds } from "api/queries/workspaceBuilds";
|
||||
import { Workspace } from "api/typesGenerated";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarCaption,
|
||||
SidebarItem,
|
||||
SidebarLink,
|
||||
} from "components/FullPageLayout/Sidebar";
|
||||
import {
|
||||
WorkspaceBuildData,
|
||||
WorkspaceBuildDataSkeleton,
|
||||
} from "components/WorkspaceBuild/WorkspaceBuildData";
|
||||
import { useInfiniteQuery } from "react-query";
|
||||
|
||||
export const HistorySidebar = ({ workspace }: { workspace: Workspace }) => {
|
||||
const buildsQuery = useInfiniteQuery({
|
||||
...infiniteWorkspaceBuilds(workspace?.id ?? ""),
|
||||
enabled: workspace !== undefined,
|
||||
});
|
||||
const builds = buildsQuery.data?.pages.flat();
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarCaption>History</SidebarCaption>
|
||||
{builds
|
||||
? builds.map((build) => (
|
||||
<SidebarLink
|
||||
target="_blank"
|
||||
key={build.id}
|
||||
to={`/@${build.workspace_owner_name}/${build.workspace_name}/builds/${build.build_number}`}
|
||||
>
|
||||
<WorkspaceBuildData build={build} />
|
||||
</SidebarLink>
|
||||
))
|
||||
: Array.from({ length: 15 }, (_, i) => (
|
||||
<SidebarItem key={i}>
|
||||
<WorkspaceBuildDataSkeleton />
|
||||
</SidebarItem>
|
||||
))}
|
||||
{buildsQuery.hasNextPage && (
|
||||
<div css={{ padding: 16 }}>
|
||||
<LoadingButton
|
||||
fullWidth
|
||||
onClick={() => buildsQuery.fetchNextPage()}
|
||||
loading={buildsQuery.isFetchingNextPage}
|
||||
loadingPosition="start"
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
startIcon={<ArrowDownwardOutlined />}
|
||||
css={{
|
||||
display: "inline-flex",
|
||||
borderRadius: "9999px",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Show more builds
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Workspace } from "api/typesGenerated";
|
||||
import { SidebarLink, SidebarCaption } from "components/FullPageLayout/Sidebar";
|
||||
|
||||
export const ResourcesSidebarContent = ({
|
||||
workspace,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarCaption>Resources</SidebarCaption>
|
||||
{workspace.latest_build.resources.map((r) => (
|
||||
<SidebarLink
|
||||
key={r.id}
|
||||
to={{ search: `r=${r.id}` }}
|
||||
css={{ display: "flex", flexDirection: "column", lineHeight: 1.6 }}
|
||||
>
|
||||
<span css={{ fontWeight: 500 }}>{r.name}</span>
|
||||
<span css={{ fontSize: 13, color: theme.palette.text.secondary }}>
|
||||
{r.type}
|
||||
</span>
|
||||
</SidebarLink>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -54,7 +54,6 @@ const meta: Meta<typeof Workspace> = {
|
||||
withReactContext({
|
||||
Context: WatchAgentMetadataContext,
|
||||
initialState: (_: string): EventSource => {
|
||||
// Need Bruno's help here.
|
||||
return new EventSource();
|
||||
},
|
||||
}),
|
||||
@@ -75,7 +74,6 @@ export const Running: Story = {
|
||||
Mocks.MockWorkspaceImageResource,
|
||||
Mocks.MockWorkspaceContainerResource,
|
||||
],
|
||||
builds: [Mocks.MockWorkspaceBuild],
|
||||
canUpdateWorkspace: true,
|
||||
workspaceErrors: {},
|
||||
buildInfo: Mocks.MockBuildInfo,
|
||||
|
||||
@@ -2,11 +2,10 @@ import { type Interpolation, type Theme } from "@emotion/react";
|
||||
import Button from "@mui/material/Button";
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Alert, AlertDetail } from "components/Alert/Alert";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { Resources } from "components/Resources/Resources";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
@@ -17,9 +16,14 @@ import {
|
||||
ActiveTransition,
|
||||
WorkspaceBuildProgress,
|
||||
} from "./WorkspaceBuildProgress";
|
||||
import { BuildsTable } from "./BuildsTable";
|
||||
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
|
||||
import { WorkspaceTopbar } from "./WorkspaceTopbar";
|
||||
import { HistorySidebar } from "./HistorySidebar";
|
||||
import { dashboardContentBottomPadding, navHeight } from "theme/constants";
|
||||
import { bannerHeight } from "components/Dashboard/DeploymentBanner/DeploymentBannerView";
|
||||
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
|
||||
|
||||
export type WorkspaceError =
|
||||
| "getBuildsError"
|
||||
@@ -55,10 +59,6 @@ export interface WorkspaceProps {
|
||||
handleBuildRetry: () => void;
|
||||
handleBuildRetryDebug: () => void;
|
||||
buildLogs?: React.ReactNode;
|
||||
builds: TypesGen.WorkspaceBuild[] | undefined;
|
||||
onLoadMoreBuilds: () => void;
|
||||
isLoadingMoreBuilds: boolean;
|
||||
hasMoreBuilds: boolean;
|
||||
canAutostart: boolean;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
isUpdating,
|
||||
isRestarting,
|
||||
resources,
|
||||
builds,
|
||||
|
||||
canUpdateWorkspace,
|
||||
updateMessage,
|
||||
canChangeVersions,
|
||||
@@ -93,13 +93,13 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
handleBuildRetry,
|
||||
handleBuildRetryDebug,
|
||||
buildLogs,
|
||||
onLoadMoreBuilds,
|
||||
isLoadingMoreBuilds,
|
||||
hasMoreBuilds,
|
||||
canAutostart,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { saveLocal, getLocal } = useLocalStorage();
|
||||
const theme = useTheme();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [showAlertPendingInQueue, setShowAlertPendingInQueue] = useState(false);
|
||||
|
||||
@@ -149,7 +149,18 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
template !== undefined ? ActiveTransition(template, workspace) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
css={{
|
||||
flex: 1,
|
||||
display: "grid",
|
||||
gridTemplate: `
|
||||
"topbar topbar topbar" auto
|
||||
"leftbar sidebar content" 1fr / auto auto 1fr
|
||||
`,
|
||||
maxHeight: `calc(100vh - ${navHeight + bannerHeight}px)`,
|
||||
marginBottom: `-${dashboardContentBottomPadding}px`,
|
||||
}}
|
||||
>
|
||||
<WorkspaceTopbar
|
||||
workspace={workspace}
|
||||
handleStart={handleStart}
|
||||
@@ -170,175 +181,217 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
canUpdateWorkspace={canUpdateWorkspace}
|
||||
/>
|
||||
|
||||
<Margins css={styles.content}>
|
||||
<Stack direction="column" css={styles.firstColumnSpacer} spacing={4}>
|
||||
{workspace.outdated &&
|
||||
(requiresManualUpdate ? (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle>
|
||||
Autostart has been disabled for your workspace.
|
||||
</AlertTitle>
|
||||
<AlertDetail>
|
||||
Autostart is unable to automatically update your workspace.
|
||||
Manually update your workspace to reenable Autostart.
|
||||
</AlertDetail>
|
||||
</Alert>
|
||||
) : (
|
||||
<div
|
||||
css={{
|
||||
gridArea: "leftbar",
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
borderRight: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<SidebarIconButton
|
||||
isActive={searchParams.get("sidebar") === "history"}
|
||||
onClick={() => {
|
||||
const sidebarOption = searchParams.get("sidebar");
|
||||
if (sidebarOption === "history") {
|
||||
searchParams.delete("sidebar");
|
||||
} else {
|
||||
searchParams.set("sidebar", "history");
|
||||
}
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
>
|
||||
<HistoryOutlined />
|
||||
</SidebarIconButton>
|
||||
</div>
|
||||
|
||||
{searchParams.get("sidebar") === "history" && (
|
||||
<HistorySidebar workspace={workspace} />
|
||||
)}
|
||||
|
||||
<div css={styles.content}>
|
||||
<div css={styles.dotBackground}>
|
||||
<Stack direction="column" css={styles.firstColumnSpacer} spacing={4}>
|
||||
{workspace.outdated &&
|
||||
(requiresManualUpdate ? (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle>
|
||||
Autostart has been disabled for your workspace.
|
||||
</AlertTitle>
|
||||
<AlertDetail>
|
||||
Autostart is unable to automatically update your workspace.
|
||||
Manually update your workspace to reenable Autostart.
|
||||
</AlertDetail>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
<AlertTitle>
|
||||
An update is available for your workspace
|
||||
</AlertTitle>
|
||||
{updateMessage && <AlertDetail>{updateMessage}</AlertDetail>}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
{Boolean(workspaceErrors.buildError) && (
|
||||
<ErrorAlert error={workspaceErrors.buildError} dismissible />
|
||||
)}
|
||||
|
||||
{Boolean(workspaceErrors.cancellationError) && (
|
||||
<ErrorAlert
|
||||
error={workspaceErrors.cancellationError}
|
||||
dismissible
|
||||
/>
|
||||
)}
|
||||
|
||||
{workspace.latest_build.status === "running" &&
|
||||
!workspace.health.healthy && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
actions={
|
||||
canUpdateWorkspace && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
handleRestart();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<AlertTitle>Workspace is unhealthy</AlertTitle>
|
||||
<AlertDetail>
|
||||
Your workspace is running but{" "}
|
||||
{workspace.health.failing_agents.length > 1
|
||||
? `${workspace.health.failing_agents.length} agents are unhealthy`
|
||||
: `1 agent is unhealthy`}
|
||||
.
|
||||
</AlertDetail>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{workspace.latest_build.status === "deleted" && (
|
||||
<WorkspaceDeletedBanner
|
||||
handleClick={() => navigate(`/templates`)}
|
||||
/>
|
||||
)}
|
||||
{/* <DormantWorkspaceBanner/> determines its own visibility */}
|
||||
<DormantWorkspaceBanner
|
||||
workspace={workspace}
|
||||
shouldRedisplayBanner={
|
||||
getLocal("dismissedWorkspace") !== workspace.id
|
||||
}
|
||||
onDismiss={() => saveLocal("dismissedWorkspace", workspace.id)}
|
||||
/>
|
||||
|
||||
{showAlertPendingInQueue && (
|
||||
<Alert severity="info">
|
||||
<AlertTitle>
|
||||
An update is available for your workspace
|
||||
</AlertTitle>
|
||||
{updateMessage && <AlertDetail>{updateMessage}</AlertDetail>}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
{Boolean(workspaceErrors.buildError) && (
|
||||
<ErrorAlert error={workspaceErrors.buildError} dismissible />
|
||||
)}
|
||||
|
||||
{Boolean(workspaceErrors.cancellationError) && (
|
||||
<ErrorAlert error={workspaceErrors.cancellationError} dismissible />
|
||||
)}
|
||||
|
||||
{workspace.latest_build.status === "running" &&
|
||||
!workspace.health.healthy && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
actions={
|
||||
canUpdateWorkspace && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
handleRestart();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<AlertTitle>Workspace is unhealthy</AlertTitle>
|
||||
<AlertTitle>Workspace build is pending</AlertTitle>
|
||||
<AlertDetail>
|
||||
Your workspace is running but{" "}
|
||||
{workspace.health.failing_agents.length > 1
|
||||
? `${workspace.health.failing_agents.length} agents are unhealthy`
|
||||
: `1 agent is unhealthy`}
|
||||
.
|
||||
<div css={styles.alertPendingInQueue}>
|
||||
This workspace build job is waiting for a provisioner to
|
||||
become available. If you have been waiting for an extended
|
||||
period of time, please contact your administrator for
|
||||
assistance.
|
||||
</div>
|
||||
<div>
|
||||
Position in queue:{" "}
|
||||
<strong>{workspace.latest_build.job.queue_position}</strong>
|
||||
</div>
|
||||
</AlertDetail>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{workspace.latest_build.status === "deleted" && (
|
||||
<WorkspaceDeletedBanner
|
||||
handleClick={() => navigate(`/templates`)}
|
||||
/>
|
||||
)}
|
||||
{/* <DormantWorkspaceBanner/> determines its own visibility */}
|
||||
<DormantWorkspaceBanner
|
||||
workspace={workspace}
|
||||
shouldRedisplayBanner={
|
||||
getLocal("dismissedWorkspace") !== workspace.id
|
||||
}
|
||||
onDismiss={() => saveLocal("dismissedWorkspace", workspace.id)}
|
||||
/>
|
||||
{workspace.latest_build.job.error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
actions={
|
||||
<Button
|
||||
onClick={
|
||||
canRetryDebugMode
|
||||
? handleBuildRetryDebug
|
||||
: handleBuildRetry
|
||||
}
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
Retry{canRetryDebugMode && " in debug mode"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AlertTitle>Workspace build failed</AlertTitle>
|
||||
<AlertDetail>{workspace.latest_build.job.error}</AlertDetail>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showAlertPendingInQueue && (
|
||||
<Alert severity="info">
|
||||
<AlertTitle>Workspace build is pending</AlertTitle>
|
||||
<AlertDetail>
|
||||
<div css={styles.alertPendingInQueue}>
|
||||
This workspace build job is waiting for a provisioner to
|
||||
become available. If you have been waiting for an extended
|
||||
period of time, please contact your administrator for
|
||||
assistance.
|
||||
</div>
|
||||
<div>
|
||||
Position in queue:{" "}
|
||||
<strong>{workspace.latest_build.job.queue_position}</strong>
|
||||
</div>
|
||||
</AlertDetail>
|
||||
</Alert>
|
||||
)}
|
||||
{template?.deprecated && (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle>Workspace using deprecated template</AlertTitle>
|
||||
<AlertDetail>{template?.deprecation_message}</AlertDetail>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{workspace.latest_build.job.error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
actions={
|
||||
<Button
|
||||
onClick={
|
||||
canRetryDebugMode ? handleBuildRetryDebug : handleBuildRetry
|
||||
}
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
Retry{canRetryDebugMode && " in debug mode"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AlertTitle>Workspace build failed</AlertTitle>
|
||||
<AlertDetail>{workspace.latest_build.job.error}</AlertDetail>
|
||||
</Alert>
|
||||
)}
|
||||
{transitionStats !== undefined && (
|
||||
<WorkspaceBuildProgress
|
||||
workspace={workspace}
|
||||
transitionStats={transitionStats}
|
||||
/>
|
||||
)}
|
||||
|
||||
{template?.deprecated && (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle>Workspace using deprecated template</AlertTitle>
|
||||
<AlertDetail>{template?.deprecation_message}</AlertDetail>
|
||||
</Alert>
|
||||
)}
|
||||
{buildLogs}
|
||||
|
||||
{transitionStats !== undefined && (
|
||||
<WorkspaceBuildProgress
|
||||
workspace={workspace}
|
||||
transitionStats={transitionStats}
|
||||
/>
|
||||
)}
|
||||
|
||||
{buildLogs}
|
||||
|
||||
{typeof resources !== "undefined" && resources.length > 0 && (
|
||||
<Resources
|
||||
resources={resources}
|
||||
agentRow={(agent) => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
workspace={workspace}
|
||||
sshPrefix={sshPrefix}
|
||||
showApps={canUpdateWorkspace}
|
||||
showBuiltinApps={canUpdateWorkspace}
|
||||
hideSSHButton={hideSSHButton}
|
||||
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
|
||||
serverVersion={buildInfo?.version || ""}
|
||||
serverAPIVersion={buildInfo?.agent_api_version || ""}
|
||||
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{workspaceErrors.getBuildsError ? (
|
||||
<ErrorAlert error={workspaceErrors.getBuildsError} />
|
||||
) : (
|
||||
<BuildsTable
|
||||
builds={builds}
|
||||
onLoadMoreBuilds={onLoadMoreBuilds}
|
||||
isLoadingMoreBuilds={isLoadingMoreBuilds}
|
||||
hasMoreBuilds={hasMoreBuilds}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Margins>
|
||||
</>
|
||||
{resources && resources.length > 0 && (
|
||||
<Resources
|
||||
resources={resources}
|
||||
agentRow={(agent) => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
workspace={workspace}
|
||||
sshPrefix={sshPrefix}
|
||||
showApps={canUpdateWorkspace}
|
||||
showBuiltinApps={canUpdateWorkspace}
|
||||
hideSSHButton={hideSSHButton}
|
||||
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
|
||||
serverVersion={buildInfo?.version || ""}
|
||||
serverAPIVersion={buildInfo?.agent_api_version || ""}
|
||||
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
content: {
|
||||
marginTop: 32,
|
||||
padding: 24,
|
||||
gridArea: "content",
|
||||
overflowY: "auto",
|
||||
},
|
||||
|
||||
dotBackground: (theme) => ({
|
||||
padding: 24,
|
||||
"--d": "1px",
|
||||
background: `
|
||||
radial-gradient(
|
||||
circle at
|
||||
var(--d)
|
||||
var(--d),
|
||||
|
||||
${theme.palette.text.secondary} calc(var(--d) - 1px),
|
||||
${theme.palette.background.default} var(--d)
|
||||
)
|
||||
0 0 / 24px 24px
|
||||
`,
|
||||
}),
|
||||
|
||||
actions: (theme) => ({
|
||||
[theme.breakpoints.down("md")]: {
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -32,6 +32,7 @@ export const WorkspaceBuildLogsSection: FC<WorkspaceBuildLogsSectionProps> = ({
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
overflow: "hidden",
|
||||
background: theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
<header
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
MockOutdatedWorkspace,
|
||||
MockTemplateVersionParameter1,
|
||||
MockTemplateVersionParameter2,
|
||||
MockBuilds,
|
||||
MockUser,
|
||||
MockDeploymentConfig,
|
||||
MockWorkspaceBuildDelete,
|
||||
@@ -317,18 +316,6 @@ describe("WorkspacePage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the timeline build", async () => {
|
||||
await renderWorkspacePage(MockWorkspace);
|
||||
const table = await screen.findByTestId("builds-table");
|
||||
|
||||
// Wait for the results to be loaded
|
||||
await waitFor(async () => {
|
||||
const rows = table.querySelectorAll("tbody > tr");
|
||||
// Added +1 because of the date row
|
||||
expect(rows).toHaveLength(MockBuilds.length + 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("restart the workspace with one time parameters when having the confirmation dialog", async () => {
|
||||
window.localStorage.removeItem(`${MockUser.id}_ignoredWarnings`);
|
||||
jest.spyOn(api, "getWorkspaceParameters").mockResolvedValue({
|
||||
|
||||
@@ -5,8 +5,8 @@ import { WorkspaceReadyPage } from "./WorkspaceReadyPage";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { useOrganizationId } from "hooks";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from "react-query";
|
||||
import { infiniteWorkspaceBuilds } from "api/queries/workspaceBuilds";
|
||||
import { useQuery, useQueryClient } from "react-query";
|
||||
import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
|
||||
import { templateByName } from "api/queries/templates";
|
||||
import { workspaceByOwnerAndName } from "api/queries/workspaces";
|
||||
import { checkAuthorization } from "api/queries/authCheck";
|
||||
@@ -49,27 +49,29 @@ export const WorkspacePage: FC = () => {
|
||||
});
|
||||
const permissions = permissionsQuery.data as WorkspacePermissions | undefined;
|
||||
|
||||
// Builds
|
||||
const buildsQuery = useInfiniteQuery({
|
||||
...infiniteWorkspaceBuilds(workspace?.id ?? ""),
|
||||
enabled: workspace !== undefined,
|
||||
});
|
||||
|
||||
// Watch workspace changes
|
||||
const updateWorkspaceData = useEffectEvent(
|
||||
async (newWorkspaceData: Workspace) => {
|
||||
if (!workspace) {
|
||||
throw new Error(
|
||||
"Applying an update for a workspace that is undefined.",
|
||||
);
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
workspaceQueryOptions.queryKey,
|
||||
newWorkspaceData,
|
||||
);
|
||||
|
||||
const hasNewBuild =
|
||||
newWorkspaceData.latest_build.id !== workspace!.latest_build.id;
|
||||
newWorkspaceData.latest_build.id !== workspace.latest_build.id;
|
||||
const lastBuildHasChanged =
|
||||
newWorkspaceData.latest_build.status !== workspace!.latest_build.status;
|
||||
newWorkspaceData.latest_build.status !== workspace.latest_build.status;
|
||||
|
||||
if (hasNewBuild || lastBuildHasChanged) {
|
||||
await buildsQuery.refetch();
|
||||
await queryClient.invalidateQueries(
|
||||
workspaceBuildsKey(newWorkspaceData.id),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -120,13 +122,6 @@ export const WorkspacePage: FC = () => {
|
||||
workspace={workspace}
|
||||
template={template}
|
||||
permissions={permissions}
|
||||
builds={buildsQuery.data?.pages.flat()}
|
||||
buildsError={buildsQuery.error}
|
||||
isLoadingMoreBuilds={buildsQuery.isFetchingNextPage}
|
||||
onLoadMoreBuilds={async () => {
|
||||
await buildsQuery.fetchNextPage();
|
||||
}}
|
||||
hasMoreBuilds={Boolean(buildsQuery.hasNextPage)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,22 +41,12 @@ interface WorkspaceReadyPageProps {
|
||||
template: TypesGen.Template;
|
||||
workspace: TypesGen.Workspace;
|
||||
permissions: WorkspacePermissions;
|
||||
builds: TypesGen.WorkspaceBuild[] | undefined;
|
||||
buildsError: unknown;
|
||||
onLoadMoreBuilds: () => void;
|
||||
isLoadingMoreBuilds: boolean;
|
||||
hasMoreBuilds: boolean;
|
||||
}
|
||||
|
||||
export const WorkspaceReadyPage = ({
|
||||
workspace,
|
||||
template,
|
||||
permissions,
|
||||
builds,
|
||||
buildsError,
|
||||
onLoadMoreBuilds,
|
||||
isLoadingMoreBuilds,
|
||||
hasMoreBuilds,
|
||||
}: WorkspaceReadyPageProps): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -235,17 +225,12 @@ export const WorkspaceReadyPage = ({
|
||||
}
|
||||
}}
|
||||
resources={workspace.latest_build.resources}
|
||||
builds={builds}
|
||||
onLoadMoreBuilds={onLoadMoreBuilds}
|
||||
isLoadingMoreBuilds={isLoadingMoreBuilds}
|
||||
hasMoreBuilds={hasMoreBuilds}
|
||||
canUpdateWorkspace={canUpdateWorkspace}
|
||||
updateMessage={latestVersion?.message}
|
||||
canChangeVersions={canChangeVersions}
|
||||
hideSSHButton={featureVisibility["browser_only"]}
|
||||
hideVSCodeDesktopButton={featureVisibility["browser_only"]}
|
||||
workspaceErrors={{
|
||||
getBuildsError: buildsError,
|
||||
buildError:
|
||||
restartBuildError ??
|
||||
startWorkspaceMutation.error ??
|
||||
|
||||
@@ -101,7 +101,7 @@ export const WorkspaceTopbar = (props: WorkspaceProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Topbar>
|
||||
<Topbar css={{ gridArea: "topbar" }}>
|
||||
<Tooltip title="Back to workspaces">
|
||||
<TopbarIconButton component={RouterLink} to="/workspaces">
|
||||
<ArrowBackOutlined />
|
||||
|
||||
@@ -94,6 +94,10 @@ export const components = {
|
||||
"& .MuiLoadingButton-loadingIndicator": {
|
||||
width: 14,
|
||||
height: 14,
|
||||
// Idk why but I found the loading indicator in the loading buttons
|
||||
// does not align with the start icon from the regular button so this
|
||||
// is a visual adjustment.
|
||||
left: -6,
|
||||
},
|
||||
|
||||
"& .MuiLoadingButton-loadingIndicator .MuiCircularProgress-root": {
|
||||
|
||||
Reference in New Issue
Block a user