99 Code ,
1010 ActionIcon ,
1111 Tooltip ,
12+ Collapse ,
1213} from "@mantine/core" ;
1314import { useClipboard } from "@mantine/hooks" ;
1415import {
@@ -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
2227interface 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