mirror of https://github.com/coder/coder.git
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:
parent
acd22b2c65
commit
9abf6ec170
|
@ -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`);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in New Issue