Skip to content

feat: add extra workspace actions in the workspaces table #17775

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 13, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Refactor change workspace version
  • Loading branch information
BrunoQuaresma committed May 12, 2025
commit 22759e37790e40b0d15b64d868eaf7484399d3a2
7 changes: 6 additions & 1 deletion site/src/api/queries/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,14 @@ export const templateVersionByName = (
};
};

export const templateVersionsQueryKey = (templateId: string) => [
"templateVersions",
templateId,
];

export const templateVersions = (templateId: string) => {
return {
queryKey: ["templateVersions", templateId],
queryKey: templateVersionsQueryKey(templateId),
queryFn: () => API.getTemplateVersions(templateId),
};
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockTemplate,
MockTemplateVersion,
MockTemplateVersionWithMarkdownMessage,
MockWorkspace,
} from "testHelpers/entities";
import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog";
import { templateVersionsQueryKey } from "api/queries/templates";

const noMessage = {
...MockTemplateVersion,
id: "no-message",
message: "",
};

const meta: Meta<typeof ChangeWorkspaceVersionDialog> = {
title: "modules/workspaces/ChangeWorkspaceVersionDialog",
component: ChangeWorkspaceVersionDialog,
args: {
open: true,
workspace: MockWorkspace,
},
parameters: {
queries: [
{
key: templateVersionsQueryKey(MockWorkspace.template_id),
data: [
MockTemplateVersion,
MockTemplateVersionWithMarkdownMessage,
noMessage,
],
},
],
},
};

export default meta;
type Story = StoryObj<typeof ChangeWorkspaceVersionDialog>;

export const NoVersionSelected: Story = {};

export const NoMessage: Story = {
args: {
workspace: {
...MockWorkspace,
template_active_version_id: noMessage.id,
},
},
};

export const ShortMessage: Story = {
args: {
workspace: {
...MockWorkspace,
template_active_version_id: MockTemplateVersion.id,
},
},
};

export const LongMessage: Story = {
args: {
workspace: {
...MockWorkspace,
template_active_version_id: MockTemplateVersionWithMarkdownMessage.id,
},
},
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we rename these stories? When I'm looking at them in the list of stories, it's not clear why they're different, especially when the only input that changed is a template ID

Maybe something like PlaintextMessage and MarkdownMessage?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, just be aware it is not in the scope of this PR. It was just a renaming.

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import AlertTitle from "@mui/material/AlertTitle";
import Autocomplete from "@mui/material/Autocomplete";
import CircularProgress from "@mui/material/CircularProgress";
import TextField from "@mui/material/TextField";
import type { Template, TemplateVersion } from "api/typesGenerated";
import { templateVersions } from "api/queries/templates";
import type { TemplateVersion, Workspace } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
Expand All @@ -15,41 +16,40 @@ import { Pill } from "components/Pill/Pill";
import { Stack } from "components/Stack/Stack";
import { InfoIcon } from "lucide-react";
import { TemplateUpdateMessage } from "modules/templates/TemplateUpdateMessage";
import { type FC, useRef, useState } from "react";
import { type FC, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { createDayString } from "utils/createDayString";

export type ChangeVersionDialogProps = DialogProps & {
template: Template | undefined;
templateVersions: TemplateVersion[] | undefined;
defaultTemplateVersion: TemplateVersion | undefined;
export type ChangeWorkspaceVersionDialogProps = DialogProps & {
workspace: Workspace;
onClose: () => void;
onConfirm: (templateVersion: TemplateVersion) => void;
onConfirm: (version: TemplateVersion) => void;
};

export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
onConfirm,
onClose,
template,
templateVersions,
defaultTemplateVersion,
...dialogProps
}) => {
export const ChangeWorkspaceVersionDialog: FC<
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's worth blocking on this, but I think it's worth thinking about for the future: do we want to limit the number of versions we show by default? Right now, we're loading everything, which makes the UI chug for a second, and I think that realistically, most users aren't going to be switching to anything that's more than 10 versions older than the most recent one

We have a lot of versions from the past two years, and I could see some customers having more than us, just because their companies are so much bigger

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I totally agree with you, but I have to say it is not on the scope for this PR. Feel free to open an issue tho

ChangeWorkspaceVersionDialogProps
> = ({ workspace, onClose, onConfirm, ...dialogProps }) => {
const { data: versions } = useQuery(templateVersions(workspace.template_id));
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
const selectedTemplateVersion = useRef<TemplateVersion | undefined>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, yeah, good call on getting rid of the ref, since reading from that isn't render-safe

defaultTemplateVersion,
const activeVersion = versions?.find(
(v) => workspace.template_active_version_id === v.id,
);
const version = selectedTemplateVersion.current;
const validTemplateVersions = templateVersions?.filter((version) => {
return version.job.status === "succeeded";
});
const [selectedVersion, setSelectedVersion] = useState<TemplateVersion>();
const validVersions = versions?.filter((v) => v.job.status === "succeeded");

useEffect(() => {
if (activeVersion) {
setSelectedVersion(activeVersion);
}
}, [activeVersion]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a user input race condition. What happens if:

  1. The user selects a version, and is ready to hit the Confirm button
  2. The list of versions gets invalidated, either in this component, or in another component that's using the same pocket in the query cache
  3. The value of activeVersion changes, and updates the value of activeVersion. The user is now selecting a version that they didn't explicitly select
  4. If the user hits the Change button without realizing that their selected version changed, they'll switch to a version that they didn't want

I feel like it'd actually be better if we got rid of this useEffect call entirely and didn't replace it with anything

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If anything, I feel like maybe we could change things so that if there's a new version, that makes us display a message somewhere. That way, their selection stays the same, but the user knows that they can go back to the dropdown and try a new option that's there

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!


return (
<ConfirmDialog
{...dialogProps}
onClose={onClose}
onConfirm={() => {
if (selectedTemplateVersion.current) {
onConfirm(selectedTemplateVersion.current);
if (selectedVersion) {
onConfirm(selectedVersion);
}
}}
hideCancel={false}
Expand All @@ -60,18 +60,17 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
description={
<Stack>
<p>You are about to change the version of this workspace.</p>
{validTemplateVersions ? (
{validVersions ? (
<>
<FormFields>
<Autocomplete
disableClearable
options={validTemplateVersions}
defaultValue={defaultTemplateVersion}
options={validVersions}
defaultValue={activeVersion}
id="template-version-autocomplete"
open={isAutocompleteOpen}
onChange={(_, newTemplateVersion) => {
selectedTemplateVersion.current =
newTemplateVersion ?? undefined;
setSelectedVersion(newTemplateVersion);
}}
onOpen={() => {
setIsAutocompleteOpen(true);
Expand Down Expand Up @@ -112,9 +111,8 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
/>
)}
</Stack>
{template?.active_version_id === option.id && (
<Pill type="success">Active</Pill>
)}
{workspace.template_active_version_id ===
option.id && <Pill type="success">Active</Pill>}
</Stack>
}
subtitle={createDayString(option.created_at)}
Expand All @@ -131,9 +129,7 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
...params.InputProps,
endAdornment: (
<>
{!templateVersions ? (
<CircularProgress size={16} />
) : null}
{!versions && <CircularProgress size={16} />}
{params.InputProps.endAdornment}
</>
),
Expand All @@ -144,16 +140,16 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
)}
/>
</FormFields>
{version && (
{selectedVersion && (
<>
{version.message && (
{selectedVersion.message && (
<TemplateUpdateMessage>
{version.message}
{selectedVersion.message}
</TemplateUpdateMessage>
)}
<Alert severity="info">
<AlertTitle>
Published by {version.created_by.username}
Published by {selectedVersion.created_by.username}
</AlertTitle>
</Alert>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { MissingBuildParameters } from "api/api";
import { changeVersion } from "api/queries/workspaces";
import type { Workspace } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
Expand All @@ -14,31 +17,42 @@ import {
CopyIcon,
DownloadIcon,
} from "lucide-react";
import { UpdateBuildParametersDialog } from "pages/WorkspacePage/UpdateBuildParametersDialog";
import { DownloadLogsDialog } from "pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog";
import { useState, type FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { Link as RouterLink } from "react-router-dom";
import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog";

type WorkspaceMoreActionsProps = {
workspace: Workspace;
isDuplicationReady: boolean;
disabled?: boolean;
Copy link
Member

@Parkreiner Parkreiner May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two points of feedback on this prop:

  1. I don't think a disabled prop like this should ever be optional. I think that whoever is using this component should be forced to think though when it's allowed to be used. Otherwise, there's a risk that someone forgets the prop, and that'd let the user do an operation on a workspace while it's going through a state transition (which I don't think is ever valid)
  2. But I think that we can actually do better, because even with the prop being required, there's still this race condition that I was able to reproduce reliably:
image The dropdown is able to be open while the button is disabled. Steps to reproduce:
  1. Find a workspace that's running
  2. Go into the workspace details page
  3. Stop the workspace
  4. Quickly go back to the Workspaces directory page, and click the More Actions button
  5. The UI will eventually update to prevent the dropdown from opening, but if it's already open, there's nothing in place to make sure it closes

You have more context than I do, but I was thinking:

  1. We remove the disabled prop entirely
  2. We make it so that when a workspace is having a transition, the MoreActions component unmounts entirely. That way, the user still can't click a button they shouldn't be able to, but that also causes any open dropdowns and messages to close (because they no longer exist)
  3. If the user had a dropdown open already, we display a toast message explaining why the dropdown suddenly closed without them doing anything

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though I guess if we want to make sure the dots always appear for aesthetic consistency, no matter what transitions we have, another option is to make it so that the dropdowns have conditional rendering tied to the disabled prop

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be solved not in the component level, but in the data level. What I want to say is, when stopping the workspace, or triggering a build, we should revalidate the workspaces data to represent the correct state of each workspace. I'm going to take a look into that 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol, update the data is more complex than I thought because of pagination so I will just close the menu when the button becomes disabled.

onDuplicate: () => void;
onDelete: () => void;
onChangeVersion?: () => void;
permissions?: {
changeWorkspaceVersion?: boolean;
};
onDuplicate: () => void;
onDelete: () => void;
};

export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
workspace,
disabled,
permissions,
isDuplicationReady,
onDuplicate,
onDelete,
onChangeVersion,
permissions,
}) => {
const queryClient = useQueryClient();

// Download logs
const [isDownloadDialogOpen, setIsDownloadDialogOpen] = useState(false);
const canChangeVersion = permissions?.changeWorkspaceVersion !== false;

// Change version
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const changeVersionMutation = useMutation(
changeVersion(workspace, queryClient),
);

return (
<>
Expand All @@ -64,8 +78,12 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
</RouterLink>
</DropdownMenuItem>

{onChangeVersion && canChangeVersion && (
<DropdownMenuItem onClick={onChangeVersion}>
{permissions?.changeWorkspaceVersion && (
<DropdownMenuItem
onClick={() => {
setChangeVersionDialogOpen(true);
}}
>
<HistoryIcon />
Change version&hellip;
</DropdownMenuItem>
Expand Down Expand Up @@ -96,6 +114,48 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

<DownloadLogsDialog
workspace={workspace}
open={isDownloadDialogOpen}
onClose={() => setIsDownloadDialogOpen(false)}
/>

<UpdateBuildParametersDialog
missedParameters={
isMissingBuildParameters(changeVersionMutation.error)
? changeVersionMutation.error.parameters
: []
}
open={isMissingBuildParameters(changeVersionMutation.error)}
onClose={() => {
changeVersionMutation.reset();
}}
onUpdate={(buildParameters) => {
if (isMissingBuildParameters(changeVersionMutation.error)) {
changeVersionMutation.mutate({
versionId: changeVersionMutation.error.versionId,
buildParameters,
});
}
}}
/>

<ChangeWorkspaceVersionDialog
workspace={workspace}
open={changeVersionDialogOpen}
onClose={() => {
setChangeVersionDialogOpen(false);
}}
onConfirm={(version) => {
setChangeVersionDialogOpen(false);
changeVersionMutation.mutate({ versionId: version.id });
}}
/>
</>
);
};

const isMissingBuildParameters = (e: unknown): e is MissingBuildParameters => {
return Boolean(e && e instanceof MissingBuildParameters);
Copy link
Member

@Parkreiner Parkreiner May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need this function. We can get rid of the Boolean(e &&) part, because:

  1. instanceof works with null and undefined values no problem – they'd just always evaluate to false
  2. instanceof always evaluates to a boolean

So this can be cleaned up to

const isMissingBuildParameters = (e: unknown): e is MissingBuildParameters => {
	return e instanceof MissingBuildParameters;
};

And that point, I think we can get away with just using the instanceof operator directly. It still has support for type-narrowing, but unlike a custom predicate function, there's no risk of getting the predicate logic wrong, and accidentally lying to the type system

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is true 👍

};
49 changes: 0 additions & 49 deletions site/src/pages/WorkspacePage/ChangeVersionDialog.stories.tsx

This file was deleted.

Loading