From 8a7d3be0ac132e53da10003b9e76b00b4c9df8b9 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 20 May 2026 09:51:05 +0000 Subject: [PATCH 1/2] feat(table): add hierarchical column picker with tree group component - add ColumnPickerTreeGroup for nested parent/child column visibility - add ColumnPickerTemplate to TanStackTableBuilder for picker configuration - expand useTanStackTableBuilder with column picker state and toolbar hooks --- .../AssessmentsListing.tsx | 2 +- .../MaterialsListing.tsx | 2 +- .../VideosListing.tsx | 2 +- .../ItemsSelector/AchievementsSelector.tsx | 2 +- .../ItemsSelector/AssessmentsSelector.tsx | 2 +- .../ItemsSelector/MaterialsSelector.tsx | 2 +- .../ItemsSelector/SurveysSelector.tsx | 2 +- .../ItemsSelector/VideosSelector.tsx | 2 +- .../components/core}/IndentedCheckbox.tsx | 0 .../MuiTableAdapter/ColumnPickerTreeGroup.tsx | 52 ++ .../MuiTableAdapter/MuiColumnPickerPrompt.tsx | 121 +++ .../table/MuiTableAdapter/MuiTableToolbar.tsx | 92 +- .../TanStackTableBuilder/columnsBuilder.ts | 8 +- .../TanStackTableBuilder/csvGenerator.ts | 58 +- .../useTanStackTableBuilder.tsx | 181 +++- .../__tests__/ColumnPickerTreeGroup.test.tsx | 169 ++++ .../__tests__/MuiColumnPickerPrompt.test.tsx | 230 +++++ .../table/__tests__/MuiTableToolbar.test.tsx | 62 ++ .../table/__tests__/csvGenerator.test.ts | 202 +++++ .../useTanStackTableBuilder.test.tsx | 819 ++++++++++++++++++ .../app/lib/components/table/adapters/Body.ts | 4 + .../lib/components/table/adapters/Toolbar.ts | 11 + .../table/builder/ColumnPickerTemplate.ts | 54 ++ .../table/builder/ColumnTemplate.ts | 2 + .../components/table/builder/TableTemplate.ts | 2 + .../app/lib/components/table/builder/index.ts | 4 + client/app/lib/components/table/index.tsx | 7 +- client/locales/en.json | 18 + client/locales/ko.json | 18 + client/locales/zh.json | 18 + 30 files changed, 2083 insertions(+), 65 deletions(-) rename client/app/{bundles/course/duplication/components => lib/components/core}/IndentedCheckbox.tsx (100%) create mode 100644 client/app/lib/components/table/MuiTableAdapter/ColumnPickerTreeGroup.tsx create mode 100644 client/app/lib/components/table/MuiTableAdapter/MuiColumnPickerPrompt.tsx create mode 100644 client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx create mode 100644 client/app/lib/components/table/__tests__/MuiColumnPickerPrompt.test.tsx create mode 100644 client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx create mode 100644 client/app/lib/components/table/__tests__/csvGenerator.test.ts create mode 100644 client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx create mode 100644 client/app/lib/components/table/builder/ColumnPickerTemplate.ts diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx index 12f47b012e3..4799c28d3b3 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx @@ -2,7 +2,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, ListSubheader } from '@mui/material'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -12,6 +11,7 @@ import { DuplicationTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx index f2beac495af..8f44a3496ae 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx @@ -2,7 +2,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, ListSubheader } from '@mui/material'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { @@ -10,6 +9,7 @@ import { DuplicationMaterialData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx index 55610147699..3425d9bf1cb 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx @@ -2,7 +2,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, ListSubheader } from '@mui/material'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -11,6 +10,7 @@ import { DuplicationVideoTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx index 03a1b32fb3a..84a7e486043 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -11,6 +10,7 @@ import { actions } from 'course/duplication/store'; import { DuplicationAchievementData } from 'course/duplication/types'; import { getAchievementBadgeUrl } from 'course/helper/achievements'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import Thumbnail from 'lib/components/core/Thumbnail'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx index 3b4817e9590..0f0d78708b5 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { @@ -17,6 +16,7 @@ import { DuplicationTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx index 9d6c22a01ad..fd1d09852d8 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { actions } from 'course/duplication/store'; @@ -12,6 +11,7 @@ import { DuplicationMaterialData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx index 1c1a6a77068..fbd3cdebd03 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx @@ -3,13 +3,13 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { actions } from 'course/duplication/store'; import { DuplicationSurveyData } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx index 25ab8fa9505..fb010970d96 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -13,6 +12,7 @@ import { DuplicationVideoTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/components/IndentedCheckbox.tsx b/client/app/lib/components/core/IndentedCheckbox.tsx similarity index 100% rename from client/app/bundles/course/duplication/components/IndentedCheckbox.tsx rename to client/app/lib/components/core/IndentedCheckbox.tsx diff --git a/client/app/lib/components/table/MuiTableAdapter/ColumnPickerTreeGroup.tsx b/client/app/lib/components/table/MuiTableAdapter/ColumnPickerTreeGroup.tsx new file mode 100644 index 00000000000..daaea755ae8 --- /dev/null +++ b/client/app/lib/components/table/MuiTableAdapter/ColumnPickerTreeGroup.tsx @@ -0,0 +1,52 @@ +import { FC, ReactNode } from 'react'; + +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; + +import { ColumnPickerRenderContext } from '../builder'; + +interface ColumnPickerTreeGroupProps { + label: string; + /** All leaf column ids that belong to this group (used to derive parent state). */ + childIds: string[]; + context: ColumnPickerRenderContext; + /** Ids that are locked visible — parent checkbox is disabled when all children are locked. */ + locked?: string[]; + indentLevel?: number; + children: ReactNode; +} + +/** + * Renders a parent checkbox whose checked/indeterminate state mirrors its children's + * visibility, and whose onChange bulk-toggles all children via context.setManyVisible. + * Children are rendered below (not inline), giving a vertical tree layout. + */ +const ColumnPickerTreeGroup: FC = ({ + label, + childIds, + context, + locked = [], + indentLevel = 0, + children, +}) => { + const visibleCount = childIds.filter((id) => context.isVisible(id)).length; + const allVisible = childIds.length > 0 && visibleCount === childIds.length; + const someVisible = visibleCount > 0 && !allVisible; + const allLocked = + childIds.length > 0 && childIds.every((id) => locked.includes(id)); + + return ( +
+ context.setManyVisible(childIds, e.target.checked)} + /> +
{children}
+
+ ); +}; + +export default ColumnPickerTreeGroup; diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiColumnPickerPrompt.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiColumnPickerPrompt.tsx new file mode 100644 index 00000000000..cfcd90f6291 --- /dev/null +++ b/client/app/lib/components/table/MuiTableAdapter/MuiColumnPickerPrompt.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert } from '@mui/material'; + +import Prompt from 'lib/components/core/dialogs/Prompt'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { ColumnPickerTemplate } from '../builder'; + +const translations = defineMessages({ + defaultTitle: { + id: 'lib.components.table.MuiColumnPickerPrompt.defaultTitle', + defaultMessage: 'Select columns', + }, + apply: { + id: 'lib.components.table.MuiColumnPickerPrompt.apply', + defaultMessage: 'Apply to view', + }, + cancel: { + id: 'lib.components.table.MuiColumnPickerPrompt.cancel', + defaultMessage: 'Cancel', + }, +}); + +interface MuiColumnPickerPromptProps { + open: boolean; + onClose: () => void; + initialVisibility: Record; + locked?: string[]; + columnPicker: ColumnPickerTemplate; + commitColumnVisibility: (next: Record) => void; +} + +const enforceLockedLocal = ( + next: Record, + locked: string[] | undefined, +): Record => { + if (!locked || locked.length === 0) return next; + const enforced = { ...next }; + locked.forEach((id) => { + enforced[id] = true; + }); + return enforced; +}; + +const MuiColumnPickerPrompt = ({ + open, + onClose, + initialVisibility, + locked, + columnPicker, + commitColumnVisibility, +}: MuiColumnPickerPromptProps): JSX.Element => { + const { t } = useTranslation(); + const [staged, setStaged] = useState>(() => + enforceLockedLocal({ ...initialVisibility }, locked), + ); + + const dataColumnIds = columnPicker.dataColumnIds; + const hasDataColumns = + !dataColumnIds || + dataColumnIds.length === 0 || + dataColumnIds.some((id) => staged[id]); + + useEffect(() => { + if (open) { + setStaged(enforceLockedLocal({ ...initialVisibility }, locked)); + } + }, [open, initialVisibility, locked]); + + const context = { + isVisible: (id: string): boolean => staged[id] ?? false, + setVisible: (id: string, v: boolean): void => { + if (locked?.includes(id)) return; + setStaged((prev) => + Object.hasOwn(prev, id) ? { ...prev, [id]: v } : prev, + ); + }, + setManyVisible: (ids: string[], v: boolean): void => { + setStaged((prev) => { + const next = { ...prev }; + let changed = false; + ids.forEach((id) => { + if (!Object.hasOwn(next, id)) return; + if (locked?.includes(id)) return; + if (next[id] !== v) { + next[id] = v; + changed = true; + } + }); + return changed ? next : prev; + }); + }, + }; + + const commitAndClose = (): void => { + commitColumnVisibility(enforceLockedLocal(staged, locked)); + onClose(); + }; + + return ( + + {columnPicker.render(context)} + {!hasDataColumns && columnPicker.noDataColumnsHint && ( + + {columnPicker.noDataColumnsHint} + + )} + + ); +}; + +export default MuiColumnPickerPrompt; diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx index 174ad2de405..1e352fd26e7 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx @@ -1,23 +1,38 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; import { Download } from '@mui/icons-material'; -import { IconButton, Tooltip } from '@mui/material'; +import { Button, IconButton, Tooltip } from '@mui/material'; import SearchField from 'lib/components/core/fields/SearchField'; import useTranslation from 'lib/hooks/useTranslation'; import { ToolbarProps } from '../adapters'; +import MuiColumnPickerPrompt from './MuiColumnPickerPrompt'; import translations from './translations'; interface ToolbarContainerProps { children: React.ReactNode; } +const localTranslations = defineMessages({ + defaultPickerTrigger: { + id: 'lib.components.table.MuiTableToolbar.exportTrigger', + defaultMessage: 'Export…', + }, + defaultDirectExport: { + id: 'lib.components.table.MuiTableToolbar.directExport', + defaultMessage: 'Export', + }, +}); + const ToolbarContainer = ({ children }: ToolbarContainerProps): JSX.Element => (
{children}
); const MuiTableToolbar = (props: ToolbarProps): JSX.Element | null => { const { t } = useTranslation(); + const [pickerOpen, setPickerOpen] = useState(false); const renderAlternative = props.alternative?.when(); const renderNative = renderAlternative @@ -26,33 +41,76 @@ const MuiTableToolbar = (props: ToolbarProps): JSX.Element | null => { if (!renderAlternative && !renderNative) return null; + const triggerLabel = + props.columnPicker?.triggerLabel ?? + t(localTranslations.defaultPickerTrigger); + + const directExportLabel = + props.columnPicker?.directExportLabel ?? + t(localTranslations.defaultDirectExport); + return ( -
+
{renderNative && ( )} -
- {renderAlternative && props.alternative?.render()} - {renderNative && !renderAlternative && props.buttons} - - {renderNative && props.onDownloadCsv && ( - - - - - - )} -
+ {renderAlternative && props.alternative?.render()} + {renderNative && !renderAlternative && props.buttons} + + {renderNative && props.columnPicker && ( + + )} + + {renderNative && props.columnPicker && props.onDirectExport && ( + + + + + + )} + + {renderNative && props.onDownloadCsv && ( + + + + + + )}
+ + {props.columnPicker && props.commitColumnVisibility && ( + setPickerOpen(false)} + open={pickerOpen} + /> + )} ); }; diff --git a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts index 5fdf50fd16a..8ebb3127667 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts @@ -45,8 +45,12 @@ const buildTanStackColumns = ( (column) => ({ id: column.id, - accessorKey: column.of, - accessorFn: column.searchProps?.getValue, + ...(column.accessorFn !== undefined + ? { accessorFn: column.accessorFn } + : { + accessorKey: column.of, + accessorFn: column.searchProps?.getValue, + }), header: column.title, cell: ({ row: { original: datum } }) => column.cell(datum), enableSorting: Boolean(column.sortable), diff --git a/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts b/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts index 965a2c684e4..5c1fc8bf6cc 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts @@ -1,34 +1,60 @@ import { ReactNode } from 'react'; -import { Row } from '@tanstack/react-table'; +import { Column, Table } from '@tanstack/react-table'; import { unparse } from 'papaparse'; import { ColumnTemplate, Data } from '../builder'; interface CsvGenerator { - headers: string[]; - rows: () => Row[]; - getRealColumn: (index: number) => ColumnTemplate | undefined; + table: Table; + getRealColumn: (id: string) => ColumnTemplate | undefined; + getExtraHeaderRows?: (columnIds: string[]) => string[][]; + onlySelected?: boolean; } +const extractHeader = ( + col: Column, + realColumn: ColumnTemplate | undefined, +): string => { + const title = realColumn?.title; + if (typeof title === 'string') return title; + return realColumn?.id ?? col.id; +}; + const generateCsv = ( options: CsvGenerator, ): Promise => new Promise((resolve) => { - const rows = [options.headers]; + // Keep ONLY columns where the consumer explicitly set csvDownloadable === true. + // Columns with `csvDownloadable: undefined` or `false` are excluded (matches the + // original behaviour where `csvDownloadable ?? false` gated headers). + const leafColumns = options.table.getVisibleLeafColumns(); + const exportColumns = leafColumns.filter( + (col) => options.getRealColumn(col.id)?.csvDownloadable === true, + ); + + const headers = exportColumns.map((col) => + extractHeader(col, options.getRealColumn(col.id)), + ); + + const rows: string[][] = [headers]; - options.rows().forEach((row) => { - const rowData = row - .getAllCells() - .reduce((cells, cell, index) => { - const realColumn = options.getRealColumn(index); - const csvDownloadable = realColumn?.csvDownloadable; - if (!csvDownloadable) return cells; + if (options.getExtraHeaderRows) { + const extraRows = options.getExtraHeaderRows( + exportColumns.map((col) => col.id), + ); + extraRows.forEach((extraRow) => rows.push(extraRow)); + } - const value = cell.getValue() as ReactNode; - cells.push(realColumn.csvValue?.(value) ?? value?.toString() ?? ''); - return cells; - }, []); + const dataRows = options.onlySelected + ? options.table.getSelectedRowModel().rows + : options.table.getCoreRowModel().rows; + dataRows.forEach((row) => { + const rowData = exportColumns.map((col) => { + const realColumn = options.getRealColumn(col.id); + const value = row.getValue(col.id) as ReactNode; + return realColumn?.csvValue?.(value) ?? value?.toString() ?? ''; + }); rows.push(rowData); }); diff --git a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx index 16806f3445e..dc648e2f28e 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx +++ b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Cell, ColumnFiltersState, @@ -9,12 +9,17 @@ import { getSortedRowModel, Header, Row, + Updater, useReactTable, + VisibilityState, } from '@tanstack/react-table'; import isEmpty from 'lodash-es/isEmpty'; +import { getUserEntity } from 'bundles/users/selectors'; +import { useAppSelector } from 'lib/hooks/store'; + import { RowEqualityData, TableProps } from '../adapters'; -import { TableTemplate } from '../builder'; +import { ColumnTemplate, TableTemplate } from '../builder'; import { downloadCsv } from '../utils'; import buildTanStackColumns from './columnsBuilder'; @@ -30,6 +35,14 @@ type TanStackTableProps = TableProps< const useTanStackTableBuilder = ( props: TableTemplate, ): TanStackTableProps => { + const currentUserId = useAppSelector(getUserEntity).id; + // Namespace the caller's key by userId so two users on the same device + // don't share visibility preferences. Guard against userId=0 (not yet loaded). + const effectiveStorageKey = + props.columnPicker?.storageKey && currentUserId > 0 + ? `${currentUserId}:${props.columnPicker.storageKey}` + : undefined; + const [columns, getRealColumn] = buildTanStackColumns( props.columns, props.indexing?.rowSelectable, @@ -47,6 +60,91 @@ const useTanStackTableBuilder = ( pageIndex: props.pagination?.initialPageIndex ?? 0, }); + const initialVisibility = useMemo(() => { + let stored: VisibilityState | null = null; + if (effectiveStorageKey) { + try { + const raw = localStorage.getItem(effectiveStorageKey); + stored = raw ? (JSON.parse(raw) as VisibilityState) : null; + } catch { + stored = null; + } + } + return Object.fromEntries( + props.columns.map((c) => { + const id = c.id ?? (c.of as string); + const storedValue = stored?.[id]; + return [ + id, + storedValue !== undefined ? storedValue : c.defaultVisible ?? true, + ]; + }), + ); + }, []); + const [columnVisibility, setColumnVisibility] = + useState(initialVisibility); + + // Ref-based so enforceLocked is stable and never a changing useEffect dep. + const lockedRef = useRef(props.columnPicker?.locked); + lockedRef.current = props.columnPicker?.locked; + + const enforceLocked = useCallback( + (next: VisibilityState): VisibilityState => { + const locked = lockedRef.current; + if (!locked || locked.length === 0) return next; + const enforced = { ...next }; + locked.forEach((id) => { + enforced[id] = true; + }); + return enforced; + }, + [], + ); + + const safeSetVisibility = (updater: Updater): void => { + setColumnVisibility((prev) => { + const next = typeof updater === 'function' ? updater(prev) : updater; + return enforceLocked(next); + }); + }; + + useEffect(() => { + if (!effectiveStorageKey) return; + try { + localStorage.setItem( + effectiveStorageKey, + JSON.stringify(columnVisibility), + ); + } catch { + // setItem throws QuotaExceededError (storage full) or SecurityError (private + // browsing on some browsers). Persistence is best-effort; the current session + // is unaffected if it fails. + } + }, [columnVisibility, effectiveStorageKey]); + + // Reconcile when columns change (e.g. async-loaded gradebook assessments). + useEffect(() => { + setColumnVisibility((prev) => { + const currentIds = props.columns.map((c) => c.id ?? (c.of as string)); + const colMap = new Map( + props.columns.map((c) => [c.id ?? (c.of as string), c]), + ); + const next: VisibilityState = {}; + currentIds.forEach((id) => { + next[id] = Object.hasOwn(prev, id) + ? prev[id] + : colMap.get(id)?.defaultVisible ?? true; + }); + const enforced = enforceLocked(next); + // Return prev reference when nothing changed — prevents infinite re-render + // loop when columns/locked arrays are new references on every render. + const changed = + Object.keys(enforced).length !== Object.keys(prev).length || + Object.keys(enforced).some((k) => enforced[k] !== prev[k]); + return changed ? enforced : prev; + }); + }, [props.columns, enforceLocked]); + const resetPagination = (): void => setPagination((current) => ({ ...current, pageIndex: 0 })); @@ -83,7 +181,9 @@ const useTanStackTableBuilder = ( columnFilters, globalFilter: searchKeyword.trim(), pagination, + columnVisibility, }, + onColumnVisibilityChange: safeSetVisibility, initialState: { sorting: props.sort?.initially && [ { @@ -94,24 +194,40 @@ const useTanStackTableBuilder = ( }, }); - const generateAndDownloadCsv = async (): Promise => { - const headers = table.options.columns.reduce( - (acc, column, index) => { - const header = column.header || column.id; - if (header && (getRealColumn(index)?.csvDownloadable ?? false)) { - acc.push(header as string); - } - return acc; - }, - [], - ); + const getRealColumnById = (id: string): ColumnTemplate | undefined => { + // Use the position within getAllLeafColumns() as the index into getRealColumn. + // We cannot search table.options.columns by c.id (undefined for accessorKey-based columns), + // and we cannot use col.columnDef reference equality because TanStack's createColumn spreads + // the def ({ ...defaultColumn, ...columnDef }), so col.columnDef is never === the original. + // + // Why getAllLeafColumns() index === getRealColumn() index: + // table.options.columns (ColumnDef[]) + // → _getColumnDefs() returns it directly + // → getAllColumns() maps each def → Column, preserving order + // → getAllLeafColumns() flatMaps + applies _getOrderColumnsFn + // (identity when columnOrder state is empty — we never set it) + // NOTE: if user-reorderable columns are added, columnOrder state will be set and + // getAllLeafColumns() will no longer match getRealColumn() by position. At that point + // getRealColumnById must be rewritten to look up by id rather than position. + // getRealColumn is built by buildColumns, which maps built-array position → ColumnTemplate + // using the same table.options.columns as input in the same order. + // Both arrays share the same positional index, so getRealColumn(i) matches getAllLeafColumns()[i]. + // + // Visibility safety: getAllLeafColumns() includes hidden columns, so the index is stable + // regardless of columnVisibility state. getVisibleLeafColumns() would shift indices and + // break the mapping (the root cause of the bug fixed in PR #8226). + const index = table.getAllLeafColumns().findIndex((c) => c.id === id); + if (index === -1) return undefined; + return getRealColumn(index); + }; + const generateAndDownloadCsv = async (): Promise => { const csvData = await generateCsv({ - headers, - rows: () => table.getCoreRowModel().rows, - getRealColumn, + table, + getRealColumn: getRealColumnById, + getExtraHeaderRows: props.columnPicker?.getExtraHeaderRows, + onlySelected: !isEmpty(rowSelection), }); - downloadCsv(csvData, props.csvDownload?.filename); }; @@ -161,12 +277,17 @@ const useTanStackTableBuilder = ( body: { rows: table.getRowModel().rows, getCells: (row) => row.getVisibleCells(), - forEachCell: (cell, row, index) => ({ + // Use getRealColumnById (ID-based) not getRealColumn(index). getVisibleCells() skips hidden + // columns, so its positional index diverges from getRealColumn's full-column-list index + // whenever any column is hidden — the same misalignment fixed for CSV in PR #8226. + forEachCell: (cell, row) => ({ id: cell.id, render: customCellRender(cell), - className: getRealColumn(index)?.className, - colSpan: getRealColumn(index)?.colSpan?.(row.original), - shouldNotRender: getRealColumn(index)?.cellUnless?.(row.original), + className: getRealColumnById(cell.column.id)?.className, + colSpan: getRealColumnById(cell.column.id)?.colSpan?.(row.original), + shouldNotRender: getRealColumnById(cell.column.id)?.cellUnless?.( + row.original, + ), }), forEachRow: (row) => ({ id: row.id, @@ -178,6 +299,18 @@ const useTanStackTableBuilder = ( selected: rowSelection[row.id], })), }), + selectedCount: table.getSelectedRowModel().rows.length, + allFilteredSelected: + table.getFilteredRowModel().rows.length > 0 && + table.getFilteredRowModel().rows.every((r) => r.getIsSelected()), + someFilteredSelected: table + .getFilteredRowModel() + .rows.some((r) => r.getIsSelected()), + toggleAllFiltered: (): void => { + const filteredRows = table.getFilteredRowModel().rows; + const allSelected = filteredRows.every((r) => r.getIsSelected()); + filteredRows.forEach((r) => r.toggleSelected(!allSelected)); + }, }, handles: { getPaginationState: () => pagination, @@ -212,6 +345,12 @@ const useTanStackTableBuilder = ( csvDownloadLabel: props.csvDownload?.downloadButtonLabel, searchPlaceholder: props.search?.searchPlaceholder, buttons: props.toolbar?.buttons, + columnPicker: props.columnPicker, + getColumnVisibility: () => columnVisibility, + commitColumnVisibility: (next) => safeSetVisibility(() => next), + onDirectExport: props.columnPicker + ? (): Promise => generateAndDownloadCsv() + : undefined, }, }; }; diff --git a/client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx b/client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx new file mode 100644 index 00000000000..bcf71eecd68 --- /dev/null +++ b/client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx @@ -0,0 +1,169 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { ColumnPickerRenderContext } from '../builder'; +import ColumnPickerTreeGroup from '../MuiTableAdapter/ColumnPickerTreeGroup'; + +const makeCtx = ( + visible: Record, +): ColumnPickerRenderContext & { setManyVisible: jest.Mock } => ({ + isVisible: (id) => visible[id] ?? false, + setVisible: jest.fn(), + setManyVisible: jest.fn(), +}); + +describe('ColumnPickerTreeGroup', () => { + describe('parent checkbox state mirrors children visibility', () => { + it('is checked when all children are visible', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect(screen.getByRole('checkbox', { name: 'Group' })).toBeChecked(); + }); + + it('is unchecked and not indeterminate when no children are visible', () => { + const ctx = makeCtx({ a: false, b: false }); + render( + + + , + ); + const checkbox = screen.getByRole('checkbox', { name: 'Group' }); + expect(checkbox).not.toBeChecked(); + expect(checkbox.getAttribute('data-indeterminate')).toBe('false'); + }); + + it('is indeterminate and not checked when some but not all children are visible', () => { + const ctx = makeCtx({ a: true, b: false }); + render( + + + , + ); + const checkbox = screen.getByRole('checkbox', { name: 'Group' }); + expect(checkbox).not.toBeChecked(); + expect(checkbox.getAttribute('data-indeterminate')).toBe('true'); + }); + }); + + describe('cascading toggle', () => { + it('calls setManyVisible(childIds, true) when parent is clicked while unchecked', () => { + const ctx = makeCtx({ a: false, b: false }); + render( + + + , + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Group' })); + expect(ctx.setManyVisible).toHaveBeenCalledWith(['a', 'b'], true); + }); + + it('calls setManyVisible(childIds, false) when parent is clicked while checked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Group' })); + expect(ctx.setManyVisible).toHaveBeenCalledWith(['a', 'b'], false); + }); + + it('calls setManyVisible(childIds, true) when parent is clicked while indeterminate', () => { + const ctx = makeCtx({ a: true, b: false }); + render( + + + , + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Group' })); + expect(ctx.setManyVisible).toHaveBeenCalledWith(['a', 'b'], true); + }); + }); + + describe('locked behavior', () => { + it('disables the parent checkbox when all children are locked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect(screen.getByRole('checkbox', { name: 'Group' })).toBeDisabled(); + }); + + it('does not disable the parent when only some children are locked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect( + screen.getByRole('checkbox', { name: 'Group' }), + ).not.toBeDisabled(); + }); + + it('does not disable the parent when no children are locked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect( + screen.getByRole('checkbox', { name: 'Group' }), + ).not.toBeDisabled(); + }); + }); + + it('renders children below the parent checkbox', () => { + const ctx = makeCtx({}); + render( + + Child + , + ); + expect(screen.getByTestId('child-node')).toBeInTheDocument(); + }); +}); diff --git a/client/app/lib/components/table/__tests__/MuiColumnPickerPrompt.test.tsx b/client/app/lib/components/table/__tests__/MuiColumnPickerPrompt.test.tsx new file mode 100644 index 00000000000..4acc32fa13c --- /dev/null +++ b/client/app/lib/components/table/__tests__/MuiColumnPickerPrompt.test.tsx @@ -0,0 +1,230 @@ +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ColumnPickerRenderContext } from '../builder'; +import MuiColumnPickerPrompt from '../MuiTableAdapter/MuiColumnPickerPrompt'; + +const TITLE = 'Select columns'; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +const makeRender = (ids: readonly string[]): jest.Mock => + jest.fn((ctx: ColumnPickerRenderContext) => ( + <> + {ids.map((id) => ( + + ))} + + )); + +const setup = ( + overrides: Partial> = {}, +): ReturnType & { + commitColumnVisibility: jest.Mock; + props: React.ComponentProps; +} => { + const commitColumnVisibility = jest.fn(); + const props = { + open: true, + onClose: jest.fn(), + initialVisibility: { name: true, email: true }, + locked: ['name'], + columnPicker: { + render: makeRender(['name', 'email']), + dialogTitle: TITLE, + }, + commitColumnVisibility, + ...overrides, + }; + return { + ...render(wrap()), + commitColumnVisibility, + props, + }; +}; + +describe('MuiColumnPickerPrompt', () => { + it('renders the title', () => { + setup(); + expect(screen.getByText(TITLE)).toBeInTheDocument(); + }); + + it('Apply commits staged changes and closes', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility, props } = setup(); + await user.click(screen.getByLabelText('email')); + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: false, + }); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('Cancel discards staged and closes without commit', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility, props } = setup(); + await user.click(screen.getByLabelText('email')); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(commitColumnVisibility).not.toHaveBeenCalled(); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('locked id forcibly restored to true on commit even if staged false', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility } = setup({ + initialVisibility: { name: false, email: true }, // malformed input + }); + + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: true, + }); + }); + + describe('locked column behavior', () => { + const makeGroupRender = (ids: readonly string[]): jest.Mock => + jest.fn( + (ctx: ColumnPickerRenderContext): JSX.Element => ( + <> + + + + ), + ); + + it('deselect-all leaves the locked column checked', async () => { + const user = userEvent.setup(); + const commitColumnVisibility = jest.fn(); + render( + wrap( + , + ), + ); + await user.click(screen.getByRole('button', { name: 'Deselect all' })); + await user.click(screen.getByRole('button', { name: /apply/i })); + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: false, + }); + }); + + it('select-all from indeterminate state selects non-locked column', async () => { + const user = userEvent.setup(); + const commitColumnVisibility = jest.fn(); + render( + wrap( + , + ), + ); + await user.click(screen.getByRole('button', { name: 'Select all' })); + await user.click(screen.getByRole('button', { name: /apply/i })); + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: true, + }); + }); + + it('clicking a locked column checkbox has no effect on its visibility', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility } = setup(); + await user.click(screen.getByLabelText('name')); + await user.click(screen.getByRole('button', { name: /apply/i })); + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: true, + }); + }); + }); + + it('Esc key dismisses without committing', () => { + const { commitColumnVisibility, props } = setup(); + fireEvent.keyDown(screen.getByRole('dialog'), { + key: 'Escape', + code: 'Escape', + }); + expect(props.onClose).toHaveBeenCalled(); + expect(commitColumnVisibility).not.toHaveBeenCalled(); + }); + + describe('noDataColumnsHint', () => { + const dataSetup = ( + dataColumnIds: string[], + initialVisibility: Record, + ): ReturnType => + setup({ + initialVisibility, + columnPicker: { + render: makeRender(['name', 'grade']), + dialogTitle: TITLE, + dataColumnIds, + noDataColumnsHint: 'No grade columns selected.', + }, + }); + + it('shows hint when no data columns are selected', () => { + dataSetup(['grade'], { name: true, grade: false }); + expect( + screen.getByText('No grade columns selected.'), + ).toBeInTheDocument(); + }); + + it('hides hint when at least one data column is selected', () => { + dataSetup(['grade'], { name: true, grade: true }); + expect( + screen.queryByText('No grade columns selected.'), + ).not.toBeInTheDocument(); + }); + + it('Apply button is enabled even when no data columns are selected', () => { + dataSetup(['grade'], { name: true, grade: false }); + expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled(); + }); + }); +}); diff --git a/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx b/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx new file mode 100644 index 00000000000..e8ef504f02c --- /dev/null +++ b/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx @@ -0,0 +1,62 @@ +import { IntlProvider } from 'react-intl'; +import { render, screen } from '@testing-library/react'; + +import { ToolbarProps } from '../adapters'; +import MuiTableToolbar from '../MuiTableAdapter/MuiTableToolbar'; + +const baseToolbar: ToolbarProps = { + renderNative: true, + searchKeyword: '', + onSearchKeywordChange: () => {}, +}; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +describe('MuiTableToolbar columnPicker trigger', () => { + it('does not render Export… button when columnPicker is unset', () => { + render(wrap()); + expect( + screen.queryByRole('button', { name: /export/i }), + ).not.toBeInTheDocument(); + }); + + it('renders Export… button when columnPicker is set', () => { + const props: ToolbarProps = { + ...baseToolbar, + columnPicker: { + render: () => null, + triggerLabel: 'Export…', + }, + getColumnVisibility: () => ({}), + commitColumnVisibility: () => {}, + }; + render(wrap()); + expect( + screen.getByRole('button', { name: /export…/i }), + ).toBeInTheDocument(); + }); +}); + +describe('MuiTableToolbar direct export button', () => { + const directExportProps: ToolbarProps = { + ...baseToolbar, + columnPicker: { + render: () => null, + directExportLabel: 'Export all rows', + }, + getColumnVisibility: () => ({}), + commitColumnVisibility: () => {}, + onDirectExport: async () => {}, + }; + + it('direct export button is enabled by default', () => { + render(wrap()); + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).not.toBeDisabled(); + }); +}); diff --git a/client/app/lib/components/table/__tests__/csvGenerator.test.ts b/client/app/lib/components/table/__tests__/csvGenerator.test.ts new file mode 100644 index 00000000000..c97b085e911 --- /dev/null +++ b/client/app/lib/components/table/__tests__/csvGenerator.test.ts @@ -0,0 +1,202 @@ +import { + ColumnDef, + getCoreRowModel, + Table, + useReactTable, +} from '@tanstack/react-table'; +import { renderHook } from '@testing-library/react'; + +import { ColumnTemplate } from '../builder'; +import generateCsv from '../TanStackTableBuilder/csvGenerator'; + +interface Row { + id: number; + name: string; + email: string; + score: number; +} + +const fixture: Row[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com', score: 90 }, + { id: 2, name: 'Bob', email: 'bob@example.com', score: 80 }, +]; + +const buildHarness = ( + visibility: Record, +): { + table: Table; + getRealColumn: (id: string) => ColumnTemplate | undefined; +} => { + const templates: Record> = { + name: { + id: 'name', + title: 'Name', + cell: (r) => r.name, + csvDownloadable: true, + }, + email: { + id: 'email', + title: 'Email', + cell: (r) => r.email, + csvDownloadable: true, + }, + score: { + id: 'score', + title: 'Score', + cell: (r) => r.score, + csvDownloadable: false, + }, + }; + + const columnDefs: ColumnDef[] = Object.values(templates).map( + (tpl) => ({ + id: tpl.id, + header: tpl.title as string, + accessorFn: (row) => + (row as unknown as Record)[tpl.id as string], + cell: ({ row: { original } }) => tpl.cell(original), + }), + ); + + const { result } = renderHook(() => + useReactTable({ + data: fixture, + columns: columnDefs, + getCoreRowModel: getCoreRowModel(), + state: { columnVisibility: visibility }, + onColumnVisibilityChange: () => {}, + }), + ); + + return { + table: result.current, + getRealColumn: (id: string) => templates[id], + }; +}; + +describe('csvGenerator', () => { + it('emits headers and rows ordered by visible csv-downloadable columns', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: true, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); // score has csvDownloadable: false + expect(lines[1]).toBe('Alice,alice@example.com'); + expect(lines[2]).toBe('Bob,bob@example.com'); + }); + + it('excludes hidden columns from output', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: false, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name'); + expect(lines[1]).toBe('Alice'); + expect(lines[2]).toBe('Bob'); + }); + + it('row cell count always equals header count', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: false, + score: true, + }); + + const csv = await generateCsv({ table, getRealColumn }); + + const lines = csv.trim().split(/\r?\n/); + const headerCount = lines[0].split(',').length; + lines + .slice(1) + .forEach((row) => expect(row.split(',')).toHaveLength(headerCount)); + }); + + describe('getExtraHeaderRows', () => { + it('inserts extra rows between the header row and data rows', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: true, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + getExtraHeaderRows: () => [['Extra A', 'Extra B']], + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toBe('Extra A,Extra B'); + expect(lines[2]).toBe('Alice,alice@example.com'); + }); + + it('is called with the visible csvDownloadable column ids', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: false, + score: true, + }); + const getExtraHeaderRows = jest.fn(() => []); + + await generateCsv({ table, getRealColumn, getExtraHeaderRows }); + + // email is hidden; score has csvDownloadable: false — only 'name' remains + expect(getExtraHeaderRows).toHaveBeenCalledWith(['name']); + }); + + it('supports multiple extra rows', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: true, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + getExtraHeaderRows: () => [ + ['Row1A', 'Row1B'], + ['Row2A', 'Row2B'], + ], + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toBe('Row1A,Row1B'); + expect(lines[2]).toBe('Row2A,Row2B'); + expect(lines[3]).toBe('Alice,alice@example.com'); + }); + }); + + it('respects csvValue override', async () => { + const { getRealColumn: baseGet, table } = buildHarness({ + name: true, + email: true, + score: true, + }); + const wrapped = (id: string): ColumnTemplate | undefined => + id === 'name' + ? { ...baseGet('name')!, csvValue: (v: unknown) => `<<${String(v)}>>` } + : baseGet(id); + + const csv = await generateCsv({ table, getRealColumn: wrapped }); + expect(csv).toContain('<>'); + }); +}); diff --git a/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx b/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx new file mode 100644 index 00000000000..b8899dbf676 --- /dev/null +++ b/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx @@ -0,0 +1,819 @@ +import { FC, JSX, ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { act, renderHook, type RenderHookResult } from '@testing-library/react'; +import { setUpStoreWithState, store as appStore } from 'store'; +import { downloadFile } from 'utilities/downloadFile'; + +import { ColumnTemplate, TableTemplate } from '../builder'; +import useTanStackTableBuilder from '../TanStackTableBuilder'; + +jest.mock('utilities/downloadFile', () => ({ + downloadFile: jest.fn(), +})); + +const mockedDownloadFile = jest.mocked(downloadFile); + +interface Row { + id: number; + name: string; + email: string; +} + +const baseColumns: ColumnTemplate[] = [ + { id: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { id: 'email', title: 'Email', cell: (r) => r.email, csvDownloadable: true }, +]; + +const baseProps = ( + overrides: Partial> = {}, +): TableTemplate => ({ + data: [{ id: 1, name: 'Alice', email: 'alice@example.com' }], + columns: baseColumns, + getRowId: (r) => r.id.toString(), + ...overrides, +}); + +// Wraps renderHook with the given store. Defaults to the global appStore +// (userId=0, no localStorage scoping) for tests that don't exercise persistence. +const withStore = (store = appStore): FC<{ children: ReactNode }> => + Object.assign( + ({ children }: { children: ReactNode }): JSX.Element => ( + {children} + ), + { displayName: 'WithStoreWrapper' }, + ); + +// Creates a store pre-loaded with a specific userId for localStorage isolation tests. +const storeForUser = (userId: number): typeof appStore => + setUpStoreWithState({ + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: userId, name: '', imageUrl: '' }, + }, + }, + }); + +describe('useTanStackTableBuilder columnPicker state', () => { + it('initial visibility marks every column visible', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + render: () => null, + }, + }), + ), + { wrapper: withStore() }, + ); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + }); + }); + + it('locked id cannot be set to false via setVisible', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + render: () => null, + locked: ['name'], + }, + }), + ), + { wrapper: withStore() }, + ); + + const commit = result.current.toolbar!.commitColumnVisibility!; + act(() => commit({ name: false, email: true })); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, // forced back to true + email: true, + }); + }); + + it('setManyVisible toggles only unlocked descendants', () => { + // This test exercises the contract used by BulkSelectors in PR2 callers: + // when a branch deselects, locked descendants must remain visible. + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + render: () => null, + locked: ['name'], + }, + }), + ), + { wrapper: withStore() }, + ); + + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: false, + email: false, + }), + ); + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: false, + }); + }); + + it('dynamic columns: adding a new column with defaultVisible: false defaults it hidden', () => { + const { result, rerender } = renderHook( + ({ extra }: { extra: boolean }) => + useTanStackTableBuilder( + baseProps({ + columns: extra + ? [ + ...baseColumns, + { + id: 'phone', + title: 'Phone', + cell: (): string => '', + csvDownloadable: true, + defaultVisible: false, + }, + ] + : baseColumns, + columnPicker: { + render: () => null, + }, + }), + ), + { initialProps: { extra: false }, wrapper: withStore() }, + ); + + rerender({ extra: true }); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + phone: false, + }); + }); + + it('dynamic columns: adding a new column id after mount defaults it visible', () => { + const { result, rerender } = renderHook( + ({ extra }: { extra: boolean }) => + useTanStackTableBuilder( + baseProps({ + columns: extra + ? [ + ...baseColumns, + { + id: 'phone', + title: 'Phone', + cell: (): string => '', + csvDownloadable: true, + }, + ] + : baseColumns, + columnPicker: { render: () => null }, + }), + ), + { initialProps: { extra: false }, wrapper: withStore() }, + ); + + expect( + Object.keys(result.current.toolbar!.getColumnVisibility?.() ?? {}), + ).toEqual(['name', 'email']); + + rerender({ extra: true }); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + phone: true, // new column defaults visible + }); + }); +}); + +// CSV tests use `of:` (accessorKey) so TanStack can extract values via row.getValue(). +// The student statistics table uses the same `of:` pattern. +const csvColumns: ColumnTemplate[] = [ + { of: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { of: 'email', title: 'Email', cell: (r) => r.email, csvDownloadable: true }, +]; + +describe('useTanStackTableBuilder CSV download', () => { + beforeEach(() => { + mockedDownloadFile.mockClear(); + }); + + it('CSV contains headers and rows for all csvDownloadable columns', async () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + csvDownload: { filename: 'test' }, + }), + ), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toContain('Alice'); + }); + + it('CSV with indices: true still maps columns correctly (student statistics pattern)', async () => { + // Student statistics sets indexing.indices: true, which prepends an index column + // at position 0 in getAllLeafColumns(). getRealColumnById must offset correctly. + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + csvDownload: { filename: 'test' }, + indexing: { indices: true }, + }), + ), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + // Headers must be Name and Email (not blank or offset titles from wrong template lookup) + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toContain('Alice'); + }); + + it('CSV columns using accessorFn (not of) emit correct values', async () => { + // Regression: assessment columns have no `of` key — they use accessorFn to + // expose the grade value. row.getValue() must return the fn result, not undefined. + interface ScoreRow { + id: number; + name: string; + grades: Record; + } + const scoreData: ScoreRow[] = [ + { id: 1, name: 'Alice', grades: { 42: 9 } }, + { id: 2, name: 'Bob', grades: { 42: null } }, + ]; + const scoreColumns: ColumnTemplate[] = [ + { of: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { + id: 'asn_42', + title: 'Quiz', + accessorFn: (r) => r.grades[42], + cell: (r) => r.grades[42] ?? '—', + csvDownloadable: true, + }, + ]; + const { result } = renderHook( + () => + useTanStackTableBuilder({ + data: scoreData, + columns: scoreColumns, + getRowId: (r) => r.id.toString(), + csvDownload: { filename: 'test' }, + }), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Quiz'); + expect(lines[1]).toBe('Alice,9'); + expect(lines[2]).toBe('Bob,'); + }); + + it('columnPicker getExtraHeaderRows inserts extra rows after the header', async () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + columnPicker: { + render: () => null, + getExtraHeaderRows: (colIds) => [colIds.map(() => 'max')], + }, + }), + ), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toBe('max,max'); + expect(lines[2]).toContain('Alice'); + }); + + it('exports only selected rows when rows are selected', async () => { + const twoRowData = [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ]; + const { result } = renderHook( + () => + useTanStackTableBuilder({ + data: twoRowData, + columns: csvColumns, + getRowId: (r) => r.id.toString(), + indexing: { rowSelectable: true }, + columnPicker: { + render: () => null, + }, + }), + { wrapper: withStore() }, + ); + + // Select only Alice (row index 0) + act(() => result.current.body.rows[0].toggleSelected()); + + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines).toHaveLength(2); // header + Alice only + expect(lines[1]).toContain('Alice'); + expect(csv).not.toContain('Bob'); + }); + + it('CSV excludes columns where csvDownloadable is false', async () => { + const columns: ColumnTemplate[] = [ + { of: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { + of: 'email', + title: 'Email', + cell: (r) => r.email, + csvDownloadable: false, + }, + ]; + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ columns, csvDownload: { filename: 'test' } }), + ), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name'); + expect(lines[0]).not.toContain('Email'); + }); +}); + +// ---------- columnVisibility alignment regression (PR #8226) ---------- +// +// Root cause: getVisibleLeafColumns() / getVisibleCells() skips hidden columns, +// so its positional index diverges from getRealColumn()'s full-column-list index. +// Both the CSV path (csvGenerator) and the cell render path (forEachCell) must +// use getRealColumnById (ID-based) to stay stable when columns are hidden. +// +// Each scenario is tested on BOTH paths. A test would fail on the relevant path +// if it regressed to positional index. + +interface ThreeColRow { + id: number; + name: string; + email: string; + phone: string; +} + +const threeColData: ThreeColRow[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com', phone: '111' }, +]; + +const threeColCsvColumns: ColumnTemplate[] = [ + { + of: 'name', + title: 'Name', + cell: (r) => r.name, + csvDownloadable: true, + className: 'col-name', + }, + { + of: 'email', + title: 'Email', + cell: (r) => r.email, + csvDownloadable: true, + className: 'col-email', + }, + { + of: 'phone', + title: 'Phone', + cell: (r) => r.phone, + csvDownloadable: true, + className: 'col-phone', + }, +]; + +describe('columnVisibility alignment — hiding a middle column', () => { + let result: RenderHookResult< + ReturnType>, + TableTemplate + >['result']; + + beforeEach(() => { + mockedDownloadFile.mockClear(); + ({ result } = renderHook( + () => + useTanStackTableBuilder({ + data: threeColData, + columns: threeColCsvColumns, + getRowId: (r) => r.id.toString(), + columnPicker: { render: () => null }, + }), + { wrapper: withStore() }, + )); + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: true, + email: false, + phone: true, + }), + ); + }); + + it('CSV: remaining columns have correct headers and data', async () => { + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Phone'); + expect(lines[1]).toBe('Alice,111'); + }); + + it('forEachCell: remaining visible cells have correct className', () => { + const row = result.current.body.rows[0]; + const cells = result.current.body.getCells(row); + expect(cells).toHaveLength(2); // email hidden + const renders = cells.map((cell, i) => + result.current.body.forEachCell(cell, row, i), + ); + expect(renders[0].className).toBe('col-name'); + expect(renders[1].className).toBe('col-phone'); // not 'col-email' + }); +}); + +describe('columnVisibility alignment — indices: true + hidden column', () => { + let result: RenderHookResult< + ReturnType>, + TableTemplate + >['result']; + + beforeEach(() => { + mockedDownloadFile.mockClear(); + ({ result } = renderHook( + () => + useTanStackTableBuilder({ + data: threeColData, + columns: threeColCsvColumns, + getRowId: (r) => r.id.toString(), + indexing: { indices: true }, + columnPicker: { render: () => null }, + }), + { wrapper: withStore() }, + )); + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: false, + email: true, + phone: true, + }), + ); + }); + + it('CSV: remaining columns have correct headers and data', async () => { + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Email,Phone'); + expect(lines[1]).toBe('alice@example.com,111'); + }); + + it('forEachCell: remaining visible cells have correct className', () => { + const row = result.current.body.rows[0]; + const cells = result.current.body.getCells(row); + // index col (no template) + email + phone visible; name hidden + const userCells = cells.filter((c) => c.column.id !== 'index'); + const renders = userCells.map((cell, i) => + result.current.body.forEachCell(cell, row, i), + ); + expect(renders[0].className).toBe('col-email'); // not 'col-name' + expect(renders[1].className).toBe('col-phone'); + }); +}); + +// ---------- cross-page row selection ---------- + +const threeRowData: Row[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + { id: 3, name: 'Carol', email: 'carol@example.com' }, +]; + +describe('cross-page row selection', () => { + const selectionProps = (): TableTemplate => + baseProps({ + data: threeRowData, + indexing: { rowSelectable: true }, + pagination: { rowsPerPage: [2] }, + }); + + it('body.selectedCount is 0 when nothing is selected', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + expect(result.current.body.selectedCount).toBe(0); + }); + + it('body.selectedCount increments when a row on the current page is selected', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + act(() => result.current.body.rows[0].toggleSelected()); + expect(result.current.body.selectedCount).toBe(1); + }); + + it('body.selectedCount persists after navigating away from the page where the selection was made', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + // Page 1: Alice (id 1) and Bob (id 2) + act(() => result.current.body.rows[0].toggleSelected()); // select Alice + expect(result.current.body.selectedCount).toBe(1); + + // Navigate to page 2: Carol (id 3) only + act(() => result.current.pagination!.onPageChange?.(1)); + expect(result.current.body.rows).toHaveLength(1); // only Carol visible + expect(result.current.body.selectedCount).toBe(1); // Alice still counted + }); + + it('toggleAllFiltered selects all rows across all pages', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + act(() => result.current.body.toggleAllFiltered?.()); + expect(result.current.body.selectedCount).toBe(3); + expect(result.current.body.allFilteredSelected).toBe(true); + }); + + it('someFilteredSelected is true when only some rows are selected', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + act(() => result.current.body.rows[0].toggleSelected()); // Alice only + expect(result.current.body.someFilteredSelected).toBe(true); + expect(result.current.body.allFilteredSelected).toBe(false); + }); + + it('toggleAllFiltered twice deselects all rows', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + act(() => result.current.body.toggleAllFiltered?.()); // select all + act(() => result.current.body.toggleAllFiltered?.()); // deselect all + expect(result.current.body.selectedCount).toBe(0); + expect(result.current.body.allFilteredSelected).toBe(false); + }); +}); + +describe('localStorage persistence', () => { + beforeEach(() => localStorage.clear()); + + it('reads initial visibility from the user-scoped key', () => { + localStorage.setItem( + '42:test_key', + JSON.stringify({ name: false, email: true }), + ); + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null, storageKey: 'test_key' }, + }), + ), + { wrapper: withStore(storeForUser(42)) }, + ); + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: false, + email: true, + }); + }); + + it('writes visibility to the user-scoped key on change', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null, storageKey: 'test_key' }, + }), + ), + { wrapper: withStore(storeForUser(42)) }, + ); + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: false, + email: true, + }), + ); + expect(JSON.parse(localStorage.getItem('42:test_key')!)).toMatchObject({ + name: false, + email: true, + }); + // Unsecoped key must not be written + expect(localStorage.getItem('test_key')).toBeNull(); + }); + + it('falls back to defaultVisible when the user-scoped key has no entry', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columns: [ + baseColumns[0], + { ...baseColumns[1], defaultVisible: false }, + ], + columnPicker: { render: () => null, storageKey: 'missing_key' }, + }), + ), + { wrapper: withStore(storeForUser(42)) }, + ); + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: false, + }); + }); + + it('does not read or write localStorage when userId is 0 (not yet loaded)', () => { + // Pre-populate a 0-prefixed key to prove it is not read on mount. + const sentinel = JSON.stringify({ name: false, email: false }); + localStorage.setItem('0:test_key', sentinel); + + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null, storageKey: 'test_key' }, + }), + ), + { wrapper: withStore() }, // appStore has userId=0 + ); + // 0-prefixed key is ignored; visibility falls back to defaultVisible (true) + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + }); + + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: true, + email: false, + }), + ); + // The 0-prefixed key is untouched (still holds the original sentinel value) + expect(localStorage.getItem('0:test_key')).toBe(sentinel); + // The unscoped key was never written + expect(localStorage.getItem('test_key')).toBeNull(); + }); + + it('two users on the same device have independent visibility preferences', () => { + localStorage.setItem( + '1:shared_key', + JSON.stringify({ name: false, email: true }), + ); + localStorage.setItem( + '2:shared_key', + JSON.stringify({ name: true, email: false }), + ); + + const { result: result1 } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null, storageKey: 'shared_key' }, + }), + ), + { wrapper: withStore(storeForUser(1)) }, + ); + const { result: result2 } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null, storageKey: 'shared_key' }, + }), + ), + { wrapper: withStore(storeForUser(2)) }, + ); + + expect(result1.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: false, + email: true, + }); + expect(result2.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: false, + }); + }); +}); + +describe('useTanStackTableBuilder onDirectExport', () => { + beforeEach(() => { + mockedDownloadFile.mockClear(); + }); + + it('toolbar.onDirectExport is defined when columnPicker is provided', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null }, + }), + ), + { wrapper: withStore() }, + ); + expect(result.current.toolbar!.onDirectExport).toBeDefined(); + }); + + it('toolbar.onDirectExport is undefined when no columnPicker is provided', () => { + const { result } = renderHook(() => useTanStackTableBuilder(baseProps()), { + wrapper: withStore(), + }); + expect(result.current.toolbar!.onDirectExport).toBeUndefined(); + }); + + it('toolbar.onDirectExport downloads CSV using committed column visibility', async () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + csvDownload: { filename: 'my_gradebook' }, + columnPicker: { render: () => null }, + }), + ), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toContain('Alice'); + }); +}); diff --git a/client/app/lib/components/table/adapters/Body.ts b/client/app/lib/components/table/adapters/Body.ts index 4230762eb4f..955602d0dd9 100644 --- a/client/app/lib/components/table/adapters/Body.ts +++ b/client/app/lib/components/table/adapters/Body.ts @@ -26,6 +26,10 @@ interface BodyProps { getCells: (row: B) => C[]; forEachCell: (cell: C, row: B, index: number) => CellRender; forEachRow: (row: B, index: number) => RowRender; + selectedCount?: number; + allFilteredSelected?: boolean; + someFilteredSelected?: boolean; + toggleAllFiltered?: () => void; } export default BodyProps; diff --git a/client/app/lib/components/table/adapters/Toolbar.ts b/client/app/lib/components/table/adapters/Toolbar.ts index fe8fad0e38a..0091606cd40 100644 --- a/client/app/lib/components/table/adapters/Toolbar.ts +++ b/client/app/lib/components/table/adapters/Toolbar.ts @@ -1,5 +1,7 @@ import { ReactNode } from 'react'; +import { ColumnPickerTemplate } from '../builder'; + interface ToolbarProps { renderNative?: boolean; alternative?: { @@ -13,6 +15,15 @@ interface ToolbarProps { csvDownloadLabel?: string; searchPlaceholder?: string; buttons?: ReactNode[]; + + /** Set when consumer passes `columnPicker` on TableTemplate. Drives Export… button + dialog. */ + columnPicker?: ColumnPickerTemplate; + /** Read-side accessor — called by the dialog to seed staged state. */ + getColumnVisibility?: () => Record; + /** Commit-side updater — called by the dialog on Apply. */ + commitColumnVisibility?: (next: Record) => void; + /** Export with current visibility (no picker dialog). */ + onDirectExport?: () => Promise; } export default ToolbarProps; diff --git a/client/app/lib/components/table/builder/ColumnPickerTemplate.ts b/client/app/lib/components/table/builder/ColumnPickerTemplate.ts new file mode 100644 index 00000000000..9fc78e76923 --- /dev/null +++ b/client/app/lib/components/table/builder/ColumnPickerTemplate.ts @@ -0,0 +1,54 @@ +import { ReactNode } from 'react'; + +export interface ColumnPickerRenderContext { + isVisible: (columnId: string) => boolean; + setVisible: (columnId: string, value: boolean) => void; + setManyVisible: (columnIds: string[], value: boolean) => void; +} + +interface ColumnPickerTemplate { + /** Caller renders columns using the provided context helpers. */ + render: (context: ColumnPickerRenderContext) => ReactNode; + + /** Column ids that render disabled-checked. Forcibly kept visible on every commit. */ + locked?: string[]; + + /** Toolbar trigger button text, default "Export…". Opens the picker dialog. */ + triggerLabel?: string; + + /** Label for the direct-export button rendered next to the trigger in the toolbar. */ + directExportLabel?: string; + + /** Tooltip shown on the direct-export button. */ + directExportTooltip?: string; + + /** Modal title, default "Select columns". */ + dialogTitle?: string; + + /** + * Called at CSV export time with the ordered visible column IDs. + * Return one array per extra row to insert after the header row. + */ + getExtraHeaderRows?: (columnIds: string[]) => string[][]; + + /** + * localStorage key for persisting column visibility across page loads. + * When set, visibility is read from storage on mount and written on every change. + */ + storageKey?: string; + + /** + * Column ids that count as "data" columns (e.g. grade/gamification columns). + * When provided and none of these ids are visible in the staged selection, + * `noDataColumnsHint` is shown above the dialog actions. + */ + dataColumnIds?: string[]; + + /** + * Hint shown above the dialog actions when no `dataColumnIds` are selected. + * Has no effect if `dataColumnIds` is not provided. + */ + noDataColumnsHint?: string; +} + +export default ColumnPickerTemplate; diff --git a/client/app/lib/components/table/builder/ColumnTemplate.ts b/client/app/lib/components/table/builder/ColumnTemplate.ts index eda11a70c9d..cbfe6132ac7 100644 --- a/client/app/lib/components/table/builder/ColumnTemplate.ts +++ b/client/app/lib/components/table/builder/ColumnTemplate.ts @@ -23,6 +23,7 @@ interface ColumnTemplate { title: StringOrTemplateHeader; cell: (datum: D) => ReactNode; of?: keyof D; + accessorFn?: (datum: D) => unknown; id?: string; unless?: boolean; sortable?: boolean; @@ -36,6 +37,7 @@ interface ColumnTemplate { className?: string; colSpan?: (datum: D) => number; cellUnless?: (datum: D) => boolean; + defaultVisible?: boolean; } export default ColumnTemplate; diff --git a/client/app/lib/components/table/builder/TableTemplate.ts b/client/app/lib/components/table/builder/TableTemplate.ts index c6de07ae6d9..d6bba03f117 100644 --- a/client/app/lib/components/table/builder/TableTemplate.ts +++ b/client/app/lib/components/table/builder/TableTemplate.ts @@ -1,3 +1,4 @@ +import ColumnPickerTemplate from './ColumnPickerTemplate'; import ColumnTemplate, { Data } from './ColumnTemplate'; import { CsvDownloadTemplate, @@ -23,6 +24,7 @@ interface TableTemplate { filter?: FilterTemplate; toolbar?: ToolbarTemplate; sort?: SortTemplate; + columnPicker?: ColumnPickerTemplate; } export default TableTemplate; diff --git a/client/app/lib/components/table/builder/index.ts b/client/app/lib/components/table/builder/index.ts index 869466251d4..d36a6dc7e0f 100644 --- a/client/app/lib/components/table/builder/index.ts +++ b/client/app/lib/components/table/builder/index.ts @@ -1,4 +1,8 @@ export type { BuiltColumns } from './buildColumns'; export { buildColumns } from './buildColumns'; +export type { + ColumnPickerRenderContext, + default as ColumnPickerTemplate, +} from './ColumnPickerTemplate'; export type { default as ColumnTemplate, Data } from './ColumnTemplate'; export type { default as TableTemplate } from './TableTemplate'; diff --git a/client/app/lib/components/table/index.tsx b/client/app/lib/components/table/index.tsx index 1161e5689ca..4871f2c152c 100644 --- a/client/app/lib/components/table/index.tsx +++ b/client/app/lib/components/table/index.tsx @@ -1,2 +1,7 @@ -export type { ColumnTemplate } from './builder'; +export type { + ColumnPickerRenderContext, + ColumnPickerTemplate, + ColumnTemplate, +} from './builder'; +export { default as ColumnPickerTreeGroup } from './MuiTableAdapter/ColumnPickerTreeGroup'; export { default } from './Table'; diff --git a/client/locales/en.json b/client/locales/en.json index 20257536e1b..8fe85f27a42 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -7878,6 +7878,24 @@ "lib.components.getHelp.validation.exceedDateRange": { "defaultMessage": "Date range cannot exceed 365 days" }, + "lib.components.table.MuiColumnPickerDialog.apply": { + "defaultMessage": "Apply to view" + }, + "lib.components.table.MuiColumnPickerDialog.cancel": { + "defaultMessage": "Cancel" + }, + "lib.components.table.MuiColumnPickerDialog.defaultTitle": { + "defaultMessage": "Select columns" + }, + "lib.components.table.MuiColumnPickerDialog.export": { + "defaultMessage": "Apply and Export" + }, + "lib.components.table.MuiTableToolbar.directExport": { + "defaultMessage": "Export" + }, + "lib.components.table.MuiTableToolbar.exportTrigger": { + "defaultMessage": "Export…" + }, "lib.translations.course.users.fetchUsersFailure": { "defaultMessage": "Failed to fetch users." }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 931d3bacbd3..3b4953e1df3 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -7868,6 +7868,24 @@ "lib.components.getHelp.validation.exceedDateRange": { "defaultMessage": "날짜 범위는 365일을 초과할 수 없습니다" }, + "lib.components.table.MuiColumnPickerDialog.apply": { + "defaultMessage": "뷰에 적용" + }, + "lib.components.table.MuiColumnPickerDialog.cancel": { + "defaultMessage": "취소" + }, + "lib.components.table.MuiColumnPickerDialog.defaultTitle": { + "defaultMessage": "열 선택" + }, + "lib.components.table.MuiColumnPickerDialog.export": { + "defaultMessage": "적용 및 내보내기" + }, + "lib.components.table.MuiTableToolbar.directExport": { + "defaultMessage": "내보내기" + }, + "lib.components.table.MuiTableToolbar.exportTrigger": { + "defaultMessage": "내보내기…" + }, "lib.translations.course.users.fetchUsersFailure": { "defaultMessage": "사용자를 가져오는 데 실패했습니다." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index e0fb2e36398..36368126fb6 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -7862,6 +7862,24 @@ "lib.components.getHelp.validation.exceedDateRange": { "defaultMessage": "日期范围不能超过365天" }, + "lib.components.table.MuiColumnPickerDialog.apply": { + "defaultMessage": "应用至视图" + }, + "lib.components.table.MuiColumnPickerDialog.cancel": { + "defaultMessage": "取消" + }, + "lib.components.table.MuiColumnPickerDialog.defaultTitle": { + "defaultMessage": "选择列" + }, + "lib.components.table.MuiColumnPickerDialog.export": { + "defaultMessage": "应用并导出" + }, + "lib.components.table.MuiTableToolbar.directExport": { + "defaultMessage": "导出" + }, + "lib.components.table.MuiTableToolbar.exportTrigger": { + "defaultMessage": "导出…" + }, "lib.translations.course.users.fetchUsersFailure": { "defaultMessage": "无法获取用户。" }, From 64927b1314ac2623486c6f9b5d545d6e689f3a96 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 20 May 2026 09:51:45 +0000 Subject: [PATCH 2/2] feat(gradebook): add gradebook page with column picker and CSV export Introduces a course-wide gradebook showing per-student grades across all assessments. Instructors can toggle which assessment columns are visible via a hierarchical column picker (grouped by category/tab), then export the current view to CSV. Backend adds GradebookController#index (JSON), ability guard, and model methods on Assessment and Submission for fetching grade data. Table lib gains reusable ColumnPickerTemplate, MuiColumnPickerPrompt, ColumnPickerTreeGroup, and toolbar integration used by the gradebook. --- .../components/course/gradebook_component.rb | 23 + .../course/gradebook_controller.rb | 49 ++ .../course/gradebook_ability_component.rb | 9 + app/models/course/assessment.rb | 16 + app/models/course/assessment/submission.rb | 21 + .../course/gradebook/index.json.jbuilder | 34 + client/app/api/course/Gradebook.ts | 15 + client/app/api/course/index.js | 2 + .../__tests__/GradebookColumnTree.test.tsx | 265 ++++++++ .../__tests__/GradebookIndex.test.tsx | 146 ++++ .../__tests__/GradebookTable.test.tsx | 426 ++++++++++++ .../components/GradebookColumnTree.tsx | 235 +++++++ .../gradebook/components/GradebookTable.tsx | 636 ++++++++++++++++++ .../components/buildAssessmentColumnIds.ts | 7 + .../app/bundles/course/gradebook/constants.ts | 5 + .../app/bundles/course/gradebook/handles.ts | 21 + .../bundles/course/gradebook/operations.ts | 12 + .../gradebook/pages/GradebookIndex/index.tsx | 102 +++ .../app/bundles/course/gradebook/selectors.ts | 24 + client/app/bundles/course/gradebook/store.ts | 63 ++ client/app/bundles/course/gradebook/types.ts | 8 + client/app/bundles/course/translations.ts | 4 + .../lib/components/core/dialogs/Prompt.tsx | 3 + .../MuiTableAdapter/MuiColumnPickerPrompt.tsx | 12 +- .../TanStackTableBuilder/csvGenerator.ts | 16 +- .../useTanStackTableBuilder.tsx | 5 +- .../table/__tests__/csvGenerator.test.ts | 22 +- .../components/table/__tests__/utils.test.ts | 33 + .../lib/components/table/adapters/Toolbar.ts | 2 +- .../table/builder/featureTemplates.ts | 1 + client/app/lib/components/table/utils.ts | 5 +- client/app/lib/constants/icons.ts | 3 + client/app/routers/course/gradebook.tsx | 23 + client/app/routers/course/index.tsx | 2 + client/app/store.ts | 2 + client/app/types/course/gradebook.ts | 40 ++ client/jest.config.js | 1 + client/locales/en.json | 66 ++ client/locales/ko.json | 60 ++ client/locales/zh.json | 60 ++ client/package.json | 2 +- config/locales/en/course/gradebook.yml | 5 + config/locales/ko/course/gradebook.yml | 5 + config/locales/zh/course/gradebook.yml | 5 + config/routes.rb | 4 + .../coursemology/seed_600_gradebook.rake | 239 +++++++ lib/tasks/coursemology/seed_gradebook.rake | 246 +++++++ .../course/gradebook_controller_spec.rb | 189 ++++++ .../course/assessment/submission_spec.rb | 66 ++ spec/models/course/assessment_spec.rb | 29 + spec/models/instance_spec.rb | 10 + 51 files changed, 3258 insertions(+), 21 deletions(-) create mode 100644 app/controllers/components/course/gradebook_component.rb create mode 100644 app/controllers/course/gradebook_controller.rb create mode 100644 app/models/components/course/gradebook_ability_component.rb create mode 100644 app/views/course/gradebook/index.json.jbuilder create mode 100644 client/app/api/course/Gradebook.ts create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx create mode 100644 client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx create mode 100644 client/app/bundles/course/gradebook/components/GradebookTable.tsx create mode 100644 client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts create mode 100644 client/app/bundles/course/gradebook/constants.ts create mode 100644 client/app/bundles/course/gradebook/handles.ts create mode 100644 client/app/bundles/course/gradebook/operations.ts create mode 100644 client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx create mode 100644 client/app/bundles/course/gradebook/selectors.ts create mode 100644 client/app/bundles/course/gradebook/store.ts create mode 100644 client/app/bundles/course/gradebook/types.ts create mode 100644 client/app/lib/components/table/__tests__/utils.test.ts create mode 100644 client/app/routers/course/gradebook.tsx create mode 100644 client/app/types/course/gradebook.ts create mode 100644 config/locales/en/course/gradebook.yml create mode 100644 config/locales/ko/course/gradebook.yml create mode 100644 config/locales/zh/course/gradebook.yml create mode 100644 lib/tasks/coursemology/seed_600_gradebook.rake create mode 100644 lib/tasks/coursemology/seed_gradebook.rake create mode 100644 spec/controllers/course/gradebook_controller_spec.rb diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb new file mode 100644 index 00000000000..fe2ccfe09e2 --- /dev/null +++ b/app/controllers/components/course/gradebook_component.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +class Course::GradebookComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + + def self.display_name + 'Gradebook' + end + + def sidebar_items + return [] unless can?(:read_gradebook, current_course) + + [ + { + key: self.class.key, + icon: :gradebook, + title: I18n.t('course.gradebook.component.sidebar_title'), + type: :normal, + weight: 9, + path: course_gradebook_path(current_course) + } + ] + end +end diff --git a/app/controllers/course/gradebook_controller.rb b/app/controllers/course/gradebook_controller.rb new file mode 100644 index 00000000000..71cfa4fc145 --- /dev/null +++ b/app/controllers/course/gradebook_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +class Course::GradebookController < Course::ComponentController + before_action :authorize_read_gradebook! + + def index + respond_to do |format| + format.json do + @published_assessments = fetch_published_assessments + @categories, @tabs = fetch_categories_and_tabs + @students = fetch_students + assessment_ids = @published_assessments.pluck(:id) + @assessment_max_grades = Course::Assessment.max_grades(assessment_ids) + @submissions = Course::Assessment::Submission.grade_summary( + student_ids: @students.map(&:user_id), + assessment_ids: assessment_ids + ) + end + end + end + + private + + def authorize_read_gradebook! + authorize! :read_gradebook, current_course + end + + def component + current_component_host[:course_gradebook_component] + end + + def fetch_categories_and_tabs + tabs = @published_assessments.map(&:tab).uniq(&:id) + [tabs.map(&:category).uniq(&:id), tabs] + end + + def fetch_students + current_course.levels.to_a + current_course.course_users.students.without_phantom_users. + calculated(:experience_points).includes(:user).to_a + end + + def fetch_published_assessments + current_course.assessments. + published. + includes(tab: :category). + joins(tab: :category). + reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id') + end +end diff --git a/app/models/components/course/gradebook_ability_component.rb b/app/models/components/course/gradebook_ability_component.rb new file mode 100644 index 00000000000..d5b9862f299 --- /dev/null +++ b/app/models/components/course/gradebook_ability_component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +module Course::GradebookAbilityComponent + include AbilityHost::Component + + def define_permissions + can :read_gradebook, Course, id: course.id if course_user&.staff? + super + end +end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 3128ed42528..aec93f9de08 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -160,6 +160,22 @@ def self.use_relative_model_naming? true end + # Returns a hash of assessment_id => max_grade (sum of question maximum_grades). + def self.max_grades(assessment_ids) + return {} if assessment_ids.empty? + + rows = find_by_sql( + sanitize_sql_array([<<-SQL.squish, assessment_ids]) + SELECT cqa.assessment_id, COALESCE(SUM(caq.maximum_grade), 0) AS max_grade + FROM course_question_assessments cqa + JOIN course_assessment_questions caq ON caq.id = cqa.question_id + WHERE cqa.assessment_id IN (?) + GROUP BY cqa.assessment_id + SQL + ) + rows.to_h { |row| [row.assessment_id, row.max_grade.to_f] } + end + def to_partial_path 'course/assessment/assessments/assessment' end diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index c4919d6ec14..ec9a2d0de48 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -323,6 +323,27 @@ def self.on_dependent_status_change(answer) answer.submission.last_graded_time = Time.now end + # Returns an array of submission rows for the given students and assessments. + # Each row has: student_id (creator_id), assessment_id, grade (float). + # Only graded/published submissions are included. + def self.grade_summary(student_ids:, assessment_ids:) + return [] if student_ids.empty? || assessment_ids.empty? + + find_by_sql( + sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids]) + SELECT cas.creator_id AS student_id, cas.assessment_id, + SUM(caa.grade) AS grade + FROM course_assessment_submissions cas + JOIN course_assessment_answers caa ON caa.submission_id = cas.id + WHERE cas.creator_id IN (?) + AND cas.assessment_id IN (?) + AND cas.workflow_state IN ('graded', 'published') + AND caa.current_answer = TRUE + GROUP BY cas.creator_id, cas.assessment_id + SQL + ) + end + private # Queues the submission for auto grading, after the submission has changed to the submitted state. diff --git a/app/views/course/gradebook/index.json.jbuilder b/app/views/course/gradebook/index.json.jbuilder new file mode 100644 index 00000000000..8c67f0a0703 --- /dev/null +++ b/app/views/course/gradebook/index.json.jbuilder @@ -0,0 +1,34 @@ +# frozen_string_literal: true +json.categories @categories do |cat| + json.id cat.id + json.title cat.title +end + +json.tabs @tabs do |tab| + json.id tab.id + json.title tab.title + json.categoryId tab.category_id +end + +json.assessments @published_assessments do |assessment| + json.id assessment.id + json.title assessment.title + json.tabId assessment.tab_id + json.maxGrade @assessment_max_grades[assessment.id] || 0 +end + +json.students @students do |course_user| + json.id course_user.user_id + json.name course_user.name + json.email course_user.user.email + json.level course_user.level_number + json.totalXp course_user.experience_points +end + +json.submissions @submissions do |sub| + json.studentId sub.student_id + json.assessmentId sub.assessment_id + json.grade sub.grade&.to_f +end + +json.gamificationEnabled current_course.gamified? diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts new file mode 100644 index 00000000000..e00c94a64c3 --- /dev/null +++ b/client/app/api/course/Gradebook.ts @@ -0,0 +1,15 @@ +import { GradebookData } from 'types/course/gradebook'; + +import { APIResponse } from 'api/types'; + +import BaseCourseAPI from './Base'; + +export default class GradebookAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/gradebook`; + } + + index(): APIResponse { + return this.client.get(this.#urlPrefix); + } +} diff --git a/client/app/api/course/index.js b/client/app/api/course/index.js index 8f5df6176fe..355a5878c53 100644 --- a/client/app/api/course/index.js +++ b/client/app/api/course/index.js @@ -12,6 +12,7 @@ import DuplicationAPI from './Duplication'; import EnrolRequestsAPI from './EnrolRequests'; import ExperiencePointsRecordAPI from './ExperiencePointsRecord'; import ForumAPI from './Forum'; +import GradebookAPI from './Gradebook'; import GroupsAPI from './Groups'; import LeaderboardAPI from './Leaderboard'; import LearningMapAPI from './LearningMap'; @@ -48,6 +49,7 @@ const CourseAPI = { experiencePointsRecord: new ExperiencePointsRecordAPI(), folders: new FoldersAPI(), forum: ForumAPI, + gradebook: new GradebookAPI(), groups: new GroupsAPI(), leaderboard: new LeaderboardAPI(), learningMap: new LearningMapAPI(), diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx new file mode 100644 index 00000000000..261abaa3cdf --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx @@ -0,0 +1,265 @@ +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { buildAssessmentColumnId } from '../components/buildAssessmentColumnIds'; +import GradebookColumnTree from '../components/GradebookColumnTree'; +import type { AssessmentData, CategoryData, TabData } from '../types'; + +const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; +const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; +const assessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, + { id: 101, title: 'Quiz 2', tabId: 10, maxGrade: 10 }, +]; + +const asnId100 = buildAssessmentColumnId(100); +const asnId101 = buildAssessmentColumnId(101); +const allIds = ['name', 'email', 'level', asnId100, asnId101]; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +describe('GradebookColumnTree', () => { + it('renders Student info and Grades branch labels', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Student info')).toBeInTheDocument(); + expect(screen.getByText('Grades')).toBeInTheDocument(); + }); + + it('renders Gamification branch when gamificationEnabled', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Gamification')).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /^level$/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /^total xp$/i }), + ).toBeInTheDocument(); + }); + + it('hides Gamification branch when gamificationEnabled is false', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.queryByText('Gamification')).not.toBeInTheDocument(); + expect( + screen.queryByRole('checkbox', { name: /^level$/i }), + ).not.toBeInTheDocument(); + }); + + it('name checkbox is disabled and always checked', () => { + const visibility: Record = { + name: false, + email: true, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + const nameCheckbox = screen.getByRole('checkbox', { name: /^name/i }); + expect(nameCheckbox).toBeDisabled(); + expect(nameCheckbox).toBeChecked(); + }); + + it('non-name student info checkboxes are enabled and reflect visibility state', () => { + const visibility: Record = { + name: true, + email: false, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + const emailCheckbox = screen.getByRole('checkbox', { name: /^email$/i }); + expect(emailCheckbox).not.toBeDisabled(); + expect(emailCheckbox).not.toBeChecked(); + }); + + it('clicking a student info checkbox calls setVisible with its column id', () => { + const setVisible = jest.fn(); + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={setVisible} + tabs={tabs} + />, + ), + ); + fireEvent.click(screen.getByRole('checkbox', { name: /^email$/i })); + expect(setVisible).toHaveBeenCalledWith('email', expect.any(Boolean)); + }); + + it('renders Category, Tab, and assessment checkboxes', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Cat A')).toBeInTheDocument(); + expect(screen.getByText('Tab 1')).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /quiz 1/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /quiz 2/i }), + ).toBeInTheDocument(); + }); + + it('clicking an assessment checkbox calls setVisible with the single column id', () => { + const setVisible = jest.fn(); + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={setVisible} + tabs={tabs} + />, + ), + ); + fireEvent.click(screen.getByRole('checkbox', { name: /quiz 1/i })); + expect(setVisible).toHaveBeenCalledWith(asnId100, expect.any(Boolean)); + }); + + it('renders "Always included" chip next to the Name row', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Always included')).toBeInTheDocument(); + }); + + it('does not render "Always included" chip next to email row', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getAllByText('Always included')).toHaveLength(1); + }); + + it('Student info branch is indeterminate when some but not all student cols are visible', () => { + const visibility: Record = { + name: true, + email: false, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect( + screen.getByRole('checkbox', { name: /student info/i }), + ).toHaveAttribute('data-indeterminate', 'true'); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx new file mode 100644 index 00000000000..e0fc76ae2ce --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -0,0 +1,146 @@ +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import toast from 'lib/hooks/toast'; + +import fetchGradebook from '../operations'; +import GradebookIndex from '../pages/GradebookIndex'; + +jest.mock('../../container/CourseLoader', () => ({ + useCourseContext: (): { courseTitle: string; id: number } => ({ + courseTitle: 'Test Course', + id: 1, + }), +})); + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { error: jest.fn(), success: jest.fn() }, +})); + +jest.mock('../operations', () => ({ + __esModule: true, + default: jest.fn(() => (): Promise => Promise.resolve()), +})); + +const mockFetchGradebook = fetchGradebook as jest.Mock; + +const emptyState = { + gradebook: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + }, +}; + +const noStudentsState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [], + submissions: [], + gamificationEnabled: false, + }, +}; + +const populatedState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + level: 3, + totalXp: 150, + }, + ], + submissions: [{ studentId: 1, assessmentId: 100, grade: 8 }], + gamificationEnabled: false, + }, +}; + +const populatedStateWithGamification = { + gradebook: { + ...populatedState.gradebook, + gamificationEnabled: true, + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockFetchGradebook.mockReturnValue((): Promise => Promise.resolve()); +}); + +describe('GradebookIndex', () => { + it('shows loading indicator initially', () => { + render(, { state: emptyState }); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows the gradebook table after data loads', async () => { + render(, { state: populatedState }); + expect( + await screen.findByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + + it('shows the page title', async () => { + render(, { state: populatedState }); + expect(await screen.findByText('Gradebook')).toBeInTheDocument(); + }); + + it('shows empty students message when there are no students', async () => { + render(, { state: noStudentsState }); + expect( + await screen.findByText('No students enrolled yet'), + ).toBeInTheDocument(); + }); + + it('shows empty students message when both assessments and students are absent', async () => { + render(, { state: emptyState }); + expect( + await screen.findByText('No students enrolled yet'), + ).toBeInTheDocument(); + }); + + it('shows error toast when fetch fails', async () => { + mockFetchGradebook.mockReturnValueOnce( + (): Promise => Promise.reject(new Error('Network error')), + ); + render(, { state: emptyState }); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + }); + + it('shows grade-only hint in column picker when gamification is disabled and no data cols selected', async () => { + render(, { state: populatedState }); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + expect( + await screen.findByText( + 'No grade columns selected - export will include student info only.', + ), + ).toBeInTheDocument(); + }); + + it('shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected', async () => { + render(, { state: populatedStateWithGamification }); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + fireEvent.click( + await screen.findByRole('checkbox', { name: /gamification/i }), + ); + expect( + await screen.findByText( + 'No grade or gamification columns selected - export will include student info only.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx new file mode 100644 index 00000000000..46ca8388a67 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -0,0 +1,426 @@ +import userEvent from '@testing-library/user-event'; +import { store as appStore } from 'store'; +import { render, screen, waitFor, within } from 'test-utils'; + +import GradebookTable from '../components/GradebookTable'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; +const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; +const assessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, +]; +const students: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + level: 3, + totalXp: 150, + }, + { + id: 2, + name: 'Bob', + email: 'bob@example.com', + level: 5, + totalXp: 300, + }, +]; +const submissions: SubmissionData[] = [ + { studentId: 1, assessmentId: 100, grade: 8 }, +]; + +const makeStudents = (n: number): StudentData[] => + Array.from({ length: n }, (_, i) => ({ + id: i + 1, + name: `Student ${i + 1}`, + email: `student${i + 1}@example.com`, + level: 1, + totalXp: 0, + })); + +// User id used in all renders so localStorage is keyed as `${USER_ID}:gradebook_columns_1` +const USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:gradebook_columns_1`; + +// Preloaded state that gives a non-zero userId so useTanStackTableBuilder +// activates the effectiveStorageKey and reads/writes localStorage. +const userState = { + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: USER_ID, name: '', imageUrl: '' }, + }, + }, +}; + +interface RenderOptions { + gamificationEnabled?: boolean; +} + +const renderTable = ({ + gamificationEnabled = true, +}: RenderOptions = {}): void => { + render( + , + { state: userState }, + ); +}; + +const renderTableWithAssessmentVisible = ( + options: RenderOptions = {}, +): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + 'asn-100': true, + }), + ); + renderTable(options); +}; + +describe('GradebookTable', () => { + beforeEach(() => localStorage.clear()); + + it('renders both student names', async () => { + renderTableWithAssessmentVisible(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(await screen.findByText('Bob')).toBeInTheDocument(); + }); + + it('renders two header rows (column titles and max marks)', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + const { container } = render( + , + { state: userState }, + ); + await screen.findByText('Alice'); + expect(container.querySelectorAll('thead tr')).toHaveLength(2); + }); + + it('shows Select Columns button and Export button', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect( + screen.getByRole('button', { name: /select columns/i }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument(); + }); + + describe('export button label reflects selection', () => { + it('shows "Export all rows" when no rows are selected', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(); + }); + + it('shows tooltip "all rows will be exported" when no rows are selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const exportBtn = await screen.findByRole('button', { + name: /export all rows/i, + }); + await user.hover(exportBtn); + expect( + await screen.findByText(/all rows will be exported/i), + ).toBeInTheDocument(); + }); + + it('hides the tooltip when a row is selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + const exportBtn = await screen.findByRole('button', { + name: /export 1 row/i, + }); + await user.hover(exportBtn); + expect( + screen.queryByText(/all rows will be exported/i), + ).not.toBeInTheDocument(); + }); + + it('shows "Export 1 row" when one row is selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(), + ); + }); + + it('shows "Export all rows" when all rows are selected via the corner checkbox', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[0]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(), + ); + expect( + screen.queryByRole('button', { name: /export \d+ row/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('shows the Max Marks header row', async () => { + renderTableWithAssessmentVisible(); + expect(await screen.findByText('Max Marks')).toBeInTheDocument(); + }); + + it('renders row selection checkboxes', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect(screen.getAllByRole('checkbox').length).toBeGreaterThanOrEqual(2); + }); + + describe('row selection', () => { + it('keeps search input visible after selecting a row', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('keeps Export button visible after selecting a row', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + expect( + screen.getByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + }); + + it('does not show assessment columns in the table by default', async () => { + renderTable(); + await screen.findByText('Alice'); + expect(screen.queryByText('Quiz 1')).not.toBeInTheDocument(); + }); + + it('shows gamification columns by default when gamification is enabled', async () => { + renderTable({ gamificationEnabled: true }); + expect(await screen.findByText('Level')).toBeInTheDocument(); + expect(screen.getByText('Total XP')).toBeInTheDocument(); + }); + + describe('gamification columns', () => { + it('shows level and totalXp in the column picker when gamification is enabled', async () => { + const user = userEvent.setup(); + renderTable({ gamificationEnabled: true }); + const selectColumnsBtn = await screen.findByRole('button', { + name: /select columns/i, + }); + await user.click(selectColumnsBtn); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Level')).toBeInTheDocument(); + expect(within(dialog).getByText('Total XP')).toBeInTheDocument(); + }); + }); + + describe('locked name column', () => { + it('name is always visible even when localStorage sets it to false', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: false, + email: true, + + 'asn-100': true, + }), + ); + renderTable(); + await waitFor(() => + expect(screen.getByText('Alice')).toBeInTheDocument(), + ); + }); + }); + + describe('gamification disabled', () => { + it('level and totalXp absent from table headers when gamification is disabled', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + level: true, + totalXp: true, + 'asn-100': true, + }), + ); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.queryByText('Level')).not.toBeInTheDocument(); + expect(screen.queryByText('Total XP')).not.toBeInTheDocument(); + }); + }); + + it('shows the table when gamification columns are visible and assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: true }); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('export button is always enabled regardless of which columns are selected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.getByRole('button', { name: /export/i })).not.toBeDisabled(); + }); + + it('shows the table (not an empty state) when all assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + expect(await screen.findByRole('table')).toBeInTheDocument(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + }); + + it('shows the table when all optional columns are deselected with gamification', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ 'asn-100': false, level: false, totalXp: false }), + ); + renderTable({ gamificationEnabled: true }); + expect(await screen.findByRole('table')).toBeInTheDocument(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + }); + + it('shows pagination when all assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.getByText(/rows per page/i)).toBeInTheDocument(); + }); + + it('shows the table with assessment columns when restored from localStorage', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + renderTable(); + expect(await screen.findByText('Quiz 1')).toBeInTheDocument(); + }); + + describe('search', () => { + it('filters by name', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const input = await screen.findByRole('textbox'); + await user.type(input, 'Alice'); + await waitFor(() => + expect(screen.queryByText('Bob')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('filters by email', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const input = await screen.findByRole('textbox'); + await user.type(input, 'bob@example.com'); + await waitFor(() => + expect(screen.queryByText('Alice')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); + }); + + describe('cross-page selection', () => { + it('export label reflects selection count across pages', async () => { + const user = userEvent.setup(); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + render( + , + { state: userState }, + ); + + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(), + ); + + await user.click( + screen.getByRole('button', { name: /go to next page/i }), + ); + await waitFor(() => + expect(screen.getByText('Student 11')).toBeInTheDocument(), + ); + expect(screen.queryByText('Student 1')).not.toBeInTheDocument(); + + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx new file mode 100644 index 00000000000..84dd01108cb --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx @@ -0,0 +1,235 @@ +import { useMemo } from 'react'; +import { defineMessages } from 'react-intl'; +import { Chip } from '@mui/material'; + +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; +import { + ColumnPickerRenderContext, + ColumnPickerTreeGroup, +} from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { + GAMIFICATION_COL_IDS, + type GamificationColId, + STUDENT_INFO_COL_IDS, + type StudentInfoColId, +} from '../constants'; +import type { AssessmentData, CategoryData, TabData } from '../types'; + +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from './buildAssessmentColumnIds'; + +const translations = defineMessages({ + studentInfo: { + id: 'course.gradebook.GradebookColumnTree.studentInfo', + defaultMessage: 'Student info', + }, + name: { + id: 'course.gradebook.GradebookColumnTree.name', + defaultMessage: 'Name', + }, + email: { + id: 'course.gradebook.GradebookColumnTree.email', + defaultMessage: 'Email', + }, + level: { + id: 'course.gradebook.GradebookColumnTree.level', + defaultMessage: 'Level', + }, + totalXp: { + id: 'course.gradebook.GradebookColumnTree.totalXp', + defaultMessage: 'Total XP', + }, + gamification: { + id: 'course.gradebook.GradebookColumnTree.gamification', + defaultMessage: 'Gamification', + }, + grades: { + id: 'course.gradebook.GradebookColumnTree.grades', + defaultMessage: 'Grades', + }, + alwaysIncluded: { + id: 'course.gradebook.GradebookColumnTree.alwaysIncluded', + defaultMessage: 'Always included', + }, +}); + +interface GradebookColumnTreeProps extends ColumnPickerRenderContext { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + gamificationEnabled: boolean; +} + +const STUDENT_ALL_IDS = [...STUDENT_INFO_COL_IDS]; +const GAMIFICATION_ALL_IDS = [...GAMIFICATION_COL_IDS]; + +const GradebookColumnTree = ({ + isVisible, + setVisible, + setManyVisible, + categories, + tabs, + assessments, + gamificationEnabled, +}: GradebookColumnTreeProps): JSX.Element => { + const { t } = useTranslation(); + const context: ColumnPickerRenderContext = { + isVisible, + setVisible, + setManyVisible, + }; + + const asnIds = useMemo( + () => assessments.map((a) => buildAssessmentColumnId(a.id)), + [assessments], + ); + + const tabAsnIds = useMemo(() => { + const map = new Map(); + assessments.forEach((a) => { + const existing = map.get(a.tabId) ?? []; + map.set(a.tabId, [...existing, buildAssessmentColumnId(a.id)]); + }); + return map; + }, [assessments]); + + const catTabs = useMemo(() => { + const map = new Map(); + tabs.forEach((tab) => { + const existing = map.get(tab.categoryId) ?? []; + map.set(tab.categoryId, [...existing, tab]); + }); + return map; + }, [tabs]); + + const asnById = useMemo( + () => new Map(assessments.map((a) => [a.id, a])), + [assessments], + ); + + const catAsnIds = useMemo(() => { + const map = new Map(); + tabs.forEach((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + const existing = map.get(tab.categoryId) ?? []; + map.set(tab.categoryId, [...existing, ...tabIds]); + }); + return map; + }, [tabs, tabAsnIds]); + + return ( +
+ + {STUDENT_INFO_COL_IDS.map((id: StudentInfoColId) => + id === 'name' ? ( + + {t(translations[id])} + + + } + /> + ) : ( + setVisible(id, e.target.checked)} + /> + ), + )} + + + {gamificationEnabled && ( + + {GAMIFICATION_COL_IDS.map((id: GamificationColId) => ( + setVisible(id, e.target.checked)} + /> + ))} + + )} + + + {categories.map((cat) => { + const catIds = catAsnIds.get(cat.id) ?? []; + const thisCatTabs = catTabs.get(cat.id) ?? []; + return ( + + {thisCatTabs.map((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + return ( + + {tabIds.map((id) => { + const asnId = parseAssessmentColumnId(id); + const asn = + asnId !== null ? asnById.get(asnId) : undefined; + if (!asn) return null; + return ( + setVisible(id, e.target.checked)} + /> + ); + })} + + ); + })} + + ); + })} + +
+ ); +}; + +export default GradebookColumnTree; diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx new file mode 100644 index 00000000000..43e160ab23e --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -0,0 +1,636 @@ +import { + forwardRef, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { defineMessages } from 'react-intl'; +import { + Checkbox, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, +} from '@mui/material'; +import { flexRender } from '@tanstack/react-table'; + +import type { + ColumnPickerRenderContext, + ColumnTemplate, +} from 'lib/components/table/builder'; +import MuiTablePagination from 'lib/components/table/MuiTableAdapter/MuiTablePagination'; +import MuiTableToolbar from 'lib/components/table/MuiTableAdapter/MuiTableToolbar'; +import useTanStackTableBuilder from 'lib/components/table/TanStackTableBuilder'; +import { + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + DEFAULT_TABLE_ROWS_PER_PAGE, +} from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { GAMIFICATION_COL_IDS } from '../constants'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from './buildAssessmentColumnIds'; +import GradebookColumnTree from './GradebookColumnTree'; + +const COL_WIDTHS = { + name: 160, + email: 220, + level: 70, + totalXp: 100, + assessment: 150, +} as const; + +const CHECKBOX_WIDTH = 56; + +const getColWidth = (id: string): number => + COL_WIDTHS[id as keyof typeof COL_WIDTHS] ?? COL_WIDTHS.assessment; + +const isLeftAligned = (id: string): boolean => id === 'name' || id === 'email'; + +const translations = defineMessages({ + searchStudents: { + id: 'course.gradebook.GradebookIndex.searchStudents', + defaultMessage: 'Search by name or email', + }, + exportButton: { + id: 'course.gradebook.GradebookIndex.exportButton', + defaultMessage: 'Export all rows', + }, + exportRows: { + id: 'course.gradebook.GradebookIndex.exportRows', + defaultMessage: 'Export {count, plural, one {# row} other {# rows}}', + }, + exportAllTooltip: { + id: 'course.gradebook.GradebookIndex.exportAllTooltip', + defaultMessage: 'No rows selected - all rows will be exported.', + }, + selectColumns: { + id: 'course.gradebook.GradebookIndex.selectColumns', + defaultMessage: 'Select Columns', + }, + dialogTitle: { + id: 'course.gradebook.GradebookIndex.dialogTitle', + defaultMessage: 'Select columns', + }, + name: { + id: 'course.gradebook.GradebookColumnTree.name', + defaultMessage: 'Name', + }, + email: { + id: 'course.gradebook.GradebookColumnTree.email', + defaultMessage: 'Email', + }, + level: { + id: 'course.gradebook.GradebookColumnTree.level', + defaultMessage: 'Level', + }, + totalXp: { + id: 'course.gradebook.GradebookColumnTree.totalXp', + defaultMessage: 'Total XP', + }, + maxMarks: { + id: 'course.gradebook.GradebookTable.maxMarks', + defaultMessage: 'Max Marks', + }, + noDataColumnsHint: { + id: 'course.gradebook.GradebookTable.noDataColumnsHint', + defaultMessage: + 'No grade columns selected - export will include student info only.', + }, + noDataColumnsHintWithGamification: { + id: 'course.gradebook.GradebookTable.noDataColumnsHintWithGamification', + defaultMessage: + 'No grade or gamification columns selected - export will include student info only.', + }, +}); + +const HeaderLabel = forwardRef< + HTMLSpanElement, + { text: string; onSingleLine: (fits: boolean) => void } +>(({ text, onSingleLine }, forwardedRef): JSX.Element => { + const innerRef = useRef(null); + const [display, setDisplay] = useState(text); + + useLayoutEffect(() => { + const el = innerRef.current; + if (!el) return; + + const lh = parseFloat(getComputedStyle(el).lineHeight) || 20; + const oneLineH = lh + 1; + const twoLineH = lh * 2 + 1; + + el.textContent = text; + + if (el.scrollHeight <= oneLineH) { + onSingleLine(true); + setDisplay(text); + return; + } + + onSingleLine(false); + + if (el.scrollHeight <= twoLineH) { + setDisplay(text); + return; + } + + let lo = 1; + let hi = text.length; + let best = `${text[0]}…`; + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const candidate = `${text.slice(0, mid)}…`; + el.textContent = candidate; + if (el.scrollHeight <= twoLineH) { + best = candidate; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + // Ensure DOM reflects `best` before React reconciles — the loop's last + // el.textContent assignment may be a too-long candidate, not `best`. + el.textContent = best; + setDisplay(best); + }, [text, onSingleLine]); + + return ( + { + innerRef.current = node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }} + style={{ display: 'block' }} + > + {display} + + ); +}); +HeaderLabel.displayName = 'HeaderLabel'; + +interface GradebookRow { + studentId: number; + name: string; + email: string; + level: number; + totalXp: number; + grades: Partial>; +} + +interface GradebookTableProps { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + courseTitle: string; + courseId: number; + gamificationEnabled: boolean; +} + +const GradebookTable = ({ + categories, + tabs, + assessments, + students, + submissions, + courseTitle, + courseId, + gamificationEnabled, +}: GradebookTableProps): JSX.Element => { + const { t } = useTranslation(); + + const submissionsByStudent = useMemo(() => { + const map = new Map(); + submissions.forEach((s) => { + const existing = map.get(s.studentId); + if (existing) { + existing.push(s); + } else { + map.set(s.studentId, [s]); + } + }); + return map; + }, [submissions]); + + const rows = useMemo( + () => + students.map((student) => { + const subs = submissionsByStudent.get(student.id) ?? []; + const grades: Partial> = {}; + assessments.forEach((a) => { + const sub = subs.find((s) => s.assessmentId === a.id); + if (sub != null) grades[a.id] = sub.grade; + }); + return { + studentId: student.id, + name: student.name, + email: student.email, + level: student.level, + totalXp: student.totalXp, + grades, + }; + }), + [students, assessments, submissionsByStudent], + ); + + const columns = useMemo[]>(() => { + const cols: ColumnTemplate[] = [ + { + id: 'name', + title: t(translations.name), + of: 'name', + cell: (row) => row.name, + csvDownloadable: true, + searchable: true, + searchProps: { getValue: (row) => row.name }, + }, + { + id: 'email', + title: t(translations.email), + of: 'email', + cell: (row) => row.email, + csvDownloadable: true, + searchable: true, + }, + ]; + + if (gamificationEnabled) { + cols.push({ + id: 'level', + title: t(translations.level), + of: 'level', + cell: (row) => row.level, + csvDownloadable: true, + }); + cols.push({ + id: 'totalXp', + title: t(translations.totalXp), + of: 'totalXp', + cell: (row) => row.totalXp, + csvDownloadable: true, + }); + } + + assessments.forEach((asn) => { + const colId = buildAssessmentColumnId(asn.id); + cols.push({ + id: colId, + title: asn.title, + accessorFn: (row) => row.grades[asn.id], + cell: (row) => { + const grade = row.grades[asn.id]; + if (grade === undefined) return '—'; + if (grade === null) return ''; + return grade; + }, + csvDownloadable: true, + defaultVisible: false, + }); + }); + return cols; + }, [assessments, gamificationEnabled, t]); + + const assessmentMaxGrades = useMemo( + () => new Map(assessments.map((a) => [a.id, a.maxGrade])), + [assessments], + ); + + const dataColumnIds = useMemo( + () => [ + ...assessments.map((a) => buildAssessmentColumnId(a.id)), + ...GAMIFICATION_COL_IDS, + ], + [assessments], + ); + + const columnPicker = useMemo( + () => ({ + render: (context: ColumnPickerRenderContext) => ( + + ), + locked: ['name'], + triggerLabel: t(translations.selectColumns), + dialogTitle: t(translations.dialogTitle), + getExtraHeaderRows: (colIds): string[][] => { + const hasAssessments = colIds.some( + (id) => parseAssessmentColumnId(id) !== null, + ); + if (!hasAssessments) return []; + return [ + colIds.map((id) => { + if (id === 'name') return t(translations.maxMarks); + const asnId = parseAssessmentColumnId(id); + if (asnId !== null) + return String(assessmentMaxGrades.get(asnId) ?? ''); + return ''; + }), + ]; + }, + storageKey: `gradebook_columns_${courseId}`, + dataColumnIds, + noDataColumnsHint: gamificationEnabled + ? t(translations.noDataColumnsHintWithGamification) + : t(translations.noDataColumnsHint), + }), + [ + assessments, + categories, + gamificationEnabled, + tabs, + t, + assessmentMaxGrades, + courseId, + dataColumnIds, + ], + ); + + const { toolbar, body, pagination } = useTanStackTableBuilder({ + data: rows, + columns, + getRowId: (row) => row.studentId.toString(), + getRowEqualityData: (row) => row, + indexing: { rowSelectable: true }, + pagination: { + rowsPerPage: [ + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + 25, + 50, + DEFAULT_TABLE_ROWS_PER_PAGE, + ], + showAllRows: true, + }, + search: { searchPlaceholder: t(translations.searchStudents) }, + toolbar: { show: true, keepNative: true }, + csvDownload: { + filename: `${courseTitle}_gradebook`, + showDownloadButton: false, + }, + columnPicker, + }); + + const visibility = toolbar?.getColumnVisibility?.() ?? {}; + const isColVisible = (id: string): boolean => visibility[id] ?? true; + const visibleCols = columns.filter((c) => + isColVisible(c.id ?? (c.of as string)), + ); + + const selectedCount = body.selectedCount ?? 0; + + const directExportLabel = useMemo((): string => { + const isPartialSelection = selectedCount > 0 && selectedCount < rows.length; + if (isPartialSelection) + return t(translations.exportRows, { count: selectedCount }); + return t(translations.exportButton); + }, [selectedCount, rows.length, t]); + + const toolbarWithLabel = toolbar?.columnPicker + ? { + ...toolbar, + columnPicker: { + ...toolbar.columnPicker, + directExportLabel, + directExportTooltip: + selectedCount === 0 ? t(translations.exportAllTooltip) : undefined, + }, + } + : toolbar; + + const totalWidth = useMemo( + () => + CHECKBOX_WIDTH + + visibleCols.reduce((sum, c) => { + const id = c.id ?? (c.of as string); + return sum + getColWidth(id); + }, 0), + [visibleCols], + ); + + const allRowsSelected = body.allFilteredSelected ?? false; + const someRowsSelected = body.someFilteredSelected ?? false; + const toggleAllRows = (): void => body.toggleAllFiltered?.(); + + const hasVisibleAssessments = useMemo( + () => + visibleCols.some( + (c) => parseAssessmentColumnId(c.id ?? (c.of as string)) !== null, + ), + [visibleCols], + ); + + const row1Ref = useRef(null); + const [row2Top, setRow2Top] = useState(0); + useLayoutEffect(() => { + setRow2Top(row1Ref.current?.offsetHeight ?? 0); + }, [visibleCols]); + + const headerFitsRef = useRef>({}); + const [headerFits, setHeaderFits] = useState>({}); + const onSingleLine = useCallback((id: string, fits: boolean): void => { + if (headerFitsRef.current[id] !== fits) { + headerFitsRef.current[id] = fits; + setHeaderFits((prev) => ({ ...prev, [id]: fits })); + } + }, []); + const singleLineCallbacks = useMemo( + () => + new Map( + visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + return [id, (f: boolean): void => onSingleLine(id, f)]; + }), + ), + [visibleCols, onSingleLine], + ); + + return ( +
+ +
+ + + ({ + tableLayout: 'fixed', + borderCollapse: 'separate', + borderSpacing: 0, + + '& th, & td': { + boxSizing: 'border-box', + border: 0, + + // Draws the cell grid without relying on collapsed borders. + borderBottom: `0.5px solid ${theme.palette.grey[200]}`, + }, + })} + > + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + return ; + })} + + + + + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const label = typeof c.title === 'string' ? c.title : id; + const isLeft = isLeftAligned(id); + const fits = headerFits[id] ?? false; + return ( + + + + + + ); + })} + + {hasVisibleAssessments && ( + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const asnId = parseAssessmentColumnId(id); + let cellContent: string | number = ''; + if (id === 'name') cellContent = t(translations.maxMarks); + else if (asnId !== null) + cellContent = assessmentMaxGrades.get(asnId) ?? ''; + return ( + + {cellContent} + + ); + })} + + )} + + + {body.rows.map((row, idx) => { + const rowProps = body.forEachRow(row, idx); + return ( + + + + + {row + .getVisibleCells() + .filter((cell) => cell.column.id !== 'rowSelector') + .map((cell) => { + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + ); + })} + +
+
+ {pagination && } +
+
+
+ ); +}; + +export default GradebookTable; diff --git a/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts new file mode 100644 index 00000000000..d12a4bd26a7 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts @@ -0,0 +1,7 @@ +export const buildAssessmentColumnId = (asnId: number): string => + `asn-${asnId}`; + +export const parseAssessmentColumnId = (colId: string): number | null => { + const match = colId.match(/^asn-(\d+)$/); + return match ? Number(match[1]) : null; +}; diff --git a/client/app/bundles/course/gradebook/constants.ts b/client/app/bundles/course/gradebook/constants.ts new file mode 100644 index 00000000000..87a49f50a7c --- /dev/null +++ b/client/app/bundles/course/gradebook/constants.ts @@ -0,0 +1,5 @@ +export const STUDENT_INFO_COL_IDS = ['name', 'email'] as const; +export type StudentInfoColId = (typeof STUDENT_INFO_COL_IDS)[number]; + +export const GAMIFICATION_COL_IDS = ['level', 'totalXp'] as const; +export type GamificationColId = (typeof GAMIFICATION_COL_IDS)[number]; diff --git a/client/app/bundles/course/gradebook/handles.ts b/client/app/bundles/course/gradebook/handles.ts new file mode 100644 index 00000000000..0022bfbd02c --- /dev/null +++ b/client/app/bundles/course/gradebook/handles.ts @@ -0,0 +1,21 @@ +import { defineMessages } from 'react-intl'; + +import type { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; + +const translations = defineMessages({ + header: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, +}); + +export const gradebookHandle: DataHandle = (match) => { + const courseId = match.params.courseId; + + return { + getData: async (): Promise => ({ + activePath: `/courses/${courseId}/gradebook`, + content: { title: translations.header }, + }), + }; +}; diff --git a/client/app/bundles/course/gradebook/operations.ts b/client/app/bundles/course/gradebook/operations.ts new file mode 100644 index 00000000000..35790580ed0 --- /dev/null +++ b/client/app/bundles/course/gradebook/operations.ts @@ -0,0 +1,12 @@ +import type { Operation } from 'store'; + +import CourseAPI from 'api/course'; + +import { actions } from './store'; + +const fetchGradebook = (): Operation => async (dispatch) => { + const response = await CourseAPI.gradebook.index(); + dispatch(actions.saveGradebook(response.data)); +}; + +export default fetchGradebook; diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx new file mode 100644 index 00000000000..cc140af58fd --- /dev/null +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -0,0 +1,102 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { PeopleAlt } from '@mui/icons-material'; +import { Typography } from '@mui/material'; + +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { useCourseContext } from '../../../container/CourseLoader'; +import GradebookTable from '../../components/GradebookTable'; +import fetchGradebook from '../../operations'; +import { + getAssessments, + getCategories, + getGamificationEnabled, + getStudents, + getSubmissions, + getTabs, +} from '../../selectors'; + +const translations = defineMessages({ + gradebook: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, + fetchFailure: { + id: 'course.gradebook.GradebookIndex.fetchFailure', + defaultMessage: 'Failed to retrieve Gradebook.', + }, + noStudents: { + id: 'course.gradebook.GradebookIndex.noStudents', + defaultMessage: 'No students enrolled yet', + }, + noStudentsHint: { + id: 'course.gradebook.GradebookIndex.noStudentsHint', + defaultMessage: 'Grades will appear here once students join the course.', + }, +}); + +const GradebookIndex: FC = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { courseTitle } = useCourseContext(); + const { courseId: courseIdParam } = useParams(); + const courseId = parseInt(courseIdParam!, 10); + const [isLoading, setIsLoading] = useState(true); + + const assessments = useAppSelector(getAssessments); + const categories = useAppSelector(getCategories); + const tabs = useAppSelector(getTabs); + const students = useAppSelector(getStudents); + const submissions = useAppSelector(getSubmissions); + const gamificationEnabled = useAppSelector(getGamificationEnabled); + + useEffect(() => { + dispatch(fetchGradebook()) + .finally(() => setIsLoading(false)) + .catch(() => toast.error(t(translations.fetchFailure))); + }, [dispatch]); + + let content: JSX.Element; + if (isLoading) { + content = ; + } else if (students.length === 0) { + content = ( +
+ + + {t(translations.noStudents)} + + + {t(translations.noStudentsHint)} + +
+ ); + } else { + content = ( + + ); + } + + return ( + + {content} + + ); +}; + +export default GradebookIndex; diff --git a/client/app/bundles/course/gradebook/selectors.ts b/client/app/bundles/course/gradebook/selectors.ts new file mode 100644 index 00000000000..fbe62e2611a --- /dev/null +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -0,0 +1,24 @@ +import type { AppState } from 'store'; + +type GradebookState = AppState['gradebook']; + +function getLocalState(state: AppState): GradebookState { + return state.gradebook; +} + +export const getCategories = (state: AppState): GradebookState['categories'] => + getLocalState(state).categories; +export const getTabs = (state: AppState): GradebookState['tabs'] => + getLocalState(state).tabs; +export const getAssessments = ( + state: AppState, +): GradebookState['assessments'] => getLocalState(state).assessments; +export const getStudents = (state: AppState): GradebookState['students'] => + getLocalState(state).students; +export const getSubmissions = ( + state: AppState, +): GradebookState['submissions'] => getLocalState(state).submissions; +export const getGamificationEnabled = ( + state: AppState, +): GradebookState['gamificationEnabled'] => + getLocalState(state).gamificationEnabled; diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts new file mode 100644 index 00000000000..00e3291032b --- /dev/null +++ b/client/app/bundles/course/gradebook/store.ts @@ -0,0 +1,63 @@ +import { produce } from 'immer'; +import type { GradebookData } from 'types/course/gradebook'; + +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from './types'; + +const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; + +interface GradebookState { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + gamificationEnabled: boolean; +} + +interface SaveGradebookAction { + type: typeof SAVE_GRADEBOOK; + payload: GradebookData; +} + +const initialState: GradebookState = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, +}; + +const reducer = produce( + (draft: GradebookState, action: SaveGradebookAction) => { + switch (action.type) { + case SAVE_GRADEBOOK: { + draft.categories = action.payload.categories; + draft.tabs = action.payload.tabs; + draft.assessments = action.payload.assessments; + draft.students = action.payload.students; + draft.submissions = action.payload.submissions; + draft.gamificationEnabled = action.payload.gamificationEnabled; + break; + } + default: + break; + } + }, + initialState, +); + +export const actions = { + saveGradebook: (data: GradebookData): SaveGradebookAction => ({ + type: SAVE_GRADEBOOK, + payload: data, + }), +}; + +export default reducer; diff --git a/client/app/bundles/course/gradebook/types.ts b/client/app/bundles/course/gradebook/types.ts new file mode 100644 index 00000000000..f94aa7bf9c5 --- /dev/null +++ b/client/app/bundles/course/gradebook/types.ts @@ -0,0 +1,8 @@ +export type { + AssessmentData, + CategoryData, + GradebookData, + StudentData, + SubmissionData, + TabData, +} from 'types/course/gradebook'; diff --git a/client/app/bundles/course/translations.ts b/client/app/bundles/course/translations.ts index b92b165a744..c52ce359071 100644 --- a/client/app/bundles/course/translations.ts +++ b/client/app/bundles/course/translations.ts @@ -75,6 +75,10 @@ const translations = defineMessages({ id: 'course.componentTitles.course_forums_component', defaultMessage: 'Forums', }, + course_gradebook_component: { + id: 'course.componentTitles.course_gradebook_component', + defaultMessage: 'Gradebook', + }, course_groups_component: { id: 'course.componentTitles.course_groups_component', defaultMessage: 'Groups', diff --git a/client/app/lib/components/core/dialogs/Prompt.tsx b/client/app/lib/components/core/dialogs/Prompt.tsx index 5330d10c7c4..32f4c7f251a 100644 --- a/client/app/lib/components/core/dialogs/Prompt.tsx +++ b/client/app/lib/components/core/dialogs/Prompt.tsx @@ -15,6 +15,7 @@ interface BasePromptProps { open?: boolean; title?: string | ReactNode; children?: string | ReactNode; + footer?: ReactNode; onClose?: () => void; onClosed?: () => void; disabled?: boolean; @@ -84,6 +85,8 @@ const Prompt = (props: PromptProps): JSX.Element => { )} + {props.footer} + {!props.cancel ? (