Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Group actions #55

Merged
merged 12 commits into from
Jan 5, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { columns } from './components/columns'
import { useLayoutEffect, useState } from 'react'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Loader2, SearchIcon } from 'lucide-react'
import { FilterMenu } from './components/FilterMenu'
Expand All @@ -32,48 +32,23 @@ import { FilterBadges } from './components/FilterBadges'
import { ApplicationDetailsView } from './ApplicationDetailsView'
import { ApplicationParticipation } from '@/interfaces/application_participations'
import { getApplicationParticipations } from '../../network/queries/applicationParticipations'
import { GroupActionsMenu } from './components/GroupActionsMenu'
import { downloadApplications } from './utils/downloadApplications'
import AssessmentScoreUpload from './ScoreUpload/ScoreUpload'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { getAdditionalScoreNames } from '../../network/queries/additionalScoreNames'
import { useCustomElementWidth } from '../../handlers/useCustomElementWidth'

export const ApplicationsOverview = (): JSX.Element => {
const { phaseId } = useParams<{ phaseId: string }>()
const [sorting, setSorting] = useState<SortingState>([])
const [sorting, setSorting] = useState<SortingState>([{ id: 'last_name', desc: false }])
const [globalFilter, setGlobalFilter] = useState<string>('')
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ gender: false })

const [dialogOpen, setDialogOpen] = useState(false)
const [selectedApplicationID, setSelectedApplicationID] = useState<string | null>(null)
// for the weird horizontal scrolling bug
const [elementWidth, setElementWidth] = useState(0)

useLayoutEffect(() => {
const updateWidth = () => {
const element = document.getElementById('table-view')
if (element) {
setElementWidth(element.clientWidth - 100)
}
}

// Create a ResizeObserver instance to observe changes to the div's size
const resizeObserver = new ResizeObserver(() => {
updateWidth()
})

const element = document.getElementById('table-view')
if (element) {
resizeObserver.observe(element)
}

updateWidth()

return () => {
if (element) {
resizeObserver.unobserve(element)
}
}
}, [])
const tableWidth = useCustomElementWidth('table-view')

