diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 6814ad1b624a0..80c2802b387d1 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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`); +}; diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 7b1ef8a7e8e0c..25b74984eb0af 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -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, + ), + }); + }, + }; +}; diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 2a289593acdca..cd6cedee0f692 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -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, diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index b3fb4d1d6e1ee..31efc004c2aa7 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -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 = ({ handleSettings, handleChangeVersion, handleDormantActivate, + handleToggleFavorite, workspace, isUpdating, isRestarting, @@ -131,6 +133,7 @@ export const Workspace: FC = ({ handleBuildRetryDebug={handleBuildRetryDebug} handleChangeVersion={handleChangeVersion} handleDormantActivate={handleDormantActivate} + handleToggleFavorite={handleToggleFavorite} canRetryDebugMode={canRetryDebugMode} canChangeVersions={canChangeVersions} isUpdating={isUpdating} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx index 360d66dbcdc24..d8cc82be6a7f0 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx @@ -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 = ({ ); }; + +interface FavoriteButtonProps { + onToggle: (workspaceID: string) => void; + workspaceID: string; + isFavorite: boolean; +} + +export const FavoriteButton: FC = ({ + onToggle: onToggle, + workspaceID, + isFavorite, +}) => { + return ( + : } + onClick={() => onToggle(workspaceID)} + > + {isFavorite ? "Unfavorite" : "Favorite"} + + ); +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index a72251978b1e5..fb6536b2a08f1 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -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 = ({ workspace, + handleToggleFavorite, handleStart, handleStop, handleRestart, @@ -131,6 +134,13 @@ export const WorkspaceActions: FC = ({ activating: , retry: , retryDebug: , + toggleFavorite: ( + + ), }; return ( @@ -150,6 +160,8 @@ export const WorkspaceActions: FC = ({ {showCancel && } + {buttonMapping.toggleFavorite} + = ({ 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 = ({ displayError(message); } }} + handleToggleFavorite={() => { + toggleFavoriteMutation.mutate(); + }} latestVersion={latestVersion} canChangeVersions={canChangeVersions} hideSSHButton={featureVisibility["browser_only"]} diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index 88e2634ef4418..0bba8c41a533b 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -63,6 +63,7 @@ export interface WorkspaceProps { template: TypesGen.Template; permissions: WorkspacePermissions; latestVersion?: TypesGen.TemplateVersion; + handleToggleFavorite: () => void; } export const WorkspaceTopbar: FC = ({ @@ -75,6 +76,7 @@ export const WorkspaceTopbar: FC = ({ handleSettings, handleChangeVersion, handleDormantActivate, + handleToggleFavorite, workspace, isUpdating, isRestarting, @@ -278,6 +280,7 @@ export const WorkspaceTopbar: FC = ({ handleRetryDebug={handleBuildRetryDebug} handleChangeVersion={handleChangeVersion} handleDormantActivate={handleDormantActivate} + handleToggleFavorite={handleToggleFavorite} canRetryDebug={canRetryDebugMode} canChangeVersions={canChangeVersions} isUpdating={isUpdating} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index cfff23823889e..73f5ac74dacf7 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -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", diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 3afdc8b257e30..4e96e4fdc6867 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -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 = ({ alignItems="center" > {workspace.name} + {workspace.favorite && ( + + )} {workspace.outdated && ( { + 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, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index b3ba287be5060..e35a44b26cfff 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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, };