Skip to content

Commit a0f3106

Browse files
authored
Better data validation for mass member querying (#290)
1 parent 81f5c7a commit a0f3106

File tree

2 files changed

+153
-58
lines changed

2 files changed

+153
-58
lines changed

src/ui/pages/membershipLists/InternalMembershipQuery.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ describe("MembershipListQuery Tests", () => {
8181
const queryButton = screen.getByRole("button", {
8282
name: /Query Memberships/i,
8383
});
84-
const inputText = "rjjones, invalid, TEST2@illinois.edu, rjjones";
84+
const inputText =
85+
"rjjones, invalid, TEST2@illinois.edu, rjjones, someone@gmail.com";
8586

8687
await user.type(textarea, inputText);
8788
await user.click(queryButton);
@@ -94,8 +95,7 @@ describe("MembershipListQuery Tests", () => {
9495
]);
9596

9697
expect(await screen.findByText(/Paid Members \(2\)/i)).toBeVisible();
97-
98-
expect(screen.getByText("rjjones")).toBeVisible();
99-
expect(screen.getByText("test2")).toBeVisible();
98+
expect(await screen.findByText(/Not Paid Members \(1\)/i)).toBeVisible();
99+
expect(await screen.findByText(/Invalid Entries \(1\)/i)).toBeVisible();
100100
});
101101
});

src/ui/pages/membershipLists/InternalMembershipQuery.tsx

