From 9a3bbb7e6e4236c8713693c53dc6cc5f0ee7fb59 Mon Sep 17 00:00:00 2001 From: mrafie1 Date: Sat, 13 Jun 2026 12:01:52 -0400 Subject: [PATCH 01/24] Updated aria-label to match previous implementations of search bars --- app/javascript/Components/table/search_filter.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/Components/table/search_filter.jsx b/app/javascript/Components/table/search_filter.jsx index 1827f8fc39..48e6217b72 100644 --- a/app/javascript/Components/table/search_filter.jsx +++ b/app/javascript/Components/table/search_filter.jsx @@ -10,7 +10,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={`${I18n.t("search")} ${column.columnDef.header || ""}`} /> ); } From 5a4d69f2cefe2988b2a275c686d347d0089dc6b9 Mon Sep 17 00:00:00 2001 From: mrafie1 Date: Sat, 13 Jun 2026 12:03:15 -0400 Subject: [PATCH 02/24] Added new search component to include filtering by case sensitivity. Updated filter.jsx to account for new component --- .../table/case_sensitive_search_filter.jsx | 37 +++++++++++++++++++ app/javascript/Components/table/filter.jsx | 24 +++++++----- 2 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 app/javascript/Components/table/case_sensitive_search_filter.jsx 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..c77719ceac --- /dev/null +++ b/app/javascript/Components/table/case_sensitive_search_filter.jsx @@ -0,0 +1,37 @@ +import React from "react"; + +export const defaultSearchPlaceholderText = () => I18n.t("table.search"); + +export default function CaseSensitiveSearchFilter({column, filterValue}) { + let caseSensitive; + const toggleCaseSensitivity = column.columnDef.meta.toggleCaseSensitivity; + return ( +
+ column.setFilterValue(event.target.value)} + /> + +
+ ); +} 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}) { From f60d6211309177d812296036be6b26c5e11f054f Mon Sep 17 00:00:00 2001 From: mrafie1 Date: Sat, 13 Jun 2026 12:03:46 -0400 Subject: [PATCH 03/24] Migrated grader and group tables to react table v8 --- app/javascript/Components/graders_manager.jsx | 422 ++++++++++-------- 1 file changed, 237 insertions(+), 185 deletions(-) diff --git a/app/javascript/Components/graders_manager.jsx b/app/javascript/Components/graders_manager.jsx index 7f31e8c32f..3efa0a8cc6 100644 --- a/app/javascript/Components/graders_manager.jsx +++ b/app/javascript/Components/graders_manager.jsx @@ -3,13 +3,10 @@ import {createRoot} from "react-dom/client"; import {Tab, Tabs, TabList, TabPanel} from "react-tabs"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import Table from "./table/table"; +import {createColumnHelper} from "@tanstack/react-table"; import {withSelection, CheckboxTable} from "./markus_with_selection_hoc"; -import { - caseSensitiveStringFilterMethod, - caseSensitiveTextFilter, - selectFilter, - textFilter, -} from "./Helpers/table_helpers"; +import {caseSensitiveIncludes} from "./Helpers/table_helpers"; import {GraderDistributionModal} from "./Modals/graders_distribution_modal"; import {SectionDistributionModal} from "./Modals/section_distribution_modal"; @@ -41,9 +38,9 @@ class GradersManager extends React.Component { } openGraderDistributionModal = () => { - let groups = this.groupsTable ? this.groupsTable.state.selection : []; + let groups = this.groupsTable ? this.groupsTable.getSelectedRows() : []; let criteria = this.criteriaTable ? this.criteriaTable.state.selection : []; - let graders = this.gradersTable.state.selection; + let graders = this.gradersTable ? this.gradersTable.getSelectedRows() : []; if (groups.length === 0 && criteria.length === 0) { alert(I18n.t("groups.select_a_group")); return; @@ -107,9 +104,9 @@ class GradersManager extends React.Component { }; assignAll = () => { - let groups = this.groupsTable ? this.groupsTable.state.selection : []; + let groups = this.groupsTable ? this.groupsTable.getSelectedRows() : []; let criteria = this.criteriaTable ? this.criteriaTable.state.selection : []; - let graders = this.gradersTable.state.selection; + let graders = this.gradersTable ? this.gradersTable.getSelectedRows() : []; if (groups.length === 0 && criteria.length === 0) { alert(I18n.t("groups.select_a_group")); @@ -157,7 +154,7 @@ class GradersManager extends React.Component { }; assignRandomly = weightings => { - let groups = this.groupsTable ? this.groupsTable.state.selection : []; + let groups = this.groupsTable ? this.groupsTable.getSelectedRows() : []; let criteria = this.criteriaTable ? this.criteriaTable.state.selection : []; let graders = Object.keys(weightings); let weights = Object.values(weightings); @@ -180,9 +177,9 @@ class GradersManager extends React.Component { }; unassignAll = () => { - let groups = this.groupsTable ? this.groupsTable.state.selection : []; + let groups = this.groupsTable ? this.groupsTable.getSelectedRows() : []; let criteria = this.criteriaTable ? this.criteriaTable.state.selection : []; - let graders = this.gradersTable.state.selection; + let graders = this.gradersTable ? this.gradersTable.getSelectedRows() : []; if (groups.length === 0 && criteria.length === 0) { alert(I18n.t("groups.select_a_group")); @@ -291,7 +288,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 +450,247 @@ 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", + enableSorting: true, + minSize: 90, + }), + + columnHelper.accessor("full_name", { + header: I18n.t("activerecord.attributes.user.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()); + enableColumnFilter: true, + cell: props => `${props.row.original.first_name} ${props.row.original.last_name}`, + 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; } }, - 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}) => { + 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.state.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(), + rowSelection: {}, + isCaseSensitive: false, }; } - 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(), + }); + } + 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 = () => { 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] || ""; + }), + 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", + toggleCaseSensitivity: isSensitive => { + this.setState({isCaseSensitive: isSensitive}); + }, }, - 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, + this.state.isCaseSensitive + ); }, - 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,54 +707,67 @@ 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, + }), + 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.state.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} /> ); } @@ -797,8 +851,6 @@ class RawCriteriaTable extends React.Component { } } -const GradersTable = withSelection(RawGradersTable); -const GroupsTable = withSelection(RawGroupsTable); const CriteriaTable = withSelection(RawCriteriaTable); class GradersActionBox extends React.Component { From 2f7acc3ba39237e17dc49c6830db267c08217a5c Mon Sep 17 00:00:00 2001 From: mrafie1 Date: Sat, 13 Jun 2026 12:06:01 -0400 Subject: [PATCH 04/24] Updated columnVisibility for group table. Awaiting feedback --- app/javascript/Components/graders_manager.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/javascript/Components/graders_manager.jsx b/app/javascript/Components/graders_manager.jsx index 3efa0a8cc6..4ff58b646d 100644 --- a/app/javascript/Components/graders_manager.jsx +++ b/app/javascript/Components/graders_manager.jsx @@ -746,8 +746,6 @@ class GroupsTable extends React.Component { sorting: [{id: "group_name"}], columnVisibility: { _id: false, - section: this.props.showSections || false, - criteria_coverage_count: this.props.showCoverage, }, }} columnFilters={this.state.columnFilters} From 1d6e1d7c1d4895f458f11b23ec1153e86fcc0c91 Mon Sep 17 00:00:00 2001 From: mrafie1 Date: Sat, 13 Jun 2026 12:41:55 -0400 Subject: [PATCH 05/24] update changelog --- Changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Changelog.md b/Changelog.md index a168e7a906..363a4e51a6 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,7 @@ ### 🚨 Breaking changes ### ✨ New features and improvements +- Migrated GradersManager's `GradersTable` and `GroupsTable` components to React Table V8 (#8002) - 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) - Removed Graders Subcomponent and added a Graders column in the Assignment Grades tab (#7967) From a0abe67ef7145c9f8e23f055a3a53abbbbe2a087 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Sat, 13 Jun 2026 13:06:15 -0400 Subject: [PATCH 06/24] Update Changelog.md --- Changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index 363a4e51a6..fe20f19ae4 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,7 +7,7 @@ ### 🚨 Breaking changes ### ✨ New features and improvements -- Migrated GradersManager's `GradersTable` and `GroupsTable` components to React Table V8 (#8002) +- Migrated `GradersManager`'s `GradersTable` and `GroupsTable` components to React Table V8 (#8002) - 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) - Removed Graders Subcomponent and added a Graders column in the Assignment Grades tab (#7967) From 03dbbc77998ab407e7e2965f1647555168dd1762 Mon Sep 17 00:00:00 2001 From: mrafie1 Date: Sat, 20 Jun 2026 12:13:26 -0230 Subject: [PATCH 07/24] implemented minor feedback --- app/javascript/Components/graders_manager.jsx | 7 +++---- app/javascript/Components/table/search_filter.jsx | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/javascript/Components/graders_manager.jsx b/app/javascript/Components/graders_manager.jsx index 4ff58b646d..327612a17c 100644 --- a/app/javascript/Components/graders_manager.jsx +++ b/app/javascript/Components/graders_manager.jsx @@ -489,7 +489,6 @@ class GradersTable extends React.Component { enableSorting: true, minSize: 90, }), - columnHelper.accessor("full_name", { header: I18n.t("activerecord.attributes.user.full_name"), id: "full_name", @@ -506,7 +505,6 @@ class GradersTable extends React.Component { enableSorting: true, minSize: 170, }), - columnHelper.accessor("groups", { header: I18n.t("activerecord.models.group.other"), enableColumnFilter: false, @@ -534,12 +532,13 @@ class GradersTable extends React.Component { } resetSelection = () => { - this.state.rowSelection = {}; + this.setState({rowSelection: {}}); }; getSelectedRows = () => { return Object.keys(this.state.rowSelection).map(id => Number(id)); }; + componentDidUpdate(prevProps, prevState, snapshot) { if (prevProps.showHidden !== this.props.showHidden) { this.setState(prevState => { @@ -729,7 +728,7 @@ class GroupsTable extends React.Component { }; resetSelection = () => { - this.state.rowSelection = {}; + this.setState({rowSelection: {}}); }; getSelectedRows = () => { diff --git a/app/javascript/Components/table/search_filter.jsx b/app/javascript/Components/table/search_filter.jsx index 48e6217b72..6890470bff 100644 --- a/app/javascript/Components/table/search_filter.jsx +++ b/app/javascript/Components/table/search_filter.jsx @@ -1,6 +1,8 @@ import React from "react"; -export const defaultSearchPlaceholderText = () => I18n.t("table.search"); +export const defaultSearchPlaceholderText = (header = "") => { + return `${I18n.t("table.search")} ${header}`; +}; export default function SearchFilter({column, filterValue}) { return ( @@ -10,7 +12,7 @@ export default function SearchFilter({column, filterValue}) { onChange={e => column.setFilterValue(e.target.value)} value={filterValue?.toString() || ""} style={{width: "100%"}} - aria-label={`${I18n.t("search")} ${column.columnDef.header || ""}`} + aria-label={defaultSearchPlaceholderText(column.columnDef.header)} /> ); } From 4cabefab31c3c2dc18a3d6f5cf6a23bb40f57943 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:27:06 -0400 Subject: [PATCH 08/24] build(deps): bump dayjs from 1.11.13 to 1.11.21 (#7982) Bumps [dayjs](https://github.com/iamkun/dayjs) from 1.11.13 to 1.11.21. - [Release notes](https://github.com/iamkun/dayjs/releases) - [Changelog](https://github.com/iamkun/dayjs/blob/dev/CHANGELOG.md) - [Commits](https://github.com/iamkun/dayjs/compare/v1.11.13...v1.11.21) --- updated-dependencies: - dependency-name: dayjs dependency-version: 1.11.21 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 965307422e..da86029095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@tanstack/react-table": "^8.21.3", "chart.js": "^4.5.0", "core-js": "^3.43.0", - "dayjs": "^1.11.13", + "dayjs": "^1.11.21", "dompurify": "^3.4.0", "flatpickr": "^4.6.13", "heic2any": "^0.0.4", @@ -5412,9 +5412,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, "node_modules/debug": { diff --git a/package.json b/package.json index 7f56309f3c..b89b9fdcca 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@tanstack/react-table": "^8.21.3", "chart.js": "^4.5.0", "core-js": "^3.43.0", - "dayjs": "^1.11.13", + "dayjs": "^1.11.21", "dompurify": "^3.4.0", "flatpickr": "^4.6.13", "heic2any": "^0.0.4", From 76679d7e0aa04ce3360b28646a4a32ee45486308 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:27:33 -0400 Subject: [PATCH 09/24] build(deps): bump action_policy from 0.7.5 to 0.7.6 (#7976) Bumps [action_policy](https://github.com/palkan/action_policy) from 0.7.5 to 0.7.6. - [Release notes](https://github.com/palkan/action_policy/releases) - [Changelog](https://github.com/palkan/action_policy/blob/master/CHANGELOG.md) - [Commits](https://github.com/palkan/action_policy/compare/v0.7.5...v0.7.6) --- updated-dependencies: - dependency-name: action_policy dependency-version: 0.7.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f83b8bfa91..eff80b3720 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - action_policy (0.7.5) + action_policy (0.7.6) ruby-next-core (>= 1.0) action_text-trix (2.1.18) railties @@ -444,7 +444,7 @@ GEM language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 5) - ruby-next-core (1.1.1) + ruby-next-core (1.2.0) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) ruby2_keywords (0.0.5) From df4a04df7ebc33ad1e063030ba4944015ef3908e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:03:38 -0400 Subject: [PATCH 10/24] build(deps): bump dompurify from 3.4.0 to 3.4.9 (#8005) Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.0 to 3.4.9. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.4.0...3.4.9) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.9 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index da86029095..8e7dccb251 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "chart.js": "^4.5.0", "core-js": "^3.43.0", "dayjs": "^1.11.21", - "dompurify": "^3.4.0", + "dompurify": "^3.4.9", "flatpickr": "^4.6.13", "heic2any": "^0.0.4", "i18n-js": "^4.5.1", @@ -5504,9 +5504,9 @@ "dev": true }, "node_modules/dompurify": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", - "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz", + "integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" diff --git a/package.json b/package.json index b89b9fdcca..f7a9fe4ce7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "chart.js": "^4.5.0", "core-js": "^3.43.0", "dayjs": "^1.11.21", - "dompurify": "^3.4.0", + "dompurify": "^3.4.9", "flatpickr": "^4.6.13", "heic2any": "^0.0.4", "i18n-js": "^4.5.1", From 42ad043a47571ce5709dc3560a328e8fc290afd4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:06:24 -0400 Subject: [PATCH 11/24] build(deps): bump playwright from 1.59.0 to 1.60.0 (#7980) Bumps [playwright](https://github.com/microsoft/playwright-python) from 1.59.0 to 1.60.0. - [Release notes](https://github.com/microsoft/playwright-python/releases) - [Commits](https://github.com/microsoft/playwright-python/compare/v1.59.0...v1.60.0) --- updated-dependencies: - dependency-name: playwright dependency-version: 1.60.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-jupyter.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-jupyter.txt b/requirements-jupyter.txt index e4be055397..7df944c099 100644 --- a/requirements-jupyter.txt +++ b/requirements-jupyter.txt @@ -1,3 +1,3 @@ # TODO: replace requirements files with Pipfile file (when it is stable and supported by pip) nbconvert==7.17.1 -playwright==1.59.0 +playwright==1.60.0 From 61d8a105a12d8a6a91de3b1c6902cdaa2711a33b Mon Sep 17 00:00:00 2001 From: donny-wong <141858744+donny-wong@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:53:55 -0400 Subject: [PATCH 12/24] Fixed SVG rendering by converting base64 SVG data URIs to inline (#8001) --- Changelog.md | 1 + app/helpers/application_helper.rb | 7 ++ app/views/submissions/html_content.html.erb | 3 +- spec/helpers/application_helper_spec.rb | 118 ++++++++++++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 spec/helpers/application_helper_spec.rb diff --git a/Changelog.md b/Changelog.md index fe20f19ae4..00d2e16d0e 100644 --- a/Changelog.md +++ b/Changelog.md @@ -19,6 +19,7 @@ - Fixed bug where merge commits were incorrectly flagged as making a new assignment submission when no assignment files were changed (#7988) - Fixed shift+up/shift+down keybinding being suppressed when a criterion input had focus; active criterion now scrolls into view when navigated to via keyboard (#7989) - Fixed autotester spec upload when spec contains non-existent criterion (#7998) +- Fix SVG rendering by converting base64 SVG data URIs to inline (#8001) ### 🔧 Internal changes - Added release automation scripts (#7914) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 05e875675e..a2a86aa4a0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -57,4 +57,11 @@ def markdown(text) def yield_content!(content_key) view_flow.content.delete(content_key) end + + def inline_svg_data_uris(html) + html.to_s.gsub(%r{]*?\bsrc=(["'])data:image/svg\+xml;base64,([^"']+)\1[^>]*?>}i) do + svg = Base64.decode64(Regexp.last_match(2)).force_encoding('UTF-8') + svg.sub(/\A.*?(?= prolog / DOCTYPE / comments + end + end end diff --git a/app/views/submissions/html_content.html.erb b/app/views/submissions/html_content.html.erb index a67d8a2b9a..be85b5c966 100644 --- a/app/views/submissions/html_content.html.erb +++ b/app/views/submissions/html_content.html.erb @@ -1 +1,2 @@ -<%= sanitize(@html_content, scrubber: Rails::HTML::TargetScrubber.new(prune: true)) %> +<%= sanitize(inline_svg_data_uris(@html_content), + scrubber: Rails::HTML::TargetScrubber.new(prune: true)) %> diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb new file mode 100644 index 0000000000..69d5e59808 --- /dev/null +++ b/spec/helpers/application_helper_spec.rb @@ -0,0 +1,118 @@ +describe ApplicationHelper do + describe '#inline_svg_data_uris' do + # Build an tag whose src is a base64 SVG data URI. + # quote: wrap the src value in " or ' + # attrs: extra attributes inserted right after `) + end + + let(:plain_svg) do + '' + end + + # --- happy path ------------------------------------------------------- + + it 'replaces an SVG data-URI with the inlined SVG markup' do + result = helper.inline_svg_data_uris(svg_img(plain_svg)) + expect(result).to eq(plain_svg) + end + + it 'preserves surrounding HTML around the replaced image' do + html = %(
#{svg_img(plain_svg)}
) + result = helper.inline_svg_data_uris(html) + expect(result).to eq(%(
#{plain_svg}
)) + end + + it 'replaces multiple SVG data-URI images in one document' do + svg_a = '' + svg_b = '' + html = "#{svg_img(svg_a)} and #{svg_img(svg_b)}" + result = helper.inline_svg_data_uris(html) + expect(result).to eq("#{svg_a} and #{svg_b}") + end + + # --- prolog / doctype / comment stripping ----------------------------- + + it 'strips an prolog before the root' do + svg = %(\n#{plain_svg}) + result = helper.inline_svg_data_uris(svg_img(svg)) + expect(result).to eq(plain_svg) + expect(result).not_to include(' root' do + svg = %(\n\n#{plain_svg}) + result = helper.inline_svg_data_uris(svg_img(svg)) + expect(result).to eq(plain_svg) + expect(result).not_to include('DOCTYPE') + expect(result).not_to include('