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..852685225ff --- /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 { ColumnPickerRenderCtx } from '../builder'; + +interface ColumnPickerTreeGroupProps { + label: string; + /** All leaf column ids that belong to this group (used to derive parent state). */ + childIds: string[]; + ctx: ColumnPickerRenderCtx; + /** 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 ctx.setManyVisible. + * Children are rendered below (not inline), giving a vertical tree layout. + */ +const ColumnPickerTreeGroup: FC = ({ + label, + childIds, + ctx, + locked = [], + indentLevel = 0, + children, +}) => { + const visibleCount = childIds.filter((id) => ctx.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 ( +
+ ctx.setManyVisible(childIds, e.target.checked)} + /> +
{children}
+
+ ); +}; + +export default ColumnPickerTreeGroup; diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiColumnPickerDialog.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiColumnPickerDialog.tsx new file mode 100644 index 00000000000..388eed3b935 --- /dev/null +++ b/client/app/lib/components/table/MuiTableAdapter/MuiColumnPickerDialog.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import { ColumnPickerTemplate } from '../builder'; + +const translations = defineMessages({ + defaultTitle: { + id: 'lib.components.table.MuiColumnPickerDialog.defaultTitle', + defaultMessage: 'Select columns', + }, + apply: { + id: 'lib.components.table.MuiColumnPickerDialog.apply', + defaultMessage: 'Apply to view', + }, + cancel: { + id: 'lib.components.table.MuiColumnPickerDialog.cancel', + defaultMessage: 'Cancel', + }, + defaultExport: { + id: 'lib.components.table.MuiColumnPickerDialog.export', + defaultMessage: 'Apply and Export', + }, +}); + +interface MuiColumnPickerDialogProps { + open: boolean; + onClose: () => void; + initialVisibility: Record; + locked?: string[]; + columnPicker: ColumnPickerTemplate; + commitColumnVisibility: (next: Record) => void; + onExportFromPicker?: (visibility: 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 MuiColumnPickerDialog = ({ + open, + onClose, + initialVisibility, + locked, + columnPicker, + commitColumnVisibility, + onExportFromPicker, +}: MuiColumnPickerDialogProps): 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 ctx = { + 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(); + }; + + const cancelAndClose = (): void => { + onClose(); + }; + + const exportAndClose = (): void => { + const enforced = enforceLockedLocal(staged, locked); + commitColumnVisibility(enforced); + onExportFromPicker?.(enforced); + onClose(); + }; + + return ( + + + {columnPicker.dialogTitle ?? t(translations.defaultTitle)} + + {columnPicker.renderTree(ctx)} + {!hasDataColumns && columnPicker.noDataColumnsHint && ( + + {columnPicker.noDataColumnsHint} + + )} + + + + + + + ); +}; + +export default MuiColumnPickerDialog; diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx index 174ad2de405..40e20b3c385 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 MuiColumnPickerDialog from './MuiColumnPickerDialog'; 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,77 @@ 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)} + onExportFromPicker={props.onExportFromPicker} + 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..f966bd6d6ce 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts @@ -1,34 +1,65 @@ 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; + visibilityOverride?: Record; + 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.visibilityOverride + ? options.table + .getAllLeafColumns() + .filter((col) => options.visibilityOverride?.[col.id] !== false) + : 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..37a029ee723 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,14 @@ import { getSortedRowModel, Header, Row, + Updater, useReactTable, + VisibilityState, } from '@tanstack/react-table'; import isEmpty from 'lodash-es/isEmpty'; import { RowEqualityData, TableProps } from '../adapters'; -import { TableTemplate } from '../builder'; +import { ColumnTemplate, TableTemplate } from '../builder'; import { downloadCsv } from '../utils'; import buildTanStackColumns from './columnsBuilder'; @@ -47,6 +49,90 @@ const useTanStackTableBuilder = ( pageIndex: props.pagination?.initialPageIndex ?? 0, }); + const initialVisibility = useMemo(() => { + const storageKey = props.columnPicker?.storageKey; + let stored: VisibilityState | null = null; + if (storageKey) { + try { + const raw = localStorage.getItem(storageKey); + 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(() => { + const key = props.columnPicker?.storageKey; + if (!key) return; + try { + localStorage.setItem(key, 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, props.columnPicker?.storageKey]); + + // 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 +169,9 @@ const useTanStackTableBuilder = ( columnFilters, globalFilter: searchKeyword.trim(), pagination, + columnVisibility, }, + onColumnVisibilityChange: safeSetVisibility, initialState: { sorting: props.sort?.initially && [ { @@ -94,24 +182,39 @@ 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]. + const index = table.getAllLeafColumns().findIndex((c) => c.id === id); + if (index === -1) return undefined; + return getRealColumn(index); + }; + const generateAndDownloadCsv = async ( + visibilityOverride?: Record, + ): Promise => { const csvData = await generateCsv({ - headers, - rows: () => table.getCoreRowModel().rows, - getRealColumn, + table, + getRealColumn: getRealColumnById, + visibilityOverride, + getExtraHeaderRows: props.columnPicker?.getExtraHeaderRows, + onlySelected: !isEmpty(rowSelection), }); - downloadCsv(csvData, props.csvDownload?.filename); }; @@ -178,6 +281,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 +327,16 @@ const useTanStackTableBuilder = ( csvDownloadLabel: props.csvDownload?.downloadButtonLabel, searchPlaceholder: props.search?.searchPlaceholder, buttons: props.toolbar?.buttons, + columnPicker: props.columnPicker, + getColumnVisibility: () => columnVisibility, + commitColumnVisibility: (next) => safeSetVisibility(() => next), + onExportFromPicker: + props.columnPicker && + ((visibility: Record): Promise => + generateAndDownloadCsv(visibility)), + 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..c70d672997c --- /dev/null +++ b/client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx @@ -0,0 +1,141 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { ColumnPickerRenderCtx } from '../builder'; +import ColumnPickerTreeGroup from '../MuiTableAdapter/ColumnPickerTreeGroup'; + +const makeCtx = ( + visible: Record, +): ColumnPickerRenderCtx & { 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__/MuiColumnPickerDialog.test.tsx b/client/app/lib/components/table/__tests__/MuiColumnPickerDialog.test.tsx new file mode 100644 index 00000000000..f1cac338422 --- /dev/null +++ b/client/app/lib/components/table/__tests__/MuiColumnPickerDialog.test.tsx @@ -0,0 +1,269 @@ +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ColumnPickerRenderCtx } from '../builder'; +import MuiColumnPickerDialog from '../MuiTableAdapter/MuiColumnPickerDialog'; + +const DIALOG_TITLE = 'Select columns'; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +const makeRenderTree = (ids: readonly string[]): jest.Mock => + jest.fn((ctx: ColumnPickerRenderCtx) => ( + <> + {ids.map((id) => ( + + ))} + + )); + +const setup = ( + overrides: Partial> = {}, +): ReturnType & { + commitColumnVisibility: jest.Mock; + onExportFromPicker: jest.Mock; + renderTree: jest.Mock; + props: React.ComponentProps; +} => { + const commitColumnVisibility = jest.fn(); + const onExportFromPicker = jest.fn(); + const renderTree = makeRenderTree(['name', 'email']); + const props = { + open: true, + onClose: jest.fn(), + initialVisibility: { name: true, email: true }, + locked: ['name'], + columnPicker: { + renderTree, + dialogTitle: DIALOG_TITLE, + exportLabel: 'Export CSV', + onExport: 'csv' as const, + }, + commitColumnVisibility, + onExportFromPicker, + ...overrides, + }; + return { + ...render(wrap()), + commitColumnVisibility, + onExportFromPicker, + renderTree, + props, + }; +}; + +describe('MuiColumnPickerDialog', () => { + it('renders the dialog title', () => { + setup(); + expect(screen.getByText(DIALOG_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('Export CSV commits + invokes onExportFromPicker + closes', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility, onExportFromPicker, props } = setup(); + await user.click(screen.getByLabelText('email')); + await user.click(screen.getByRole('button', { name: /export csv/i })); + + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: false, + }); + expect(onExportFromPicker).toHaveBeenCalledWith({ + name: true, + email: false, + }); + 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 makeGroupRenderTree = (ids: readonly string[]): jest.Mock => + jest.fn( + (ctx: ColumnPickerRenderCtx): 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: { + renderTree: makeRenderTree(['name', 'grade']), + dialogTitle: DIALOG_TITLE, + exportLabel: 'Export CSV', + onExport: 'csv' as const, + 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('Export button is enabled even when no data columns are selected', () => { + dataSetup(['grade'], { name: true, grade: false }); + expect( + screen.getByRole('button', { name: /export csv/i }), + ).not.toBeDisabled(); + }); + + 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..76870f763c6 --- /dev/null +++ b/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx @@ -0,0 +1,64 @@ +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: { + renderTree: () => null, + triggerLabel: 'Export…', + onExport: 'csv' as const, + }, + getColumnVisibility: () => ({}), + commitColumnVisibility: () => {}, + }; + render(wrap()); + expect( + screen.getByRole('button', { name: /export…/i }), + ).toBeInTheDocument(); + }); +}); + +describe('MuiTableToolbar direct export button', () => { + const directExportProps: ToolbarProps = { + ...baseToolbar, + columnPicker: { + renderTree: () => null, + directExportLabel: 'Export all rows', + onExport: 'csv' as const, + }, + 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..74ed0c12820 --- /dev/null +++ b/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx @@ -0,0 +1,558 @@ +import { act, renderHook } from '@testing-library/react'; +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, +}); + +describe('useTanStackTableBuilder columnPicker state', () => { + it('initial visibility marks every column visible', () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + }, + }), + ), + ); + + 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: { + renderTree: () => null, + locked: ['name'], + onExport: 'csv' as const, + }, + }), + ), + ); + + 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: { + renderTree: () => null, + locked: ['name'], + onExport: 'csv' as const, + }, + }), + ), + ); + + 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: { + renderTree: () => null, + onExport: 'csv' as const, + }, + }), + ), + { initialProps: { extra: false } }, + ); + + 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: { renderTree: () => null, onExport: 'csv' as const }, + }), + ), + { initialProps: { extra: false } }, + ); + + 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' }, + }), + ), + ); + + 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 }, + }), + ), + ); + + 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' }, + }), + ); + + 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: { + renderTree: () => null, + onExport: 'csv' as const, + getExtraHeaderRows: (colIds) => [colIds.map(() => 'max')], + }, + }), + ), + ); + + await act(async () => { + await result.current.toolbar!.onExportFromPicker?.({ + name: true, + email: true, + }); + }); + + 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: { + renderTree: () => null, + onExport: 'csv' as const, + }, + }), + ); + + // Select only Alice (row index 0) + act(() => result.current.body.rows[0].toggleSelected()); + + await act(async () => { + await result.current.toolbar!.onExportFromPicker?.({ + name: true, + email: true, + }); + }); + + 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' } }), + ), + ); + + 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'); + }); +}); + +// ---------- 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()), + ); + 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()), + ); + 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()), + ); + // 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()), + ); + 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()), + ); + 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()), + ); + 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 localStorage when storageKey is provided', () => { + localStorage.setItem( + 'test_key', + JSON.stringify({ name: false, email: true }), + ); + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + storageKey: 'test_key', + }, + }), + ), + ); + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: false, + email: true, + }); + }); + + it('writes visibility to localStorage on change', async () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + storageKey: 'test_key', + }, + }), + ), + ); + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: false, + email: true, + }), + ); + expect(JSON.parse(localStorage.getItem('test_key')!)).toMatchObject({ + name: false, + email: true, + }); + }); + + it('falls back to defaultVisible when storageKey has no entry', () => { + const { result } = renderHook(() => + useTanStackTableBuilder( + baseProps({ + columns: [ + baseColumns[0], + { ...baseColumns[1], defaultVisible: false }, + ], + columnPicker: { + renderTree: () => null, + onExport: 'csv' as const, + storageKey: 'missing_key', + }, + }), + ), + ); + expect(result.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: { + renderTree: () => null, + onExport: 'csv' as const, + }, + }), + ), + ); + expect(result.current.toolbar!.onDirectExport).toBeDefined(); + }); + + it('toolbar.onDirectExport is undefined when no columnPicker is provided', () => { + const { result } = renderHook(() => useTanStackTableBuilder(baseProps())); + 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: { + renderTree: () => null, + onExport: 'csv' as const, + }, + }), + ), + ); + + 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..2f9a49a23d8 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,17 @@ 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 / Export. */ + commitColumnVisibility?: (next: Record) => void; + /** Called when the user clicks Export CSV in the dialog. Pre-bound to the CSV pipeline. */ + onExportFromPicker?: (visibility: 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..15339f19393 --- /dev/null +++ b/client/app/lib/components/table/builder/ColumnPickerTemplate.ts @@ -0,0 +1,60 @@ +import { ReactNode } from 'react'; + +export interface ColumnPickerRenderCtx { + isVisible: (columnId: string) => boolean; + setVisible: (columnId: string, value: boolean) => void; + setManyVisible: (columnIds: string[], value: boolean) => void; +} + +interface ColumnPickerTemplate { + /** Caller renders its own tree using the provided ctx helpers. */ + renderTree: (ctx: ColumnPickerRenderCtx) => 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 to export". */ + dialogTitle?: string; + + /** Reuses the table's client-side CSV pipeline for the Export CSV button. */ + onExport: 'csv'; + + /** CTA text inside the dialog, default "Apply and Export". */ + exportLabel?: 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..42675138667 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 { + ColumnPickerRenderCtx, + 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..d6baf00a17e 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 { + ColumnPickerRenderCtx, + 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 66413747666..ea17f2f6509 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 2592efdaf4a..f71909e9224 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 93d955f80be..8489ff0311d 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": "无法获取用户。" },