diff --git a/ui/src/app/groupings/[groupingPath]/_components/export-dropdown.tsx b/ui/src/app/groupings/[groupingPath]/_components/export-dropdown.tsx index 181586b1..633845fe 100644 --- a/ui/src/app/groupings/[groupingPath]/_components/export-dropdown.tsx +++ b/ui/src/app/groupings/[groupingPath]/_components/export-dropdown.tsx @@ -1,16 +1,211 @@ 'use client'; -import { Button } from '@/components/ui/button'; -import { FileInput, ChevronDown } from 'lucide-react'; +import { useReducer, useRef } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faFileExport, + faCaretDown, + faUsers, + faUserPlus, + faUserMinus, + faIdCard +} from '@fortawesome/free-solid-svg-icons'; +import { z } from 'zod'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +const memberSchema = z.object({ + firstName: z.string(), + lastName: z.string(), + uid: z.string(), + uhUuid: z.string() +}); + +const groupingMemberSchema = z.object({ + allMembers: z.object({ + members: z.array(memberSchema) + }), + groupingInclude: z.object({ + members: z.array(memberSchema) + }), + groupingExclude: z.object({ + members: z.array(memberSchema) + }), + groupingBasis: z.object({ + members: z.array(memberSchema) + }) +}); + +type Member = z.infer; +type GroupingMembers = z.infer; + +interface ExportDropdownProps { + groupingMembers: GroupingMembers; + groupPath: string; +} + +const initialState = { + isOpen: false +}; + +type State = typeof initialState; +type Action = { type: 'TOGGLE' } | { type: 'CLOSE' }; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'TOGGLE': + return { ...state, isOpen: !state.isOpen }; + case 'CLOSE': + return { ...state, isOpen: false }; + } +}; + +const ExportDropdown = ({ groupingMembers, groupPath }: ExportDropdownProps) => { + const [state, dispatch] = useReducer(reducer, initialState); + const dropdownRef = useRef(null); + + const groupings = [ + { label: 'All Members', members: groupingMembers?.allMembers?.members, icon: faUsers }, + { label: 'Basis', members: groupingMembers?.groupingBasis?.members, icon: faIdCard }, + { label: 'Include', members: groupingMembers?.groupingInclude?.members, icon: faUserPlus }, + { label: 'Exclude', members: groupingMembers?.groupingExclude?.members, icon: faUserMinus } + ]; + + const validGroupings = groupings.filter((group) => group.members.length > 0); + + const isDisabled = validGroupings.length === 0; + + const handleExportGrouping = (grouping: string) => { + const groupingPath = groupPath.split(':'); + const groupName = groupingPath[groupingPath.length - 1]; + + type Grouping = 'All Members' | 'Basis' | 'Include' | 'Exclude'; + + const groupMapping: Record = { + 'All Members': { + exportData: groupingMembers?.allMembers.members, + fileName: `${groupName}_members_list.csv` + }, + Basis: { + exportData: groupingMembers?.groupingBasis.members, + fileName: `${groupName}_basis_list.csv` + }, + Include: { + exportData: groupingMembers?.groupingInclude.members, + fileName: `${groupName}_include_list.csv` + }, + Exclude: { + exportData: groupingMembers?.groupingExclude.members, + fileName: `${groupName}_exclude_list.csv` + } + }; + + const { exportData, fileName } = groupMapping[grouping as Grouping]; + + if (exportData) { + const csv = generateCSV(exportData); + downloadCSV(csv, fileName); + } + + closeDropdown(); + }; + + const generateCSV = (members: Member[]) => { + const header = ['Last', 'First', 'Username', 'UH Number', 'Email']; + const rows = members.map((member) => { + const email = member.uid ? `${member.uid}@hawaii.edu` : ''; + return [member.lastName, member.firstName, member.uid, member.uhUuid, email]; + }); + + const csvRows = [header, ...rows].map((row) => row.join(',')).join('\n'); + return csvRows; + }; + + const downloadCSV = (csvContent: string, fileName: string) => { + const blob = new Blob([csvContent], { type: 'text/csv' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = fileName; + link.click(); + }; + + const toggleDropdown = () => { + if (!state.isOpen) { + document.addEventListener('click', handleOutsideClick); + } else { + document.removeEventListener('click', handleOutsideClick); + } + dispatch({ type: 'TOGGLE' }); + }; + + const closeDropdown = () => { + dispatch({ type: 'CLOSE' }); + document.removeEventListener('click', handleOutsideClick); + }; + + const handleOutsideClick = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + closeDropdown(); + } + }; -const ExportDropdown = () => { return ( -
- +
+ + {isDisabled ? ( + + + + + No members to export. + + ) : ( + + )} + + + {state.isOpen && ( +
+
    + {validGroupings.map(({ label, icon }) => ( +
  • + +
  • + ))} +
+
+ )}
); }; diff --git a/ui/src/app/groupings/[groupingPath]/layout.tsx b/ui/src/app/groupings/[groupingPath]/layout.tsx index 3bf6f981..32e1d605 100644 --- a/ui/src/app/groupings/[groupingPath]/layout.tsx +++ b/ui/src/app/groupings/[groupingPath]/layout.tsx @@ -2,7 +2,7 @@ import ExportDropdown from './_components/export-dropdown'; import GroupingHeader from './_components/grouping-header'; import ReturnButtons from './_components/return-buttons'; import SideNav from './_components/side-nav'; -import { groupingDescription } from '@/lib/fetchers'; +import { groupingDescription, allGroupingMembers } from '@/lib/fetchers'; const GroupingPathLayout = async ({ params, tab }: { params: { groupingPath: string }; tab: React.ReactNode }) => { const groupPath = decodeURIComponent(params.groupingPath); @@ -10,11 +10,23 @@ const GroupingPathLayout = async ({ params, tab }: { params: { groupingPath: str const groupName = groupPath.split(':').pop() as string; const fromManageSubject = groupPath.includes('manage-person'); + const groupPaths = [`${groupPath}:include`, `${groupPath}:exclude`, `${groupPath}:basis`, `${groupPath}:owners`]; + const sortString = 'name'; + const isAscending = true; + const fetchGroupingMembers = await allGroupingMembers(groupPaths, sortString, isAscending); + const defaultGroupingMembers = { + allMembers: { members: [] }, + groupingInclude: { members: [] }, + groupingExclude: { members: [] }, + groupingBasis: { members: [] } + }; + const groupingMembers = { ...defaultGroupingMembers, ...fetchGroupingMembers }; + return (
- +
diff --git a/ui/src/lib/fetchers.ts b/ui/src/lib/fetchers.ts index 4eafe814..ca0f4303 100644 --- a/ui/src/lib/fetchers.ts +++ b/ui/src/lib/fetchers.ts @@ -52,6 +52,29 @@ export const ownedGrouping = async ( return postRequestRetry(endpoint, currentUser.uid, groupPaths); }; +/** + * Get all the members of an owned grouping through paginated calls. + * + * @param groupPaths - The paths to the groups + * @param sortString - String to sort by column name + * @param isAscending - On true the data returns in ascending order + * + * @returns The promise of members of an owned grouping + */ +export const allGroupingMembers = async ( + groupPaths: string[], + sortString: string, + isAscending: boolean +): Promise => { + const currentUser = await getUser(); + const params = new URLSearchParams({ + sortString, + isAscending: isAscending.toString() + }); + const endpoint = `${baseUrl}/groupings/all-grouping-members?${params.toString()}`; + return postRequestRetry(endpoint, currentUser.uid, groupPaths); +}; + /** * Get the description of a grouping. * diff --git a/ui/tests/app/groupings/[groupingPath]/_components/export-dropdown.test.tsx b/ui/tests/app/groupings/[groupingPath]/_components/export-dropdown.test.tsx index 717563b5..6b75d6a3 100644 --- a/ui/tests/app/groupings/[groupingPath]/_components/export-dropdown.test.tsx +++ b/ui/tests/app/groupings/[groupingPath]/_components/export-dropdown.test.tsx @@ -1,11 +1,157 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import ExportDropdown from '@/app/groupings/[groupingPath]/_components/export-dropdown'; +global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); + describe('ExportDropdown', () => { - it('renders button', () => { - render(); + const mockGroupPath = 'tmp:test-group-path'; + + const test1 = { firstName: 'firstName', lastName: 'lastName', uid: 'testUid', uhUuid: '123' }; + const test2 = { firstName: 'firstName', lastName: 'lastName', uid: 'testUid', uhUuid: '456' }; + const test3 = { firstName: 'firstName', lastName: 'lastName', uid: '', uhUuid: '789' }; + + const mockGroupingMembers = { + allMembers: { members: [test1, test2] }, + groupingInclude: { members: [test1, test2] }, + groupingExclude: { members: [test1, test2] }, + groupingBasis: { members: [test1, test2] } + }; + const mockEmptyGroupingMembers = { + allMembers: { members: [] }, + groupingInclude: { members: [] }, + groupingExclude: { members: [] }, + groupingBasis: { members: [] } + }; + + const mockGroupingWithEmptyUid = { + allMembers: { members: [test3] }, + groupingInclude: { members: [] }, + groupingExclude: { members: [] }, + groupingBasis: { members: [] } + }; + + it('renders the button', () => { + render(); const buttonElement = screen.getByRole('button', { name: /Export Grouping/i }); expect(buttonElement).toBeInTheDocument(); }); + + it('toggles the dropdown menu on button click', () => { + render(); + + const button = screen.getByRole('button', { name: /Export Grouping/i }); + fireEvent.click(button); + + const dropdownMenu = screen.getByRole('list'); + expect(dropdownMenu).toBeInTheDocument(); + + fireEvent.click(button); + expect(dropdownMenu).not.toBeVisible(); + }); + + it('closes the dropdown when clicking outside', () => { + render(); + const button = screen.getByRole('button', { name: /Export Grouping/i }); + + fireEvent.click(button); + expect(screen.getByRole('list')).toBeInTheDocument(); + + fireEvent.click(document); + expect(screen.queryByRole('list')).toBeNull(); + }); + + it('displays only valid groupings in the dropdown', () => { + render(); + + const buttonElement = screen.getByRole('button', { name: /Export Grouping/i }); + fireEvent.click(buttonElement); + + const validGroupingLabels = screen.getAllByRole('button').map((btn) => btn.textContent); + const expectedLabels = ['Export All Members', 'Export Include', 'Export Exclude', 'Export Basis']; + + expectedLabels.forEach((label) => { + expect(validGroupingLabels).toContain(label); + }); + }); + + it('disables the button and applies when there are no valid groupings', () => { + render(); + const buttonElement = screen.getByRole('button', { name: /Export Grouping/i }); + + expect(buttonElement).toBeDisabled(); + + const validGroupingLabels = screen.getAllByRole('button').map((btn) => btn.textContent); + const invalidLabels = ['Export All Members', 'Export Include', 'Export Exclude', 'Export Basis']; + + invalidLabels.forEach((label) => { + expect(validGroupingLabels).not.toContain(label); + }); + }); + + it('handles members with empty UID', () => { + render(); + + const buttonElement = screen.getByRole('button', { name: /Export Grouping/i }); + fireEvent.click(buttonElement); + + const exportButton = screen.getByRole('button', { name: /Export All Members/i }); + fireEvent.click(exportButton); + + jest.spyOn(window, 'Blob').mockImplementation((data) => { + const content = data?.[0] ?? ''; + expect(content).toContain('Last,First,Username,UH Number,Email'); + expect(content).toContain('lastName,firstName,,789,'); + + return new Blob([content], { type: 'text/csv' }); + }); + + jest.restoreAllMocks(); + }); + + it('generates a CSV download link with correct content for all export options', () => { + const exportOptions = [ + { buttonName: /Export All Members/i, downloadFile: 'members_list.csv' }, + { buttonName: /Export Include/i, downloadFile: 'include_list.csv' }, + { buttonName: /Export Exclude/i, downloadFile: 'exclude_list.csv' }, + { buttonName: /Export Basis/i, downloadFile: 'basis_list.csv' } + ]; + + exportOptions.forEach(({ buttonName, downloadFile }) => { + render(); + + const buttons = screen.getAllByRole('button', { name: /Export Grouping/i }); + const exportGroupingButton = buttons[0]; + fireEvent.click(exportGroupingButton); + + const linkClickSpy = jest.spyOn(document, 'createElement').mockImplementation(() => { + const mockLink = { + setAttribute: jest.fn(), + click: jest.fn(), + download: '', + href: '' + } as unknown as HTMLAnchorElement; + return mockLink; + }); + + const exportButton = screen.getByRole('button', { name: buttonName }); + fireEvent.click(exportButton); + + const exportLink = linkClickSpy.mock.results[0].value; + const groupName = mockGroupPath.split(':').pop(); + expect(exportLink.download).toBe(`${groupName}_${downloadFile}`); + expect(exportLink.href).toContain('blob:'); + + jest.spyOn(window, 'Blob').mockImplementation((data) => { + const content = data?.[0] ?? ''; + expect(content).toContain('Last,First,Username,UH Number,Email'); + expect(content).toContain('lastName,firstName,testUid,123,testUid@hawaii.edu'); + expect(content).toContain('lastName,firstName,testUid,456,testUid@hawaii.edu'); + + return new Blob([content], { type: 'text/csv' }); + }); + + jest.restoreAllMocks(); + }); + }); }); diff --git a/ui/tests/lib/fetchers.test.ts b/ui/tests/lib/fetchers.test.ts index ae069236..594a082b 100644 --- a/ui/tests/lib/fetchers.test.ts +++ b/ui/tests/lib/fetchers.test.ts @@ -14,6 +14,7 @@ import { membershipResults, optInGroupingPaths, ownedGrouping, + allGroupingMembers, ownerGroupings } from '@/lib/fetchers'; import * as NextCasClient from 'next-cas-client/app'; @@ -160,6 +161,77 @@ describe('fetchers', () => { }); }); + describe('allGroupingMembers', () => { + const sortString = 'name'; + const isAscending = true; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should make a POST request at the correct endpoint', async () => { + fetchMock.mockResponse(JSON.stringify(mockResponse)); + await allGroupingMembers(groupPaths, sortString, isAscending); + expect(fetch).toHaveBeenCalledWith( + `${baseUrl}/groupings/all-grouping-members?` + + `sortString=${sortString}&isAscending=${isAscending}`, + { + body: JSON.stringify(groupPaths), + headers: { + current_user: currentUser.uid, + 'Content-Type': 'application/json' + }, + method: 'POST' + } + ); + }); + + it('should handle the successful response', async () => { + fetchMock.mockResponse(JSON.stringify(mockResponse)); + expect(await allGroupingMembers(groupPaths, sortString, isAscending)).toEqual(mockResponse); + + fetchMock + .mockResponseOnce(JSON.stringify(mockResponse), { status: 500 }) + .mockResponseOnce(JSON.stringify(mockResponse)); + let res = allGroupingMembers(groupPaths, sortString, isAscending); + await jest.advanceTimersByTimeAsync(5000); + expect(await res).toEqual(mockResponse); + + fetchMock + .mockResponseOnce(JSON.stringify(mockResponse), { status: 500 }) + .mockResponseOnce(JSON.stringify(mockResponse), { status: 500 }) + .mockResponseOnce(JSON.stringify(mockResponse)); + res = allGroupingMembers(groupPaths, sortString, isAscending); + await jest.advanceTimersByTimeAsync(5000); + expect(await res).toEqual(mockResponse); + + fetchMock + .mockResponseOnce(JSON.stringify(mockResponse), { status: 500 }) + .mockResponseOnce(JSON.stringify(mockResponse), { status: 500 }) + .mockResponseOnce(JSON.stringify(mockResponse), { status: 500 }) + .mockResponseOnce(JSON.stringify(mockResponse)); + res = allGroupingMembers(groupPaths, sortString, isAscending); + await jest.advanceTimersByTimeAsync(5000); + expect(await res).toEqual(mockResponse); + }); + + it('should handle the error response', async () => { + fetchMock.mockResponse(JSON.stringify(mockError), { status: 500 }); + let res = allGroupingMembers(groupPaths, sortString, isAscending); + await jest.advanceTimersByTimeAsync(5000); + expect(await res).toEqual(mockError); + + fetchMock.mockReject(() => Promise.reject(mockError)); + res = allGroupingMembers(groupPaths, sortString, isAscending); + await jest.advanceTimersByTimeAsync(5000); + expect(await res).toEqual(mockError); + }); + }); + describe('groupingDescription', () => { it('should make a GET request at the correct endpoint', async () => { await groupingDescription(groupingPath);