Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions admin-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/node": "^16.18.97",
"@types/papaparse": "^5.5.2",
"@types/react": "18.3.17",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^6.15.0",
Expand Down Expand Up @@ -71,6 +72,7 @@
"i18next-browser-languagedetector": "^8.0.0",
"jwt-decode": "^4.0.0",
"notistack": "^3.0.1",
"papaparse": "^5.5.4",
"react": "18.3.1",
"react-dom": "~18.2.0",
"react-i18next": "^14.1.2",
Expand Down
54 changes: 44 additions & 10 deletions admin-frontend/src/components/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import {
Popover,
Divider,
Pagination,
Button,
} from "@mui/material";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import FilterAltIcon from "@mui/icons-material/FilterAlt";
import DownloadIcon from "@mui/icons-material/Download";
import { useTranslation } from "react-i18next";
import { useSortableData } from "src/hooks/useSortableData";

Expand All @@ -42,6 +44,13 @@ export interface SearchConfig {
onChange: (value: string) => void;
}

export interface ExportConfig {
label: string;
onExport: () => void;
/** When true, the export button is rendered but disabled (e.g. nothing to export or still loading) */
disabled?: boolean;
}

export interface ColumnDef<T> {
key: keyof T;
label: string;
Expand Down Expand Up @@ -76,6 +85,8 @@ export interface DataTableProps<T extends { id: string }> {
emptyMessage?: string;
/** When provided, renders a search field above the table */
search?: SearchConfig;
/** When provided, renders an export button at the end of the toolbar row above the table */
exportButton?: ExportConfig;
// Pagination
page?: number;
totalPages?: number;
Expand Down Expand Up @@ -257,6 +268,7 @@ function DataTable<T extends { id: string }>({
skeletonRows = 8,
emptyMessage,
search,
exportButton,
page,
totalPages,
onPageChange,
Expand Down Expand Up @@ -469,16 +481,38 @@ function DataTable<T extends { id: string }>({
// ── Render ────────────────────────────────────────────────────────────────
return (
<>
{search && (
<Box mb={theme.fixedSpacing(theme.tabiyaSpacing.sm)}>
<TextField
size="small"
placeholder={search.placeholder}
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
slotProps={{ htmlInput: { "aria-label": search.ariaLabel } }}
sx={{ width: 280 }}
/>
{(search || exportButton) && (
<Box
mb={theme.fixedSpacing(theme.tabiyaSpacing.sm)}
sx={{
display: "flex",
alignItems: "center",
gap: theme.fixedSpacing(theme.tabiyaSpacing.sm),
flexWrap: "wrap",
}}
>
{search && (
<TextField
size="small"
placeholder={search.placeholder}
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
slotProps={{ htmlInput: { "aria-label": search.ariaLabel } }}
sx={{ width: 280 }}
/>
)}
{exportButton && (
<Button
variant="contained"
size="small"
startIcon={<DownloadIcon />}
onClick={exportButton.onExport}
disabled={exportButton.disabled}
sx={{ ml: "auto" }}
>
{exportButton.label}
</Button>
)}
</Box>
)}
<TableContainer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import { Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import Papa from "papaparse";
import { useTranslation } from "react-i18next";
import { getModuleLabelKey, PLACEHOLDER_SYMBOL } from "src/constants";
import { useInstructorStudentsTableState, type StudentsSortKey } from "src/hooks/useInstructorStudentsTableState";
Expand All @@ -14,6 +15,23 @@ export interface InstructorStudentsTableProps {
onLoadMoreRows?: () => Promise<void>;
}

interface CsvColumn<T> {
header: string;
getValue: (row: T) => string | number | null | undefined;
}

const downloadCsvFile = (filename: string, csvContent: string): void => {
const blob = new Blob([`${csvContent}`], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};

const fracComplete = (s: string): boolean => {
const m = s.trim().match(/^(\d+)\s*\/\s*(\d+)$/);
return !!m && m[1] === m[2] && +m[2] > 0;
Expand Down Expand Up @@ -51,6 +69,7 @@ const InstructorStudentsTable: React.FC<InstructorStudentsTableProps> = ({
modules,
treatmentGroups,
filteredRows,
sortedRows,
pagedRows,
pageSize,
totalItems,
Expand All @@ -74,6 +93,64 @@ const InstructorStudentsTable: React.FC<InstructorStudentsTableProps> = ({
return key ? t(key) : PLACEHOLDER_SYMBOL;
};

const handleDownloadCsv = () => {
const csvColumns: CsvColumn<InstructorStudentRow>[] = [
{ header: t("instructorDashboard.studentsTable.headers.id"), getValue: (row) => row.id },
{ header: t("instructorDashboard.studentsTable.headers.studentName"), getValue: (row) => row.studentName },
{ header: t("instructorDashboard.studentsTable.headers.programme"), getValue: (row) => row.programme },
{
header: t("instructorDashboard.studentsTable.headers.qualificationType"),
getValue: (row) => row.qualificationType,
},
{ header: t("instructorDashboard.studentsTable.headers.year"), getValue: (row) => row.year },
{ header: t("instructorDashboard.studentsTable.headers.gender"), getValue: (row) => row.gender },
...(treatmentGroups.length > 0
? [
{
header: t("instructorDashboard.studentsTable.headers.treatmentGroup"),
getValue: (row: InstructorStudentRow) => row.treatmentGroup ?? "",
},
]
: []),
{ header: t("instructorDashboard.studentsTable.headers.lastLogin"), getValue: (row) => row.lastLogin },
{
header: t("instructorDashboard.studentsTable.headers.lastActiveModule"),
getValue: (row) => formatModuleLabel(row.lastActiveModuleId),
},
{
header: t("instructorDashboard.studentsTable.headers.careerReadinessStarted"),
getValue: (row) => row.careerReadinessStarted,
},
{
header: t("instructorDashboard.studentsTable.headers.careerReadinessCompleted"),
getValue: (row) => row.careerReadinessCompleted,
},
{
header: t("instructorDashboard.studentsTable.headers.skillsInterestsExplored"),
getValue: (row) => t(`instructorDashboard.studentsTable.statusLabels.${row.skillsDiscoveryStatus}`),
},
{
header: t("instructorDashboard.studentsTable.headers.careerExplorer"),
getValue: (row) =>
row.careerExplorerMessagesSent !== null && row.careerExplorerMessagesSent >= 2
? t("instructorDashboard.studentsTable.statusLabels.started")
: t("instructorDashboard.studentsTable.statusLabels.not_started"),
},
];

const csv = Papa.unparse({
fields: csvColumns.map((column) => column.header),
data: sortedRows.map((row) =>
csvColumns.map((column) => {
const value = column.getValue(row);
return value === null || value === undefined ? "" : String(value);
})
),
});
const date = new Date().toISOString().slice(0, 10);
downloadCsvFile(`students_export_${date}.csv`, csv);
};

const columns: ColumnDef<InstructorStudentRow>[] = [
{
key: "studentName",
Expand Down Expand Up @@ -272,6 +349,11 @@ const InstructorStudentsTable: React.FC<InstructorStudentsTableProps> = ({
value: nameSearch,
onChange: setNameSearch,
}}
exportButton={{
label: t("instructorDashboard.studentsTable.downloadCsv"),
onExport: handleDownloadCsv,
disabled: loading || sortedRows.length === 0,
}}
skeletonRows={8}
emptyMessage={filteredRows.length === 0 ? t("instructorDashboard.studentsTable.empty") : undefined}
ariaLabel={t("instructorDashboard.studentsTable.ariaLabel")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export function useInstructorStudentsTableState(
modules,
treatmentGroups,
filteredRows,
sortedRows,
pagedRows,
pageSize,
totalItems: sortedRows.length,
Expand Down
2 changes: 2 additions & 0 deletions admin-frontend/src/i18n/locales/en-GB/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,9 @@
"studentsTable": {
"ariaLabel": "Instructor students table",
"empty": "No students match the current filters.",
"downloadCsv": "Download CSV",
"headers": {
"id": "ID",
"studentName": "Student Name",
"programme": "Programme",
"qualificationType": "Qualification Type",
Expand Down
2 changes: 2 additions & 0 deletions admin-frontend/src/i18n/locales/en-US/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,9 @@
"studentsTable": {
"ariaLabel": "Instructor students table",
"empty": "No students match the current filters.",
"downloadCsv": "Download CSV",
"headers": {
"id": "ID",
"studentName": "Student Name",
"programme": "Programme",
"qualificationType": "Qualification Type",
Expand Down
2 changes: 2 additions & 0 deletions admin-frontend/src/i18n/locales/es-AR/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,9 @@
"studentsTable": {
"ariaLabel": "Mesa de alumnos instructores",
"empty": "Ningún estudiante coincide con los filtros actuales.",
"downloadCsv": "Descargar CSV",
"headers": {
"id": "ID",
"studentName": "Nombre del estudiante",
"programme": "Programa",
"qualificationType": "Tipo de Calificación",
Expand Down
2 changes: 2 additions & 0 deletions admin-frontend/src/i18n/locales/es-ES/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,9 @@
"studentsTable": {
"ariaLabel": "Mesa de alumnos instructores",
"empty": "Ningún estudiante coincide con los filtros actuales.",
"downloadCsv": "Descargar CSV",
"headers": {
"id": "ID",
"studentName": "Nombre del estudiante",
"programme": "Programa",
"qualificationType": "Tipo de Calificación",
Expand Down
2 changes: 2 additions & 0 deletions admin-frontend/src/i18n/locales/ny-ZM/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,9 @@
"studentsTable": {
"ariaLabel": "Gome la ophunzira a Mlangizi",
"empty": "Palibe ophunzira omwe akufanana ndi zosefera pano.",
"downloadCsv": "Tsitsani CSV",
"headers": {
"id": "ID",
"studentName": "Dzina la Wophunzira",
"programme": "Pulogalamu",
"qualificationType": "Mtundu wa Mayeso",
Expand Down
2 changes: 2 additions & 0 deletions admin-frontend/src/i18n/locales/pt-MZ/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,9 @@
"studentsTable": {
"ariaLabel": "Tabela de estudantes do instrutor",
"empty": "Nenhum estudante corresponde aos filtros atuais.",
"downloadCsv": "Descarregar CSV",
"headers": {
"id": "ID",
"studentName": "Nome do Estudante",
"programme": "Programa",
"qualificationType": "Tipo de Qualificação",
Expand Down
2 changes: 2 additions & 0 deletions admin-frontend/src/i18n/locales/sw-KE/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,9 @@
"studentsTable": {
"ariaLabel": "Jedwali la wanafunzi wa mwalimu",
"empty": "Hakuna wanafunzi wanaolingana na vichujio vya sasa.",
"downloadCsv": "Pakua CSV",
"headers": {
"id": "ID",
"studentName": "Jina la Mwanafunzi",
"programme": "Mpango",
"qualificationType": "Aina ya Sifa",
Expand Down
12 changes: 12 additions & 0 deletions admin-frontend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4809,6 +4809,13 @@
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901"
integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==

"@types/papaparse@^5.5.2":
version "5.5.2"
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.5.2.tgz#cb450a1cd183deb43728e593eb1ac2da60f4fa4d"
integrity sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==
dependencies:
"@types/node" "*"

"@types/parse-json@^4.0.0":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239"
Expand Down Expand Up @@ -12046,6 +12053,11 @@ pako@~0.2.0:
resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==

papaparse@^5.5.4:
version "5.5.4"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.5.4.tgz#7923f375a5d43850fd313160371c9567e5608c11"
integrity sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==

param-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"
Expand Down
Loading