Compare commits

...

1 Commits

Author SHA1 Message Date
Jake Howell a5ab11bc86 chore: remove <CircularProgress /> 2026-04-12 14:48:08 +00:00
9 changed files with 229 additions and 331 deletions
@@ -1,5 +1,5 @@
import Link from "@mui/material/Link";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Link } from "#/components/Link/Link";
import { FileUpload } from "./FileUpload";
const meta: Meta<typeof FileUpload> = {
@@ -10,8 +10,10 @@ const meta: Meta<typeof FileUpload> = {
description: (
<>
The template has to be a .tar or .zip file. You can also use our{" "}
<Link href="/starter-templates">starter templates</Link> to getting
started with Coder.
<Link href="/starter-templates" showExternalIcon={false}>
starter templates
</Link>{" "}
to getting started with Coder.
</>
),
},
+21 -77
View File
@@ -1,10 +1,9 @@
import { css, type Interpolation, type Theme } from "@emotion/react";
import CircularProgress from "@mui/material/CircularProgress";
import { CloudUploadIcon, FolderIcon, TrashIcon } from "lucide-react";
import { type DragEvent, type FC, type ReactNode, useRef } from "react";
import { Button } from "#/components/Button/Button";
import { Stack } from "#/components/Stack/Stack";
import { useClickable } from "#/hooks/useClickable";
import { cn } from "#/utils/cn";
import { Spinner } from "../Spinner/Spinner";
interface FileUploadProps {
isUploading: boolean;
@@ -35,16 +34,11 @@ export const FileUpload: FC<FileUploadProps> = ({
if (!isUploading && file) {
return (
<Stack
css={styles.file}
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Stack direction="row" alignItems="center">
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-surface-secondary p-4">
<div className="flex flex-row items-center gap-4">
<FolderIcon className="size-icon-sm" />
<span>{file.name}</span>
</Stack>
</div>
<Button
variant="subtle"
@@ -54,7 +48,7 @@ export const FileUpload: FC<FileUploadProps> = ({
>
<TrashIcon className="size-icon-sm" />
</Button>
</Stack>
</div>
);
}
@@ -62,31 +56,36 @@ export const FileUpload: FC<FileUploadProps> = ({
<>
<div
data-testid="drop-zone"
css={[styles.root, isUploading && styles.disabled]}
className={cn(
"flex cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border p-12 hover:bg-surface-secondary",
isUploading && "pointer-events-none opacity-75",
)}
{...clickable}
{...fileDrop}
>
<Stack alignItems="center" spacing={1}>
<div css={styles.iconWrapper}>
<div className="flex flex-col items-center gap-2">
<div className="flex size-16 items-center justify-center">
{isUploading ? (
<CircularProgress size={32} />
<Spinner size="lg" loading />
) : (
<CloudUploadIcon className="size-16" />
)}
</div>
<Stack alignItems="center" spacing={0.5}>
<span css={styles.title}>{title}</span>
<span css={styles.description}>{description}</span>
</Stack>
</Stack>
<div className="flex flex-col items-center gap-1">
<span className="text-base leading-none">{title}</span>
<span className="mt-1 max-w-md text-center text-sm leading-normal text-content-secondary">
{description}
</span>
</div>
</div>
</div>
<input
type="file"
data-testid="file-upload"
ref={inputRef}
css={styles.input}
className="hidden"
accept={extensions?.map((ext) => `.${ext}`).join(",")}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
@@ -139,58 +138,3 @@ const useFileDrop = (
onDrop,
};
};
const styles = {
root: (theme) => css`
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 2px dashed ${theme.palette.divider};
padding: 48px;
cursor: pointer;
&:hover {
background-color: ${theme.palette.background.paper};
}
`,
disabled: {
pointerEvents: "none",
opacity: 0.75,
},
// Used to maintain the size of icon and spinner
iconWrapper: {
width: 64,
height: 64,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
title: {
fontSize: 16,
lineHeight: "1",
},
description: (theme) => ({
color: theme.palette.text.secondary,
textAlign: "center",
maxWidth: 400,
fontSize: 14,
lineHeight: "1.5",
marginTop: 4,
}),
input: {
display: "none",
},
file: (theme) => ({
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
padding: 16,
background: theme.palette.background.paper,
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -1,6 +1,3 @@
import type { Interpolation, Theme } from "@emotion/react";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import { isAxiosError } from "axios";
import { ExternalLinkIcon } from "lucide-react";
import type { FC } from "react";
@@ -8,6 +5,9 @@ import type { ApiErrorResponse } from "#/api/errors";
import type { ExternalAuthDevice } from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { CopyButton } from "#/components/CopyButton/CopyButton";
import { Link } from "#/components/Link/Link";
import { cn } from "#/utils/cn";
import { Spinner } from "../Spinner/Spinner";
interface GitDeviceAuthProps {
externalAuthDevice?: ExternalAuthDevice;
@@ -72,8 +72,12 @@ export const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
deviceExchangeError,
}) => {
let status = (
<p css={styles.status}>
<CircularProgress size={16} color="secondary" data-chromatic="ignore" />
<p
className={cn(
"m-0 flex items-center justify-center gap-2 text-content-disabled",
)}
>
<Spinner size="sm" loading />
Checking for authentication...
</p>
);
@@ -126,30 +130,33 @@ export const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
}
if (!externalAuthDevice) {
return <CircularProgress />;
return <Spinner size="sm" loading />;
}
return (
<div>
<p css={styles.text}>
<div className="m-0 text-center text-base leading-[160%] text-content-secondary">
Copy your one-time code:&nbsp;
<div css={styles.copyCode}>
<span css={styles.code}>{externalAuthDevice.user_code}</span>
<span className="inline-flex items-center">
<span className="font-bold text-content-primary">
{externalAuthDevice.user_code}
</span>
&nbsp;{" "}
<CopyButton
text={externalAuthDevice.user_code}
label="Copy user code"
/>
</div>
</span>
<br />
Then open the link below and paste it:
</p>
<div css={styles.links}>
</div>
<div className="my-4 flex flex-col gap-1">
<Link
css={styles.link}
className="flex items-center justify-center gap-2 text-base"
href={externalAuthDevice.verification_uri}
target="_blank"
rel="noreferrer"
showExternalIcon={false}
>
<ExternalLinkIcon className="size-icon-xs" />
Open and Paste
@@ -160,46 +167,3 @@ export const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
</div>
);
};
const styles = {
text: (theme) => ({
fontSize: 16,
color: theme.palette.text.secondary,
textAlign: "center",
lineHeight: "160%",
margin: 0,
}),
copyCode: {
display: "inline-flex",
alignItems: "center",
},
code: (theme) => ({
fontWeight: "bold",
color: theme.palette.text.primary,
}),
links: {
display: "flex",
gap: 4,
margin: 16,
flexDirection: "column",
},
link: {
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
gap: 8,
},
status: (theme) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
color: theme.palette.text.disabled,
}),
} satisfies Record<string, Interpolation<Theme>>;
+75 -76
View File
@@ -1,9 +1,8 @@
import type { Interpolation, Theme } from "@emotion/react";
import CircularProgress, {
type CircularProgressProps,
} from "@mui/material/CircularProgress";
import { type FC, type ReactNode, useMemo } from "react";
import { cva } from "class-variance-authority";
import type { FC, ReactNode } from "react";
import type { ThemeRole } from "#/theme/roles";
import { cn } from "#/utils/cn";
import { Spinner, type SpinnerProps } from "../Spinner/Spinner";
type PillProps = React.ComponentPropsWithRef<"div"> & {
icon?: ReactNode;
@@ -11,36 +10,88 @@ type PillProps = React.ComponentPropsWithRef<"div"> & {
size?: "md" | "lg";
};
const themeStyles = (type: ThemeRole) => (theme: Theme) => {
const palette = theme.roles[type];
return {
backgroundColor: palette.background,
borderColor: palette.outline,
};
};
const pillRoleVariants = cva("text-content-primary", {
variants: {
type: {
error: "border-border-destructive bg-surface-red",
warning: "border-border-warning bg-surface-orange",
notice: "border-border-pending bg-surface-sky",
info: "border-border bg-surface-quaternary",
success: "border-border-green bg-surface-green",
active: "border-border-pending bg-surface-sky",
inactive: "border-border bg-surface-secondary",
danger: "border-border-warning bg-surface-orange",
preview: "border-border-purple bg-surface-purple",
},
},
defaultVariants: {
type: "inactive",
},
});
const PILL_HEIGHT = 24;
const PILL_ICON_SIZE = 14;
const PILL_ICON_SPACING = (PILL_HEIGHT - PILL_ICON_SIZE) / 2;
const pillLayoutVariants = cva(
"inline-flex cursor-default items-center whitespace-nowrap border border-solid font-normal [&_svg]:size-[14px]",
{
variants: {
size: {
md: "h-6 rounded-full text-xs leading-none",
lg: "rounded-full py-3.5 pl-4 pr-4 text-sm leading-none",
},
withIcon: {
true: "",
false: "",
},
},
compoundVariants: [
{
size: "md",
withIcon: false,
class: "gap-[5px] px-3",
},
{
size: "md",
withIcon: true,
class: "gap-[5px] pr-3 pl-[5px]",
},
{
size: "lg",
withIcon: false,
class: "gap-2.5",
},
{
size: "lg",
withIcon: true,
class: "gap-2.5 pl-2.5 pr-4",
},
],
defaultVariants: {
size: "md",
withIcon: false,
},
},
);
type PillSpinnerProps = SpinnerProps;
export const PillSpinner: FC<PillSpinnerProps> = ({ size = "sm" }) => {
return <Spinner size={size} loading />;
};
export const Pill: FC<PillProps> = ({
icon,
type = "inactive",
children,
size = "md",
className,
...divProps
}) => {
const typeStyles = useMemo(() => themeStyles(type), [type]);
return (
<div
css={[
styles.pill,
Boolean(icon) && size === "md" && styles.pillWithIcon,
size === "lg" && styles.pillLg,
Boolean(icon) && size === "lg" && styles.pillLgWithIcon,
typeStyles,
]}
className={cn(
pillLayoutVariants({ size, withIcon: Boolean(icon) }),
pillRoleVariants({ type }),
className,
)}
{...divProps}
>
{icon}
@@ -48,55 +99,3 @@ export const Pill: FC<PillProps> = ({
</div>
);
};
export const PillSpinner: FC<CircularProgressProps> = (props) => {
return (
<CircularProgress size={PILL_ICON_SIZE} css={styles.spinner} {...props} />
);
};
const styles = {
pill: (theme) => ({
fontSize: 12,
color: theme.experimental.l1.text,
cursor: "default",
display: "inline-flex",
alignItems: "center",
whiteSpace: "nowrap",
fontWeight: 400,
borderWidth: 1,
borderStyle: "solid",
borderRadius: 99999,
lineHeight: 1,
height: PILL_HEIGHT,
gap: PILL_ICON_SPACING,
paddingLeft: 12,
paddingRight: 12,
"& svg": {
width: PILL_ICON_SIZE,
height: PILL_ICON_SIZE,
},
}),
pillWithIcon: {
paddingLeft: PILL_ICON_SPACING,
},
pillLg: {
gap: PILL_ICON_SPACING * 2,
padding: "14px 16px",
},
pillLgWithIcon: {
paddingLeft: PILL_ICON_SPACING * 2,
},
spinner: (theme) => ({
color: theme.experimental.l1.text,
// It is necessary to align it with the MUI Icons internal padding
"& svg": {
transform: "scale(.75)",
},
}),
} satisfies Record<string, Interpolation<Theme>>;
+1 -1
View File
@@ -23,7 +23,7 @@ const spinnerVariants = cva("", {
},
});
type SpinnerProps = React.SVGProps<SVGSVGElement> &
export type SpinnerProps = React.SVGProps<SVGSVGElement> &
VariantProps<typeof spinnerVariants> & {
children?: ReactNode;
loading?: boolean;
@@ -1,22 +1,29 @@
import { css } from "@emotion/css";
import Autocomplete from "@mui/material/Autocomplete";
import CircularProgress from "@mui/material/CircularProgress";
import TextField from "@mui/material/TextField";
import { InfoIcon } from "lucide-react";
import { type FC, useState } from "react";
import { type FC, useMemo, useState } from "react";
import { useQuery } from "react-query";
import { templateVersions } from "#/api/queries/templates";
import type { TemplateVersion, Workspace } from "#/api/typesGenerated";
import { Alert, AlertTitle } from "#/components/Alert/Alert";
import { Avatar } from "#/components/Avatar/Avatar";
import { AvatarData } from "#/components/Avatar/AvatarData";
import {
Combobox,
ComboboxButton,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "#/components/Combobox/Combobox";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import type { DialogProps } from "#/components/Dialogs/Dialog";
import type { SelectFilterOption } from "#/components/Filter/SelectFilter";
import { FormFields } from "#/components/Form/Form";
import { Loader } from "#/components/Loader/Loader";
import { Pill } from "#/components/Pill/Pill";
import { Stack } from "#/components/Stack/Stack";
import { TemplateUpdateMessage } from "#/modules/templates/TemplateUpdateMessage";
import { cn } from "#/utils/cn";
import { createDayString } from "#/utils/createDayString";
type ChangeWorkspaceVersionDialogProps = DialogProps & {
@@ -32,14 +39,30 @@ export const ChangeWorkspaceVersionDialog: FC<
...templateVersions(workspace.template_id),
select: (data) => [...data].reverse(),
});
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
const [newVersion, setNewVersion] = useState<TemplateVersion>();
const currentVersion = versions?.find(
(v) => workspace.latest_build.template_version_id === v.id,
);
const [newVersion, setNewVersion] = useState<TemplateVersion>();
const validVersions = versions?.filter((v) => v.job.status === "succeeded");
const selectedVersion = newVersion || currentVersion;
const selectedOption: SelectFilterOption | undefined = useMemo(() => {
if (!selectedVersion) {
return undefined;
}
return {
value: selectedVersion.id,
label: selectedVersion.name,
startIcon: (
<Avatar
size="sm"
src={selectedVersion.created_by.avatar_url}
fallback={selectedVersion.name}
/>
),
};
}, [selectedVersion]);
return (
<ConfirmDialog
{...dialogProps}
@@ -55,87 +78,79 @@ export const ChangeWorkspaceVersionDialog: FC<
confirmText="Change"
title="Change version"
description={
<Stack>
<div className="flex flex-col gap-4">
<p>You are about to change the version of this workspace.</p>
{validVersions ? (
<>
<FormFields>
<Autocomplete
disableClearable
options={validVersions}
defaultValue={selectedVersion}
id="template-version-autocomplete"
open={isAutocompleteOpen}
onChange={(_, newTemplateVersion) => {
setNewVersion(newTemplateVersion);
<Combobox
value={selectedVersion?.id}
onValueChange={(id) => {
if (!id) {
setNewVersion(undefined);
return;
}
const next = validVersions.find((v) => v.id === id);
setNewVersion(next);
}}
onOpen={() => {
setIsAutocompleteOpen(true);
}}
onClose={() => {
setIsAutocompleteOpen(false);
}}
isOptionEqualToValue={(
option: TemplateVersion,
value: TemplateVersion,
) => option.id === value.id}
getOptionLabel={(option) => option.name}
renderOption={(props, option: TemplateVersion) => (
<li {...props}>
<AvatarData
avatar={
<Avatar
src={option.created_by.avatar_url}
fallback={option.name}
>
<ComboboxTrigger asChild>
<ComboboxButton
id="template-version-autocomplete"
aria-label="Template version"
selectedOption={selectedOption}
placeholder="Template version name"
className="w-full min-w-0 pl-3.5"
/>
</ComboboxTrigger>
<ComboboxContent
className="max-w-none min-w-[min(100%,320px)]"
align="start"
>
<ComboboxInput placeholder="Search versions…" />
<ComboboxList>
{validVersions.map((option) => (
<ComboboxItem
key={option.id}
value={option.id}
keywords={[option.name]}
className={cn(
"px-3 py-2 font-normal",
"data-[selected=true]:bg-surface-tertiary",
)}
>
<AvatarData
avatar={
<Avatar
src={option.created_by.avatar_url}
fallback={option.name}
/>
}
title={
<div className="flex w-full flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center gap-2">
{option.name}
{option.message && (
<InfoIcon
aria-hidden="true"
className="size-icon-xs"
/>
)}
</div>
{workspace.template_active_version_id ===
option.id && (
<Pill type="success">Active</Pill>
)}
</div>
}
subtitle={createDayString(option.created_at)}
/>
}
title={
<Stack
direction="row"
justifyContent="space-between"
style={{ width: "100%" }}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
>
{option.name}
{option.message && (
<InfoIcon
aria-hidden="true"
className="size-icon-xs"
/>
)}
</Stack>
{workspace.template_active_version_id ===
option.id && <Pill type="success">Active</Pill>}
</Stack>
}
subtitle={createDayString(option.created_at)}
/>
</li>
)}
renderInput={(params) => (
<>
<TextField
{...params}
fullWidth
placeholder="Template version name"
InputProps={{
...params.InputProps,
endAdornment: (
<>
{!versions && <CircularProgress size={16} />}
{params.InputProps.endAdornment}
</>
),
classes: { root: classNames.root },
}}
/>
</>
)}
/>
</ComboboxItem>
))}
</ComboboxList>
<ComboboxEmpty>No template versions found</ComboboxEmpty>
</ComboboxContent>
</Combobox>
</FormFields>
{selectedVersion && (
<>
@@ -155,15 +170,8 @@ export const ChangeWorkspaceVersionDialog: FC<
) : (
<Loader />
)}
</Stack>
</div>
}
/>
);
};
const classNames = {
// Same `padding-left` as input
root: css`
padding-left: 14px !important;
`,
};
+2 -2
View File
@@ -1,4 +1,3 @@
import CircularProgress from "@mui/material/CircularProgress";
import kebabCase from "lodash/fp/kebabCase";
import { BellOffIcon, RotateCcwIcon } from "lucide-react";
import { type FC, Suspense } from "react";
@@ -9,6 +8,7 @@ import type { HealthSeverity } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { Loader } from "#/components/Loader/Loader";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
@@ -92,7 +92,7 @@ export const HealthLayout: FC = () => {
}}
>
{isRefreshing ? (
<CircularProgress size={16} />
<Spinner size="sm" loading />
) : (
<RotateCcwIcon className="size-5" />
)}
@@ -1,11 +1,9 @@
import { useTheme } from "@emotion/react";
import CircularProgress from "@mui/material/CircularProgress";
import type { FC } from "react";
import type { GitSSHKey } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { CodeExample } from "#/components/CodeExample/CodeExample";
import { Stack } from "#/components/Stack/Stack";
import { Spinner } from "#/components/Spinner/Spinner";
interface SSHKeysPageViewProps {
isLoading: boolean;
@@ -20,43 +18,27 @@ export const SSHKeysPageView: FC<SSHKeysPageViewProps> = ({
sshKey,
onRegenerateClick,
}) => {
const theme = useTheme();
if (isLoading) {
return (
<div className="p-8">
<CircularProgress size={26} />
<Spinner size="lg" loading />
</div>
);
}
return (
<Stack>
<div className="flex flex-col gap-4">
{/* Regenerating the key is not an option if getSSHKey fails.
Only one of the error messages will exist at a single time */}
{Boolean(getSSHKeyError) && <ErrorAlert error={getSSHKeyError} />}
{sshKey && (
<>
<p
css={{
fontSize: 14,
color: theme.palette.text.secondary,
margin: 0,
}}
>
<p className="m-0 text-sm text-content-secondary">
The following public key is used to authenticate Git in workspaces.
You may add it to Git services (such as GitHub) that you need to
access from your workspace. Coder configures authentication via{" "}
<code
css={{
background: theme.palette.divider,
fontSize: 12,
padding: "2px 4px",
color: theme.palette.text.primary,
borderRadius: 2,
}}
>
<code className="rounded-sm border border-border bg-surface-secondary px-1 py-0.5 text-xs text-content-primary">
$GIT_SSH_COMMAND
</code>
.
@@ -73,6 +55,6 @@ export const SSHKeysPageView: FC<SSHKeysPageViewProps> = ({
</div>
</>
)}
</Stack>
</div>
);
};
-1
View File
@@ -165,7 +165,6 @@ export default defineConfig({
"@mui/material/CardActionArea",
"@mui/material/CardContent",
"@mui/material/Checkbox",
"@mui/material/CircularProgress",
"@mui/material/Collapse",
"@mui/material/CssBaseline",
"@mui/material/Dialog",