Skip to content

feat: add list of user's groups to Accounts page #10522

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
00d819f
chore: add query for a user's groups
Parkreiner Nov 1, 2023
1d646eb
chore: integrate user groups into UI
Parkreiner Nov 1, 2023
024879d
refactor: split UI card into separate component
Parkreiner Nov 2, 2023
102b1e0
chore: enforce alt text for AvatarCard
Parkreiner Nov 2, 2023
e8c713c
chore: add proper alt text support for Avatar
Parkreiner Nov 2, 2023
22e9573
fix: update props for Avatar call sites
Parkreiner Nov 2, 2023
acf2500
finish AccountPage changes
Parkreiner Nov 2, 2023
f4b9a63
wip: commit progress on AvatarCard
Parkreiner Nov 2, 2023
426260d
fix: add better UI error handling
Parkreiner Nov 3, 2023
fecf41e
fix: update theme setup for AvatarCard
Parkreiner Nov 3, 2023
831d110
fix: update styling for AccountPage
Parkreiner Nov 3, 2023
69fab82
fix: make error message conditional
Parkreiner Nov 3, 2023
f7cffd1
chore: update styling for AvatarCard
Parkreiner Nov 3, 2023
2e43d5f
chore: finish AvatarCard
Parkreiner Nov 3, 2023
7c322fa
fix: add maxWidth support to AvatarCard
Parkreiner Nov 3, 2023
b92c5f7
chore: update how no max width is defined
Parkreiner Nov 3, 2023
d5b5323
chore: add AvatarCard stories
Parkreiner Nov 3, 2023
43ae56b
fix: remove incorrect semantics for AvatarCard
Parkreiner Nov 6, 2023
ba57fff
docs: add comment about flexbox behavior
Parkreiner Nov 6, 2023
387c033
docs: add clarifying text about prop
Parkreiner Nov 6, 2023
7dcddbf
fix: fix grammar for singular groups
Parkreiner Nov 6, 2023
68ae492
refactor: split off AccountUserGroups and add story
Parkreiner Nov 6, 2023
d56a9cc
fix: differentiate mock groups more
Parkreiner Nov 6, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions site/src/api/queries/groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import {
} from "api/typesGenerated";

const GROUPS_QUERY_KEY = ["groups"];
type GroupSortOrder = "asc" | "desc";

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

export const groups = (organizationId: string) => {
return {
queryKey: GROUPS_QUERY_KEY,
queryFn: () => API.getGroups(organizationId),
};
} satisfies UseQueryOptions<Group[]>;
};

export const group = (groupId: string) => {
Expand All @@ -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);
Expand All @@ -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"],
Expand Down Expand Up @@ -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;
}
});
}
2 changes: 1 addition & 1 deletion site/src/components/Avatar/Avatar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
};
32 changes: 23 additions & 9 deletions site/src/components/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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>
</>
);
};

Expand Down
32 changes: 32 additions & 0 deletions site/src/components/AvatarCard/AvatarCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -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",
},
};
84 changes: 84 additions & 0 deletions site/src/components/AvatarCard/AvatarCard.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
2 changes: 1 addition & 1 deletion site/src/components/Resources/ResourceAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const ResourceAvatar: FC<ResourceAvatarProps> = ({ resource }) => {

return (
<Avatar colorScheme="darken">
<AvatarIcon src={avatarSrc} />
<AvatarIcon src={avatarSrc} alt={resource.name} />
</Avatar>
);
};
49 changes: 32 additions & 17 deletions site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};

Expand Down
Loading