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:
Michael Smith
2023-11-07 08:36:53 -05:00
committed by GitHub
parent 8c3828b531
commit f5c4826e4c
9 changed files with 352 additions and 40 deletions
+37 -12
View File
@@ -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",
},
};
+23 -9
View File
@@ -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>
);
}