mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add list of user's groups to Accounts page (#10522)
* chore: add query for a user's groups * chore: integrate user groups into UI * refactor: split UI card into separate component * chore: enforce alt text for AvatarCard * chore: add proper alt text support for Avatar * fix: update props for Avatar call sites * finish AccountPage changes * wip: commit progress on AvatarCard * fix: add better UI error handling * fix: update theme setup for AvatarCard * fix: update styling for AccountPage * fix: make error message conditional * chore: update styling for AvatarCard * chore: finish AvatarCard * fix: add maxWidth support to AvatarCard * chore: update how no max width is defined * chore: add AvatarCard stories * fix: remove incorrect semantics for AvatarCard * docs: add comment about flexbox behavior * docs: add clarifying text about prop * fix: fix grammar for singular groups * refactor: split off AccountUserGroups and add story * fix: differentiate mock groups more
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
} from "api/typesGenerated";
|
||||
|
||||
const GROUPS_QUERY_KEY = ["groups"];
|
||||
type GroupSortOrder = "asc" | "desc";
|
||||
|
||||
const getGroupQueryKey = (groupId: string) => ["group", groupId];
|
||||
|
||||
@@ -15,7 +16,7 @@ export const groups = (organizationId: string) => {
|
||||
return {
|
||||
queryKey: GROUPS_QUERY_KEY,
|
||||
queryFn: () => API.getGroups(organizationId),
|
||||
};
|
||||
} satisfies UseQueryOptions<Group[]>;
|
||||
};
|
||||
|
||||
export const group = (groupId: string) => {
|
||||
@@ -33,18 +34,9 @@ export function groupsByUserId(organizationId: string) {
|
||||
select: (allGroups) => {
|
||||
// Sorting here means that nothing has to be sorted for the individual
|
||||
// user arrays later
|
||||
const sorted = [...allGroups].sort((g1, g2) => {
|
||||
const key =
|
||||
g1.display_name && g2.display_name ? "display_name" : "name";
|
||||
|
||||
if (g1[key] === g2[key]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return g1[key] < g2[key] ? -1 : 1;
|
||||
});
|
||||
|
||||
const sorted = sortGroupsByName(allGroups, "asc");
|
||||
const userIdMapper = new Map<string, Group[]>();
|
||||
|
||||
for (const group of sorted) {
|
||||
for (const user of group.members) {
|
||||
let groupsForUser = userIdMapper.get(user.id);
|
||||
@@ -62,6 +54,20 @@ export function groupsByUserId(organizationId: string) {
|
||||
} satisfies UseQueryOptions<Group[], unknown, GroupsByUserId>;
|
||||
}
|
||||
|
||||
export function groupsForUser(organizationId: string, userId: string) {
|
||||
return {
|
||||
...groups(organizationId),
|
||||
select: (allGroups) => {
|
||||
const groupsForUser = allGroups.filter((group) => {
|
||||
const groupMemberIds = group.members.map((member) => member.id);
|
||||
return groupMemberIds.includes(userId);
|
||||
});
|
||||
|
||||
return sortGroupsByName(groupsForUser, "asc");
|
||||
},
|
||||
} as const satisfies UseQueryOptions<Group[], unknown, readonly Group[]>;
|
||||
}
|
||||
|
||||
export const groupPermissions = (groupId: string) => {
|
||||
return {
|
||||
queryKey: [...getGroupQueryKey(groupId), "permissions"],
|
||||
@@ -136,3 +142,22 @@ export const invalidateGroup = (queryClient: QueryClient, groupId: string) =>
|
||||
queryClient.invalidateQueries(GROUPS_QUERY_KEY),
|
||||
queryClient.invalidateQueries(getGroupQueryKey(groupId)),
|
||||
]);
|
||||
|
||||
export function sortGroupsByName(
|
||||
groups: readonly Group[],
|
||||
order: GroupSortOrder,
|
||||
) {
|
||||
return [...groups].sort((g1, g2) => {
|
||||
const key = g1.display_name && g2.display_name ? "display_name" : "name";
|
||||
|
||||
if (g1[key] === g2[key]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (order === "asc") {
|
||||
return g1[key] < g2[key] ? -1 : 1;
|
||||
} else {
|
||||
return g1[key] < g2[key] ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const MuiIconXL = {
|
||||
|
||||
export const AvatarIconDarken = {
|
||||
args: {
|
||||
children: <AvatarIcon src="/icon/database.svg" />,
|
||||
children: <AvatarIcon src="/icon/database.svg" alt="Database" />,
|
||||
colorScheme: "darken",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// This is the only place MuiAvatar can be used
|
||||
// eslint-disable-next-line no-restricted-imports -- Read above
|
||||
import MuiAvatar, { AvatarProps as MuiAvatarProps } from "@mui/material/Avatar";
|
||||
import { FC } from "react";
|
||||
import { FC, useId } from "react";
|
||||
import { css, type Interpolation, type Theme } from "@emotion/react";
|
||||
import { Box } from "@mui/system";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
|
||||
export type AvatarProps = MuiAvatarProps & {
|
||||
size?: "xs" | "sm" | "md" | "xl";
|
||||
@@ -66,18 +68,30 @@ export const Avatar: FC<AvatarProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
type AvatarIconProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use it to make an img element behaves like a MaterialUI Icon component
|
||||
*/
|
||||
export const AvatarIcon: FC<{ src: string }> = ({ src }) => {
|
||||
export const AvatarIcon: FC<AvatarIconProps> = ({ src, alt }) => {
|
||||
const hookId = useId();
|
||||
const avatarId = `${hookId}-avatar`;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
css={{
|
||||
maxWidth: "50%",
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
css={{ maxWidth: "50%" }}
|
||||
aria-labelledby={avatarId}
|
||||
/>
|
||||
<Box id={avatarId} sx={visuallyHidden}>
|
||||
{alt}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { type Meta, type StoryObj } from "@storybook/react";
|
||||
import { AvatarCard } from "./AvatarCard";
|
||||
|
||||
const meta: Meta<typeof AvatarCard> = {
|
||||
title: "components/AvatarCard",
|
||||
component: AvatarCard,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AvatarCard>;
|
||||
|
||||
export const WithImage: Story = {
|
||||
args: {
|
||||
header: "Coder",
|
||||
imgUrl: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
|
||||
altText: "Coder",
|
||||
subtitle: "56 members",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutImage: Story = {
|
||||
args: {
|
||||
header: "Patrick Star",
|
||||
subtitle: "Friends with 723 people",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutSubtitleOrImage: Story = {
|
||||
args: {
|
||||
header: "Sandy Cheeks",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { type CSSObject, useTheme } from "@emotion/react";
|
||||
import { colors } from "theme/colors";
|
||||
|
||||
type AvatarCardProps = {
|
||||
header: string;
|
||||
imgUrl: string;
|
||||
altText: string;
|
||||
|
||||
subtitle?: ReactNode;
|
||||
maxWidth?: number | "none";
|
||||
};
|
||||
|
||||
export function AvatarCard({
|
||||
header,
|
||||
imgUrl,
|
||||
altText,
|
||||
subtitle,
|
||||
maxWidth = "none",
|
||||
}: AvatarCardProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
maxWidth: maxWidth === "none" ? undefined : `${maxWidth}px`,
|
||||
display: "flex",
|
||||
flexFlow: "row nowrap",
|
||||
alignItems: "center",
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
gap: "16px",
|
||||
padding: "16px",
|
||||
borderRadius: "8px",
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
{/**
|
||||
* minWidth is necessary to ensure that the text truncation works properly
|
||||
* with flex containers that don't have fixed width
|
||||
*
|
||||
* @see {@link https://css-tricks.com/flexbox-truncated-text/}
|
||||
*/}
|
||||
<div css={{ marginRight: "auto", minWidth: 0 }}>
|
||||
<h3
|
||||
// Lets users hover over truncated text to see whole thing
|
||||
title={header}
|
||||
css={[
|
||||
theme.typography.body1 as CSSObject,
|
||||
{
|
||||
lineHeight: 1.4,
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{header}
|
||||
</h3>
|
||||
|
||||
{subtitle && (
|
||||
<div
|
||||
css={[
|
||||
theme.typography.body2 as CSSObject,
|
||||
{ color: theme.palette.text.secondary },
|
||||
]}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Avatar
|
||||
src={imgUrl}
|
||||
alt={altText}
|
||||
size="md"
|
||||
css={{ backgroundColor: colors.gray[7] }}
|
||||
>
|
||||
{header}
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export const ResourceAvatar: FC<ResourceAvatarProps> = ({ resource }) => {
|
||||
|
||||
return (
|
||||
<Avatar colorScheme="darken">
|
||||
<AvatarIcon src={avatarSrc} />
|
||||
<AvatarIcon src={avatarSrc} alt={resource.name} />
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
import { FC } from "react";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import { AccountForm } from "./AccountForm";
|
||||
import { useAuth } from "components/AuthProvider/AuthProvider";
|
||||
import { type FC } from "react";
|
||||
import { useMe } from "hooks/useMe";
|
||||
import { usePermissions } from "hooks/usePermissions";
|
||||
import { useQuery } from "react-query";
|
||||
import { groupsForUser } from "api/queries/groups";
|
||||
import { useOrganizationId } from "hooks";
|
||||
import { useAuth } from "components/AuthProvider/AuthProvider";
|
||||
|
||||
import { Stack } from "@mui/system";
|
||||
import { AccountUserGroups } from "./AccountUserGroups";
|
||||
import { AccountForm } from "./AccountForm";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
|
||||
export const AccountPage: FC = () => {
|
||||
const { updateProfile, updateProfileError, isUpdatingProfile } = useAuth();
|
||||
const me = useMe();
|
||||
const permissions = usePermissions();
|
||||
const canEditUsers = permissions && permissions.updateUsers;
|
||||
|
||||
const me = useMe();
|
||||
const organizationId = useOrganizationId();
|
||||
const groupsQuery = useQuery(groupsForUser(organizationId, me.id));
|
||||
|
||||
return (
|
||||
<Section title="Account" description="Update your account info">
|
||||
<AccountForm
|
||||
editable={Boolean(canEditUsers)}
|
||||
email={me.email}
|
||||
updateProfileError={updateProfileError}
|
||||
isLoading={isUpdatingProfile}
|
||||
initialValues={{
|
||||
username: me.username,
|
||||
}}
|
||||
onSubmit={updateProfile}
|
||||
<Stack spacing={6}>
|
||||
<Section title="Account" description="Update your account info">
|
||||
<AccountForm
|
||||
editable={permissions?.updateUsers ?? false}
|
||||
email={me.email}
|
||||
updateProfileError={updateProfileError}
|
||||
isLoading={isUpdatingProfile}
|
||||
initialValues={{ username: me.username }}
|
||||
onSubmit={updateProfile}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Has <Section> embedded inside because its description is dynamic */}
|
||||
<AccountUserGroups
|
||||
groups={groupsQuery.data}
|
||||
loading={groupsQuery.isLoading}
|
||||
error={groupsQuery.error}
|
||||
/>
|
||||
</Section>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type Group } from "api/typesGenerated";
|
||||
import { type Meta, type StoryObj } from "@storybook/react";
|
||||
|
||||
import { AccountUserGroups } from "./AccountUserGroups";
|
||||
import {
|
||||
MockGroup as MockGroup1,
|
||||
MockUser,
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
|
||||
const MockGroup2: Group = {
|
||||
...MockGroup1,
|
||||
avatar_url: "",
|
||||
display_name: "Goofy Goobers",
|
||||
members: [MockUser],
|
||||
};
|
||||
|
||||
const mockError = mockApiError({
|
||||
message: "Failed to retrieve your groups",
|
||||
});
|
||||
|
||||
const meta: Meta<typeof AccountUserGroups> = {
|
||||
title: "pages/UserSettingsPage/AccountUserGroups",
|
||||
component: AccountUserGroups,
|
||||
args: {
|
||||
groups: [MockGroup1, MockGroup2],
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AccountUserGroups>;
|
||||
|
||||
export const Example: Story = {};
|
||||
|
||||
export const NoGroups: Story = {
|
||||
args: {
|
||||
groups: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const OneGroup: Story = {
|
||||
args: {
|
||||
groups: [MockGroup1],
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
groups: undefined,
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
groups: undefined,
|
||||
error: mockError,
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorWithPreviousData: Story = {
|
||||
args: {
|
||||
groups: [MockGroup1, MockGroup2],
|
||||
error: mockError,
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { isApiError } from "api/errors";
|
||||
import { type Group } from "api/typesGenerated";
|
||||
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { AvatarCard } from "components/AvatarCard/AvatarCard";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Section } from "components/SettingsLayout/Section";
|
||||
import Grid from "@mui/material/Grid";
|
||||
|
||||
type AccountGroupsProps = {
|
||||
groups: readonly Group[] | undefined;
|
||||
error: unknown;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export function AccountUserGroups({
|
||||
groups,
|
||||
error,
|
||||
loading,
|
||||
}: AccountGroupsProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Your groups"
|
||||
layout="fluid"
|
||||
description={
|
||||
groups && (
|
||||
<span>
|
||||
You are in{" "}
|
||||
<em
|
||||
css={{
|
||||
fontStyle: "normal",
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{groups.length} group
|
||||
{groups.length !== 1 && "s"}
|
||||
</em>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div css={{ display: "flex", flexFlow: "column nowrap", rowGap: "24px" }}>
|
||||
{isApiError(error) && <ErrorAlert error={error} />}
|
||||
|
||||
{groups && (
|
||||
<Grid container columns={{ xs: 1, md: 2 }} spacing="16px">
|
||||
{groups.map((group) => (
|
||||
<Grid item key={group.id} xs={1}>
|
||||
<AvatarCard
|
||||
imgUrl={group.avatar_url}
|
||||
altText={group.display_name || group.name}
|
||||
header={group.display_name || group.name}
|
||||
subtitle={
|
||||
<>
|
||||
{group.members.length} member
|
||||
{group.members.length !== 1 && "s"}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{loading && <Loader />}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user