diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 3dc6759c12484..6dff724772233 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -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; }; 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(); + 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; } +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; +} + 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; + } + }); +} diff --git a/site/src/components/Avatar/Avatar.stories.tsx b/site/src/components/Avatar/Avatar.stories.tsx index 67c5a5596fa54..fb510e6ee3e0f 100644 --- a/site/src/components/Avatar/Avatar.stories.tsx +++ b/site/src/components/Avatar/Avatar.stories.tsx @@ -65,7 +65,7 @@ export const MuiIconXL = { export const AvatarIconDarken = { args: { - children: , + children: , colorScheme: "darken", }, }; diff --git a/site/src/components/Avatar/Avatar.tsx b/site/src/components/Avatar/Avatar.tsx index 89de1f174ba4b..12108a053b919 100644 --- a/site/src/components/Avatar/Avatar.tsx +++ b/site/src/components/Avatar/Avatar.tsx @@ -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 = ({ ); }; +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 = ({ src, alt }) => { + const hookId = useId(); + const avatarId = `${hookId}-avatar`; + return ( - + <> + + + {alt} + + ); }; diff --git a/site/src/components/AvatarCard/AvatarCard.stories.tsx b/site/src/components/AvatarCard/AvatarCard.stories.tsx new file mode 100644 index 0000000000000..9f0204a9c844c --- /dev/null +++ b/site/src/components/AvatarCard/AvatarCard.stories.tsx @@ -0,0 +1,32 @@ +import { type Meta, type StoryObj } from "@storybook/react"; +import { AvatarCard } from "./AvatarCard"; + +const meta: Meta = { + title: "components/AvatarCard", + component: AvatarCard, +}; + +export default meta; +type Story = StoryObj; + +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", + }, +}; diff --git a/site/src/components/AvatarCard/AvatarCard.tsx b/site/src/components/AvatarCard/AvatarCard.tsx new file mode 100644 index 0000000000000..7896789abf5b1 --- /dev/null +++ b/site/src/components/AvatarCard/AvatarCard.tsx @@ -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 ( +
+ {/** + * 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/} + */} +
+

+ {header} +

+ + {subtitle && ( +
+ {subtitle} +
+ )} +
+ + + {header} + +
+ ); +} diff --git a/site/src/components/Resources/ResourceAvatar.tsx b/site/src/components/Resources/ResourceAvatar.tsx index 35aa96987518f..102f0c128dc8b 100644 --- a/site/src/components/Resources/ResourceAvatar.tsx +++ b/site/src/components/Resources/ResourceAvatar.tsx @@ -40,7 +40,7 @@ export const ResourceAvatar: FC = ({ resource }) => { return ( - + ); }; diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx index f266383aa2ca4..4a38df0a1852e 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx @@ -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 ( -
- +
+ +
+ + {/* Has
embedded inside because its description is dynamic */} + -
+ ); }; diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.stories.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.stories.tsx new file mode 100644 index 0000000000000..9ce7df88124e3 --- /dev/null +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.stories.tsx @@ -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 = { + title: "pages/UserSettingsPage/AccountUserGroups", + component: AccountUserGroups, + args: { + groups: [MockGroup1, MockGroup2], + loading: false, + }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.tsx new file mode 100644 index 0000000000000..4a6a6dd969c04 --- /dev/null +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountUserGroups.tsx @@ -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 ( +
+ You are in{" "} + + {groups.length} group + {groups.length !== 1 && "s"} + + + ) + } + > +
+ {isApiError(error) && } + + {groups && ( + + {groups.map((group) => ( + + + {group.members.length} member + {group.members.length !== 1 && "s"} + + } + /> + + ))} + + )} + + {loading && } +
+
+ ); +}