feat: show update messages on workspace page (#9705)

This commit is contained in:
Kayla Washburn
2023-09-15 13:09:29 -06:00
committed by GitHub
parent efe804498b
commit 9e053ce220
9 changed files with 95 additions and 196 deletions
+14
View File
@@ -49,3 +49,17 @@ export const templateExamples = (orgId: string) => {
queryFn: () => API.getTemplateExamples(orgId),
};
};
export const templateVersion = (versionId: string) => {
return {
queryKey: ["templateVersion", versionId],
queryFn: () => API.getTemplateVersion(versionId),
};
};
export const templateVersions = (templateId: string) => {
return {
queryKey: ["templateVersions", templateId],
queryFn: () => API.getTemplateVersions(templateId),
};
};
@@ -11,7 +11,7 @@ import InfoIcon from "@mui/icons-material/InfoOutlined";
import { makeStyles } from "@mui/styles";
import { colors } from "theme/colors";
import { useQuery } from "@tanstack/react-query";
import { getTemplate, getTemplateVersion } from "api/api";
import { templateVersion } from "api/queries/templates";
import Box from "@mui/material/Box";
import Skeleton from "@mui/material/Skeleton";
import Link from "@mui/material/Link";
@@ -25,7 +25,7 @@ export const Language = {
interface TooltipProps {
onUpdateVersion: () => void;
templateId: string;
latestVersionId: string;
templateName: string;
ariaLabel?: string;
}
@@ -33,20 +33,11 @@ interface TooltipProps {
export const WorkspaceOutdatedTooltip: FC<TooltipProps> = ({
onUpdateVersion,
ariaLabel,
templateId,
latestVersionId,
templateName,
}) => {
const styles = useStyles();
const { data: activeVersion } = useQuery({
queryFn: async () => {
const template = await getTemplate(templateId);
const activeVersion = await getTemplateVersion(
template.active_version_id,
);
return activeVersion;
},
queryKey: ["templates", templateId, "activeVersion"],
});
const { data: activeVersion } = useQuery(templateVersion(latestVersionId));
return (
<HelpTooltip
@@ -60,6 +60,7 @@ export interface WorkspaceProps {
builds?: TypesGen.WorkspaceBuild[];
templateWarnings?: TypesGen.TemplateVersionWarning[];
canUpdateWorkspace: boolean;
updateMessage?: string;
canRetryDebugMode: boolean;
canChangeVersions: boolean;
hideSSHButton?: boolean;
@@ -93,6 +94,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
resources,
builds,
canUpdateWorkspace,
updateMessage,
canRetryDebugMode,
canChangeVersions,
workspaceErrors,
@@ -219,6 +221,12 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
className={styles.firstColumnSpacer}
spacing={4}
>
{workspace.outdated && (
<Alert severity="info">
<AlertTitle>An update is available for your workspace</AlertTitle>
{updateMessage && <AlertDetail>{updateMessage}</AlertDetail>}
</Alert>
)}
{buildError}
{cancellationError}
{workspace.latest_build.status === "running" &&
@@ -24,13 +24,13 @@ export const UpdateButton: FC<WorkspaceAction> = ({
return (
<LoadingButton
loading={loading}
loadingIndicator="Updating..."
loadingIndicator={<>Updating&hellip;</>}
loadingPosition="start"
data-testid="workspace-update-button"
startIcon={<CloudQueueIcon />}
onClick={handleAction}
>
Update
Update&hellip;
</LoadingButton>
);
};
@@ -42,7 +42,7 @@ export const ActivateButton: FC<WorkspaceAction> = ({
return (
<LoadingButton
loading={loading}
loadingIndicator="Activating..."
loadingIndicator={<>Activating&hellip;</>}
loadingPosition="start"
startIcon={<PowerSettingsNewIcon />}
onClick={handleAction}
@@ -70,7 +70,7 @@ export const StartButton: FC<
>
<LoadingButton
loading={loading}
loadingIndicator="Starting..."
loadingIndicator={<>Starting&hellip;</>}
loadingPosition="start"
startIcon={<PlayCircleOutlineIcon />}
onClick={() => handleAction()}
@@ -90,7 +90,7 @@ export const StopButton: FC<WorkspaceAction> = ({ handleAction, loading }) => {
return (
<LoadingButton
loading={loading}
loadingIndicator="Stopping..."
loadingIndicator={<>Stopping&hellip;</>}
loadingPosition="start"
startIcon={<CropSquareIcon />}
onClick={handleAction}
@@ -119,13 +119,13 @@ export const RestartButton: FC<
>
<LoadingButton
loading={loading}
loadingIndicator="Restarting..."
loadingIndicator={<>Restarting&hellip;</>}
loadingPosition="start"
startIcon={<ReplayIcon />}
onClick={() => handleAction()}
data-testid="workspace-restart-button"
>
Restart
Restart&hellip;
</LoadingButton>
<BuildParametersPopover
workspace={workspace}
@@ -148,12 +148,15 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
{canChangeVersions && (
<MenuItem onClick={onMenuItemClick(handleChangeVersion)}>
<HistoryOutlined />
Change version
Change version&hellip;
</MenuItem>
)}
<MenuItem onClick={onMenuItemClick(handleDelete)}>
<MenuItem
onClick={onMenuItemClick(handleDelete)}
data-testid="delete-button"
>
<DeleteOutlined />
Delete
Delete&hellip;
</MenuItem>
</Menu>
</div>
@@ -24,13 +24,13 @@ import {
MockEntitlementsWithScheduling,
MockDeploymentConfig,
} from "testHelpers/entities";
import * as api from "../../api/api";
import { Workspace } from "../../api/typesGenerated";
import * as api from "api/api";
import { Workspace } from "api/typesGenerated";
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "../../testHelpers/renderHelpers";
import { server } from "../../testHelpers/server";
} from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import { WorkspacePage } from "./WorkspacePage";
// It renders the workspace page and waits for it be loaded
@@ -113,7 +113,7 @@ describe("WorkspacePage", () => {
await user.click(trigger);
// Click on delete
const button = await screen.findByText("Delete");
const button = await screen.findByTestId("delete-button");
await user.click(button);
// Get dialog and confirm
@@ -172,28 +172,6 @@ describe("WorkspacePage", () => {
});
});
it("requests a stop without confirmation when the user presses Restart", async () => {
const stopWorkspaceMock = jest
.spyOn(api, "stopWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
window.localStorage.setItem(
`${MockUser.id}_ignoredWarnings`,
JSON.stringify({ restart: new Date().toISOString() }),
);
// Render
await renderWorkspacePage();
// Actions
const user = userEvent.setup();
await user.click(screen.getByTestId("workspace-restart-button"));
// Assertions
await waitFor(() => {
expect(stopWorkspaceMock).toBeCalled();
});
});
it("requests cancellation when the user presses Cancel", async () => {
server.use(
rest.get(
@@ -409,44 +387,4 @@ describe("WorkspacePage", () => {
});
});
});
it("restart the workspace with one time parameters without the confirmation dialog", async () => {
window.localStorage.setItem(
`${MockUser.id}_ignoredWarnings`,
JSON.stringify({
restart: new Date().toISOString(),
}),
);
jest.spyOn(api, "getWorkspaceParameters").mockResolvedValue({
templateVersionRichParameters: [
{
...MockTemplateVersionParameter1,
ephemeral: true,
name: "rebuild",
description: "Rebuild",
required: false,
},
],
buildParameters: [{ name: "rebuild", value: "false" }],
});
const restartWorkspaceSpy = jest.spyOn(api, "restartWorkspace");
const user = userEvent.setup();
await renderWorkspacePage();
await user.click(screen.getByTestId("build-parameters-button"));
const buildParametersForm = await screen.findByTestId(
"build-parameters-form",
);
const rebuildField = within(buildParametersForm).getByLabelText("Rebuild", {
exact: false,
});
await user.clear(rebuildField);
await user.type(rebuildField, "true");
await user.click(screen.getByTestId("build-parameters-submit"));
await waitFor(() => {
expect(restartWorkspaceSpy).toBeCalledWith({
workspace: MockWorkspace,
buildParameters: [{ name: "rebuild", value: "true" }],
});
});
});
});
@@ -23,17 +23,17 @@ import {
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog";
import { ChangeVersionDialog } from "./ChangeVersionDialog";
import { useMutation, useQuery } from "@tanstack/react-query";
import { getTemplateVersions, restartWorkspace } from "api/api";
import { restartWorkspace } from "api/api";
import {
ConfirmDialog,
ConfirmDialogProps,
} from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { useMe } from "hooks/useMe";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import { workspaceBuildMachine } from "xServices/workspaceBuild/workspaceBuildXService";
import * as TypesGen from "api/typesGenerated";
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection";
import { templateVersion, templateVersions } from "api/queries/templates";
import { Alert } from "components/Alert/Alert";
import { Stack } from "components/Stack/Stack";
interface WorkspaceReadyPageProps {
workspaceState: StateFrom<typeof workspaceMachine>;
@@ -54,7 +54,7 @@ export const WorkspaceReadyPage = ({
const {
workspace,
template,
templateVersion,
templateVersion: currentVersion,
deploymentValues,
builds,
getBuildsError,
@@ -76,18 +76,21 @@ export const WorkspaceReadyPage = ({
const favicon = getFaviconByStatus(workspace.latest_build);
const navigate = useNavigate();
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const { data: templateVersions } = useQuery({
queryKey: ["template", "versions", workspace.template_id],
queryFn: () => getTemplateVersions(workspace.template_id),
enabled: changeVersionDialogOpen,
});
const [isConfirmingUpdate, setIsConfirmingUpdate] = useState(false);
const [confirmingRestart, setConfirmingRestart] = useState<{
open: boolean;
buildParameters?: TypesGen.WorkspaceBuildParameter[];
}>({ open: false });
const user = useMe();
const { isWarningIgnored, ignoreWarning } = useIgnoreWarnings(user.id);
const { data: allVersions } = useQuery({
...templateVersions(workspace.template_id),
enabled: changeVersionDialogOpen,
});
const { data: latestVersion } = useQuery({
...templateVersion(workspace.template_active_version_id),
enabled: workspace.outdated,
});
const buildLogs = useBuildLogs(workspace);
const shouldDisplayBuildLogs =
hasJobError(workspace) ||
@@ -105,6 +108,7 @@ export const WorkspaceReadyPage = ({
useEffect(() => {
bannerSend({ type: "REFRESH_WORKSPACE", workspace });
}, [bannerSend, workspace]);
return (
<>
<Helmet>
@@ -150,18 +154,10 @@ export const WorkspaceReadyPage = ({
handleStop={() => workspaceSend({ type: "STOP" })}
handleDelete={() => workspaceSend({ type: "ASK_DELETE" })}
handleRestart={(buildParameters) => {
if (isWarningIgnored("restart")) {
mutateRestartWorkspace({ workspace, buildParameters });
} else {
setConfirmingRestart({ open: true, buildParameters });
}
setConfirmingRestart({ open: true, buildParameters });
}}
handleUpdate={() => {
if (isWarningIgnored("update")) {
workspaceSend({ type: "UPDATE" });
} else {
setIsConfirmingUpdate(true);
}
setIsConfirmingUpdate(true);
}}
handleCancel={() => workspaceSend({ type: "CANCEL" })}
handleSettings={() => navigate("settings")}
@@ -173,6 +169,7 @@ export const WorkspaceReadyPage = ({
resources={workspace.latest_build.resources}
builds={builds}
canUpdateWorkspace={canUpdateWorkspace}
updateMessage={latestVersion?.message}
canRetryDebugMode={canRetryDebugMode}
canChangeVersions={canUpdateTemplate}
hideSSHButton={featureVisibility["browser_only"]}
@@ -186,7 +183,7 @@ export const WorkspaceReadyPage = ({
sshPrefix={sshPrefix}
template={template}
quotaBudget={quota?.budget}
templateWarnings={templateVersion?.warnings}
templateWarnings={currentVersion?.warnings}
buildLogs={
shouldDisplayBuildLogs && (
<WorkspaceBuildLogsSection logs={buildLogs} />
@@ -218,9 +215,9 @@ export const WorkspaceReadyPage = ({
}}
/>
<ChangeVersionDialog
templateVersions={templateVersions?.reverse()}
templateVersions={allVersions?.reverse()}
template={template}
defaultTemplateVersion={templateVersions?.find(
defaultTemplateVersion={allVersions?.find(
(v) => workspace.latest_build.template_version_id === v.id,
)}
open={changeVersionDialogOpen}
@@ -237,25 +234,29 @@ export const WorkspaceReadyPage = ({
/>
<WarningDialog
open={isConfirmingUpdate}
onConfirm={(shouldIgnore) => {
if (shouldIgnore) {
ignoreWarning("update");
}
onConfirm={() => {
workspaceSend({ type: "UPDATE" });
setIsConfirmingUpdate(false);
}}
onClose={() => setIsConfirmingUpdate(false)}
title="Confirm update"
title="Update and restart?"
confirmText="Update"
description="Are you sure you want to update your workspace? Updating your workspace will stop all running processes and delete non-persistent data."
description={
<Stack>
<p>
Restarting your workspace will stop all running processes and{" "}
<strong>delete non-persistent data</strong>.
</p>
{latestVersion && (
<Alert severity="info">{latestVersion.message}</Alert>
)}
</Stack>
}
/>
<WarningDialog
open={confirmingRestart.open}
onConfirm={(shouldIgnore) => {
if (shouldIgnore) {
ignoreWarning("restart");
}
onConfirm={() => {
mutateRestartWorkspace({
workspace,
buildParameters: confirmingRestart.buildParameters,
@@ -263,84 +264,26 @@ export const WorkspaceReadyPage = ({
setConfirmingRestart({ open: false });
}}
onClose={() => setConfirmingRestart({ open: false })}
title="Confirm restart"
title="Restart your workspace?"
confirmText="Restart"
description="Are you sure you want to restart your workspace? Updating your workspace will stop all running processes and delete non-persistent data."
description={
<>
Restarting your workspace will stop all running processes and{" "}
<strong>delete non-persistent data</strong>.
</>
}
/>
</>
);
};
type IgnoredWarnings = Record<string, string>;
const useIgnoreWarnings = (prefix: string) => {
const ignoredWarningsJSON = localStorage.getItem(`${prefix}_ignoredWarnings`);
let ignoredWarnings: IgnoredWarnings | undefined;
if (ignoredWarningsJSON) {
ignoredWarnings = JSON.parse(ignoredWarningsJSON);
}
const isWarningIgnored = (warningId: string) => {
return Boolean(ignoredWarnings?.[warningId]);
};
const ignoreWarning = (warningId: string) => {
if (!ignoredWarnings) {
ignoredWarnings = {};
}
ignoredWarnings[warningId] = new Date().toISOString();
localStorage.setItem(
`${prefix}_ignoredWarnings`,
JSON.stringify(ignoredWarnings),
);
};
return {
isWarningIgnored,
ignoreWarning,
};
};
const WarningDialog: FC<
Pick<
ConfirmDialogProps,
"open" | "onClose" | "title" | "confirmText" | "description"
> & { onConfirm: (shouldIgnore: boolean) => void }
> = ({ open, onConfirm, onClose, title, confirmText, description }) => {
const [shouldIgnore, setShouldIgnore] = useState(false);
return (
<ConfirmDialog
type="info"
hideCancel={false}
open={open}
onConfirm={() => {
onConfirm(shouldIgnore);
}}
onClose={onClose}
title={title}
confirmText={confirmText}
description={
<>
<div>{description}</div>
<FormControlLabel
sx={{
marginTop: 2,
}}
control={
<Checkbox
size="small"
onChange={(e) => {
setShouldIgnore(e.target.checked);
}}
/>
}
label="Don't show me this message again"
/>
</>
}
/>
);
"open" | "onClose" | "title" | "confirmText" | "description" | "onConfirm"
>
> = (props) => {
return <ConfirmDialog type="info" hideCancel={false} {...props} />;
};
const useBuildLogs = (workspace: TypesGen.Workspace) => {
@@ -103,7 +103,7 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
{workspace.outdated && (
<WorkspaceOutdatedTooltip
templateName={workspace.template_name}
templateId={workspace.template_id}
latestVersionId={workspace.template_active_version_id}
onUpdateVersion={handleUpdate}
ariaLabel="update version"
/>
@@ -177,7 +177,9 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
{workspace.outdated && (
<WorkspaceOutdatedTooltip
templateName={workspace.template_name}
templateId={workspace.template_id}
latestVersionId={
workspace.template_active_version_id
}
onUpdateVersion={() => {
onUpdateWorkspace(workspace);
}}