• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MarkUsProject / Markus / 27919845468

21 Jun 2026 10:43PM UTC coverage: 90.291% (+0.03%) from 90.266%
27919845468

Pull #8008

github

web-flow
Merge 7325913a3 into 8705e4b48
Pull Request #8008: WIP: feat: grades upload flow

1101 of 2313 branches covered (47.6%)

Branch coverage included in aggregate %.

172 of 177 new or added lines in 8 files covered. (97.18%)

56 existing lines in 3 files now uncovered.

46688 of 50615 relevant lines covered (92.24%)

127.09 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.03
/app/javascript/Components/assignment_summary_table.jsx
1
import React from "react";
2
import {getMarkingStates, selectFilter} from "./Helpers/table_helpers";
3

4
import AssignmentGradesUploadModal from "./Modals/assignment_grades_upload_modal";
5
import DownloadTestResultsModal from "./Modals/download_test_results_modal";
6
import LtiGradeModal from "./Modals/send_lti_grades_modal";
7
import {createColumnHelper} from "@tanstack/react-table";
8
import Table from "./table/table";
9

10
export class AssignmentSummaryTable extends React.Component {
11
  constructor(props) {
12
    super(props);
13✔
13
    const markingStates = getMarkingStates([]);
12✔
14
    this.state = {
12✔
15
      data: [],
16
      criteriaColumns: [],
17
      loading: true,
18
      num_assigned: 0,
19
      num_marked: 0,
20
      enable_test: false,
21
      marking_states: markingStates,
22
      markingStateFilter: "all",
23
      showDownloadTestsModal: false,
24
      showGradesUploadModal: false,
25
      showLtiGradeModal: false,
26
      lti_deployments: [],
27
      columnFilters: [{id: "inactive", value: false}],
28
      inactiveGroupsCount: 0,
29
      columns: this.getColumns([], markingStates, "all"),
30
    };
31
  }
32

33
  componentDidMount() {
34
    this.fetchData();
12✔
35
  }
36

37
  getColumns = (criteriaColumns, marking_states, markingStateFilter) => {
12✔
38
    const columnHelper = createColumnHelper();
24✔
39

40
    const fixedColumns = [
24✔
41
      columnHelper.accessor("inactive", {
42
        id: "inactive",
43
      }),
44
      columnHelper.accessor("group_name", {
45
        id: "group_name",
46
        header: () => I18n.t("activerecord.models.group.one"),
24✔
47
        size: 100,
48
        enableResizing: true,
49
        cell: props => {
50
          if (props.row.original.result_id) {
22!
51
            const path = Routes.edit_course_result_path(
22✔
52
              this.props.course_id,
53
              props.row.original.result_id
54
            );
55
            return (
22✔
56
              <a href={path}>
57
                {props.getValue()}
58
                {this.memberDisplay(props.getValue(), props.row.original.members)}
59
              </a>
60
            );
61
          } else {
62
            return (
×
63
              <span>
64
                {props.row.original.group_name}
65
                {this.memberDisplay(props.row.original.group_name, props.row.original.members)}
66
              </span>
67
            );
68
          }
69
        },
70
        filterFn: (row, columnId, filterValue) => {
71
          if (filterValue) {
6!
72
            filterValue = filterValue.toLowerCase();
6✔
73
            // Check group name
74
            if (row.original.group_name.toLowerCase().includes(filterValue)) {
6✔
75
              return true;
2✔
76
            }
77

78
            // Check member names (first three values of each "member" array)
79
            const member_matches = row.original.members.some(member =>
4✔
80
              member.slice(0, 3).some(name => name.toLowerCase().includes(filterValue))
12✔
81
            );
82

83
            return member_matches;
4✔
84
          } else {
85
            return true;
×
86
          }
87
        },
88
      }),
89
      columnHelper.accessor("marking_state", {
90
        header: () => I18n.t("activerecord.attributes.result.marking_state"),
24✔
91
        accessorKey: "marking_state",
92
        size: 100,
93
        enableResizing: true,
94
        cell: props => props.getValue(),
22✔
95
        filterFn: "equalsString",
96
        meta: {
97
          filterVariant: "select",
98
        },
99
        filterAllOptionText:
100
          I18n.t("all") +
101
          (markingStateFilter === "all"
24!
102
            ? ` (${Object.values(marking_states).reduce((a, b) => a + b)})`
130✔
103
            : ""),
104
        filterOptions: [
105
          {
106
            value: "before_due_date",
107
            text:
108
              I18n.t("submissions.state.before_due_date") +
109
              (["before_due_date", "all"].includes(markingStateFilter)
24!
110
                ? ` (${marking_states["before_due_date"]})`
111
                : ""),
112
          },
113
          {
114
            value: "not_collected",
115
            text:
116
              I18n.t("submissions.state.not_collected") +
117
              (["not_collected", "all"].includes(markingStateFilter)
24!
118
                ? ` (${marking_states["not_collected"]})`
119
                : ""),
120
          },
121
          {
122
            value: "incomplete",
123
            text:
124
              I18n.t("submissions.state.in_progress") +
125
              (["incomplete", "all"].includes(markingStateFilter)
24!
126
                ? ` (${marking_states["incomplete"]})`
127
                : ""),
128
          },
129
          {
130
            value: "complete",
131
            text:
132
              I18n.t("submissions.state.complete") +
133
              (["complete", "all"].includes(markingStateFilter)
24!
134
                ? ` (${marking_states["complete"]})`
135
                : ""),
136
          },
137
          {
138
            value: "released",
139
            text:
140
              I18n.t("submissions.state.released") +
141
              (["released", "all"].includes(markingStateFilter)
24!
142
                ? ` (${marking_states["released"]})`
143
                : ""),
144
          },
145
          {
146
            value: "remark",
147
            text:
148
              I18n.t("submissions.state.remark_requested") +
149
              (["remark", "all"].includes(markingStateFilter)
24!
150
                ? ` (${marking_states["remark"]})`
151
                : ""),
152
          },
153
        ],
154
        Filter: selectFilter,
155
      }),
156
      columnHelper.accessor("tags", {
157
        header: () => I18n.t("activerecord.models.tag.other"),
24✔
158
        size: 90,
159
        enableResizing: true,
160
        cell: props => (
161
          <ul className="tag-list">
22✔
162
            {props.row.original.tags.map(tag => (
163
              <li key={`${props.row.original._id}-${tag}`} className="tag-element">
×
164
                {tag}
165
              </li>
166
            ))}
167
          </ul>
168
        ),
169
        minWidth: 80,
170
        enableSorting: false,
171
        filterFn: (row, columnId, filterValue) => {
172
          if (filterValue) {
×
173
            // Check tag names
174
            return row.original.tags.some(tag => tag.includes(filterValue));
×
175
          } else {
176
            return true;
×
177
          }
178
        },
179
      }),
180
      columnHelper.accessor("final_grade", {
181
        header: () => I18n.t("results.total_mark"),
24✔
182
        size: 100,
183
        enableResizing: true,
184
        cell: props => {
185
          if (props.row.original.final_grade || props.row.original.final_grade === 0) {
22!
186
            const max_mark = Math.round(props.row.original.max_mark * 100) / 100;
22✔
187
            return props.row.original.final_grade + " / " + max_mark;
22✔
188
          } else {
189
            return "";
×
190
          }
191
        },
192
        meta: {className: "number"},
193
        enableColumnFilter: false,
194
        sortDescFirst: true,
195
      }),
196
    ];
197

198
    const criteriaColumnDefs = criteriaColumns.map(col =>
24✔
199
      columnHelper.accessor(col.accessor, {
4✔
200
        id: col.id,
201
        header: () => col.Header,
4✔
202
        size: col.size || 100,
8✔
203
        meta: {
204
          className: col.className,
205
          headerClassName: col.headerClassName,
206
        },
207
        enableColumnFilter: col.enableColumnFilter,
208
        sortDescFirst: col.sortDescFirst,
209
      })
210
    );
211

212
    const bonusColumn = columnHelper.accessor("total_extra_marks", {
24✔
213
      header: () => I18n.t("activerecord.models.extra_mark.other"),
24✔
214
      size: 100,
215
      enableResizing: true,
216
      meta: {className: "number"},
217
      enableColumnFilter: false,
218
      sortDescFirst: true,
219
    });
220

221
    const gradersColumn = columnHelper.accessor("graders", {
24✔
222
      id: "graders",
223
      header: () => I18n.t("activerecord.models.ta.other"),
24✔
224
      size: 200,
225
      enableResizing: true,
226
      cell: props => {
227
        const graders = props.row.original.graders;
22✔
228
        return this.graderDisplay(graders);
22✔
229
      },
230
      filterFn: (row, columnId, filterValue) => {
231
        if (filterValue) {
10!
232
          filterValue = filterValue.toLowerCase();
10✔
233
          // Check grader usernames, first names, or last names
234
          return row.original.graders.some(grader =>
10✔
235
            grader.some(name => name.toLowerCase().includes(filterValue))
37✔
236
          );
237
        } else {
238
          return true;
×
239
        }
240
      },
241
    });
242

243
    return [...fixedColumns, ...criteriaColumnDefs, bonusColumn, gradersColumn];
24✔
244
  };
245

246
  toggleShowInactiveGroups = showInactiveGroups => {
12✔
247
    let columnFilters = this.state.columnFilters.filter(group => group.id !== "inactive");
3✔
248

249
    if (!showInactiveGroups) {
3✔
250
      columnFilters.push({id: "inactive", value: false});
1✔
251
    }
252

253
    this.setState({columnFilters});
3✔
254
  };
255

256
  memberDisplay = (group_name, members) => {
12✔
257
    if (members.length !== 0 && !(members.length === 1 && members[0][0] === group_name)) {
22!
258
      return (
22✔
259
        " (" +
260
        members
261
          .map(member => {
262
            return member[0];
28✔
263
          })
264
          .join(", ") +
265
        ")"
266
      );
267
    }
268
  };
269

270
  graderDisplay = graders => {
12✔
271
    return graders.map((grader, index) => (
22✔
272
      <span key={index}>
30✔
273
        {grader[0]}{" "}
274
        <span title={grader[1] + " " + grader[2]}>
275
          <i className="fa-solid fa-circle-info" />
276
        </span>
277
        {index < graders.length - 1 ? ", " : ""}
30✔
278
      </span>
279
    ));
280
  };
281

282
  fetchData = () => {
12✔
283
    return fetch(
12✔
284
      Routes.summary_course_assignment_path(this.props.course_id, this.props.assignment_id),
285
      {
286
        headers: {
287
          Accept: "application/json",
288
        },
289
      }
290
    )
291
      .then(response => {
292
        if (response.ok) {
12!
293
          return response.json();
12✔
294
        }
295
      })
296
      .then(res => {
297
        res.criteriaColumns.forEach(col => {
12✔
298
          col.enableColumnFilter = false;
4✔
299
          col.sortDescFirst = true;
4✔
300
        });
301

302
        let inactive_groups_count = 0;
12✔
303
        res.data.forEach(group => {
12✔
304
          if (group.members.length && group.members.every(member => member[3])) {
24✔
305
            group.inactive = true;
4✔
306
            inactive_groups_count++;
4✔
307
          } else {
308
            group.inactive = false;
16✔
309
          }
310
        });
311

312
        const processedData = this.processData(res.data);
12✔
313
        const markingStates = getMarkingStates(processedData);
12✔
314
        this.setState({
12✔
315
          data: processedData,
316
          criteriaColumns: res.criteriaColumns,
317
          num_assigned: res.numAssigned,
318
          num_marked: res.numMarked,
319
          enable_test: res.enableTest,
320
          loading: false,
321
          marking_states: markingStates,
322
          lti_deployments: res.ltiDeployments,
323
          inactiveGroupsCount: inactive_groups_count,
324
          columns: this.getColumns(
325
            res.criteriaColumns,
326
            markingStates,
327
            this.state.markingStateFilter
328
          ),
329
        });
330
      });
331
  };
332

333
  processData(data) {
334
    data.forEach(row => {
12✔
335
      switch (row.marking_state) {
20!
336
        case "not_collected":
337
          row.marking_state = I18n.t("submissions.state.not_collected");
×
338
          break;
×
339
        case "incomplete":
340
          row.marking_state = I18n.t("submissions.state.in_progress");
×
341
          break;
×
342
        case "complete":
343
          row.marking_state = I18n.t("submissions.state.complete");
12✔
344
          break;
12✔
345
        case "released":
346
          row.marking_state = I18n.t("submissions.state.released");
8✔
347
          break;
8✔
348
        case "remark":
349
          row.marking_state = I18n.t("submissions.state.remark_requested");
×
350
          break;
×
351
        case "before_due_date":
352
          row.marking_state = I18n.t("submissions.state.before_due_date");
×
353
          break;
×
354
        default:
355
          // should not get here
356
          row.marking_state = row.original.marking_state;
×
357
      }
358
    });
359
    return data;
12✔
360
  }
361

362
  onDownloadTestsModal = () => {
12✔
363
    this.setState({showDownloadTestsModal: true});
×
364
  };
365

366
  onGradesUploadModal = () => {
12✔
NEW
367
    this.setState({showGradesUploadModal: true});
×
368
  };
369

370
  onLtiGradeModal = () => {
12✔
371
    this.setState({showLtiGradeModal: true});
×
372
  };
373

374
  render() {
375
    const {data} = this.state;
37✔
376
    let ltiButton;
377
    if (this.state.lti_deployments.length > 0) {
37!
378
      ltiButton = (
×
379
        <button type="submit" name="sync_grades" onClick={this.onLtiGradeModal}>
380
          {I18n.t("lti.sync_grades_lms")}
381
        </button>
382
      );
383
    }
384

385
    let displayInactiveGroupsTooltip = "";
37✔
386

387
    if (this.state.inactiveGroupsCount !== null) {
37!
388
      displayInactiveGroupsTooltip = `${I18n.t("activerecord.attributes.grouping.inactive_groups", {
37✔
389
        count: this.state.inactiveGroupsCount,
390
      })}`;
391
    }
392

393
    return (
37✔
394
      <div>
395
        <div style={{display: "inline-block"}}>
396
          <div className="progress">
397
            <meter
398
              value={this.state.num_marked}
399
              min={0}
400
              max={this.state.num_assigned}
401
              low={this.state.num_assigned * 0.35}
402
              high={this.state.num_assigned * 0.75}
403
              optimum={this.state.num_assigned}
404
            >
405
              {this.state.num_marked}/{this.state.num_assigned}
406
            </meter>
407
            {this.state.num_marked}/{this.state.num_assigned}&nbsp;
408
            {I18n.t("submissions.state.complete")}
409
          </div>
410
        </div>
411
        <div className="rt-action-box">
412
          <input
413
            id="show_inactive_groups"
414
            name="show_inactive_groups"
415
            type="checkbox"
416
            onChange={e => this.toggleShowInactiveGroups(e.target.checked)}
3✔
417
            className={"hide-user-checkbox"}
418
            data-testid={"show_inactive_groups"}
419
          />
420
          <label
421
            title={displayInactiveGroupsTooltip}
422
            htmlFor="show_inactive_groups"
423
            data-testid={"show_inactive_groups_tooltip"}
424
          >
425
            {I18n.t("submissions.groups.display_inactive")}
426
          </label>
427
          {this.props.is_instructor && (
41✔
428
            <>
429
              <form
430
                action={Routes.summary_course_assignment_path({
431
                  course_id: this.props.course_id,
432
                  id: this.props.assignment_id,
433
                  format: "csv",
434
                  _options: true,
435
                })}
436
                method="get"
437
              >
438
                <button type="submit" name="download">
439
                  {I18n.t("download")}
440
                </button>
441
              </form>
442
              <button type="button" name="upload" onClick={this.onGradesUploadModal}>
443
                <i className="fa-solid fa-upload" aria-hidden="true" />
444
                {I18n.t("upload")}
445
              </button>
446
              {this.state.enable_test && (
5✔
447
                <button type="submit" name="download_tests" onClick={this.onDownloadTestsModal}>
448
                  {I18n.t("download_the", {
449
                    item: I18n.t("activerecord.models.test_result.other"),
450
                  })}
451
                </button>
452
              )}
453
              {ltiButton}
454
            </>
455
          )}
456
        </div>
457
        <Table
458
          data={data}
459
          columns={this.state.columns}
460
          initialState={{
461
            sorting: [{id: "group_name"}],
462
          }}
463
          columnFilters={this.state.columnFilters}
464
          onColumnFiltersChange={updaterOrValue => {
465
            this.setState(prevState => {
10✔
466
              const newFilters =
467
                typeof updaterOrValue === "function"
10!
468
                  ? updaterOrValue(prevState.columnFilters)
469
                  : updaterOrValue;
470
              return {columnFilters: newFilters};
10✔
471
            });
472
          }}
473
          loading={this.state.loading}
474
        />
475
        <AssignmentGradesUploadModal
476
          course_id={this.props.course_id}
477
          assignment_id={this.props.assignment_id}
478
          encodings={this.props.encodings || []}
74✔
479
          isOpen={this.state.showGradesUploadModal}
NEW
480
          onRequestClose={() => this.setState({showGradesUploadModal: false})}
×
481
        />
482
        <DownloadTestResultsModal
483
          course_id={this.props.course_id}
484
          assignment_id={this.props.assignment_id}
485
          isOpen={this.state.showDownloadTestsModal}
486
          onRequestClose={() => this.setState({showDownloadTestsModal: false})}
×
487
          onSubmit={() => {}}
488
        />
489
        <LtiGradeModal
490
          isOpen={this.state.showLtiGradeModal}
491
          onRequestClose={() => this.setState({showLtiGradeModal: false})}
×
492
          lti_deployments={this.state.lti_deployments}
493
          assignment_id={this.props.assignment_id}
494
          course_id={this.props.course_id}
495
        />
496
      </div>
497
    );
498
  }
499
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc