Skip to content

Commit f5c4826

Browse files
authored
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
1 parent 8c3828b commit f5c4826

File tree

9 files changed

+352
-40
lines changed

9 files changed

+352
-40
lines changed

site/src/api/queries/groups.ts

+37-12
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import {
88
} from "api/typesGenerated";
99

1010
const GROUPS_QUERY_KEY = ["groups"];
11+
type GroupSortOrder = "asc" | "desc";
1112

1213
const getGroupQueryKey = (groupId: string) => ["group", groupId];
1314

1415
export const groups = (organizationId: string) => {
1516
return {
1617
queryKey: GROUPS_QUERY_KEY,
1718
queryFn: () => API.getGroups(organizationId),
18-
};
19+
} satisfies UseQueryOptions<Group[]>;
1920
};
2021

2122
export const group = (groupId: string) => {
@@ -33,18 +34,9 @@ export function groupsByUserId(organizationId: string) {
3334
select: (allGroups) => {
3435
// Sorting here means that nothing has to be sorted for the individual
3536
// user arrays later
36-
const sorted = [...allGroups].sort((g1, g2) => {
37-
const key =
38-
g1.display_name && g2.display_name ? "display_name" : "name";
39-
40-
if (g1[key] === g2[key]) {
41-
return 0;
42-
}
43-
44-
return g1[key] < g2[key] ? -1 : 1;
45-
});
46-
37+
const sorted = sortGroupsByName(allGroups, "asc");
4738
const userIdMapper = new Map<string, Group[]>();
39+
4840
for (const group of sorted) {
4941
for (const user of group.members) {
5042
let groupsForUser = userIdMapper.get(user.id);
@@ -62,6 +54,20 @@ export function groupsByUserId(organizationId: string) {
6254
} satisfies UseQueryOptions<Group[], unknown, GroupsByUserId>;
6355
}
6456

57+
export function groupsForUser(organizationId: string, userId: string) {
58+
return {
59+
...groups(organizationId),
60+
select: (allGroups) => {
61+
const groupsForUser = allGroups.filter((group) => {
62+
const groupMemberIds = group.members.map((member) => member.id);
63+
return groupMemberIds.includes(userId);
64+
});
65+
66+
return sortGroupsByName(groupsForUser, "asc");
67+
},
68+
} as const satisfies UseQueryOptions<Group[], unknown, readonly Group[]>;
69+
}
70+
6571
export const groupPermissions = (groupId: string) => {
6672
return {
6773
queryKey: [...getGroupQueryKey(groupId), "permissions"],
@@ -136,3 +142,22 @@ export const invalidateGroup = (queryClient: QueryClient, groupId: string) =>
136142
queryClient.invalidateQueries(GROUPS_QUERY_KEY),
137143
queryClient.invalidateQueries(getGroupQueryKey(groupId)),
138144
]);
145+
146+
export function sortGroupsByName(
147+
groups: readonly Group[],
148+
order: GroupSortOrder,
149+
) {
150+
return [...groups].sort((g1, g2) => {
151+
const key = g1.display_name && g2.display_name ? "display_name" : "name";
152+
153+
if (g1[key] === g2[key]) {
154+
return 0;
155+
}
156+
157+
if (order === "asc") {
158+
return g1[key] < g2[key] ? -1 : 1;
159+
} else {
160+
return g1[key] < g2[key] ? 1 : -1;
161+
}
162+
});
163+
}

site/src/components/Avatar/Avatar.stories.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export const MuiIconXL = {
6565

6666
export const AvatarIconDarken = {
6767
args: {
68-
children: <AvatarIcon src="/icon/database.svg" />,
68+
children: <AvatarIcon src="/icon/database.svg" alt="Database" />,
6969
colorScheme: "darken",
7070
},
7171
};

site/src/components/Avatar/Avatar.tsx

+23-9
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// This is the only place MuiAvatar can be used
22
// eslint-disable-next-line no-restricted-imports -- Read above
33
import MuiAvatar, { AvatarProps as MuiAvatarProps } from "@mui/material/Avatar";
4-
import { FC } from "react";
4+
import { FC, useId } from "react";
55
import { css, type Interpolation, type Theme } from "@emotion/react";
6+
import { Box } from "@mui/system";
7+
import { visuallyHidden } from "@mui/utils";
68

79
export type AvatarProps = MuiAvatarProps & {
810
size?: "xs" | "sm" | "md" | "xl";
@@ -66,18 +68,30 @@ export const Avatar: FC<AvatarProps> = ({
6668
);
6769
};
6870

71+
type AvatarIconProps = {
72+
src: string;
73+
alt: string;
74+
};
75+
6976
/**
7077
* Use it to make an img element behaves like a MaterialUI Icon component
7178
*/
72-
export const AvatarIcon: FC<{ src: string }> = ({ src }) => {
79+
export const AvatarIcon: FC<AvatarIconProps> = ({ src, alt }) => {
80+
const hookId = useId();
81+
const avatarId = `${hookId}-avatar`;
82+
7383
return (
74-
<img
75-
src={src}
76-
alt=""
77-
css={{
78-
maxWidth: "50%",
79-
}}
80-
/>
84+
<>
85+
<img
86+
src={src}
87+
alt=""
88+
css={{ maxWidth: "50%" }}
89+
aria-labelledby={avatarId}
90+
/>
91+
<Box id={avatarId} sx={visuallyHidden}>
92+
{alt}
93+
</Box>
94+
</>
8195
);
8296
};
8397

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { type Meta, type StoryObj } from "@storybook/react";
2+
import { AvatarCard } from "./AvatarCard";
3+
4+
const meta: Meta<typeof AvatarCard> = {
5+
title: "components/AvatarCard",
6+
component: AvatarCard,
7+
};
8+
9+
export default meta;
10+
type Story = StoryObj<typeof AvatarCard>;
11+
12+
export const WithImage: Story = {
13+
args: {
14+
header: "Coder",
15+
imgUrl: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
16+
altText: "Coder",
17+
subtitle: "56 members",
18+
},
19+
};
20+
21+
export const WithoutImage: Story = {
22+
args: {
23+
header: "Patrick Star",
24+
subtitle: "Friends with 723 people",
25+
},
26+
};
27+
28+
export const WithoutSubtitleOrImage: Story = {
29+
args: {
30+
header: "Sandy Cheeks",
31+
},
32+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { type ReactNode } from "react";
2+
import { Avatar } from "components/Avatar/Avatar";
3+
import { type CSSObject, useTheme } from "@emotion/react";
4+
import { colors } from "theme/colors";
5+
6+
type AvatarCardProps = {
7+
header: string;
8+
imgUrl: string;
9+
altText: string;
10+
11+
subtitle?: ReactNode;
12+
maxWidth?: number | "none";
13+
};
14+
15+
export function AvatarCard({
16+
header,
17+
imgUrl,
18+
altText,
19+
subtitle,
20+
maxWidth = "none",
21+
}: AvatarCardProps) {
22+
const theme = useTheme();
23+
24+
return (
25+
<div
26+
css={{
27+
maxWidth: maxWidth === "none" ? undefined : `${maxWidth}px`,
28+
display: "flex",
29+
flexFlow: "row nowrap",
30+
alignItems: "center",
31+
border: `1px solid ${theme.palette.divider}`,
32+
gap: "16px",
33+
padding: "16px",
34+
borderRadius: "8px",
35+
cursor: "default",
36+
}}
37+
>
38+
{/**
39+
* minWidth is necessary to ensure that the text truncation works properly
40+
* with flex containers that don't have fixed width
41+
*
42+
* @see {@link https://css-tricks.com/flexbox-truncated-text/}
43+
*/}
44+
<div css={{ marginRight: "auto", minWidth: 0 }}>
45+
<h3
46+
// Lets users hover over truncated text to see whole thing
47+
title={header}
48+
css={[
49+
theme.typography.body1 as CSSObject,
50+
{
51+
lineHeight: 1.4,
52+
margin: 0,
53+
overflow: "hidden",
54+
whiteSpace: "nowrap",
55+
textOverflow: "ellipsis",
56+
},
57+
]}
58+
>
59+
{header}
60+
</h3>
61+
62+
{subtitle && (
63+
<div
64+
css={[
65+
theme.typography.body2 as CSSObject,
66+
{ color: theme.palette.text.secondary },
67+
]}
68+
>
69+
{subtitle}
70+
</div>
71+
)}
72+
</div>
73+
74+
<Avatar
75+
src={imgUrl}
76+
alt={altText}
77+
size="md"
78+
css={{ backgroundColor: colors.gray[7] }}
79+
>
80+
{header}
81+
</Avatar>
82+
</div>
83+
);
84+
}

site/src/components/Resources/ResourceAvatar.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const ResourceAvatar: FC<ResourceAvatarProps> = ({ resource }) => {
4040

4141
return (
4242
<Avatar colorScheme="darken">
43-
<AvatarIcon src={avatarSrc} />
43+
<AvatarIcon src={avatarSrc} alt={resource.name} />
4444
</Avatar>
4545
);
4646
};

site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx

+32-17
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,44 @@
1-
import { FC } from "react";
2-
import { Section } from "components/SettingsLayout/Section";
3-
import { AccountForm } from "./AccountForm";
4-
import { useAuth } from "components/AuthProvider/AuthProvider";
1+
import { type FC } from "react";
52
import { useMe } from "hooks/useMe";
63
import { usePermissions } from "hooks/usePermissions";
4+
import { useQuery } from "react-query";
5+
import { groupsForUser } from "api/queries/groups";
6+
import { useOrganizationId } from "hooks";
7+
import { useAuth } from "components/AuthProvider/AuthProvider";
8+
9+
import { Stack } from "@mui/system";
10+
import { AccountUserGroups } from "./AccountUserGroups";
11+
import { AccountForm } from "./AccountForm";
12+
import { Section } from "components/SettingsLayout/Section";
713

814
export const AccountPage: FC = () => {
915
const { updateProfile, updateProfileError, isUpdatingProfile } = useAuth();
10-
const me = useMe();
1116
const permissions = usePermissions();
12-
const canEditUsers = permissions && permissions.updateUsers;
17+
18+
const me = useMe();
19+
const organizationId = useOrganizationId();
20+
const groupsQuery = useQuery(groupsForUser(organizationId, me.id));
1321

1422
return (
15-
<Section title="Account" description="Update your account info">
16-
<AccountForm
17-
editable={Boolean(canEditUsers)}
18-
email={me.email}
19-
updateProfileError={updateProfileError}
20-
isLoading={isUpdatingProfile}
21-
initialValues={{
22-
username: me.username,
23-
}}
24-
onSubmit={updateProfile}
23+
<Stack spacing={6}>
24+
<Section title="Account" description="Update your account info">
25+
<AccountForm
26+
editable={permissions?.updateUsers ?? false}
27+
email={me.email}
28+
updateProfileError={updateProfileError}
29+
isLoading={isUpdatingProfile}
30+
initialValues={{ username: me.username }}
31+
onSubmit={updateProfile}
32+
/>
33+
</Section>
34+
35+
{/* Has <Section> embedded inside because its description is dynamic */}
36+
<AccountUserGroups
37+
groups={groupsQuery.data}
38+
loading={groupsQuery.isLoading}
39+
error={groupsQuery.error}
2540
/>
26-
</Section>
41+
</Stack>
2742
);
2843
};
2944

0 commit comments

Comments
 (0)