Skip to content

Commit

Permalink
Implement Export Button
Browse files Browse the repository at this point in the history
  • Loading branch information
hokwaichan committed Dec 13, 2024
1 parent ac14178 commit 8e0a20b
Show file tree
Hide file tree
Showing 5 changed files with 462 additions and 14 deletions.
213 changes: 204 additions & 9 deletions ui/src/app/groupings/[groupingPath]/_components/export-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof memberSchema>;
type GroupingMembers = z.infer<typeof groupingMemberSchema>;

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<HTMLDivElement>(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<Grouping, { exportData: Member[] | null; fileName: string }> = {
'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 (
<div className="btn-group dropdown float-right">
<Button aria-label="Export Grouping">
<FileInput className="mr-1" />
Export Grouping
<ChevronDown className="ml-1" />
</Button>
<div className="btn-group float-right relative" ref={dropdownRef}>
<TooltipProvider>
{isDisabled ? (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="Export Grouping"
onClick={toggleDropdown}
disabled={isDisabled}
className="flex items-center border border-solid border-gray-300 text-text-primary rounded-md
px-3 py-1.5 text-base focus-visible:ring-[3px] focus-visible:ring-blue-200
bg-gray-200 cursor-not-allowed"
>
<FontAwesomeIcon className="mr-2 text-text-primary" icon={faFileExport} />
Export Grouping
<FontAwesomeIcon className="ml-2 text-text-primary" icon={faCaretDown} />
</button>
</TooltipTrigger>
<TooltipContent>No members to export.</TooltipContent>
</Tooltip>
) : (
<button
aria-label="Export Grouping"
onClick={toggleDropdown}
disabled={isDisabled}
className="flex items-center border border-solid border-gray-300 bg-white
text-text-primary rounded-md px-3 py-1.5 text-base rounded-t rounded-b
focus-visible:ring-[3px] focus-visible:ring-blue-200 focus:ring-[2.5px]
focus:ring-blue-200"
>
<FontAwesomeIcon className="mr-2 text-text-primary" icon={faFileExport} />
Export Grouping
<FontAwesomeIcon className="ml-2 text-text-primary" icon={faCaretDown} />
</button>
)}
</TooltipProvider>

{state.isOpen && (
<div
className="flex absolute bg-white rounded-b rounded-t border-solid border-x border-y
leading-6 mx-0 mb-0 mt-0.5 py-2 px-0 min-w-40 font-normal text-left left-[-28.8px]"
>
<ul className="list-none m-0 p-0">
{validGroupings.map(({ label, icon }) => (
<li key={label}>
<button
className="mb-2 py-1 px-6 w-52 text-left font-normal hover:bg-gray-100 rounded"
onClick={() => handleExportGrouping(label)}
>
<FontAwesomeIcon className="mr-2" icon={icon} />
Export {label}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
};
Expand Down
16 changes: 14 additions & 2 deletions ui/src/app/groupings/[groupingPath]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,31 @@ 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);
const { description } = await groupingDescription(groupPath);
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 (
<div className="container">
<div className="mt-4">
<ReturnButtons fromManageSubject={fromManageSubject} />
<ExportDropdown />
<ExportDropdown groupingMembers={groupingMembers} groupPath={groupPath} />
</div>
<div className="mb-5 mt-0">
<GroupingHeader groupName={groupName} groupPath={groupPath} groupDescription={description} />
Expand Down
23 changes: 23 additions & 0 deletions ui/src/lib/fetchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ export const ownedGrouping = async (
return postRequestRetry<GroupingGroupsMembers>(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<GroupingGroupsMembers> => {
const currentUser = await getUser();
const params = new URLSearchParams({
sortString,
isAscending: isAscending.toString()
});
const endpoint = `${baseUrl}/groupings/all-grouping-members?${params.toString()}`;
return postRequestRetry<GroupingGroupsMembers>(endpoint, currentUser.uid, groupPaths);
};

/**
* Get the description of a grouping.
*
Expand Down
Loading

0 comments on commit 8e0a20b

Please sign in to comment.