diff --git a/Changelog.md b/Changelog.md index f48be37f0f..46775f3296 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,7 @@ ### 🚨 Breaking changes ### ✨ New features and improvements +- Migrated `graders_manager.jsx` file's `GradersTable`, `GroupsTable`, `CriteriaTable` to use `react-table` v8 (#8014) - Added a confirm dialog when a student tries to submit work after the deadline has passed (#8003) - Added a confirm dialog to the Upload Scans form that appears when no template divisions are assigned to the selected exam template (#7993) - Migrated `MarkingSchemesTable` component to React Table V8 (#7985) diff --git a/app/javascript/Components/graders_manager.jsx b/app/javascript/Components/graders_manager.jsx index 7f31e8c32f..861c80fd08 100644 --- a/app/javascript/Components/graders_manager.jsx +++ b/app/javascript/Components/graders_manager.jsx @@ -3,13 +3,9 @@ import {createRoot} from "react-dom/client"; import {Tab, Tabs, TabList, TabPanel} from "react-tabs"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {withSelection, CheckboxTable} from "./markus_with_selection_hoc"; -import { - caseSensitiveStringFilterMethod, - caseSensitiveTextFilter, - selectFilter, - textFilter, -} from "./Helpers/table_helpers"; +import Table from "./table/table"; +import {createColumnHelper} from "@tanstack/react-table"; +import {caseSensitiveIncludes} from "./Helpers/table_helpers"; import {GraderDistributionModal} from "./Modals/graders_distribution_modal"; import {SectionDistributionModal} from "./Modals/section_distribution_modal"; @@ -41,9 +37,9 @@ class GradersManager extends React.Component { } openGraderDistributionModal = () => { - let groups = this.groupsTable ? this.groupsTable.state.selection : []; - let criteria = this.criteriaTable ? this.criteriaTable.state.selection : []; - let graders = this.gradersTable.state.selection; + let groups = this.groupsTable ? this.groupsTable.getSelectedRows() : []; + let criteria = this.criteriaTable ? this.criteriaTable.getSelectedRows() : []; + let graders = this.gradersTable ? this.gradersTable.getSelectedRows() : []; if (groups.length === 0 && criteria.length === 0) { alert(I18n.t("groups.select_a_group")); return; @@ -107,9 +103,9 @@ class GradersManager extends React.Component { }; assignAll = () => { - let groups = this.groupsTable ? this.groupsTable.state.selection : []; - let criteria = this.criteriaTable ? this.criteriaTable.state.selection : []; - let graders = this.gradersTable.state.selection; + let groups = this.groupsTable ? this.groupsTable.getSelectedRows() : []; + let criteria = this.criteriaTable ? this.criteriaTable.getSelectedRows() : []; + let graders = this.gradersTable ? this.gradersTable.getSelectedRows() : []; if (groups.length === 0 && criteria.length === 0) { alert(I18n.t("groups.select_a_group")); @@ -157,8 +153,8 @@ class GradersManager extends React.Component { }; assignRandomly = weightings => { - let groups = this.groupsTable ? this.groupsTable.state.selection : []; - let criteria = this.criteriaTable ? this.criteriaTable.state.selection : []; + let groups = this.groupsTable ? this.groupsTable.getSelectedRows() : []; + let criteria = this.criteriaTable ? this.criteriaTable.getSelectedRows() : []; let graders = Object.keys(weightings); let weights = Object.values(weightings); @@ -180,9 +176,9 @@ class GradersManager extends React.Component { }; unassignAll = () => { - let groups = this.groupsTable ? this.groupsTable.state.selection : []; - let criteria = this.criteriaTable ? this.criteriaTable.state.selection : []; - let graders = this.gradersTable.state.selection; + let groups = this.groupsTable ? this.groupsTable.getSelectedRows() : []; + let criteria = this.criteriaTable ? this.criteriaTable.getSelectedRows() : []; + let graders = this.gradersTable ? this.gradersTable.getSelectedRows() : []; if (groups.length === 0 && criteria.length === 0) { alert(I18n.t("groups.select_a_group")); @@ -291,7 +287,7 @@ class GradersManager extends React.Component { getAssignedGraderObjects = () => { return this.state.graders.filter(grader => { - return this.gradersTable.state.selection.includes(grader._id); + return this.gradersTable.getSelectedRows().includes(grader._id); }); }; @@ -453,203 +449,253 @@ class GradersManager extends React.Component { } } -class RawGradersTable extends React.Component { +const columnHelper = createColumnHelper(); +class GradersTable extends React.Component { constructor(props) { super(props); this.state = { - filtered: [], + columnFilters: [{id: "hidden", value: false}], + rowSelection: {}, columns: [ - { - accessor: "hidden", + columnHelper.accessor("hidden", { id: "hidden", - width: 0, - className: "rt-hidden", - headerClassName: "rt-hidden", - resizable: false, - }, - { - show: false, - accessor: "_id", + size: 0, + meta: { + className: "rt-hidden", + headerClassName: "rt-hidden", + }, + enableResizing: false, + }), + columnHelper.accessor("_id", { id: "_id", - }, - { - Header: I18n.t("activerecord.attributes.user.user_name"), - accessor: "user_name", + }), + columnHelper.accessor("user_name", { + header: I18n.t("activerecord.attributes.user.user_name"), id: "user_name", - Cell: props => - props.original.hidden - ? `${props.value} (${I18n.t("activerecord.attributes.user.hidden")})` - : props.value, - filterMethod: (filter, row) => { - if (filter.value) { - return `${row._original.user_name}${ - row._original.hidden ? `, ${I18n.t("activerecord.attributes.user.hidden")}` : "" - }`.includes(filter.value); + cell: ({getValue, row}) => + row.original.hidden + ? `${getValue()} (${I18n.t("activerecord.attributes.user.hidden")})` + : getValue(), + filterFn: (row, columnId, filterValue) => { + if (filterValue) { + return `${row.original.user_name}${ + row.original.hidden ? `, ${I18n.t("activerecord.attributes.user.hidden")}` : "" + }`.includes(filterValue); } else { return true; } }, - sortable: true, - minWidth: 90, - }, - { - Header: I18n.t("activerecord.attributes.user.full_name"), - accessor: "full_name", - id: "full_name", - filterable: true, - Filter: textFilter, - Cell: row => `${row.original.first_name} ${row.original.last_name}`, - filterMethod: (filter, row) => { - if (filter.value) { - const fullName = - `${row._original.first_name} ${row._original.last_name}`.toLowerCase(); - return fullName.includes(filter.value.toLowerCase()); - } else { - return true; - } + enableSorting: true, + minSize: 90, + }), + columnHelper.accessor( + row => { + const first_name = row.first_name; + const last_name = row.last_name; + return `${first_name} ${last_name}`; }, - sortable: true, - minWidth: 170, - }, - { - Header: I18n.t("activerecord.models.group.other"), - accessor: "groups", - className: "number", - filterable: false, - }, - { - Header: I18n.t("activerecord.models.criterion.other"), - accessor: "criteria", - filterable: false, - Cell: ({value}) => { + { + header: I18n.t("activerecord.attributes.user.full_name"), + id: "full_name", + enableColumnFilter: true, + filterFn: (row, columnId, filterValue) => { + if (filterValue) { + const fullName = + `${row.original.first_name} ${row.original.last_name}`.toLowerCase(); + return fullName.includes(filterValue.toLowerCase()); + } else { + return true; + } + }, + enableSorting: true, + minSize: 170, + } + ), + columnHelper.accessor("groups", { + header: I18n.t("activerecord.models.group.other"), + enableColumnFilter: false, + meta: { + className: "number", + }, + }), + columnHelper.accessor("criteria", { + header: I18n.t("activerecord.models.criterion.other"), + enableColumnFilter: false, + cell: ({getValue}) => { if (this.props.assign_graders_to_criteria) { return ( - {value}/{this.props.numCriteria} + {getValue()}/{this.props.numCriteria} ); } else { return I18n.t("all"); } }, - }, + }), ], }; } - static getDerivedStateFromProps(props, state) { - let filtered = []; - for (let i = 0; i < state.filtered.length; i++) { - if (state.filtered[i].id !== "hidden") { - filtered.push(state.filtered[i]); - } - } - if (!props.showHidden) { - filtered.push({id: "hidden", value: false}); - } - return {filtered}; - } + resetSelection = () => { + this.setState({rowSelection: {}}); + }; - onFilteredChange = filtered => { - this.setState({filtered}); + getSelectedRows = () => { + return Object.keys(this.state.rowSelection).map(id => Number(id)); }; + componentDidUpdate(prevProps, prevState, snapshot) { + if (prevProps.showHidden !== this.props.showHidden) { + this.setState(prevState => { + let newFilters = prevState.columnFilters; + + if (this.props.showHidden) { + newFilters = newFilters.filter(f => f.id !== "hidden"); + } else { + if (!newFilters.some(f => f.id === "hidden")) { + newFilters = [...newFilters, {id: "hidden", value: false}]; + } + } + return {columnFilters: newFilters}; + }); + } + } + render() { return ( - (this.checkboxTable = r)} + { + this.setState(prevState => { + let newFilters = + typeof updaterOrValue === "function" + ? updaterOrValue(prevState.columnFilters) + : updaterOrValue; + return {columnFilters: newFilters}; + }); + }} + enableRowSelection={true} + rowSelection={this.state.rowSelection} + onRowSelectionChange={updater => { + this.setState(prevState => ({ + rowSelection: typeof updater === "function" ? updater(prevState.rowSelection) : updater, + })); + }} + getRowId={row => row._id} /> ); } } -class RawGroupsTable extends React.Component { +class GroupsTable extends React.Component { constructor(props) { super(props); this.state = { - filtered: [], - columns: this.getColumns(props.showSections, props.sections, props.showCoverage), + columnFilters: [{id: "inactive", value: false}], + columns: this.getColumns(this.props.showCoverage, this.props.showSections), + rowSelection: {}, }; } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps, prevState, snapshot) { if ( prevProps.showSections !== this.props.showSections || prevProps.sections !== this.props.sections || prevProps.showCoverage !== this.props.showCoverage ) { this.setState({ - columns: this.getColumns( - this.props.showSections, - this.props.sections, - this.props.showCoverage - ), + columns: this.getColumns(this.props.showCoverage, this.props.showSections), + }); + } + if (prevProps.showInactive !== this.props.showInactive) { + this.setState(prevState => { + let newFilters = prevState.columnFilters; + + if (this.props.showInactive) { + newFilters = newFilters.filter(f => f.id !== "inactive"); + } else { + if (!newFilters.some(f => f.id === "inactive")) { + newFilters = [...newFilters, {id: "inactive", value: false}]; + } + } + return {columnFilters: newFilters}; }); } } - getColumns = (showSections, sections, showCoverage) => { + getColumns = (showCoverage, showSections = false) => { return [ - { - accessor: "inactive", + columnHelper.accessor("inactive", { id: "inactive", - width: 0, - className: "rt-hidden", - headerClassName: "rt-hidden", - resizable: false, - }, - { - show: false, - accessor: "_id", + size: 0, + meta: { + className: "rt-hidden", + headerClassName: "rt-hidden", + }, + enableResizing: false, + }), + columnHelper.accessor("_id", { id: "_id", - }, - { - Header: I18n.t("activerecord.models.section", {count: 1}), - accessor: "section", - id: "section", - show: showSections || false, - minWidth: 70, - Cell: ({value}) => { - return this.props.sections[value] || ""; + }), + ...(showSections + ? [ + columnHelper.accessor( + row => { + const sectionId = row.section; + return this.props.sections[sectionId] || ""; + }, + { + header: I18n.t("activerecord.models.section", {count: 1}), + id: "section", + minSize: 70, + filterFn: (row, columnId, filterValue) => { + if (filterValue === "all") { + return true; + } else { + return this.props.sections[row.original.section] === filterValue; + } + }, + meta: { + filterVariant: "select", + }, + } + ), + ] + : []), + columnHelper.accessor("group_name", { + header: I18n.t("activerecord.models.group.one"), + id: "group_name", + minSize: 150, + meta: { + filterVariant: "case-sensitive-text", }, - filterMethod: (filter, row) => { - if (filter.value === "all") { + filterFn: (row, columnId, filterValue) => { + if (!filterValue) { return true; - } else { - return this.props.sections[row[filter.id]] === filter.value; } + return caseSensitiveIncludes( + row.original[columnId], + filterValue.value, + filterValue.caseSensitive + ); }, - Filter: selectFilter, - filterOptions: Object.entries(sections).map(kv => ({ - value: kv[1], - text: kv[1], - })), - }, - { - Header: I18n.t("activerecord.models.group.one"), - accessor: "group_name", - id: "group_name", - minWidth: 150, - Filter: caseSensitiveTextFilter, - filterMethod: caseSensitiveStringFilterMethod, - }, - { - Header: I18n.t("activerecord.models.ta.other"), - accessor: "graders", - Cell: row => { - return row.value.map(ta_data => ( + enableColumnFilter: true, + }), + columnHelper.accessor("graders", { + header: I18n.t("activerecord.models.ta.other"), + cell: ({getValue, row}) => { + return getValue().map(ta_data => (
{ta_data.hidden ? `${ta_data.grader} (${I18n.t("activerecord.attributes.user.hidden")})` @@ -666,79 +712,92 @@ class RawGroupsTable extends React.Component {
)); }, - filterable: false, - minWidth: 100, - }, - { - Header: I18n.t("graders.coverage"), - accessor: "criteria_coverage_count", - Cell: ({value}) => ( - - {value || 0}/{this.props.numCriteria} - - ), - minWidth: 70, - className: "number", - filterable: false, - show: showCoverage, - }, + enableColumnFilter: false, + minSize: 100, + }), + ...(showCoverage + ? [ + columnHelper.accessor("criteria_coverage_count", { + header: I18n.t("graders.coverage"), + cell: ({getValue}) => { + return ( + + {getValue() || 0}/{this.props.numCriteria} + + ); + }, + minSize: 70, + enableColumnFilter: false, + meta: { + className: "number", + }, + }), + ] + : []), ]; }; - static getDerivedStateFromProps(props, state) { - let filtered = state.filtered.filter(group => group.id !== "inactive"); - - if (!props.showInactive) { - filtered.push({id: "inactive", value: false}); - } - return {filtered}; - } + resetSelection = () => { + this.setState({rowSelection: {}}); + }; - onFilteredChange = filtered => { - this.setState({filtered}); + getSelectedRows = () => { + return Object.keys(this.state.rowSelection).map(id => Number(id)); }; render() { return ( - (this.checkboxTable = r)} +
{ + this.setState(prevState => { + let newFilters = + typeof updaterOrValue === "function" + ? updaterOrValue(prevState.columnFilters) + : updaterOrValue; + return {columnFilters: newFilters}; + }); + }} + enableRowSelection={true} + rowSelection={this.state.rowSelection} + onRowSelectionChange={updater => { + this.setState(prevState => ({ + rowSelection: typeof updater === "function" ? updater(prevState.rowSelection) : updater, + })); + }} + getRowId={row => row._id} /> ); } } -class RawCriteriaTable extends React.Component { +class CriteriaTable extends React.Component { constructor(props) { super(props); this.state = { + columnFilters: [], columns: [ - { - show: false, - accessor: "_id", + columnHelper.accessor("_id", { id: "_id", - }, - { - Header: I18n.t("activerecord.attributes.criterion.name"), - accessor: "name", - minWidth: 150, - }, - { - Header: I18n.t("activerecord.models.ta.other"), - accessor: "graders", - Cell: row => { - return row.value.map(ta_data => ( + }), + columnHelper.accessor("name", { + header: I18n.t("activerecord.attributes.criterion.name"), + minSize: 150, + }), + columnHelper.accessor("graders", { + header: I18n.t("activerecord.models.ta.other"), + cell: ({getValue, row}) => { + return getValue().map(ta_data => (
{ta_data.hidden ? `${ta_data.grader} (${I18n.t("activerecord.attributes.user.hidden")})` @@ -755,40 +814,67 @@ class RawCriteriaTable extends React.Component {
)); }, - filterable: false, - minWidth: 70, - }, - { - Header: I18n.t("graders.coverage"), - accessor: "coverage", - Cell: ({value}) => ( + enableColumnFilter: false, + minSize: 70, + }), + columnHelper.accessor("coverage", { + header: I18n.t("graders.coverage"), + cell: ({getValue}) => ( - {value}/{this.props.numGroups} + {getValue()}/{this.props.numGroups} ), - minWidth: 70, - className: "number", - filterable: false, - }, + minSize: 70, + enableColumnFilter: false, + meta: { + className: "number", + }, + }), ], + rowSelection: {}, }; } + resetSelection = () => { + this.setState({rowSelection: {}}); + }; + + getSelectedRows = () => { + return Object.keys(this.state.rowSelection).map(id => Number(id)); + }; + render() { if (this.props.display) { return ( - (this.checkboxTable = r)} +
{ + this.setState(prevState => { + let newFilters = + typeof updaterOrValue === "function" + ? updaterOrValue(prevState.columnFilters) + : updaterOrValue; + return {columnFilters: newFilters}; + }); + }} + enableRowSelection={true} + rowSelection={this.state.rowSelection} + onRowSelectionChange={updater => { + this.setState(prevState => ({ + rowSelection: + typeof updater === "function" ? updater(prevState.rowSelection) : updater, + })); + }} + getRowId={row => row._id} /> ); } else { @@ -797,10 +883,6 @@ class RawCriteriaTable extends React.Component { } } -const GradersTable = withSelection(RawGradersTable); -const GroupsTable = withSelection(RawGroupsTable); -const CriteriaTable = withSelection(RawCriteriaTable); - class GradersActionBox extends React.Component { render = () => { let showHiddenGraderTooltip = ""; diff --git a/app/javascript/Components/table/case_sensitive_search_filter.jsx b/app/javascript/Components/table/case_sensitive_search_filter.jsx new file mode 100644 index 0000000000..8de2b9ff53 --- /dev/null +++ b/app/javascript/Components/table/case_sensitive_search_filter.jsx @@ -0,0 +1,44 @@ +import React from "react"; + +export const defaultSearchPlaceholderText = () => I18n.t("table.search"); + +export default function CaseSensitiveSearchFilter({column, filterValue}) { + let caseSensitive; + return ( +
+ { + column.setFilterValue({ + value: event.target.value, + caseSensitive: filterValue?.caseSensitive ?? false, + }); + }} + /> + +
+ ); +} diff --git a/app/javascript/Components/table/filter.jsx b/app/javascript/Components/table/filter.jsx index 7957cc775a..1db6c34113 100644 --- a/app/javascript/Components/table/filter.jsx +++ b/app/javascript/Components/table/filter.jsx @@ -1,19 +1,23 @@ import React from "react"; import SearchFilter from "./search_filter"; import SelectFilter from "./select_filter"; +import CaseSensitiveSearchFilter from "./case_sensitive_search_filter"; function Filter({column, filterValue, facetedUniqueValues}) { const {filterVariant} = column.columnDef.meta ?? {}; - - return filterVariant === "select" ? ( - - ) : ( - - ); + if (filterVariant === "select") { + return ( + + ); + } else if (filterVariant === "case-sensitive-text") { + return ; + } else { + return ; + } } function FilterCell({size, column, filterValue, facetedUniqueValues}) { diff --git a/app/javascript/Components/table/search_filter.jsx b/app/javascript/Components/table/search_filter.jsx index 1827f8fc39..489b1a34d3 100644 --- a/app/javascript/Components/table/search_filter.jsx +++ b/app/javascript/Components/table/search_filter.jsx @@ -1,6 +1,9 @@ import React from "react"; -export const defaultSearchPlaceholderText = () => I18n.t("table.search"); +export const defaultSearchPlaceholderText = (header = "") => { + const base = I18n.t("table.search"); + return header ? `${base} ${header}` : base; +}; export default function SearchFilter({column, filterValue}) { return ( @@ -10,7 +13,7 @@ export default function SearchFilter({column, filterValue}) { onChange={e => column.setFilterValue(e.target.value)} value={filterValue?.toString() || ""} style={{width: "100%"}} - aria-label={defaultSearchPlaceholderText()} + aria-label={defaultSearchPlaceholderText(column.columnDef.header)} /> ); } diff --git a/app/javascript/Components/table/table.jsx b/app/javascript/Components/table/table.jsx index 406e223957..33c96380de 100644 --- a/app/javascript/Components/table/table.jsx +++ b/app/javascript/Components/table/table.jsx @@ -224,6 +224,7 @@ export default function Table({ // columnSizing is not used directly in TableRow, but is passed to trigger // re-render when column sizes change columnSizing={columnSizing} + columns={finalColumns} /> ))} {loading && table.getRowModel().rows.length > 0 && ( diff --git a/app/javascript/Components/table/table_row.jsx b/app/javascript/Components/table/table_row.jsx index 9e336d79f1..6bb1cae9e4 100644 --- a/app/javascript/Components/table/table_row.jsx +++ b/app/javascript/Components/table/table_row.jsx @@ -32,5 +32,6 @@ export default React.memo( prev.row.original === next.row.original && prev.isSelected === next.isSelected && prev.isExpanded === next.isExpanded && - prev.columnSizing === next.columnSizing + prev.columnSizing === next.columnSizing && + prev.columns === next.columns );