const viewApplication = (id: string) => {
setSelectedApplicationID(id)
Expand Down Expand Up @@ -166,6 +141,19 @@ export const ApplicationsOverview = (): JSX.Element => {
{fetchedParticipations && (
<AssessmentScoreUpload applications={fetchedParticipations} />
)}
{table.getSelectedRowModel().rows.length > 0 && (
<GroupActionsMenu
selectedRows={table.getSelectedRowModel()}
onClose={() => table.resetRowSelection()}
onExport={() => {
downloadApplications(
table.getSelectedRowModel().rows.map((row) => row.original),
fetchedAdditionalScores ?? [],
)
table.resetRowSelection()
}}
/>
)}
</div>
</div>
<div className='flex flex-wrap gap-2'>
Expand All @@ -177,7 +165,7 @@ export const ApplicationsOverview = (): JSX.Element => {
<Loader2 className='h-12 w-12 animate-spin text-primary' />
</div>
) : (
<div className='rounded-md border' style={{ width: `${elementWidth + 50}px` }}>
<div className='rounded-md border' style={{ width: `${tableWidth + 50}px` }}>
<ScrollArea className='h-[calc(100vh-300px)] overflow-x-scroll'>
<Table className='table-auto min-w-full w-full relative'>
<TableHeader className='bg-muted/100 sticky top-0 z-10'>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'

interface ActionDialogProps {
title: string
description: string
confirmLabel: string
confirmVariant?: 'default' | 'destructive'
isOpen: boolean
onClose: () => void
onConfirm: () => void
}

export const ActionDialog = ({
title,
description,
confirmLabel,
confirmVariant = 'default',
isOpen,
onClose,
onConfirm,
}: ActionDialogProps) => (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant='outline' onClick={onClose}>
Cancel
</Button>
<Button
variant={confirmVariant}
onClick={() => {
onConfirm()
onClose()
}}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreHorizontal, Trash2, FileDown, CheckCircle, XCircle } from 'lucide-react'
import { ActionDialog } from './GroupActionDialog'
import { RowModel } from '@tanstack/react-table'
import { ApplicationParticipation } from '@/interfaces/application_participations'
import { useApplicationStatusUpdate } from '../handlers/useApplicationStatusUpdate'
import { useDeleteApplications } from '../handlers/useDeleteApplications'
import { PassStatus } from '@/interfaces/course_phase_participation'

interface GroupActionsMenuProps {
selectedRows: RowModel<ApplicationParticipation>
onClose: () => void
onExport: () => void
}

export const GroupActionsMenu = ({
selectedRows,
onClose,
onExport,
}: GroupActionsMenuProps): JSX.Element => {
const [isOpen, setIsOpen] = useState(false)
const [dialogState, setDialogState] = useState<{
type: 'delete' | 'setPassed' | 'setFailed' | null
isOpen: boolean
}>({ type: null, isOpen: false })

const openDialog = (type: 'delete' | 'setPassed' | 'setFailed') => {
setIsOpen(false)
setDialogState({ type, isOpen: true })
}

const closeDialog = () => setDialogState({ type: null, isOpen: false })

// modifiers
const { mutate: mutateUpdateApplicationStatus } = useApplicationStatusUpdate()
const { mutate: mutateDeleteApplications } = useDeleteApplications()
const numberOfRowsSelected = selectedRows.rows.length

return (
<>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button>
<MoreHorizontal className='h-4 w-4' />
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
<DropdownMenuLabel>Group Actions</DropdownMenuLabel>
<DropdownMenuSeparator />

<DropdownMenuItem onClick={() => openDialog('setPassed')}>
<CheckCircle className='mr-2 h-4 w-4' />
Set Accepted
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openDialog('setFailed')}>
<XCircle className='mr-2 h-4 w-4' />
Set Rejected
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onExport}>
<FileDown className='mr-2 h-4 w-4' />
Export
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openDialog('delete')} className='text-destructive'>
<Trash2 className='mr-2 h-4 w-4' />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{dialogState.isOpen && dialogState.type === 'delete' && (
<ActionDialog
title='Confirm Deletion'
description={`Are you sure you want to delete ${numberOfRowsSelected} applications? This action cannot be undone.
The course application will be deleted for the selected students.`}
confirmLabel='Delete'
confirmVariant='destructive'
isOpen={dialogState.type === 'delete' && dialogState.isOpen}
onClose={closeDialog}
onConfirm={() => {
mutateDeleteApplications(selectedRows.rows.map((row) => row.original.id))
onClose()
}}
/>
)}

{dialogState.isOpen && dialogState.type === 'setPassed' && (
<ActionDialog
title='Confirm Set Passed'
description={`Are you sure you want to mark ${numberOfRowsSelected} applications as accepted?`}
confirmLabel='Set Accepted'
isOpen={dialogState.type === 'setPassed' && dialogState.isOpen}
onClose={closeDialog}
onConfirm={() => {
mutateUpdateApplicationStatus({
pass_status: PassStatus.PASSED,
course_phase_participation_ids: selectedRows.rows.map((row) => row.original.id),
})
onClose()
}}
/>
)}

{dialogState.isOpen && dialogState.type === 'setFailed' && (
<ActionDialog
title='Confirm Set Failed'
description={`Are you sure you want to mark ${numberOfRowsSelected} applications as rejected?`}
confirmLabel='Set Rejected'
isOpen={dialogState.type === 'setFailed' && dialogState.isOpen}
onClose={closeDialog}
onConfirm={() => {
mutateUpdateApplicationStatus({
pass_status: PassStatus.FAILED,
course_phase_participation_ids: selectedRows.rows.map((row) => row.original.id),
})
onClose()
}}
/>
)}
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { CoursePhaseParticipationWithStudent } from '@/interfaces/course_phase_participation'
import { ApplicationParticipation } from '@/interfaces/application_participations'
import { Column } from '@tanstack/react-table'
import { Columns } from 'lucide-react'

interface VisibilityMenuProps {
columns: Column<CoursePhaseParticipationWithStudent, unknown>[]
columns: Column<ApplicationParticipation, unknown>[]
}

export const VisibilityMenu = ({ columns }: VisibilityMenuProps): JSX.Element => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { CoursePhaseParticipationWithStudent } from '@/interfaces/course_phase_participation'
import { ColumnDef } from '@tanstack/react-table'
import translations from '@/lib/translations.json'
import { SortableHeader } from './SortableHeader'
Expand All @@ -14,13 +13,15 @@ import {
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Eye, MoreHorizontal, Trash2 } from 'lucide-react'
import { Checkbox } from '@/components/ui/checkbox'
import { ApplicationParticipation } from '@/interfaces/application_participations'

export const columns = (
onViewApplication: (id: string) => void,
onDeleteApplication: (coursePhaseParticipationID: string) => void,
additionalScores: string[],
): ColumnDef<CoursePhaseParticipationWithStudent>[] => {
let additionalScoreColumns: ColumnDef<CoursePhaseParticipationWithStudent>[] = []
): ColumnDef<ApplicationParticipation>[] => {
let additionalScoreColumns: ColumnDef<ApplicationParticipation>[] = []
if (additionalScores.length > 0) {
additionalScoreColumns = additionalScores.map((scoreName) => {
return {
Expand All @@ -32,15 +33,50 @@ export const columns = (
}

return [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(event) => {
event.stopPropagation()
row.toggleSelected()
}}
aria-label='Select row'
/>
),
enableSorting: false,
enableHiding: false,
},
{
id: 'first_name', // required for filter bar
accessorKey: 'student.first_name',
header: ({ column }) => <SortableHeader column={column} title='First Name' />,
sortingFn: (rowA, rowB) => {
const valueA = rowA.original.student.first_name.toLowerCase() || ''
const valueB = rowB.original.student.first_name.toLowerCase() || ''
return valueA.localeCompare(valueB)
},
},
{
id: 'last_name',
accessorKey: 'student.last_name',
header: ({ column }) => <SortableHeader column={column} title='Last Name' />,
sortingFn: (rowA, rowB) => {
const valueA = rowA.original.student.last_name.toLowerCase() || ''
const valueB = rowB.original.student.last_name.toLowerCase() || ''
return valueA.localeCompare(valueB)
},
},
{
id: 'pass_status',
Expand Down
Loading
Loading