Skip to content

Commit 2357147

Browse files
authored
Create membership list query UI (#247)
1 parent 8826c5a commit 2357147

File tree

7 files changed

+264
-30
lines changed

7 files changed

+264
-30
lines changed

src/ui/Router.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { ViewRoomRequest } from "./pages/roomRequest/ViewRoomRequest.page";
2828
import { ViewLogsPage } from "./pages/logs/ViewLogs.page";
2929
import { TermsOfService } from "./pages/tos/TermsOfService.page";
3030
import { ManageApiKeysPage } from "./pages/apiKeys/ManageKeys.page";
31-
import { ManageExternalMembershipPage } from "./pages/externalMembership/ManageExternalMembership.page";
31+
import { ManageExternalMembershipPage } from "./pages/membershipLists/MembershipListsPage";
3232

3333
const ProfileRediect: React.FC = () => {
3434
const location = useLocation();
@@ -181,7 +181,7 @@ const authenticatedRouter = createBrowserRouter([
181181
element: <ManageIamPage />,
182182
},
183183
{
184-
path: "/externalMembership",
184+
path: "/membershipLists",
185185
element: <ManageExternalMembershipPage />,
186186
},
187187
{

src/ui/components/AppShell/index.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
IconHistory,
2323
IconKey,
2424
IconExternalLink,
25+
IconUser,
2526
} from "@tabler/icons-react";
2627
import { ReactNode } from "react";
2728
import { useNavigate } from "react-router-dom";
@@ -99,9 +100,9 @@ export const navItems = [
99100
validRoles: [AppRoles.MANAGE_ORG_API_KEYS],
100101
},
101102
{
102-
link: "/externalMembership",
103-
name: "External Membership",
104-
icon: IconExternalLink,
103+
link: "/membershipLists",
104+
name: "Membership Lists",
105+
icon: IconUser,
105106
description: null,
106107
validRoles: [
107108
AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from "react";
2+
import { render, screen, act } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { vi } from "vitest";
5+
import { MantineProvider } from "@mantine/core";
6+
import { notifications } from "@mantine/notifications";
7+
import InternalMembershipQuery from "./InternalMembershipQuery";
8+
import { Modules, ModulesToHumanName } from "@common/modules";
9+
import { MemoryRouter } from "react-router-dom";
10+
11+
describe("InternalMembershipQuery Tests", () => {
12+
const validNetIds = ["rjjones", "test2"];
13+
const queryInternalMembershipMock = vi
14+
.fn()
15+
.mockImplementation((netId) => validNetIds.includes(netId));
16+
const renderComponent = async () => {
17+
await act(async () => {
18+
render(
19+
<MemoryRouter>
20+
<MantineProvider
21+
withGlobalClasses
22+
withCssVariables
23+
forceColorScheme="light"
24+
>
25+
<InternalMembershipQuery
26+
queryInternalMembership={queryInternalMembershipMock}
27+
/>
28+
</MantineProvider>
29+
</MemoryRouter>,
30+
);
31+
});
32+
};
33+
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
// Reset notification spy
37+
vi.spyOn(notifications, "show");
38+
});
39+
40+
it("renders the component correctly", async () => {
41+
await renderComponent();
42+
43+
expect(screen.getByText("NetID")).toBeInTheDocument();
44+
expect(
45+
screen.getByRole("button", { name: /Query Membership/i }),
46+
).toBeInTheDocument();
47+
});
48+
49+
it("disables query button when no NetID is provided", async () => {
50+
await renderComponent();
51+
expect(
52+
screen.getByRole("button", { name: /Query Membership/i }),
53+
).toBeDisabled();
54+
expect(queryInternalMembershipMock).not.toHaveBeenCalled();
55+
});
56+
it("correctly renders members", async () => {
57+
await renderComponent();
58+
const user = userEvent.setup();
59+
const textbox = screen.getByRole("textbox", { name: /NetID/i });
60+
await user.type(textbox, "rjjones");
61+
await user.click(screen.getByRole("button", { name: /Query Membership/i }));
62+
expect(queryInternalMembershipMock).toHaveBeenCalledExactlyOnceWith(
63+
"rjjones",
64+
);
65+
expect(screen.getByText("is a paid member.")).toBeVisible();
66+
});
67+
it("correctly renders non-members", async () => {
68+
await renderComponent();
69+
const user = userEvent.setup();
70+
const textbox = screen.getByRole("textbox", { name: /NetID/i });
71+
await user.type(textbox, "invalid");
72+
await user.click(screen.getByRole("button", { name: /Query Membership/i }));
73+
expect(queryInternalMembershipMock).toHaveBeenCalledExactlyOnceWith(
74+
"invalid",
75+
);
76+
expect(screen.getByText("is not a paid member.")).toBeVisible();
77+
});
78+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useState } from "react";
2+
import { TextInput, Button, Stack, Box, Text, Group } from "@mantine/core";
3+
import { IconCircleCheck, IconCircleX } from "@tabler/icons-react";
4+
5+
interface InternalMembershipQueryProps {
6+
queryInternalMembership: (netId: string) => Promise<boolean>;
7+
}
8+
9+
export const InternalMembershipQuery = ({
10+
queryInternalMembership,
11+
}: InternalMembershipQueryProps) => {
12+
const [netId, setNetId] = useState("");
13+
const [isLoading, setIsLoading] = useState(false);
14+
const [result, setResult] = useState<{
15+
netId: string;
16+
isMember: boolean;
17+
} | null>(null);
18+
19+
const handleQuery = async () => {
20+
if (!netId.trim()) {
21+
return;
22+
}
23+
24+
setIsLoading(true);
25+
setResult(null);
26+
try {
27+
const isMember = await queryInternalMembership(
28+
netId.trim().toLowerCase(),
29+
);
30+
setResult({ netId: netId.trim().toLowerCase(), isMember });
31+
} finally {
32+
setIsLoading(false);
33+
}
34+
};
35+
36+
return (
37+
<Stack gap="md">
38+
<TextInput
39+
label="NetID"
40+
placeholder="e.g., jdoe2"
41+
value={netId}
42+
onChange={(event) => setNetId(event.currentTarget.value)}
43+
onKeyDown={(event) => {
44+
if (event.key === "Enter") {
45+
handleQuery();
46+
}
47+
}}
48+
/>
49+
<Button
50+
onClick={handleQuery}
51+
loading={isLoading}
52+
disabled={!netId.trim()}
53+
>
54+
Query Membership
55+
</Button>
56+
57+
{result && (
58+
<Box
59+
p="md"
60+
mt="sm"
61+
style={{ borderRadius: "var(--mantine-radius-md)" }}
62+
bg={result.isMember ? "green.1" : "red.1"}
63+
>
64+
<Group>
65+
{result.isMember ? (
66+
<IconCircleCheck
67+
style={{ color: "var(--mantine-color-green-7)" }}
68+
/>
69+
) : (
70+
<IconCircleX style={{ color: "var(--mantine-color-red-7)" }} />
71+
)}
72+
<Text c={result.isMember ? "green.9" : "red.9"} fw={500} size="sm">
73+
<Text span fw={700} inherit>
74+
{result.netId}
75+
</Text>{" "}
76+
is {result.isMember ? "" : "not "}a paid member.
77+
</Text>
78+
</Group>
79+
</Box>
80+
)}
81+
</Stack>
82+
);
83+
};
84+
85+
export default InternalMembershipQuery;

src/ui/pages/externalMembership/ManageExternalMembership.page.tsx renamed to src/ui/pages/membershipLists/MembershipListsPage.tsx

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { useState, useEffect } from "react";
22
import {
33
Title,
4-
SimpleGrid,
5-
Select,
64
Stack,
7-
Text,
85
LoadingOverlay,
96
Container,
7+
Grid, // Import Grid
108
} from "@mantine/core";
119
import { AuthGuard } from "@ui/components/AuthGuard";
1210
import { useApi } from "@ui/util/api";
@@ -21,6 +19,8 @@ import { notifications } from "@mantine/notifications";
2119
import { IconAlertCircle } from "@tabler/icons-react";
2220
import ExternalMemberListManagement from "./ExternalMemberListManagement";
2321
import FullScreenLoader from "@ui/components/AuthContext/LoadingScreen";
22+
import InternalMembershipQuery from "./InternalMembershipQuery";
23+
import { AxiosError } from "axios";
2424

2525
export const ManageExternalMembershipPage = () => {
2626
const api = useApi("core");
@@ -48,6 +48,28 @@ export const ManageExternalMembershipPage = () => {
4848
fetchLists();
4949
}, [api]);
5050

51+
const queryInternalMembership = async (netId: string) => {
52+
try {
53+
const result = await api.get<{ netId: string; isPaidMember: boolean }>(
54+
`/api/v2/membership/${netId}`,
55+
);
56+
return result.data.isPaidMember;
57+
} catch (error: any) {
58+
if (error instanceof AxiosError && error.status === 400) {
59+
// Invalid NetID.
60+
return false;
61+
}
62+
console.error("Failed to check internal membership:", error);
63+
notifications.show({
64+
title: "Failed to get query membership list.",
65+
message: "Please try again or contact support.",
66+
color: "red",
67+
icon: <IconAlertCircle size={16} />,
68+
});
69+
throw error;
70+
}
71+
};
72+
5173
const handleListCreated = (listId: string) => {
5274
setValidLists((prevLists) => [...(prevLists || []), listId]);
5375
};
@@ -95,24 +117,45 @@ export const ManageExternalMembershipPage = () => {
95117
return <FullScreenLoader />;
96118
}
97119
return (
98-
<AuthGuard
99-
resourceDef={{
100-
service: "core",
101-
validRoles: [
102-
AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST,
103-
AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST,
104-
],
105-
}}
106-
>
107-
<Container>
108-
<Title order={2}>Manage External Membership Lists</Title>
109-
<ExternalMemberListManagement
110-
fetchMembers={fetchMembers}
111-
updateMembers={handlePatchMembers}
112-
validLists={validLists}
113-
onListCreated={handleListCreated}
114-
/>
115-
</Container>
116-
</AuthGuard>
120+
<Container fluid m="lg">
121+
<Grid>
122+
<Grid.Col span={{ base: 12, lg: 6 }}>
123+
<AuthGuard
124+
resourceDef={{
125+
service: "core",
126+
validRoles: [AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST],
127+
}}
128+
>
129+
<Stack>
130+
<Title order={2}>Query ACM Paid Membership List</Title>
131+
<InternalMembershipQuery
132+
queryInternalMembership={queryInternalMembership}
133+
/>
134+
</Stack>
135+
</AuthGuard>
136+
</Grid.Col>
137+
<Grid.Col span={{ base: 12, lg: 6 }}>
138+
<AuthGuard
139+
resourceDef={{
140+
service: "core",
141+
validRoles: [
142+
AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST,
143+
AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST,
144+
],
145+
}}
146+
>
147+
<Stack>
148+
<Title order={2}>Manage External Membership Lists</Title>
149+
<ExternalMemberListManagement
150+
fetchMembers={fetchMembers}
151+
updateMembers={handlePatchMembers}
152+
validLists={validLists}
153+
onListCreated={handleListCreated}
154+
/>
155+
</Stack>
156+
</AuthGuard>
157+
</Grid.Col>
158+
</Grid>
159+
</Container>
117160
);
118161
};

tests/e2e/externalMembership.spec.ts renamed to tests/e2e/membershipLists.spec.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,41 @@ import { test } from "./base.js";
33
import { describe } from "node:test";
44
import { randomUUID } from "crypto";
55

6+
describe("Internal Membership tests", () => {
7+
test("A user can query internal membership", async ({ page, becomeUser }) => {
8+
const uuid = `e2e-${randomUUID()}`;
9+
await becomeUser(page);
10+
await page.locator("a").filter({ hasText: "Membership Lists" }).click();
11+
await page
12+
.getByRole("textbox", { name: "NetID", exact: true })
13+
.fill("dsingh14");
14+
await page
15+
.getByRole("button", { name: "Query Membership", exact: true })
16+
.click();
17+
await expect(page.getByText("dsingh14 is a paid member.")).toBeVisible();
18+
await page.getByRole("textbox", { name: "NetID", exact: true }).fill("z");
19+
await page
20+
.getByRole("button", { name: "Query Membership", exact: true })
21+
.click();
22+
await expect(page.getByText("z is not a paid member.")).toBeVisible();
23+
await page
24+
.getByRole("textbox", { name: "NetID", exact: true })
25+
.fill("rjjones");
26+
await page
27+
.getByRole("button", { name: "Query Membership", exact: true })
28+
.click();
29+
await expect(page.getByText("rjjones is not a paid member.")).toBeVisible();
30+
});
31+
});
32+
633
describe("External Membership tests", () => {
734
test("A user can create, modify, and delete external memberships", async ({
835
page,
936
becomeUser,
1037
}) => {
1138
const uuid = `e2e-${randomUUID()}`;
1239
await becomeUser(page);
13-
await page.locator("a").filter({ hasText: "External Membership" }).click();
40+
await page.locator("a").filter({ hasText: "Membership Lists" }).click();
1441
await page.getByRole("button", { name: "New List" }).click();
1542
await page.getByRole("textbox", { name: "New List ID" }).fill(uuid);
1643
await page.getByRole("textbox", { name: "Initial Member NetID" }).click();
@@ -21,7 +48,7 @@ describe("External Membership tests", () => {
2148
await expect(page.getByText("corete5")).toBeVisible();
2249
await expect(page.locator("tbody")).toContainText("corete5");
2350
await expect(page.locator("tbody")).toContainText("Active");
24-
await expect(page.getByRole("main")).toContainText("Found 1 member.");
51+
await expect(page.getByText("Found 1 member.")).toBeVisible();
2552
await page.getByRole("button", { name: "Replace List" }).click();
2653
await page
2754
.getByRole("textbox", { name: "jdoe2 [email protected]" })
@@ -34,7 +61,7 @@ describe("External Membership tests", () => {
3461
.getByRole("button", { name: "Save Changes (1 Additions, 0" })
3562
.click();
3663
await page.getByRole("button", { name: "Cancel", exact: true }).click();
37-
await expect(page.getByRole("main")).toContainText(
64+
await expect(page.getByRole("main").nth(1)).toContainText(
3865
"Save Changes (1 Additions, 0 Removals)",
3966
);
4067
await page

0 commit comments

Comments
 (0)