chore: update workspaces top bar to display org name (#14596)

* chore: move schedule controls to the right side of the screen

* chore: add org display to workspace topbar

* fix: force organizations to be readonly array

* fix update type mismatch for organizations again

* fix: update quota querying logic to use new endpoint

* fix: add logic for handling long workspace or org names

* chore: add links for workspaces by org

* chore: expand tooltip styling for org

* chore: expand tooltip styling for owner

* refactor: split off breadcrumbs for readability

* fix: display correct template version name in dropdown

* fix: update overflow styling for breadcrumb segments

* fix: favor org display name

* fix: centralize org display name logic

* fix: ensure that mock query cache key and component key are properly synced for storybook
This commit is contained in:
Michael Smith
2024-09-16 16:16:59 -04:00
committed by GitHub
parent 335eb05223
commit 910225698e
12 changed files with 382 additions and 171 deletions
+3 -1
View File
@@ -1684,11 +1684,13 @@ class ApiMethods {
};
getWorkspaceQuota = async (
organizationName: string,
username: string,
): Promise<TypesGen.WorkspaceQuota> => {
const response = await this.axios.get(
`/api/v2/workspace-quota/${encodeURIComponent(username)}`,
`/api/v2/organizations/${encodeURIComponent(organizationName)}/members/${encodeURIComponent(username)}/workspace-quota`,
);
return response.data;
};
+9 -7
View File
@@ -1,14 +1,16 @@
import { API } from "api/api";
export const getWorkspaceQuotaQueryKey = (username: string) => [
username,
"workspaceQuota",
];
export const getWorkspaceQuotaQueryKey = (
organizationName: string,
username: string,
) => {
return ["workspaceQuota", organizationName, username];
};
export const workspaceQuota = (username: string) => {
export const workspaceQuota = (organizationName: string, username: string) => {
return {
queryKey: getWorkspaceQuotaQueryKey(username),
queryFn: () => API.getWorkspaceQuota(username),
queryKey: getWorkspaceQuotaQueryKey(organizationName, username),
queryFn: () => API.getWorkspaceQuota(organizationName, username),
};
};
@@ -19,7 +19,7 @@ export interface DashboardValue {
entitlements: Entitlements;
experiments: Experiments;
appearance: AppearanceConfig;
organizations: Organization[];
organizations: readonly Organization[];
showOrganizations: boolean;
}
@@ -99,5 +99,8 @@ export const GroupsPage: FC = () => {
export default GroupsPage;
export const getOrganizationNameByDefault = (organizations: Organization[]) =>
organizations.find((org) => org.is_default)?.name;
export const getOrganizationNameByDefault = (
organizations: readonly Organization[],
) => {
return organizations.find((org) => org.is_default)?.name;
};
@@ -12,9 +12,9 @@ import { Outlet } from "react-router-dom";
import { DeploySettingsContext } from "../DeploySettingsPage/DeploySettingsLayout";
import { Sidebar } from "./Sidebar";
type OrganizationSettingsValue = {
organizations: Organization[];
};
type OrganizationSettingsValue = Readonly<{
organizations: readonly Organization[];
}>;
export const useOrganizationSettings = (): OrganizationSettingsValue => {
const { organizations } = useDashboard();
@@ -59,5 +59,9 @@ const OrganizationProvisionersPage: FC = () => {
export default OrganizationProvisionersPage;
const getOrganizationByName = (organizations: Organization[], name: string) =>
organizations.find((org) => org.name === name);
const getOrganizationByName = (
organizations: readonly Organization[],
name: string,
) => {
return organizations.find((org) => org.name === name);
};
@@ -55,7 +55,7 @@ const OrganizationSettingsPage: FC = () => {
// Redirect /organizations => /organizations/default-org, or if they cannot edit
// the default org, then the first org they can edit, if any.
if (!organizationName) {
const editableOrg = organizations
const editableOrg = [...organizations]
.sort((a, b) => {
// Prefer default org (it may not be first).
// JavaScript will happily subtract booleans, but use numbers to keep
@@ -112,5 +112,9 @@ const OrganizationSettingsPage: FC = () => {
export default OrganizationSettingsPage;
const getOrganizationByName = (organizations: Organization[], name: string) =>
organizations.find((org) => org.name === name);
const getOrganizationByName = (
organizations: readonly Organization[],
name: string,
) => {
return organizations.find((org) => org.name === name);
};
@@ -45,8 +45,11 @@ export const TemplateRedirectController: FC = () => {
return <Outlet />;
};
const getOrganizationNameByDefault = (organizations: Organization[]) =>
organizations.find((org) => org.is_default)?.name;
const getOrganizationNameByDefault = (
organizations: readonly Organization[],
) => {
return organizations.find((org) => org.is_default)?.name;
};
// I really hate doing it this way, but React Router does not provide a better way.
const removePrefix = (self: string, prefix: string) =>
@@ -220,6 +220,12 @@ export const WorkspaceNotifications: FC<WorkspaceNotificationsProps> = ({
(n) => n.severity === "warning",
);
// We have to avoid rendering out a div at all if there is no content so
// that we don't introduce additional gaps via the parent flex container
if (infoNotifications.length === 0 && warningNotifications.length === 0) {
return null;
}
return (
<div css={styles.notificationsGroup}>
{infoNotifications.length > 0 && (
@@ -30,14 +30,15 @@ import {
} from "utils/schedule";
import { isWorkspaceOn } from "utils/workspace";
export interface WorkspaceScheduleContainerProps {
interface WorkspaceScheduleContainerProps {
children?: ReactNode;
onClickIcon?: () => void;
}
export const WorkspaceScheduleContainer: FC<
WorkspaceScheduleContainerProps
> = ({ children, onClickIcon }) => {
const WorkspaceScheduleContainer: FC<WorkspaceScheduleContainerProps> = ({
children,
onClickIcon,
}) => {
const icon = (
<TopbarIcon>
<ScheduleOutlined aria-label="Schedule" />
@@ -49,6 +50,7 @@ export const WorkspaceScheduleContainer: FC<
<Tooltip title="Schedule">
{onClickIcon ? (
<button
type="button"
data-testid="schedule-icon-button"
onClick={onClickIcon}
css={styles.scheduleIconButton}
@@ -294,6 +296,7 @@ const styles = {
padding: 0,
fontSize: "inherit",
lineHeight: "inherit",
cursor: "pointer",
},
scheduleValue: {
@@ -1,8 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, screen, userEvent, waitFor, within } from "@storybook/test";
import { getWorkspaceQuotaQueryKey } from "api/queries/workspaceQuota";
import type { Workspace, WorkspaceQuota } from "api/typesGenerated";
import { addHours, addMinutes } from "date-fns";
import {
MockOrganization,
MockTemplate,
MockTemplateVersion,
MockUser,
@@ -11,9 +13,12 @@ import {
import { withDashboardProvider } from "testHelpers/storybook";
import { WorkspaceTopbar } from "./WorkspaceTopbar";
// We want a workspace without a deadline to not pollute the screenshot
const baseWorkspace = {
// We want a workspace without a deadline to not pollute the screenshot. Also
// want to make sure that the workspace is synced to our other mock values
const baseWorkspace: Workspace = {
...MockWorkspace,
organization_name: MockOrganization.name,
organization_id: MockOrganization.id,
latest_build: {
...MockWorkspace.latest_build,
deadline: undefined,
@@ -262,15 +267,37 @@ export const WithFarAwayDeadlineRequiredByTemplate: Story = {
},
};
export const WithQuota: Story = {
export const WithQuotaNoOrgs: Story = {
parameters: {
showOrganizations: false,
queries: [
{
key: getWorkspaceQuotaQueryKey(MockUser.username),
key: getWorkspaceQuotaQueryKey(
MockOrganization.name,
MockUser.username,
),
data: {
credits_consumed: 2,
budget: 40,
},
} satisfies WorkspaceQuota,
},
],
},
};
export const WithQuotaWithOrgs: Story = {
parameters: {
showOrganizations: true,
queries: [
{
key: getWorkspaceQuotaQueryKey(
MockOrganization.name,
MockUser.username,
),
data: {
credits_consumed: 2,
budget: 40,
} satisfies WorkspaceQuota,
},
],
},
+298 -141
View File
@@ -1,7 +1,7 @@
import { useTheme } from "@emotion/react";
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
import DeleteOutline from "@mui/icons-material/DeleteOutline";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import QuotaIcon from "@mui/icons-material/MonetizationOnOutlined";
import Link from "@mui/material/Link";
import Tooltip from "@mui/material/Tooltip";
import { workspaceQuota } from "api/queries/workspaceQuota";
@@ -87,18 +87,22 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
latestVersion,
permissions,
}) => {
const { entitlements, organizations, showOrganizations } = useDashboard();
const getLink = useLinks();
const theme = useTheme();
// Quota
const hasDailyCost = workspace.latest_build.daily_cost > 0;
const { data: quota } = useQuery({
...workspaceQuota(workspace.owner_name),
...workspaceQuota(workspace.organization_name, workspace.owner_name),
// Don't need to tie the enabled condition to showOrganizations because
// even if the customer hasn't enabled the orgs enterprise feature, all
// workspaces have an associated organization under the hood
enabled: hasDailyCost,
});
// Dormant
const { entitlements } = useDashboard();
const allowAdvancedScheduling =
entitlements.features.advanced_template_scheduling.enabled;
// This check can be removed when https://github.com/coder/coder/milestone/19
@@ -108,6 +112,12 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
allowAdvancedScheduling,
);
const activeOrg = organizations.find(
(org) => org.id === workspace.organization_id,
);
const orgDisplayName = activeOrg?.display_name || workspace.organization_name;
const isImmutable =
workspace.latest_build.status === "deleted" ||
workspace.latest_build.status === "deleting";
@@ -124,90 +134,71 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
</TopbarIconButton>
</Tooltip>
<div
css={{
display: "flex",
alignItems: "center",
columnGap: 24,
rowGap: 8,
flexWrap: "wrap",
// 12px - It is needed to keep vertical spacing when the content is wrapped
padding: "12px 0 12px 16px",
}}
>
<div css={styles.topbarLeft}>
<TopbarData>
<UserAvatar
size="xs"
username={workspace.owner_name}
avatarURL={workspace.owner_avatar_url}
<OwnerBreadcrumb
ownerName={workspace.owner_name}
ownerAvatarUrl={workspace.owner_avatar_url}
/>
<Tooltip title="Owner">
<span>{workspace.owner_name}</span>
</Tooltip>
<TopbarDivider />
<Popover mode="hover">
<PopoverTrigger>
<span
css={{
display: "flex",
alignItems: "center",
gap: 8,
cursor: "default",
padding: "4px 0",
}}
>
<TopbarAvatar src={workspace.template_icon} />
<span css={{ fontWeight: 500 }}>{workspace.name}</span>
</span>
</PopoverTrigger>
<HelpTooltipContent
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
>
<AvatarData
title={
<Link
component={RouterLink}
to={templateLink}
css={{ color: "inherit" }}
>
{workspace.template_display_name.length > 0
? workspace.template_display_name
: workspace.template_name}
</Link>
}
subtitle={
<Link
component={RouterLink}
to={`${templateLink}/versions/${workspace.latest_build.template_version_name}`}
css={{ color: "inherit" }}
>
{workspace.latest_build.template_version_name}
</Link>
}
avatar={
workspace.template_icon !== "" && (
<ExternalAvatar
src={workspace.template_icon}
variant="square"
fitImage
/>
)
{showOrganizations && (
<>
<TopbarDivider />
<OrganizationBreadcrumb
orgName={orgDisplayName}
orgIconUrl={activeOrg?.icon}
orgPageUrl={
showOrganizations
? `/organizations/${encodeURIComponent(workspace.organization_name)}`
: undefined
}
/>
</HelpTooltipContent>
</Popover>
</TopbarData>
</>
)}
{!isImmutable && (
<WorkspaceScheduleControls
workspace={workspace}
template={template}
canUpdateSchedule={
canUpdateWorkspace && template.allow_user_autostop
<TopbarDivider />
<WorkspaceBreadcrumb
workspaceName={workspace.name}
templateIconUrl={workspace.template_icon}
rootTemplateUrl={templateLink}
templateVersionName={workspace.template_name}
templateVersionDisplayName={workspace.template_display_name}
latestBuildVersionName={
workspace.latest_build.template_version_name
}
/>
</TopbarData>
{quota && quota.budget > 0 && (
<Link
component={RouterLink}
css={{ color: "inherit" }}
to={
showOrganizations
? `/workspaces?filter=organization:${encodeURIComponent(workspace.organization_name)}`
: "/workspaces"
}
title={
showOrganizations
? `See affected workspaces for ${orgDisplayName}`
: "See affected workspaces"
}
>
<TopbarData>
<TopbarIcon>
<QuotaIcon aria-label="Daily usage" />
</TopbarIcon>
<span>
{workspace.latest_build.daily_cost}{" "}
<span css={{ color: theme.palette.text.secondary }}>
credits of
</span>{" "}
{quota.budget}
</span>
</TopbarData>
</Link>
)}
{shouldDisplayDormantData && (
@@ -221,72 +212,238 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
title="Schedule settings"
css={{ color: "inherit" }}
>
Deletion on {new Date(workspace.deleting_at!).toLocaleString()}
{workspace.deleting_at ? (
<>
Deletion on {new Date(workspace.deleting_at).toLocaleString()}
</>
) : (
"Deletion soon"
)}
</Link>
</TopbarData>
)}
{quota && quota.budget > 0 && (
<TopbarData>
<TopbarIcon>
<Tooltip title="Daily usage">
<MonetizationOnOutlined aria-label="Daily usage" />
</Tooltip>
</TopbarIcon>
<span>
{workspace.latest_build.daily_cost}{" "}
<span css={{ color: theme.palette.text.secondary }}>
credits of
</span>{" "}
{quota.budget}
</span>
</TopbarData>
)}
</div>
<div
css={{
marginLeft: "auto",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
{!isImmutable && (
<>
<WorkspaceNotifications
workspace={workspace}
template={template}
latestVersion={latestVersion}
permissions={permissions}
onRestartWorkspace={handleRestart}
onUpdateWorkspace={handleUpdate}
onActivateWorkspace={handleDormantActivate}
/>
<WorkspaceStatusBadge workspace={workspace} />
<WorkspaceActions
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleSettings={handleSettings}
handleRetry={handleRetry}
handleDebug={handleDebug}
handleChangeVersion={handleChangeVersion}
handleDormantActivate={handleDormantActivate}
handleToggleFavorite={handleToggleFavorite}
canDebug={canDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
isOwner={isOwner}
/>
</>
)}
</div>
{!isImmutable && (
<div
css={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<WorkspaceScheduleControls
workspace={workspace}
template={template}
canUpdateSchedule={
canUpdateWorkspace && template.allow_user_autostop
}
/>
<WorkspaceNotifications
workspace={workspace}
template={template}
latestVersion={latestVersion}
permissions={permissions}
onRestartWorkspace={handleRestart}
onUpdateWorkspace={handleUpdate}
onActivateWorkspace={handleDormantActivate}
/>
<WorkspaceStatusBadge workspace={workspace} />
<WorkspaceActions
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleSettings={handleSettings}
handleRetry={handleRetry}
handleDebug={handleDebug}
handleChangeVersion={handleChangeVersion}
handleDormantActivate={handleDormantActivate}
handleToggleFavorite={handleToggleFavorite}
canDebug={canDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
isOwner={isOwner}
/>
</div>
)}
</Topbar>
);
};
type OwnerBreadcrumbProps = Readonly<{
ownerName: string;
ownerAvatarUrl: string;
}>;
const OwnerBreadcrumb: FC<OwnerBreadcrumbProps> = ({
ownerName,
ownerAvatarUrl,
}) => {
return (
<Popover mode="hover">
<PopoverTrigger>
<span css={styles.breadcrumbSegment}>
<UserAvatar
size="xs"
username={ownerName}
avatarURL={ownerAvatarUrl}
/>
<span css={styles.breadcrumbText}>{ownerName}</span>
</span>
</PopoverTrigger>
<HelpTooltipContent
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
>
<AvatarData
title={ownerName}
subtitle="Owner"
avatar={ownerAvatarUrl}
/>
</HelpTooltipContent>
</Popover>
);
};
type OrganizationBreadcrumbProps = Readonly<{
orgName: string;
orgPageUrl?: string;
orgIconUrl?: string;
}>;
const OrganizationBreadcrumb: FC<OrganizationBreadcrumbProps> = ({
orgName,
orgPageUrl,
orgIconUrl,
}) => {
return (
<Popover mode="hover">
<PopoverTrigger>
<span css={styles.breadcrumbSegment}>
<UserAvatar size="xs" src={orgIconUrl ?? ""} username={orgName} />
<span css={styles.breadcrumbText}>{orgName}</span>
</span>
</PopoverTrigger>
<HelpTooltipContent
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
>
<AvatarData
title={
orgPageUrl ? (
<Link
component={RouterLink}
to={orgPageUrl}
css={{ color: "inherit" }}
>
{orgName}
</Link>
) : (
orgName
)
}
subtitle="Organization"
avatar={
orgIconUrl && (
<ExternalAvatar src={orgIconUrl} variant="square" fitImage />
)
}
/>
</HelpTooltipContent>
</Popover>
);
};
type WorkspaceBreadcrumbProps = Readonly<{
workspaceName: string;
templateIconUrl: string;
rootTemplateUrl: string;
templateVersionName: string;
latestBuildVersionName: string;
templateVersionDisplayName?: string;
}>;
const WorkspaceBreadcrumb: FC<WorkspaceBreadcrumbProps> = ({
workspaceName,
templateIconUrl,
rootTemplateUrl,
templateVersionName,
latestBuildVersionName,
templateVersionDisplayName = templateVersionName,
}) => {
return (
<Popover mode="hover">
<PopoverTrigger>
<span css={styles.breadcrumbSegment}>
<TopbarAvatar src={templateIconUrl} />
<span css={[styles.breadcrumbText, { fontWeight: 500 }]}>
{workspaceName}
</span>
</span>
</PopoverTrigger>
<HelpTooltipContent
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
>
<AvatarData
title={
<Link
component={RouterLink}
to={rootTemplateUrl}
css={{ color: "inherit" }}
>
{templateVersionDisplayName}
</Link>
}
subtitle={
<Link
component={RouterLink}
to={`${rootTemplateUrl}/versions/${encodeURIComponent(templateVersionName)}`}
css={{ color: "inherit" }}
>
Version: {latestBuildVersionName}
</Link>
}
avatar={
<ExternalAvatar src={templateIconUrl} variant="square" fitImage />
}
/>
</HelpTooltipContent>
</Popover>
);
};
const styles = {
topbarLeft: {
display: "flex",
alignItems: "center",
columnGap: 24,
rowGap: 8,
flexWrap: "wrap",
// 12px - It is needed to keep vertical spacing when the content is wrapped
padding: "12px",
marginRight: "auto",
},
breadcrumbSegment: {
display: "flex",
flexFlow: "row nowrap",
gap: "8px",
maxWidth: "160px",
whiteSpace: "nowrap",
cursor: "default",
},
breadcrumbText: {
overflowX: "hidden",
textOverflow: "ellipsis",
},
} satisfies Record<string, Interpolation<Theme>>;