Skip to content

Commit

Permalink
Clean up user lists
Browse files Browse the repository at this point in the history
  • Loading branch information
aapeliv committed Nov 27, 2024
1 parent 53874b1 commit 18405b7
Show file tree
Hide file tree
Showing 15 changed files with 384 additions and 265 deletions.
226 changes: 226 additions & 0 deletions app/web/components/UsersList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import {
render,
screen,
waitForElementToBeRemoved,
} from "@testing-library/react";
import { getProfileLinkA11yLabel } from "components/Avatar/constants";
import { USER_TITLE_SKELETON_TEST_ID } from "components/UserSummary";
import { service } from "service";
import users from "test/fixtures/liteUsers.json";
import wrapper from "test/hookWrapper";
import { getLiteUsers } from "test/serviceMockDefaults";
import { assertErrorAlert } from "test/utils";

import UsersList from "./UsersList";

const getLiteUsersMock = service.user.getLiteUsers as jest.MockedFunction<
typeof service.user.getLiteUsers
>;

describe("UsersList", () => {
beforeEach(() => {
getLiteUsersMock.mockImplementation(getLiteUsers);
});

it("shows the users in a list if the user IDs and users map have loaded", async () => {
render(<UsersList userIds={[1, 2]} />, { wrapper });

await waitForElementToBeRemoved(
screen.queryAllByTestId(USER_TITLE_SKELETON_TEST_ID)
);

// User 1
expect(screen.getByRole("img", { name: users[0].name })).toBeVisible();
expect(
screen.getByRole("heading", { name: `${users[0].name}, ${users[0].age}` })
).toBeVisible();

// User 2
expect(
screen.getByRole("link", { name: getProfileLinkA11yLabel(users[1].name) })
).toBeVisible();
expect(
screen.getByRole("heading", { name: `${users[1].name}, ${users[1].age}` })
).toBeVisible();
});

it("shows a loading spinner when userIds are undefined", () => {
render(
<UsersList
userIds={undefined}
endChildren={<>I'm at the end!</>}
emptyListChildren={<>I show up when the map is empty!</>}
/>,
{ wrapper }
);

expect(screen.queryByRole("progressbar")).toBeInTheDocument();

expect(
screen.queryByTestId(USER_TITLE_SKELETON_TEST_ID)
).not.toBeInTheDocument();

expect(screen.queryByText("I'm at the end!")).not.toBeInTheDocument();
expect(
screen.queryByText("I show up when the map is empty!")
).not.toBeInTheDocument();
});

it("shows a loading skeleton while map is fetching", () => {
render(
<UsersList
userIds={[2]}
endChildren={<>I'm at the end!</>}
emptyListChildren={<>I show up when the map is empty!</>}
/>,
{ wrapper }
);

expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();

expect(screen.getByTestId(USER_TITLE_SKELETON_TEST_ID)).toBeVisible();

expect(screen.queryByText("I'm at the end!")).not.toBeInTheDocument();
expect(
screen.queryByText("I show up when the map is empty!")
).not.toBeInTheDocument();
});

it("hides skeleton when map is done fetching", () => {
render(<UsersList userIds={[2]} />, { wrapper });

expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();

expect(screen.getByTestId(USER_TITLE_SKELETON_TEST_ID)).toBeVisible();
});

it("shows only found users when map is done fetching", async () => {
render(<UsersList userIds={[2, 99]} />, { wrapper });

expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();

await waitForElementToBeRemoved(
screen.queryAllByTestId(USER_TITLE_SKELETON_TEST_ID)
);

// have user 2
expect(
screen.getByRole("link", { name: getProfileLinkA11yLabel(users[1].name) })
).toBeVisible();
expect(
screen.getByRole("heading", { name: `${users[1].name}, ${users[1].age}` })
).toBeVisible();

// don't have non-existent user 99
expect(
screen.queryByTestId(USER_TITLE_SKELETON_TEST_ID)
).not.toBeInTheDocument();
});

it("shows endChildren but not emptyListChildren when map is not empty", async () => {
render(
<UsersList
userIds={[2, 99]}
endChildren={<>I'm at the end!</>}
emptyListChildren={<>I show up when the map is empty!</>}
/>,
{ wrapper }
);

expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();

await waitForElementToBeRemoved(
screen.queryAllByTestId(USER_TITLE_SKELETON_TEST_ID)
);

// have user 2
expect(
screen.getByRole("link", { name: getProfileLinkA11yLabel(users[1].name) })
).toBeVisible();
expect(
screen.getByRole("heading", { name: `${users[1].name}, ${users[1].age}` })
).toBeVisible();

// don't have non-existent user 99
expect(
screen.queryByTestId(USER_TITLE_SKELETON_TEST_ID)
).not.toBeInTheDocument();

// have end children
expect(await screen.findByText("I'm at the end!")).toBeVisible();
// don't have empty children
expect(
screen.queryByText("I show up when the map is empty!")
).not.toBeInTheDocument();
});

it("shows emptyListChildren but not endChildren when map is empty", async () => {
render(
<UsersList
userIds={[]}
endChildren={<>I'm at the end!</>}
emptyListChildren={<>I show up when the map is empty!</>}
/>,
{ wrapper }
);

expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();

expect(
screen.queryByTestId(USER_TITLE_SKELETON_TEST_ID)
).not.toBeInTheDocument();

// don't have end children
expect(screen.queryByText("I'm at the end!")).not.toBeInTheDocument();
// have empty children
expect(
await screen.findByText("I show up when the map is empty!")
).toBeVisible();
});

it("shows emptyListChildren but not endChildren when map is not empty but no users were found", async () => {
render(
<UsersList
userIds={[99, 102]}
endChildren={<>I'm at the end!</>}
emptyListChildren={<>I show up when the map is empty!</>}
/>,
{ wrapper }
);

expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();

await waitForElementToBeRemoved(
screen.queryAllByTestId(USER_TITLE_SKELETON_TEST_ID)
);

// don't have end children
expect(screen.queryByText("I'm at the end!")).not.toBeInTheDocument();
// have empty children
expect(
await screen.findByText("I show up when the map is empty!")
).toBeVisible();
});

it("shows an error alert if the event user IDs failed to load", async () => {
const errorMessage = "Error loading event users";
render(
<UsersList
userIds={[]}
error={{
code: 2,
message: errorMessage,
name: "grpcError",
metadata: {},
}}
/>,
{ wrapper }
);

await assertErrorAlert(errorMessage);
// Empty state should not be shown if there is an error
expect(
screen.queryByText("There aren't any users for this event yet!")
).not.toBeInTheDocument();
});
});
82 changes: 82 additions & 0 deletions app/web/components/UsersList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { CircularProgress, styled } from "@mui/material";
import UserSummary from "components/UserSummary";
import { useLiteUsers } from "features/userQueries/useLiteUsers";
import { RpcError } from "grpc-web";
import { ReactNode } from "react";

import Alert from "./Alert";

const ContainingDiv = styled("div")(({ theme }) => ({
padding: theme.spacing(2),
}));

const StyledUsersDiv = styled("div")(({ theme }) => ({
display: "grid",
marginBlockStart: theme.spacing(2),
rowGap: theme.spacing(1),
}));

export interface UsersListProps {
userIds: number[] | undefined;
emptyListChildren?: ReactNode;
endChildren?: ReactNode;
error?: RpcError | null;
}

/**
* A cute list of <UserSummary> components for each userId. Automatically fetches the user info.
*
* A spinner shows up while `userIds` is `undefined`. When this component is fetching the lite users, it will show skeletons (the right number).
*
* If any users are not found or userIds is an empty list, this will show `emptyListChildren`.
*
* The end of the list will show `endChildren` if the list is not empty (this is a good place to add a "load more" button)
*/
export default function UsersList({
userIds,
emptyListChildren,
endChildren,
error,
}: UsersListProps) {
const {
data: users,
isLoading: isLoadingLiteUsers,
error: usersError,
} = useLiteUsers(userIds || []);

// this is undefined if userIds is undefined or users hasn't loaded, otherwise it's an actual list
const foundUserIds =
userIds &&
(userIds.length > 0 ? userIds?.filter((userId) => users?.has(userId)) : []);

return (
<ContainingDiv>
{error ? (
<Alert severity="error">{error.message}</Alert>
) : usersError ? (
<Alert severity="error">{usersError.message}</Alert>
) : !userIds ? (
<CircularProgress />
) : isLoadingLiteUsers ? (
<StyledUsersDiv>
{userIds.map((userId) => (
<UserSummary headlineComponent="h3" key={userId} user={undefined} />
))}
</StyledUsersDiv>
) : foundUserIds && foundUserIds.length > 0 ? (
<StyledUsersDiv>
{foundUserIds.map((userId) => (
<UserSummary
headlineComponent="h3"
key={userId}
user={users?.get(userId)}
/>
))}
<>{endChildren}</>
</StyledUsersDiv>
) : (
<>{emptyListChildren}</>
)}
</ContainingDiv>
);
}
29 changes: 4 additions & 25 deletions app/web/features/communities/CommunityModeratorsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { CircularProgress } from "@mui/material";
import Alert from "components/Alert";
import Button from "components/Button";
import {
AccessibleDialogProps,
Expand All @@ -8,7 +6,7 @@ import {
DialogContent,
DialogTitle,
} from "components/Dialog";
import UserSummary from "components/UserSummary";
import UsersList from "components/UsersList";
import { useTranslation } from "i18n";
import { COMMUNITIES } from "i18n/namespaces";
import { Community } from "proto/communities_pb";
Expand All @@ -29,35 +27,16 @@ export default function CommunityModeratorsDialog({
open = false,
}: CommunityModeratorsDialogProps) {
const { t } = useTranslation([COMMUNITIES]);
const {
adminIds,
adminUsers,
error,
fetchNextPage,
isFetchingNextPage,
isLoading,
hasNextPage,
} = useListAdmins(community.communityId, "all");
const { adminIds, error, fetchNextPage, isFetchingNextPage, hasNextPage } =
useListAdmins(community.communityId, "all");

return (
<Dialog aria-labelledby={DIALOG_LABEL_ID} open={open} onClose={onClose}>
<DialogTitle id={DIALOG_LABEL_ID}>
{t("communities:community_moderators")}
</DialogTitle>
<DialogContent>
{error && <Alert severity="error">{error.message}</Alert>}
{isLoading ? (
<CircularProgress />
) : adminIds && adminIds.length > 0 && adminUsers ? (
adminIds.map((id) => (
<UserSummary
smallAvatar
key={id}
headlineComponent="h3"
user={adminUsers.get(id)}
/>
))
) : null}
<UsersList userIds={adminIds} error={error} />
</DialogContent>
{hasNextPage && (
<DialogActions>
Expand Down
Loading

0 comments on commit 18405b7

Please sign in to comment.