mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: show version messages in version lists (#9708)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { InfoTooltip } from "./InfoTooltip";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
const meta: Meta<typeof InfoTooltip> = {
|
||||
title: "components/InfoTooltip",
|
||||
component: InfoTooltip,
|
||||
args: {
|
||||
type: "info",
|
||||
title: "Hello, friend!",
|
||||
message: "Today is a lovely day :^)",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InfoTooltip>;
|
||||
|
||||
export const Example: Story = {};
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
type: "warning",
|
||||
message: "Unfortunately, there's a radio connected to my brain",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { type FC, type ReactNode } from "react";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import InfoIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { colors } from "theme/colors";
|
||||
|
||||
interface InfoTooltipProps {
|
||||
type?: "warning" | "info";
|
||||
title: ReactNode;
|
||||
message: ReactNode;
|
||||
}
|
||||
|
||||
export const InfoTooltip: FC<InfoTooltipProps> = (props) => {
|
||||
const { title, message, type = "info" } = props;
|
||||
|
||||
const styles = useStyles({ type });
|
||||
|
||||
return (
|
||||
<HelpTooltip
|
||||
size="small"
|
||||
icon={InfoIcon}
|
||||
iconClassName={styles.icon}
|
||||
buttonClassName={styles.button}
|
||||
>
|
||||
<HelpTooltipTitle>{title}</HelpTooltipTitle>
|
||||
<HelpTooltipText>{message}</HelpTooltipText>
|
||||
</HelpTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<unknown, Pick<InfoTooltipProps, "type">>(() => ({
|
||||
icon: ({ type }) => ({
|
||||
color: type === "info" ? colors.blue[5] : colors.yellow[5],
|
||||
}),
|
||||
|
||||
button: {
|
||||
opacity: 1,
|
||||
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -6,6 +6,7 @@ import { Pill } from "components/Pill/Pill";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { TimelineEntry } from "components/Timeline/TimelineEntry";
|
||||
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
||||
import { InfoTooltip } from "components/InfoTooltip/InfoTooltip";
|
||||
import { useClickableTableRow } from "hooks/useClickableTableRow";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { colors } from "theme/colors";
|
||||
@@ -63,6 +64,10 @@ export const VersionRow: React.FC<VersionRowProps> = ({
|
||||
version <strong>{version.name}</strong>
|
||||
</span>
|
||||
|
||||
{version.message && (
|
||||
<InfoTooltip title="Message" message={version.message} />
|
||||
)}
|
||||
|
||||
<span className={styles.versionTime}>
|
||||
{new Date(version.created_at).toLocaleTimeString()}
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DialogProps } from "components/Dialogs/Dialog";
|
||||
import { FC, useRef, useState } from "react";
|
||||
import { FormFields } from "components/Form/Form";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Template, TemplateVersion } from "api/typesGenerated";
|
||||
@@ -13,6 +14,9 @@ import { Pill } from "components/Pill/Pill";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Box from "@mui/material/Box";
|
||||
import { Alert, AlertDetail } from "components/Alert/Alert";
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import InfoIcon from "@mui/icons-material/InfoOutlined";
|
||||
|
||||
export type ChangeVersionDialogProps = DialogProps & {
|
||||
template: Template | undefined;
|
||||
@@ -32,6 +36,9 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
|
||||
}) => {
|
||||
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
|
||||
const selectedTemplateVersion = useRef<TemplateVersion | undefined>();
|
||||
const version = selectedTemplateVersion.current;
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
@@ -51,74 +58,103 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
|
||||
<Stack>
|
||||
<p>You are about to change the version of this workspace.</p>
|
||||
{templateVersions ? (
|
||||
<FormFields>
|
||||
<Autocomplete
|
||||
disableClearable
|
||||
options={templateVersions}
|
||||
defaultValue={defaultTemplateVersion}
|
||||
id="template-version-autocomplete"
|
||||
open={isAutocompleteOpen}
|
||||
onChange={(_, newTemplateVersion) => {
|
||||
selectedTemplateVersion.current =
|
||||
newTemplateVersion ?? undefined;
|
||||
}}
|
||||
onOpen={() => {
|
||||
setIsAutocompleteOpen(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsAutocompleteOpen(false);
|
||||
}}
|
||||
isOptionEqualToValue={(
|
||||
option: TemplateVersion,
|
||||
value: TemplateVersion,
|
||||
) => option.id === value.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
renderOption={(props, option: TemplateVersion) => (
|
||||
<Box component="li" {...props}>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar src={option.created_by.avatar_url}>
|
||||
{option.name}
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{option.name}
|
||||
{template?.active_version_id === option.id && (
|
||||
<Pill text="Active" type="success" />
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
subtitle={createDayString(option.created_at)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<>
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
placeholder="Template version name"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{!templateVersions ? (
|
||||
<CircularProgress size={16} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</FormFields>
|
||||
<>
|
||||
<FormFields>
|
||||
<Autocomplete
|
||||
disableClearable
|
||||
options={templateVersions}
|
||||
defaultValue={defaultTemplateVersion}
|
||||
id="template-version-autocomplete"
|
||||
open={isAutocompleteOpen}
|
||||
onChange={(_, newTemplateVersion) => {
|
||||
selectedTemplateVersion.current =
|
||||
newTemplateVersion ?? undefined;
|
||||
}}
|
||||
onOpen={() => {
|
||||
setIsAutocompleteOpen(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsAutocompleteOpen(false);
|
||||
}}
|
||||
isOptionEqualToValue={(
|
||||
option: TemplateVersion,
|
||||
value: TemplateVersion,
|
||||
) => option.id === value.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
renderOption={(props, option: TemplateVersion) => (
|
||||
<Box component="li" {...props}>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar src={option.created_by.avatar_url}>
|
||||
{option.name}
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
>
|
||||
{option.name}
|
||||
{option.message && (
|
||||
<InfoIcon
|
||||
sx={(theme) => ({
|
||||
width: theme.spacing(1.5),
|
||||
height: theme.spacing(1.5),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{template?.active_version_id === option.id && (
|
||||
<Pill text="Active" type="success" />
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
subtitle={createDayString(option.created_at)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<>
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
placeholder="Template version name"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{!templateVersions ? (
|
||||
<CircularProgress size={16} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
classes: {
|
||||
root: styles.inputRoot,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</FormFields>
|
||||
{version && (
|
||||
<Alert severity="info">
|
||||
<AlertTitle>
|
||||
Published by {version.created_by.username}
|
||||
</AlertTitle>
|
||||
{version.message && (
|
||||
<AlertDetail>{version.message}</AlertDetail>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
@@ -127,3 +163,9 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const useStyles = makeStyles((theme) => ({
|
||||
inputRoot: {
|
||||
paddingLeft: `${theme.spacing(1.75)} !important`, // Same padding left as input
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -16,13 +16,6 @@ import Button from "@mui/material/Button";
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import InfoIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { colors } from "theme/colors";
|
||||
import { useClickableTableRow } from "hooks/useClickableTableRow";
|
||||
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
|
||||
import Box from "@mui/material/Box";
|
||||
@@ -36,6 +29,7 @@ import { getDisplayWorkspaceTemplateName } from "utils/workspace";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { InfoTooltip } from "components/InfoTooltip/InfoTooltip";
|
||||
|
||||
export interface WorkspacesTableProps {
|
||||
workspaces?: Workspace[];
|
||||
@@ -215,7 +209,13 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<WorkspaceStatusBadge workspace={workspace} />
|
||||
{workspace.latest_build.status === "running" &&
|
||||
!workspace.health.healthy && <UnhealthyTooltip />}
|
||||
!workspace.health.healthy && (
|
||||
<InfoTooltip
|
||||
type="warning"
|
||||
title="Workspace is unhealthy"
|
||||
message="Your workspace is running but some agents are unhealthy."
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
@@ -269,24 +269,6 @@ const WorkspacesRow: FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const UnhealthyTooltip = () => {
|
||||
const styles = useUnhealthyTooltipStyles();
|
||||
|
||||
return (
|
||||
<HelpTooltip
|
||||
size="small"
|
||||
icon={InfoIcon}
|
||||
iconClassName={styles.unhealthyIcon}
|
||||
buttonClassName={styles.unhealthyButton}
|
||||
>
|
||||
<HelpTooltipTitle>Workspace is unhealthy</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
Your workspace is running but some agents are unhealthy.
|
||||
</HelpTooltipText>
|
||||
</HelpTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const TableLoader = ({
|
||||
canCheckWorkspaces,
|
||||
}: {
|
||||
@@ -324,20 +306,6 @@ const cantBeChecked = (workspace: Workspace) => {
|
||||
return ["deleting", "pending"].includes(workspace.latest_build.status);
|
||||
};
|
||||
|
||||
const useUnhealthyTooltipStyles = makeStyles(() => ({
|
||||
unhealthyIcon: {
|
||||
color: colors.yellow[5],
|
||||
},
|
||||
|
||||
unhealthyButton: {
|
||||
opacity: 1,
|
||||
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
withImage: {
|
||||
paddingBottom: 0,
|
||||
|
||||
Reference in New Issue
Block a user