Lines changed: 149 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Code,
1010
ActionIcon,
1111
Tooltip,
12+
Collapse,
1213
} from "@mantine/core";
1314
import { useClipboard } from "@mantine/hooks";
1415
import {
@@ -17,12 +18,16 @@ import {
1718
IconCopy,
1819
IconCheck,
1920
IconMail,
21+
IconAlertTriangle,
22+
IconChevronDown,
23+
IconChevronUp,
2024
} from "@tabler/icons-react";
25+
import { illinoisNetId } from "@common/types/generic";
2126

2227
interface ResultSectionProps {
2328
title: string;
2429
items: string[];
25-
color: "green" | "red";
30+
color: "green" | "red" | "yellow";
2631
icon: React.ReactNode;
2732
domain?: string;
2833
}
@@ -34,6 +39,7 @@ const ResultSection = ({
3439
icon,
3540
domain,
3641
}: ResultSectionProps) => {
42+
const [isOpen, setIsOpen] = useState(false);
3743
const clipboardIds = useClipboard({ timeout: 1000 });
3844
const clipboardEmails = useClipboard({ timeout: 1000 });
3945

@@ -56,6 +62,18 @@ const ResultSection = ({
5662
>
5763
<Group justify="space-between" mb="xs">
5864
<Group gap="xs">
65+
<ActionIcon
66+
variant="transparent"
67+
color="white"
68+
onClick={() => setIsOpen((o) => !o)}
69+
aria-label={isOpen ? "Collapse section" : "Expand section"}
70+
>
71+
{isOpen ? (
72+
<IconChevronUp size={20} />
73+
) : (
74+
<IconChevronDown size={20} />
75+
)}
76+
</ActionIcon>
5977
{icon}
6078
<Title order={5} c="white">
6179
{title} ({items.length})
@@ -92,22 +110,24 @@ const ResultSection = ({
92110
)}
93111
</Group>
94112
</Group>
95-
<Box>
96-
{items.map((item) => (
97-
<Code
98-
key={item}
99-
mr={5}
100-
mb={5}
101-
style={{
102-
display: "inline-block",
103-
color: "white",
104-
backgroundColor: "rgba(0, 0, 0, 0.25)",
105-
}}
106-
>
107-
{item}
108-
</Code>
109-
))}
110-
</Box>
113+
<Collapse in={isOpen}>
114+
<Box pt="xs" pl="xl">
115+
{items.map((item, index) => (
116+
<Code
117+
key={`${item}-${index}`}
118+
mr={5}
119+
mb={5}
120+
style={{
121+
display: "inline-block",
122+
color: "white",
123+
backgroundColor: "rgba(0, 0, 0, 0.25)",
124+
}}
125+
>
126+
{item}
127+
</Code>
128+
))}
129+
</Box>
130+
</Collapse>
111131
</Box>
112132
);
113133
};
@@ -117,7 +137,6 @@ interface MembershipListQueryProps {
117137
members: string[];
118138
notMembers: string[];
119139
}>;
120-
121140
domain?: string;
122141
inputLabel?: string;
123142
inputDescription?: string;
@@ -139,33 +158,93 @@ export const MembershipListQuery = ({
139158
members: string[];
140159
notMembers: string[];
141160
} | null>(null);
161+
const [invalidEntries, setInvalidEntries] = useState<string[]>([]);
142162

143163
const handleQuery = async () => {
144-
// Input processing logic remains the same
145-
const domainRegex = domain ? new RegExp(`@${domain}$`, "i") : null;
146-
const processedItems = input
147-
.split(/[;,\s\n]+/)
148-
.map((item) => {
149-
let cleanItem = item.trim().toLowerCase();
150-
if (domainRegex) {
151-
cleanItem = cleanItem.replace(domainRegex, "");
164+
setIsLoading(true);
165+
setResult(null);
166+
setInvalidEntries([]);
167+
168+
const rawItems = input.split(/[;,\s\n]+/).filter(Boolean);
169+
const validItemsForQuery = new Set<string>();
170+
171+
const allProcessedItems = rawItems.map((item) => {
172+
const trimmedItem = item.trim();
173+
let potentialNetId = trimmedItem.toLowerCase();
174+
let isValid = false;
175+
let cleanedNetId = "";
176+
177+
if (potentialNetId.includes("@")) {
178+
if (domain && potentialNetId.endsWith(`@${domain}`)) {
179+
potentialNetId = potentialNetId.replace(`@${domain}`, "");
180+
if (illinoisNetId.safeParse(potentialNetId).success) {
181+
isValid = true;
182+
cleanedNetId = potentialNetId;
183+
}
152184
}
153-
return cleanItem;
154-
})
155-
.filter(Boolean);
185+
} else if (illinoisNetId.safeParse(potentialNetId).success) {
186+
isValid = true;
187+
cleanedNetId = potentialNetId;
188+
}
156189

157-
const uniqueItems = [...new Set(processedItems)];
158-
if (uniqueItems.length === 0) {
190+
if (isValid) {
191+
validItemsForQuery.add(cleanedNetId);
192+
}
193+
194+
return {
195+
original: trimmedItem,
196+
isValid,
197+
cleaned: cleanedNetId,
198+
};
199+
});
200+
201+
if (validItemsForQuery.size === 0) {
202+
const invalidItems = allProcessedItems
203+
.filter((p) => !p.isValid)
204+
.map((p) => p.original);
205+
setInvalidEntries(
206+
invalidItems.filter(
207+
(item, index) => invalidItems.indexOf(item) === index,
208+
),
209+
);
210+
setIsLoading(false);
159211
return;
160212
}
161213

162-
setIsLoading(true);
163-
setResult(null);
164-
165214
try {
166-
const queryResult = await queryFunction(uniqueItems);
215+
const queryResult = await queryFunction([...validItemsForQuery]);
216+
const memberSet = new Set(queryResult.members);
217+
const orderedMembers: string[] = [];
218+
const orderedNotMembers: string[] = [];
219+
const orderedInvalid: string[] = [];
220+
221+
allProcessedItems.forEach((item) => {
222+
if (!item.isValid) {
223+
orderedInvalid.push(item.original);
224+
} else if (memberSet.has(item.cleaned)) {
225+
orderedMembers.push(item.cleaned);
226+
} else {
227+
orderedNotMembers.push(item.cleaned);
228+
}
229+
});
230+
231+
// --- THIS IS THE CORRECTED DEDUPLICATION LOGIC ---
232+
// For each list, keep only the first occurrence of each item.
233+
const uniqueMembers = orderedMembers.filter(
234+
(item, index) => orderedMembers.indexOf(item) === index,
235+
);
236+
const uniqueNotMembers = orderedNotMembers.filter(
237+
(item, index) => orderedNotMembers.indexOf(item) === index,
238+
);
239+
const uniqueInvalid = orderedInvalid.filter(
240+
(item, index) => orderedInvalid.indexOf(item) === index,
241+
);
167242

168-
setResult(queryResult);
243+
setResult({
244+
members: uniqueMembers,
245+
notMembers: uniqueNotMembers,
246+
});
247+
setInvalidEntries(uniqueInvalid);
169248
} catch (error) {
170249
console.error("An error occurred during the query:", error);
171250
} finally {
@@ -193,30 +272,46 @@ export const MembershipListQuery = ({
193272
{ctaText}
194273
</Button>
195274

196-
{result && (
197-
<Stack gap="md" mt="sm">
275+
<Stack gap="md" mt="sm">
276+
{result && (
277+
<>
278+
<ResultSection
279+
title="Paid Members"
280+
items={result.members}
281+
color="green"
282+
icon={
283+
<IconCircleCheck
284+
style={{ color: "var(--mantine-color-white)" }}
285+
/>
286+
}
287+
domain={domain}
288+
/>
289+
{/* --- THIS LINE IS NOW FIXED --- */}
290+
<ResultSection
291+
title="Not Paid Members"
292+
items={result.notMembers}
293+
color="red"
294+
icon={
295+
<IconCircleX style={{ color: "var(--mantine-color-white)" }} />
296+
}
297+
domain={domain}
298+
/>
299+
</>
300+
)}
301+
302+
{invalidEntries.length > 0 && (
198303
<ResultSection
199-
title="Paid Members"
200-
items={result.members}
201-
color="green"
304+
title="Invalid Entries"
305+
items={invalidEntries}
306+
color="yellow"
202307
icon={
203-
<IconCircleCheck
308+
<IconAlertTriangle
204309
style={{ color: "var(--mantine-color-white)" }}
205310
/>
206311
}
207-
domain={domain}
208-
/>
209-
<ResultSection
210-
title="Not Paid Members"
211-
items={result.notMembers}
212-
color="red"
213-
icon={
214-
<IconCircleX style={{ color: "var(--mantine-color-white)" }} />
215-
}
216-
domain={domain}
217312
/>
218-
</Stack>
219-
)}
313+
)}
314+
</Stack>
220315
</Stack>
221316
);
222317
};

0 commit comments

Comments
 (0)