feat(site): show favorite workspaces in ui (#11875)

* Add Star beside workspace name to indicate favorite status in WorkspacesList
* Add button in workspace top row to toggle workspace favorite status
This commit is contained in:
Cian Johnston 2024-01-29 13:39:31 +00:00 committed by GitHub
parent acd22b2c65
commit 9abf6ec170
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 149 additions and 1 deletions

View File

@ -1688,3 +1688,11 @@ export const updateHealthSettings = async (
);
return response.data;
};
export const putFavoriteWorkspace = async (workspaceID: string) => {
await axios.put(`/api/v2/workspaces/${workspaceID}/favorite`);
};
export const deleteFavoriteWorkspace = async (workspaceID: string) => {
await axios.delete(`/api/v2/workspaces/${workspaceID}/favorite`);
};

View File

@ -265,3 +265,30 @@ const updateWorkspaceBuild = async (
queryKey: workspaceBuildsKey(build.workspace_id),
});
};
export const toggleFavorite = (
workspace: Workspace,
queryClient: QueryClient,
) => {
return {
mutationFn: () => {
if (workspace.favorite) {
return API.deleteFavoriteWorkspace(workspace.id);
} else {
return API.putFavoriteWorkspace(workspace.id);
}
},
onSuccess: async () => {
queryClient.setQueryData(
workspaceByOwnerAndNameKey(workspace.owner_name, workspace.name),
{ ...workspace, favorite: !workspace.favorite },
);
await queryClient.invalidateQueries({
queryKey: workspaceByOwnerAndNameKey(
workspace.owner_name,
workspace.name,
),
});
},
};
};

View File

@ -90,6 +90,13 @@ export const Running: Story = {
},
};
export const Favorite: Story = {
args: {
...Running.args,
workspace: Mocks.MockFavoriteWorkspace,
},
};
export const WithoutUpdateAccess: Story = {
args: {
...Running.args,

View File

@ -33,6 +33,7 @@ export interface WorkspaceProps {
handleSettings: () => void;
handleChangeVersion: () => void;
handleDormantActivate: () => void;
handleToggleFavorite: () => void;
isUpdating: boolean;
isRestarting: boolean;
workspace: TypesGen.Workspace;
@ -64,6 +65,7 @@ export const Workspace: FC<WorkspaceProps> = ({
handleSettings,
handleChangeVersion,
handleDormantActivate,
handleToggleFavorite,
workspace,
isUpdating,
isRestarting,
@ -131,6 +133,7 @@ export const Workspace: FC<WorkspaceProps> = ({
handleBuildRetryDebug={handleBuildRetryDebug}
handleChangeVersion={handleChangeVersion}
handleDormantActivate={handleDormantActivate}
handleToggleFavorite={handleToggleFavorite}
canRetryDebugMode={canRetryDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}

View File

@ -9,6 +9,8 @@ import OutlinedBlockIcon from "@mui/icons-material/BlockOutlined";
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
import RetryIcon from "@mui/icons-material/BuildOutlined";
import RetryDebugIcon from "@mui/icons-material/BugReportOutlined";
import Star from "@mui/icons-material/Star";
import StarBorder from "@mui/icons-material/StarBorder";
import { type FC } from "react";
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { BuildParametersPopover } from "./BuildParametersPopover";
@ -190,3 +192,24 @@ export const RetryButton: FC<RetryButtonProps> = ({
</TopbarButton>
);
};
interface FavoriteButtonProps {
onToggle: (workspaceID: string) => void;
workspaceID: string;
isFavorite: boolean;
}
export const FavoriteButton: FC<FavoriteButtonProps> = ({
onToggle: onToggle,
workspaceID,
isFavorite,
}) => {
return (
<TopbarButton
startIcon={isFavorite ? <Star /> : <StarBorder />}
onClick={() => onToggle(workspaceID)}
>
{isFavorite ? "Unfavorite" : "Favorite"}
</TopbarButton>
);
};

View File

@ -12,6 +12,7 @@ import {
UpdateButton,
ActivateButton,
RetryButton,
FavoriteButton,
} from "./Buttons";
import Divider from "@mui/material/Divider";
@ -30,6 +31,7 @@ import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
export interface WorkspaceActionsProps {
workspace: Workspace;
handleToggleFavorite: () => void;
handleStart: (buildParameters?: WorkspaceBuildParameter[]) => void;
handleStop: () => void;
handleRestart: (buildParameters?: WorkspaceBuildParameter[]) => void;
@ -51,6 +53,7 @@ export interface WorkspaceActionsProps {
export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
workspace,
handleToggleFavorite,
handleStart,
handleStop,
handleRestart,
@ -131,6 +134,13 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
activating: <ActivateButton loading handleAction={handleDormantActivate} />,
retry: <RetryButton handleAction={handleRetry} />,
retryDebug: <RetryButton debug handleAction={handleRetryDebug} />,
toggleFavorite: (
<FavoriteButton
workspaceID={workspace.id}
isFavorite={workspace.favorite}
onToggle={handleToggleFavorite}
/>
),
};
return (
@ -150,6 +160,8 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
{showCancel && <CancelButton handleAction={handleCancel} />}
{buttonMapping.toggleFavorite}
<MoreMenu>
<MoreMenuTrigger>
<TopbarIconButton

View File

@ -15,6 +15,7 @@ export const actionTypes = [
"updating",
"activate",
"activating",
"toggleFavorite",
// There's no need for a retrying state because retrying starts a transition
// into one of the starting, stopping, or deleting states (based on the

View File

@ -20,6 +20,7 @@ import {
updateWorkspace,
stopWorkspace,
startWorkspace,
toggleFavorite,
cancelBuild,
} from "api/queries/workspaces";
import { Alert } from "components/Alert/Alert";
@ -144,6 +145,11 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
startWorkspace(workspace, queryClient),
);
// Toggle workspace favorite
const toggleFavoriteMutation = useMutation(
toggleFavorite(workspace, queryClient),
);
// Cancel build
const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient));
@ -217,6 +223,9 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
displayError(message);
}
}}
handleToggleFavorite={() => {
toggleFavoriteMutation.mutate();
}}
latestVersion={latestVersion}
canChangeVersions={canChangeVersions}
hideSSHButton={featureVisibility["browser_only"]}

View File

@ -63,6 +63,7 @@ export interface WorkspaceProps {
template: TypesGen.Template;
permissions: WorkspacePermissions;
latestVersion?: TypesGen.TemplateVersion;
handleToggleFavorite: () => void;
}
export const WorkspaceTopbar: FC<WorkspaceProps> = ({
@ -75,6 +76,7 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
handleSettings,
handleChangeVersion,
handleDormantActivate,
handleToggleFavorite,
workspace,
isUpdating,
isRestarting,
@ -278,6 +280,7 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
handleRetryDebug={handleBuildRetryDebug}
handleChangeVersion={handleChangeVersion}
handleDormantActivate={handleDormantActivate}
handleToggleFavorite={handleToggleFavorite}
canRetryDebug={canRetryDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}

View File

@ -165,6 +165,17 @@ export const AllStates: Story = {
},
};
export const AllStatesWithFavorites: Story = {
args: {
workspaces: allWorkspaces.map((workspace, i) => ({
...workspace,
// NOTE: testing sort order is not relevant here.
favorite: i % 2 === 0,
})),
count: allWorkspaces.length,
},
};
const icons = [
"/icon/code.svg",
"/icon/aws.svg",

View File

@ -7,6 +7,7 @@ import TableRow from "@mui/material/TableRow";
import Checkbox from "@mui/material/Checkbox";
import Skeleton from "@mui/material/Skeleton";
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
import Star from "@mui/icons-material/Star";
import { useTheme } from "@emotion/react";
import { type FC, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
@ -150,6 +151,9 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
alignItems="center"
>
{workspace.name}
{workspace.favorite && (
<Star css={{ width: 16, height: 16 }} />
)}
{workspace.outdated && (
<WorkspaceOutdatedTooltip
templateName={workspace.template_name}

View File

@ -1,6 +1,8 @@
import { useMutation } from "react-query";
import {
deleteWorkspace,
deleteFavoriteWorkspace,
putFavoriteWorkspace,
startWorkspace,
stopWorkspace,
updateWorkspace,
@ -63,12 +65,44 @@ export function useBatchActions(options: UseBatchActionsProps) {
},
});
const favoriteAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
return Promise.all(
workspaces
.filter((w) => !w.favorite)
.map((w) => putFavoriteWorkspace(w.id)),
);
},
onSuccess,
onError: () => {
displayError("Failed to favorite some workspaces");
},
});
const unfavoriteAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
return Promise.all(
workspaces
.filter((w) => w.favorite)
.map((w) => deleteFavoriteWorkspace(w.id)),
);
},
onSuccess,
onError: () => {
displayError("Failed to unfavorite some workspaces");
},
});
return {
favoriteAll: favoriteAllMutation.mutateAsync,
unfavoriteAll: unfavoriteAllMutation.mutateAsync,
startAll: startAllMutation.mutateAsync,
stopAll: stopAllMutation.mutateAsync,
deleteAll: deleteAllMutation.mutateAsync,
updateAll: updateAllMutation.mutateAsync,
isLoading:
favoriteAllMutation.isLoading ||
unfavoriteAllMutation.isLoading ||
startAllMutation.isLoading ||
stopAllMutation.isLoading ||
deleteAllMutation.isLoading,

View File

@ -4,7 +4,7 @@ import {
type DeploymentConfig,
} from "api/api";
import { FieldError } from "api/errors";
import type * as TypesGen from "api/typesGenerated";
import * as TypesGen from "api/typesGenerated";
import range from "lodash/range";
import type { Permissions } from "contexts/auth/permissions";
import { TemplateVersionFiles } from "utils/templateVersion";
@ -1020,6 +1020,12 @@ export const MockWorkspace: TypesGen.Workspace = {
},
automatic_updates: "never",
allow_renames: true,
favorite: false,
};
export const MockFavoriteWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-favorite-workspace",
favorite: true,
};