diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c846fb1021..6c88ffb23e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - id: prettier types_or: [javascript, jsx, css, scss, html] - repo: https://github.com/thibaudcolas/pre-commit-stylelint - rev: v17.10.0 + rev: v17.12.0 hooks: - id: stylelint additional_dependencies: [ @@ -39,7 +39,7 @@ repos: app/assets/stylesheets/common/_reset.scss )$ - repo: https://github.com/rubocop/rubocop - rev: v1.86.1 + rev: v1.87.0 hooks: - id: rubocop args: ["--autocorrect"] diff --git a/Changelog.md b/Changelog.md index a168e7a906..87996c8b33 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,19 +7,24 @@ ### 🚨 Breaking changes ### ✨ New features and improvements +- Migrated `GradersManager`'s `GradersTable` and `GroupsTable` components to React Table V8 (#8002) +- 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) - Removed Graders Subcomponent and added a Graders column in the Assignment Grades tab (#7967) - Added GET and PATCH /overall_comment API routes (#7963) - Add case-sensitive search toggle to group name filters in graders, groups, submissions, and annotation usage tables (#7938) +- Add pagination to Admin Users table for performance (#7997) ### 🐛 Bug fixes - Fixed bug where clicking MarkUs logo in navbar on mobile would open the sidebar instead of redirecting to courses page (#7990) - 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 +- Replaced the grading view's custom jQuery pane resizing logic with `react-resizable-panels` (#8000) - Added release automation scripts (#7914) - Refactored the `SummaryPanel` marks chart modal to use `react-modal` instead of `ModalMarkus`, with test coverage for opening and closing the modal (#7996) - Moved rubric criterion keyboard navigation (up/down/enter) from a global jQuery-based keybinding into `RubricCriterionInput`, replacing DOM class mutation with React state (`hoveredLevelIndex`); moved criterion navigation (shift+up/shift+down) into `MarksPanel`, eliminating the `window.marksPanel` global (#7989) @@ -34,6 +39,7 @@ - Refactored `SubmissionFilePanel` subcomponents to React functional components (#7969) - Migrated asset pipeline from Sprockets to Propshaft (#7970) - Simplified Chart.js usage: removed the `DataChart` wrapper component, converted `chart_config.js` to an ES module, and replaced `registerables` with a minimal set of Chart.js components (#7987) +- Added missing foreign keys in seed data (#8006) ## [v2.10.0] diff --git a/Gemfile.lock b/Gemfile.lock index f83b8bfa91..90e1c0c617 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 @@ -123,7 +123,7 @@ GEM combine_pdf (1.0.31) matrix ruby-rc4 (>= 0.1.5) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.7) config (5.6.1) deep_merge (~> 1.2, >= 1.2.1) ostruct @@ -278,7 +278,7 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.3) + nokogiri (1.19.4) mini_portile2 (~> 2.8.2) racc (~> 1.4) observer (0.1.2) @@ -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) diff --git a/app/assets/javascripts/Results/main.js b/app/assets/javascripts/Results/main.js index f80de27009..28205c0382 100644 --- a/app/assets/javascripts/Results/main.js +++ b/app/assets/javascripts/Results/main.js @@ -25,109 +25,3 @@ domContentLoadedCB(); } })(); - -/* Constants: change them to customize the columns */ -// Initial width percentage of left pane (e.g. 0.4 for 40%/60%) -var offset = 0.7; - -// Limit from left/right that you can drag to. Must be smaller than offset -var limit = 0.25; - -/* Global variables, manipulated later */ -var left, right, panes_width, panes_offset, bounds; -var $panes, $drag; - -/* Resizes the columns and handles column widths limits */ -function resize_col() { - if (offset >= limit && offset <= 1 - limit) { - $drag.draggable("option", "revert", false); - left.style.width = offset * panes_width + "px"; - var drag_width = $drag.width() + 2 * parseInt($drag.css("margin-left")); - right.style.width = (1 - offset) * panes_width - drag_width + "px"; - } else { - // Just in case we somehow go past the limit - $drag.draggable("option", "revert", true); - offset = offset < limit ? limit : 1 - limit; - } - - if (window.pdfViewer) { - window.pdfViewer.refresh_annotations(); - } -} - -/* Makes the bar draggable only along x-axis, containing to the panes box, - and handles the actual dragging event */ -function make_draggable() { - $drag.draggable({ - axis: "x", - containment: bounds, - revertDuration: 250, - drag: function (event, ui) { - // Update values in case they changed - panes_width = $panes.width(); - panes_offset = $panes.offset(); - - // Calculate offset and resize - offset = (ui.offset.left - panes_offset.left) / panes_width; - resize_col(); - }, - }); -} - -/* Calculates the bounds for the drag bar */ -function calculate_bounds() { - var drag_width = $drag.width() + 2 * parseInt($drag.css("margin-left")); - bounds = [ - panes_offset.left + limit * panes_width + drag_width, - panes_offset.top, - panes_offset.left + panes_width - limit * panes_width - drag_width, - panes_offset.top + $panes.height(), - ]; -} - -/* On page load: get DOM elements, calculate some stuff, - and initialize the drag bar/columns. */ -function initializePanes() { - left = document.getElementById("left-pane"); - right = document.getElementById("right-pane"); - $panes = $("#panes"); - $drag = $("#drag"); - panes_width = $panes.width(); - panes_offset = $panes.offset(); - - // Bounding box, taking the limit into account - calculate_bounds(); - - // Make sure the constants given are valid/positive - offset = Math.abs(offset); - limit = Math.abs(limit); - - // Initialize the drag bar and resize the columns - make_draggable(); - $drag.css("left", panes_offset.left + offset * panes_width + "px"); - resize_col(); - window.addEventListener("resize", fix_panes); -} - -/* Handle window resizing */ -// NOTE: Don't manually override window.onresize. This will conflict with -// other such uses, as we do in menu.js (TODO: change that one, too). -function fix_panes() { - panes_width = $panes.width(); - panes_offset = $panes.offset(); - resize_col(); - - // Update bounds - calculate_bounds(); - $drag.draggable("destroy"); - make_draggable(); - - // Make sure the drag bar stays in the right place - $drag.css("left", panes_offset.left + offset * panes_width + "px"); - $drag.css("position", "inherit"); - - // Fix pdfViewer, if it exists. - if (window.pdfViewer) { - window.pdfViewer.refresh_annotations(); - } -} diff --git a/app/assets/stylesheets/grader.scss b/app/assets/stylesheets/grader.scss index 1068376199..dd2aefc3a8 100644 --- a/app/assets/stylesheets/grader.scss +++ b/app/assets/stylesheets/grader.scss @@ -202,23 +202,21 @@ display: flex; flex-grow: 1; margin-top: 0.5em; + min-height: 0; width: 100%; #panes { - display: flex; width: 100%; @include mixins.breakpoint(small) { - display: block; + display: block !important; + overflow: auto !important; } } - #left-pane, - #drag, - #right-pane { + .result-pane { display: flex; flex-direction: column; - flex-grow: 1; margin: 0; padding: 0; vertical-align: top; @@ -226,7 +224,7 @@ #left-pane, #right-pane { - overflow: auto; + min-width: 0; @include mixins.breakpoint(small) { display: block; @@ -235,21 +233,21 @@ } } - #left-pane { + #left-pane .result-pane { margin-right: 3px; - width: 70%; @include mixins.breakpoint(small) { margin-bottom: 1em; + margin-right: 0; padding-right: 0; } } - #right-pane { + #right-pane .result-pane { margin-left: 3px; - width: 29.5%; @include mixins.breakpoint(small) { + margin-left: 0; padding-left: 0; } } @@ -257,11 +255,10 @@ #drag { background: #ccc; cursor: col-resize; - position: inherit; width: 4px; @include mixins.breakpoint(small) { - display: none; + display: none !important; } } } diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 7ca3263070..2a46c8b5dc 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,6 +1,8 @@ module Admin class UsersController < ApplicationController DEFAULT_FIELDS = [:id, :user_name, :email, :id_number, :type, :first_name, :last_name].freeze + SEARCHABLE_FIELDS = %w[user_name first_name last_name email id_number].freeze + SORTABLE_FIELDS = %w[user_name first_name last_name email id_number type].freeze before_action { authorize! } respond_to :html @@ -10,7 +12,19 @@ def index respond_to do |format| format.html format.json do - render json: visible_users.order(:created_at).pluck_to_hash(*DEFAULT_FIELDS) + users_scope = sorted_users(filtered_users(visible_users)) + + per_page = (params[:per_page] || 100).to_i + current_page = (params[:page] || 1).to_i + total_count = users_scope.count + total_pages = [(total_count.to_f / per_page).ceil, 1].max + offset_value = (current_page - 1) * per_page + records = users_scope.limit(per_page).offset(offset_value) + + render json: { + users: records.pluck_to_hash(*DEFAULT_FIELDS), + total_pages: total_pages + } end end end @@ -64,6 +78,42 @@ def visible_users User.where.not(type: :AutotestUser) end + # Apply column/type filters from the `filtered` param to the given scope. + def filtered_users(scope) + return scope if params[:filtered].blank? + + JSON.parse(params[:filtered]).each do |f| + next if f['value'].blank? + + if SEARCHABLE_FIELDS.include?(f['id']) + term = "%#{User.sanitize_sql_like(f['value'].strip)}%" + scope = scope.where("#{f['id']} ILIKE ?", term) + elsif f['id'] == 'type' && f['value'] != 'all' + scope = scope.where(type: f['value']) + end + end + + scope + end + + # Apply ordering from the `sorted` param to the given scope. + # Falls back to ordering by user_name when no valid sort is provided. + def sorted_users(scope) + return scope.order(:user_name) if params[:sorted].blank? + + sort_configs = JSON.parse(params[:sorted]) + order_clauses = sort_configs.filter_map do |sort_config| + next unless SORTABLE_FIELDS.include?(sort_config['id']) + + direction = sort_config['desc'] ? 'DESC' : 'ASC' + "#{sort_config['id']} #{direction}" + end + + return scope.order(:user_name) if order_clauses.empty? + + scope.order(Arel.sql(order_clauses.join(', '))) + end + def flash_interpolation_options { resource_name: @user.user_name.presence || @user.model_name.human, errors: @user.errors.full_messages.join('; ') } diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index e2e6f5e39c..17de5b3e81 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -135,6 +135,10 @@ def file_manager set_filebrowser_vars(@grouping) flash_file_manager_messages + past_due_date = @assignment.grouping_past_due_date?(@grouping) + past_collection_date = @grouping.past_collection_date? + @show_late_submit_confirmation = past_due_date && !past_collection_date + render 'file_manager', layout: 'assignment_content', locals: {} end 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/javascript/Components/Result/result.jsx b/app/javascript/Components/Result/result.jsx index fa02a9aca1..e38b4445c1 100644 --- a/app/javascript/Components/Result/result.jsx +++ b/app/javascript/Components/Result/result.jsx @@ -1,5 +1,6 @@ import React from "react"; import {createRoot} from "react-dom/client"; +import {Group, Panel, Separator} from "react-resizable-panels"; // TODO: This import seems to be required to automatically include the X-CSRF-TOKEN header on // jQuery AJAX requests in this component, unlike all other pages. Requires further investigation. @@ -72,14 +73,26 @@ class Result extends React.Component { this.leftPane = React.createRef(); } + refreshPdfViewer = () => { + if (window.pdfViewer) { + window.pdfViewer.refresh_annotations(); + } + }; + + handleFullscreenChange = () => { + this.setState({fullscreen: !!document.fullscreenElement}, this.refreshPdfViewer); + }; + componentDidMount() { this.fetchData(); window.modal = new ModalMarkus("#annotation_dialog"); window.modalNotesGroup = new ModalMarkus("#notes_dialog"); - document.addEventListener("fullscreenchange", () => { - this.setState({fullscreen: !!document.fullscreenElement}, fix_panes); - }); + if (!this.layoutListenersAdded) { + document.addEventListener("fullscreenchange", this.handleFullscreenChange); + window.addEventListener("resize", this.refreshPdfViewer); + this.layoutListenersAdded = true; + } // Clear text selection to enable shift + arrow keyboard shortcuts document.getSelection().removeAllRanges(); @@ -97,6 +110,13 @@ class Result extends React.Component { } } + componentWillUnmount() { + if (this.layoutListenersAdded) { + document.removeEventListener("fullscreenchange", this.handleFullscreenChange); + window.removeEventListener("resize", this.refreshPdfViewer); + } + } + fetchData = () => { fetch(Routes.course_result_path(this.props.course_id, this.state.result_id), { headers: {Accept: "application/json"}, @@ -112,8 +132,6 @@ class Result extends React.Component { } const markData = this.processMarks(res); this.setState({...res, ...markData, loading: false}, () => { - initializePanes(); - fix_panes(); this.updateContextMenu(); if (this.props.role !== "Student") { this.syncFilterData(); @@ -1007,8 +1025,8 @@ class Result extends React.Component { isOpen={this.state.isCreateTagModalOpen} onRequestClose={this.closeCreateTagModal} /> -
-
+ + -
-
-
+ + + -
-
+ +
diff --git a/app/javascript/Components/__tests__/admin_users_list.test.jsx b/app/javascript/Components/__tests__/admin_users_list.test.jsx new file mode 100644 index 0000000000..f034b3d06c --- /dev/null +++ b/app/javascript/Components/__tests__/admin_users_list.test.jsx @@ -0,0 +1,97 @@ +import React from "react"; +import {render, screen, fireEvent, waitFor} from "@testing-library/react"; +import "@testing-library/jest-dom"; +import {AdminUsersList} from "../admin_users_list"; + +beforeAll(() => { + global.Routes = { + admin_users_path: jest.fn(params => { + if (params) { + const query = new URLSearchParams(params).toString(); + return `/admin/users?${query}`; + } + return "/admin/users"; + }), + edit_admin_user_path: jest.fn(id => `/admin/users/${id}/edit`), + }; + global.I18n = { + t: jest.fn(key => { + const translations = { + "activerecord.attributes.user.user_name": "User Name", + "activerecord.attributes.user.email": "Email", + "activerecord.models.admin_user.one": "Admin User", + "activerecord.models.end_user.one": "End User", + actions: "Actions", + edit: "Edit", + }; + return translations[key] || key; + }), + }; +}); + +beforeEach(() => { + global.fetch = jest.fn(); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("AdminUsersList Component", () => { + const mockApiResponse = { + users: [ + { + id: 42, + user_name: "Bobby", + first_name: "Miles", + last_name: "Morales", + email: "miles@ny.edu", + id_number: "161", + type: "EndUser", + }, + ], + total_pages: 1, + }; + + it("queries the backend matching default sorting configurations on load", async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockApiResponse, + }); + + render(); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/admin/users?page=1&per_page=100"), + expect.any(Object) + ); + }); + + expect(await screen.findByText("Bobby")).toBeInTheDocument(); + expect(screen.getByText("Miles")).toBeInTheDocument(); + expect(screen.getByRole("gridcell", {name: "End User"})).toBeInTheDocument(); + }); + + it("resets targetPage to 0 if a filter configuration transition occurs", async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => mockApiResponse, + }); + + const {container} = render(); + const filterInputs = container.querySelectorAll(".rt-th input"); + if (filterInputs.length > 0) { + fireEvent.change(filterInputs[0], {target: {value: "miles"}}); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining( + "filtered=%5B%7B%22id%22%3A%22user_name%22%2C%22value%22%3A%22miles%22%7D%5D" + ), + expect.any(Object) + ); + }); + } + }); +}); diff --git a/app/javascript/Components/__tests__/submission_file_manager.test.jsx b/app/javascript/Components/__tests__/submission_file_manager.test.jsx index 53b987774b..35af318506 100644 --- a/app/javascript/Components/__tests__/submission_file_manager.test.jsx +++ b/app/javascript/Components/__tests__/submission_file_manager.test.jsx @@ -205,7 +205,211 @@ describe("For the SubmissionFileManager", () => { ]); await userEvent.click(screen.getByRole("button", {name: I18n.t("save"), hidden: true})); - expect(screen.findByRole("progressbar", {hidden: true})).rejects.toThrow(); + await expect(screen.findByRole("progressbar", {hidden: true})).rejects.toThrow(); + }); + }); +}); + +describe("For the late submit confirm dialog", () => { + const LATE_SUBMIT_MESSAGES = { + GracePeriodSubmissionRule: I18n.t( + "activerecord.attributes.grace_period_submission_rule.upload_late_confirmation_dialog" + ), + PenaltyDecayPeriodSubmissionRule: I18n.t( + "activerecord.attributes.penalty_decay_period_submission_rule.upload_late_confirmation_dialog" + ), + PenaltyPeriodSubmissionRule: I18n.t( + "activerecord.attributes.penalty_period_submission_rule.upload_late_confirmation_dialog" + ), + }; + + const files_sample = { + entries: [ + { + id: 136680, + url: "test.url", + filename: ' HelloWorld.java', + raw_name: "HelloWorld.java", + last_revised_date: "Saturday, May 14, 2022, 09:15:24 PM EDT", + last_modified_revision: "58ca2e15254aa63c4d41cb5db7dfc398b6bda3fb", + revision_by: "c5anthei", + submitted_date: "Saturday, May 14, 2022, 09:15:24 PM EDT", + type: "java", + key: "HelloWorld.java", + modified: 1652577324, + relativeKey: "HelloWorld.java", + }, + ], + only_required_files: false, + required_files: [], + max_file_size: 10, + number_of_missing_files: 0, + }; + + const file = new File(["content"], "test.txt", {type: "text/plain"}); + let confirmSpy; + + const mockPost = () => { + $.post = jest.fn().mockReturnValue({ + then: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + always: jest.fn().mockReturnThis(), + }); + }; + + const renderManager = (props = {}) => { + fetch.mockResponseOnce(JSON.stringify(files_sample)); + document.body.innerHTML = `
`; + render( + + ); + }; + + const submitFileThroughModal = async () => { + const submitLink = screen.getByText(I18n.t("submit_the", {item: I18n.t("file")})); + await userEvent.click(submitLink); + await userEvent.upload(screen.getByTitle(I18n.t("modals.file_upload.file_input_label")), [ + file, + ]); + await userEvent.click(screen.getByRole("button", {name: I18n.t("save"), hidden: true})); + }; + + const submitUrlThroughModal = async () => { + const submitLink = screen.getByText( + I18n.t("submit_the", {item: I18n.t("submissions.student.link")}) + ); + await userEvent.click(submitLink); + await userEvent.type( + document.querySelector('input[name="new_url"]'), + "https://example.com/page" + ); + await userEvent.type(document.querySelector('input[name="new_url_text"]'), "example"); + await userEvent.click(screen.getByRole("button", {name: I18n.t("save"), hidden: true})); + }; + + beforeEach(() => { + fetch.resetMocks(); + confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(true); + mockPost(); + }); + + afterEach(() => { + confirmSpy.mockRestore(); + }); + + describe("For the submission file upload modal", () => { + describe.each([ + ["GracePeriodSubmissionRule"], + ["PenaltyDecayPeriodSubmissionRule"], + ["PenaltyPeriodSubmissionRule"], + ])("when submission_rule is %s", submissionRule => { + it("calls confirm with the correct message", async () => { + renderManager({ + show_late_submit_confirmation: true, + submission_rule: submissionRule, + }); + await screen.findByText("HelloWorld.java"); + await submitFileThroughModal(); + expect(confirmSpy).toHaveBeenCalledWith(LATE_SUBMIT_MESSAGES[submissionRule]); + }); + + it("does not upload when user cancels the confirm dialog", async () => { + confirmSpy.mockReturnValue(false); + renderManager({ + show_late_submit_confirmation: true, + submission_rule: submissionRule, + }); + await screen.findByText("HelloWorld.java"); + await submitFileThroughModal(); + expect(confirmSpy).toHaveBeenCalledWith(LATE_SUBMIT_MESSAGES[submissionRule]); + expect($.post).not.toHaveBeenCalled(); + }); + + it("uploads when the user confirms the dialog", async () => { + confirmSpy.mockReturnValue(true); + renderManager({ + show_late_submit_confirmation: true, + submission_rule: submissionRule, + }); + await screen.findByText("HelloWorld.java"); + await submitFileThroughModal(); + expect(confirmSpy).toHaveBeenCalledWith(LATE_SUBMIT_MESSAGES[submissionRule]); + expect($.post).toHaveBeenCalled(); + }); + }); + + it("does not call confirm when show_late_submit_confirmation is false", async () => { + renderManager({show_late_submit_confirmation: false}); + await screen.findByText("HelloWorld.java"); + await submitFileThroughModal(); + expect(confirmSpy).not.toHaveBeenCalled(); + expect($.post).toHaveBeenCalled(); + }); + + it("does not call confirm when show_late_submit_confirmation is true but submission_rule is missing", async () => { + renderManager({show_late_submit_confirmation: true}); + await screen.findByText("HelloWorld.java"); + await submitFileThroughModal(); + expect(confirmSpy).not.toHaveBeenCalled(); + expect($.post).toHaveBeenCalled(); + }); + }); + + describe("For the submit URL upload modal", () => { + describe.each([ + ["GracePeriodSubmissionRule"], + ["PenaltyDecayPeriodSubmissionRule"], + ["PenaltyPeriodSubmissionRule"], + ])("when submission_rule is %s", submissionRule => { + it("calls confirm with the correct message", async () => { + renderManager({ + show_late_submit_confirmation: true, + submission_rule: submissionRule, + enableUrlSubmit: true, + }); + await screen.findByText("HelloWorld.java"); + await submitUrlThroughModal(); + expect(confirmSpy).toHaveBeenCalledWith(LATE_SUBMIT_MESSAGES[submissionRule]); + }); + + it("does not upload when user cancels the confirm dialog", async () => { + confirmSpy.mockReturnValue(false); + renderManager({ + show_late_submit_confirmation: true, + submission_rule: submissionRule, + enableUrlSubmit: true, + }); + await screen.findByText("HelloWorld.java"); + await submitUrlThroughModal(); + expect(confirmSpy).toHaveBeenCalledWith(LATE_SUBMIT_MESSAGES[submissionRule]); + expect($.post).not.toHaveBeenCalled(); + }); + + it("uploads when the user confirms the dialog", async () => { + confirmSpy.mockReturnValue(true); + renderManager({ + show_late_submit_confirmation: true, + submission_rule: submissionRule, + enableUrlSubmit: true, + }); + await screen.findByText("HelloWorld.java"); + await submitUrlThroughModal(); + expect(confirmSpy).toHaveBeenCalledWith(LATE_SUBMIT_MESSAGES[submissionRule]); + expect($.post).toHaveBeenCalled(); + }); + }); + + it("does not call confirm when show_late_submit_confirmation is false", async () => { + renderManager({show_late_submit_confirmation: false, enableUrlSubmit: true}); + await screen.findByText("HelloWorld.java"); + await submitUrlThroughModal(); + expect(confirmSpy).not.toHaveBeenCalled(); + expect($.post).toHaveBeenCalled(); }); }); }); diff --git a/app/javascript/Components/admin_users_list.jsx b/app/javascript/Components/admin_users_list.jsx index 6232662e3f..34602af51d 100644 --- a/app/javascript/Components/admin_users_list.jsx +++ b/app/javascript/Components/admin_users_list.jsx @@ -5,32 +5,59 @@ import {selectFilter} from "./Helpers/table_helpers"; import {faPencil} from "@fortawesome/free-solid-svg-icons"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -class AdminUsersList extends React.Component { +export class AdminUsersList extends React.Component { constructor() { super(); this.state = { users: [], + pages: 0, loading: true, + page: 0, + pageSize: 100, }; + this.previousFiltered = "[]"; + this.previousSorted = "[]"; } - componentDidMount() { - this.fetchData(); - } + fetchDataServerSide = state => { + this.setState({loading: true}); + const currentFilteredStr = JSON.stringify(state.filtered); + const currentSortedStr = JSON.stringify(state.sorted); - fetchData = () => { - fetch(Routes.admin_users_path(), { - headers: { - Accept: "application/json", - }, - }) + let targetPage = state.page; + if (this.previousFiltered !== currentFilteredStr || this.previousSorted !== currentSortedStr) { + targetPage = 0; + this.previousFiltered = currentFilteredStr; + this.previousSorted = currentSortedStr; + } + + fetch( + Routes.admin_users_path({ + page: targetPage + 1, + per_page: state.pageSize, + sorted: currentSortedStr, + filtered: currentFilteredStr, + }), + { + headers: {Accept: "application/json"}, + } + ) .then(response => { - if (response.ok) { - return response.json(); - } + if (response.ok) return response.json(); + throw new Error("Failed to fetch grid data"); }) .then(data => { - this.setState({users: data, loading: false}); + this.setState({ + users: data && data.users ? data.users : [], + pages: data && data.total_pages ? data.total_pages : 1, + loading: false, + page: targetPage, + pageSize: state.pageSize, + }); + }) + .catch(err => { + console.error("Pagination error:", err); + this.setState({users: [], pages: 1, loading: false}); }); }; @@ -73,13 +100,6 @@ class AdminUsersList extends React.Component { return I18n.t("activerecord.models.end_user.one"); } }, - filterMethod: (filter, row) => { - if (filter.value === "all") { - return true; - } else { - return filter.value === row[filter.id]; - } - }, Filter: selectFilter, filterOptions: [ { @@ -113,11 +133,21 @@ class AdminUsersList extends React.Component { render() { return ( this.setState({page})} /> ); } diff --git a/app/javascript/Components/graders_manager.jsx b/app/javascript/Components/graders_manager.jsx index 7f31e8c32f..4c5ffd8495 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,257 @@ 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: {}, + 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(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", + 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 +717,69 @@ 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} /> ); } @@ -797,8 +863,6 @@ class RawCriteriaTable extends React.Component { } } -const GradersTable = withSelection(RawGradersTable); -const GroupsTable = withSelection(RawGroupsTable); const CriteriaTable = withSelection(RawCriteriaTable); class GradersActionBox extends React.Component { diff --git a/app/javascript/Components/submission_file_manager.jsx b/app/javascript/Components/submission_file_manager.jsx index f8bb4227d4..1619395d33 100644 --- a/app/javascript/Components/submission_file_manager.jsx +++ b/app/javascript/Components/submission_file_manager.jsx @@ -77,7 +77,36 @@ class SubmissionFileManager extends React.Component { } } + confirmWhenLate = () => { + if (this.props.show_late_submit_confirmation) { + switch (this.props.submission_rule) { + case "GracePeriodSubmissionRule": + return confirm( + I18n.t( + "activerecord.attributes.grace_period_submission_rule.upload_late_confirmation_dialog" + ) + ); + case "PenaltyDecayPeriodSubmissionRule": + return confirm( + I18n.t( + "activerecord.attributes.penalty_decay_period_submission_rule.upload_late_confirmation_dialog" + ) + ); + case "PenaltyPeriodSubmissionRule": + return confirm( + I18n.t( + "activerecord.attributes.penalty_period_submission_rule.upload_late_confirmation_dialog" + ) + ); + } + } + return true; + }; + handleCreateUrl = (url, url_text) => { + if (!this.confirmWhenLate()) { + return; + } this.setState({showURLModal: false}); const data_to_upload = { new_url: url, @@ -99,6 +128,10 @@ class SubmissionFileManager extends React.Component { }; handleCreateFiles = (files, path, unzip, renameTo = "") => { + if (!this.confirmWhenLate()) { + return; + } + if ( !this.props.starterFileChanged || confirm(I18n.t("assignments.starter_file.upload_confirmation")) 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}) { diff --git a/app/javascript/Components/table/search_filter.jsx b/app/javascript/Components/table/search_filter.jsx index 1827f8fc39..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={defaultSearchPlaceholderText()} + aria-label={defaultSearchPlaceholderText(column.columnDef.header)} /> ); } diff --git a/app/models/penalty_decay_period_submission_rule.rb b/app/models/penalty_decay_period_submission_rule.rb index fec3837405..92e4f146ed 100644 --- a/app/models/penalty_decay_period_submission_rule.rb +++ b/app/models/penalty_decay_period_submission_rule.rb @@ -16,7 +16,7 @@ # # rubocop:enable Layout/LineLength, Lint/RedundantCopDisableDirective class PenaltyDecayPeriodSubmissionRule < SubmissionRule - # This message will be dislayed to Students on viewing their file manager + # This message will be displayed to Students on viewing their file manager # after the due date has passed, but before the calculated collection date. validates :penalty_type, presence: true, diff --git a/app/models/penalty_period_submission_rule.rb b/app/models/penalty_period_submission_rule.rb index 64b3a429b2..2cce57bdc1 100644 --- a/app/models/penalty_period_submission_rule.rb +++ b/app/models/penalty_period_submission_rule.rb @@ -16,7 +16,7 @@ # # rubocop:enable Layout/LineLength, Lint/RedundantCopDisableDirective class PenaltyPeriodSubmissionRule < SubmissionRule - # This message will be dislayed to Students on viewing their file manager + # This message will be displayed to Students on viewing their file manager # after the due date has passed, but before the calculated collection date. validates :penalty_type, presence: true, diff --git a/app/views/submissions/file_manager.html.erb b/app/views/submissions/file_manager.html.erb index 593057ace2..f7cef8ce3a 100644 --- a/app/views/submissions/file_manager.html.erb +++ b/app/views/submissions/file_manager.html.erb @@ -8,6 +8,8 @@ course_id: <%= @current_course.id %>, assignment_id: <%= @assignment.id %>, grouping_id: <%= @grouping.id %>, + show_late_submit_confirmation: <%= @show_late_submit_confirmation %>, + submission_rule: "<%= @assignment.submission_rule.type %>", readOnly: <%= !@assignment.allow_web_submits %>, enableSubdirs: <%= allowed_to? :manage_subdirectories? %>, enableUrlSubmit: <%= @grouping.assignment.url_submit %>, 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/config/locales/models/submission_rules/en.yml b/config/locales/models/submission_rules/en.yml index e95a72abd0..83dd7330d5 100644 --- a/config/locales/models/submission_rules/en.yml +++ b/config/locales/models/submission_rules/en.yml @@ -7,6 +7,7 @@ en: commit_after_collection_message: The due date for this assignment, plus the maximum grace period, has passed. Your changes have been recorded, but will not be included in the grading. description: You may submit up to a set time past the due date, provided you have enough remaining grace credits to do so. form_description: Automatically deduct grace credits + upload_late_confirmation_dialog: The due date for this assignment has passed. If you submit files now, you will use grace credits, or your submission may not be graded, depending on when you submit and how many grace credits you have remaining. This action cannot be undone. Are you sure you would like to proceed? no_late_submission_rule: after_collection_message: The due date for this assignment has passed. commit_after_collection_message: The due date for this assignment has passed. Your changes have been recorded, but will not be included in the grading. @@ -17,11 +18,13 @@ en: commit_after_collection_message: The due date for this assignment, plus the maximum late penalty period, has passed. Your changes have been recorded, but will not be included in the grading. description: You are able to submit up to a set time past the due date, but with the appropriate percentage deducted from your final grade. form_description: Use penalty decay formula + upload_late_confirmation_dialog: The due date for this assignment has passed. If you submit files now, you will incur a late penalty, or your submission may not be graded, depending on when you submit. This action cannot be undone. Are you sure you would like to proceed? penalty_period_submission_rule: after_collection_message: The maximum late penalty period has passed for this assignment. commit_after_collection_message: The due date for this assignment, plus the maximum late penalty period, has passed. Your changes have been recorded, but will not be included in the grading. description: You are able to submit up to a set time past the due date, but with the appropriate percentage deducted from your final grade. form_description: Set manual penalty periods + upload_late_confirmation_dialog: The due date for this assignment has passed. If you submit files now, you will incur a late penalty, or your submission may not be graded, depending on when you submit. This action cannot be undone. Are you sure you would like to proceed? models: submission_rule: one: Late Submission Policy diff --git a/lib/tasks/marks.rake b/lib/tasks/marks.rake index c381aef9da..9f13fee7ff 100644 --- a/lib/tasks/marks.rake +++ b/lib/tasks/marks.rake @@ -185,6 +185,7 @@ namespace :db do end results << { id: result_id, + submission_id: submission_id, marking_state: Result::MARKING_STATES[:complete], released_to_students: true, view_token: Result.generate_unique_secure_token @@ -214,7 +215,12 @@ namespace :db do grade: random_grade } end - students << { id: student.id, role_id: student.role.id, released_to_student: true } + students << { + id: student.id, + role_id: student.role.id, + assessment_id: grade_entry_form.id, + released_to_student: true + } end Grade.insert_all grades diff --git a/package-lock.json b/package-lock.json index 965307422e..c3020ba980 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,13 +14,13 @@ "@fortawesome/react-fontawesome": "^3.3.1", "@rails/actioncable": "^8.1.200", "@rails/ujs": "^7.1.3-4", - "@rjsf/core": "^6.5.2", - "@rjsf/validator-ajv8": "^6.5.2", + "@rjsf/core": "^6.6.1", + "@rjsf/validator-ajv8": "^6.6.1", "@tanstack/react-table": "^8.21.3", "chart.js": "^4.5.0", "core-js": "^3.43.0", - "dayjs": "^1.11.13", - "dompurify": "^3.4.0", + "dayjs": "^1.11.21", + "dompurify": "^3.4.11", "flatpickr": "^4.6.13", "heic2any": "^0.0.4", "i18n-js": "^4.5.1", @@ -41,6 +41,7 @@ "react-keyed-file-browser": "^1.14.0", "react-loader-spinner": "^8.0.2", "react-modal": "^3.16.3", + "react-resizable-panels": "^4.11.2", "react-table": "^6.11.5", "react-tabs": "^6.1.0", "tslib": "^2.8.1", @@ -3479,34 +3480,34 @@ "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" }, "node_modules/@rjsf/core": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.5.2.tgz", - "integrity": "sha512-Fx+aVNQRYQyoY0vM8zYDZkuOiNe+5PLsxUySUdHfjljlT23mJnTCpPKMkxWJwh4UEWeSN0xjmknW9LfIwuQmOg==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.6.1.tgz", + "integrity": "sha512-z67ekKil3JB2b/kBIVzeo+WJTcx4q2yyUq4viam+/053sdOZ5QNIlaCPTxS1x06lLSj4eWg7WP2G2wqahrclJA==", "license": "Apache-2.0", "dependencies": { "lodash": "^4.18.1", "lodash-es": "^4.18.1", - "markdown-to-jsx": "^8.0.0", + "markdown-to-jsx": "^9.8.1", "prop-types": "^15.8.1" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@rjsf/utils": "^6.5.x", + "@rjsf/utils": "^6.6.x", "react": ">=18" } }, "node_modules/@rjsf/utils": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.5.2.tgz", - "integrity": "sha512-qBVQ5qf9BKMOQy/DjMl/IjD5s6akRDx18cgiSYunKt/CYc+kPzHyCVA2TU0rXCsigNlhgnfM8piC3LEHye6vpA==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.6.1.tgz", + "integrity": "sha512-Hwve0OB1pOed1RkbO8qggcmyPQ5nsE+nimESympq/cb2dtBou0nIVlJ+Paa4oO9vARmN5IrVXXZODinIV+ErMw==", "license": "Apache-2.0", "peer": true, "dependencies": { "@x0k/json-schema-merge": "^1.0.3", "fast-equals": "^6.0.0", - "fast-uri": "^3.1.0", + "fast-uri": "^3.1.2", "jsonpointer": "^5.0.1", "lodash": "^4.18.1", "lodash-es": "^4.18.1", @@ -3527,12 +3528,12 @@ "peer": true }, "node_modules/@rjsf/validator-ajv8": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.5.2.tgz", - "integrity": "sha512-MSyF0Q0lZhmByDV/eL3QF03g1K7RVGGAHMytlQ2ybuakvzJGODGzWKfTLcP320ixrrCqDq5rs8IJP+WJCRnhig==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.6.1.tgz", + "integrity": "sha512-yTGubw/OvfpEa608IDV8VOC6JtiX18Ls5yve9YxCnKvlT4QNjsNMIAj8xqrrKMSI/xa6v4SWuRVS8KXpfxap8Q==", "license": "Apache-2.0", "dependencies": { - "ajv": "^8.18.0", + "ajv": "^8.20.0", "ajv-formats": "^2.1.1", "lodash": "^4.18.1", "lodash-es": "^4.18.1" @@ -3541,7 +3542,7 @@ "node": ">=20" }, "peerDependencies": { - "@rjsf/utils": "^6.5.x" + "@rjsf/utils": "^6.6.x" } }, "node_modules/@sinclair/typebox": { @@ -4447,9 +4448,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5412,9 +5413,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": { @@ -5504,9 +5505,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.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -7566,19 +7567,30 @@ } }, "node_modules/markdown-to-jsx": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-8.0.0.tgz", - "integrity": "sha512-hWEaRxeCDjes1CVUQqU+Ov0mCqBqkGhLKjL98KdbwHSgEWZZSJQeGlJQatVfeZ3RaxrfTrZZ3eczl2dhp5c/pA==", + "version": "9.8.1", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.8.1.tgz", + "integrity": "sha512-yq70dLPkBnE2LYFtGTLfRes4qyBDS+a4wDttAA/b/BzVGrbs2e0TfCeSFrMkapCg1lsxYi+42BowuBDxLP9k4Q==", "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 18" }, "peerDependencies": { - "react": ">= 0.14.0" + "react": ">= 16.0.0", + "solid-js": ">=1.0.0", + "vue": ">=3.0.0" }, "peerDependenciesMeta": { "react": { "optional": true + }, + "react-native": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "vue": { + "optional": true } } }, @@ -8554,6 +8566,16 @@ "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" } }, + "node_modules/react-resizable-panels": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.2.tgz", + "integrity": "sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-table": { "version": "6.11.5", "resolved": "https://registry.npmjs.org/react-table/-/react-table-6.11.5.tgz", diff --git a/package.json b/package.json index 7f56309f3c..2900f2b57e 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,13 @@ "@fortawesome/react-fontawesome": "^3.3.1", "@rails/actioncable": "^8.1.200", "@rails/ujs": "^7.1.3-4", - "@rjsf/core": "^6.5.2", - "@rjsf/validator-ajv8": "^6.5.2", + "@rjsf/core": "^6.6.1", + "@rjsf/validator-ajv8": "^6.6.1", "@tanstack/react-table": "^8.21.3", "chart.js": "^4.5.0", "core-js": "^3.43.0", - "dayjs": "^1.11.13", - "dompurify": "^3.4.0", + "dayjs": "^1.11.21", + "dompurify": "^3.4.11", "flatpickr": "^4.6.13", "heic2any": "^0.0.4", "i18n-js": "^4.5.1", @@ -36,6 +36,7 @@ "react-keyed-file-browser": "^1.14.0", "react-loader-spinner": "^8.0.2", "react-modal": "^3.16.3", + "react-resizable-panels": "^4.11.2", "react-table": "^6.11.5", "react-tabs": "^6.1.0", "tslib": "^2.8.1", 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 diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 9f9ea49fc7..488fb08c92 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -112,7 +112,13 @@ expect(response).to have_http_status(:ok) end - it 'sends the appropriate data' do + it 'sends the appropriate data structure and pagination keys' do + get_as admin, :index, format: 'json' + expect(response.parsed_body).to have_key('users') + expect(response.parsed_body).to have_key('total_pages') + end + + it 'sends the appropriate user payload records' do expected_data = [ { id: admin.id, @@ -134,9 +140,48 @@ } ] get_as admin, :index, format: 'json' - received_data = response.parsed_body.map(&:symbolize_keys) + received_data = response.parsed_body['users'].map(&:symbolize_keys) expect(received_data).to match_array(expected_data) end + + it 'handles server-side fuzzy string text filtering' do + create(:end_user, user_name: 'aaa_filter_test_user', first_name: 'Alexandria') + filter_params = [{ id: 'first_name', value: 'alex' }].to_json + + get_as admin, :index, format: 'json', params: { per_page: 100, filtered: filter_params } + received_names = response.parsed_body['users'].pluck('first_name') + + expect(received_names).to include('Alexandria') + expect(received_names).not_to include(user.first_name) + end + + it 'handles server-side custom column sorting rules' do + sort_params = [{ id: 'user_name', desc: true }].to_json + + get_as admin, :index, format: 'json', params: { sorted: sort_params } + received_usernames = response.parsed_body['users'].pluck('user_name') + + expect(received_usernames).to eq(received_usernames.sort.reverse) + end + + it 'handles server-side multi-column sorting' do + create(:end_user, user_name: 'aaa_user', first_name: 'Charlie', last_name: 'Smith') + create(:end_user, user_name: 'bbb_user', first_name: 'Alice', last_name: 'Smith') + create(:end_user, user_name: 'ccc_user', first_name: 'Bob', last_name: 'Brown') + + sort_params = [{ id: 'last_name', desc: false }, { id: 'first_name', desc: false }].to_json + + get_as admin, :index, format: 'json', params: { sorted: sort_params } + received = response.parsed_body['users'].map { |u| [u['last_name'], u['first_name']] } + + expect(received).to eq(received.sort_by { |last, first| [last, first] }) + end + + it 'hides AutotestUser entities from the payload entirely' do + get_as admin, :index, format: 'json' + types = response.parsed_body['users'].pluck('type') + expect(types).not_to include('AutotestUser') + end end end 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('