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

MarkUsProject / Markus / 26616535799

29 May 2026 03:40AM UTC coverage: 90.257% (-0.02%) from 90.277%
26616535799

Pull #7967

github

web-flow
Merge f5263ac75 into 5b70aab6e
Pull Request #7967: Implement Graders Column in Assignment Grades tab

965 of 2151 branches covered (44.86%)

Branch coverage included in aggregate %.

9 of 10 new or added lines in 1 file covered. (90.0%)

5 existing lines in 1 file now uncovered.

46019 of 49905 relevant lines covered (92.21%)

122.0 hits per line

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

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

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

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

31
  componentDidMount() {
32
    this.fetchData();
12✔
33
  }
34

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

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

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

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

217
    const criteriaColumnDefs = criteriaColumns.map(col =>
24✔
218
      columnHelper.accessor(col.accessor, {
4✔
219
        id: col.id,
220
        header: () => col.Header,
4✔
221
        size: col.size || 100,
8✔
222
        meta: {
223
          className: col.className,
224
          headerClassName: col.headerClassName,
225
        },
226
        enableColumnFilter: col.enableColumnFilter,
227
        sortDescFirst: col.sortDescFirst,
228
      })
229
    );
230

231
    const bonusColumn = columnHelper.accessor("total_extra_marks", {
24✔
232
      header: () => I18n.t("activerecord.models.extra_mark.other"),
24✔
233
      size: 100,
234
      enableResizing: true,
235
      meta: {className: "number"},
236
      enableColumnFilter: false,
237
      sortDescFirst: true,
238
    });
239

240
    return [...fixedColumns, ...criteriaColumnDefs, bonusColumn];
24✔
241
  };
242

243
  toggleShowInactiveGroups = showInactiveGroups => {
12✔
244
    let columnFilters = this.state.columnFilters.filter(group => group.id !== "inactive");
3✔
245

246
    if (!showInactiveGroups) {
3✔
247
      columnFilters.push({id: "inactive", value: false});
1✔
248
    }
249

250
    this.setState({columnFilters});
3✔
251
  };
252

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

267
  graderDisplay = graders => {
12✔
268
    return (
22✔
269
      "" +
270
      graders
271
        .map(grader => {
272
          return grader[0];
30✔
273
        })
274
        .join(", ") +
275
      ""
276
    );
277
  };
278

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

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

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

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

359
  onFilteredChange = (filtered, column) => {
12✔
360
    const summaryTable = this.wrappedInstance;
×
361
    if (column.id != "marking_state") {
×
362
      const markingStates = getMarkingStates(summaryTable.state.sortedData);
×
363
      this.setState({
×
364
        marking_states: markingStates,
365
        columns: this.getColumns(
366
          this.state.criteriaColumns,
367
          markingStates,
368
          this.state.markingStateFilter
369
        ),
370
      });
371
    } else {
372
      const markingStateFilter = filtered.find(filter => filter.id == "marking_state").value;
×
373
      this.setState({
×
374
        markingStateFilter,
375
        columns: this.getColumns(
376
          this.state.criteriaColumns,
377
          this.state.marking_states,
378
          markingStateFilter
379
        ),
380
      });
381
    }
382
  };
383

384
  onDownloadTestsModal = () => {
12✔
385
    this.setState({showDownloadTestsModal: true});
×
386
  };
387

388
  onLtiGradeModal = () => {
12✔
389
    this.setState({showLtiGradeModal: true});
×
390
  };
391

392
  render() {
393
    const {data} = this.state;
37✔
394
    let ltiButton;
395
    if (this.state.lti_deployments.length > 0) {
37!
396
      ltiButton = (
×
397
        <button type="submit" name="sync_grades" onClick={this.onLtiGradeModal}>
398
          {I18n.t("lti.sync_grades_lms")}
399
        </button>
400
      );
401
    }
402

403
    let displayInactiveGroupsTooltip = "";
37✔
404

405
    if (this.state.inactiveGroupsCount !== null) {
37!
406
      displayInactiveGroupsTooltip = `${I18n.t("activerecord.attributes.grouping.inactive_groups", {
37✔
407
        count: this.state.inactiveGroupsCount,
408
      })}`;
409
    }
410

411
    return (
37✔
412
      <div>
413
        <div style={{display: "inline-block"}}>
414
          <div className="progress">
415
            <meter
416
              value={this.state.num_marked}
417
              min={0}
418
              max={this.state.num_assigned}
419
              low={this.state.num_assigned * 0.35}
420
              high={this.state.num_assigned * 0.75}
421
              optimum={this.state.num_assigned}
422
            >
423
              {this.state.num_marked}/{this.state.num_assigned}
424
            </meter>
425
            {this.state.num_marked}/{this.state.num_assigned}&nbsp;
426
            {I18n.t("submissions.state.complete")}
427
          </div>
428
        </div>
429
        <div className="rt-action-box">
430
          <input
431
            id="show_inactive_groups"
432
            name="show_inactive_groups"
433
            type="checkbox"
434
            onChange={e => this.toggleShowInactiveGroups(e.target.checked)}
3✔
435
            className={"hide-user-checkbox"}
436
            data-testid={"show_inactive_groups"}
437
          />
438
          <label
439
            title={displayInactiveGroupsTooltip}
440
            htmlFor="show_inactive_groups"
441
            data-testid={"show_inactive_groups_tooltip"}
442
          >
443
            {I18n.t("submissions.groups.display_inactive")}
444
          </label>
445
          {this.props.is_instructor && (
41✔
446
            <>
447
              <form
448
                action={Routes.summary_course_assignment_path({
449
                  course_id: this.props.course_id,
450
                  id: this.props.assignment_id,
451
                  format: "csv",
452
                  _options: true,
453
                })}
454
                method="get"
455
              >
456
                <button type="submit" name="download">
457
                  {I18n.t("download")}
458
                </button>
459
              </form>
460
              {this.state.enable_test && (
5✔
461
                <button type="submit" name="download_tests" onClick={this.onDownloadTestsModal}>
462
                  {I18n.t("download_the", {
463
                    item: I18n.t("activerecord.models.test_result.other"),
464
                  })}
465
                </button>
466
              )}
467
              {ltiButton}
468
            </>
469
          )}
470
        </div>
471
        <Table
472
          data={data}
473
          columns={this.state.columns}
474
          initialState={{
475
            sorting: [{id: "group_name"}],
476
          }}
477
          columnFilters={this.state.columnFilters}
478
          onColumnFiltersChange={updaterOrValue => {
479
            this.setState(prevState => {
10✔
480
              const newFilters =
481
                typeof updaterOrValue === "function"
10!
482
                  ? updaterOrValue(prevState.columnFilters)
483
                  : updaterOrValue;
484
              return {columnFilters: newFilters};
10✔
485
            });
486
          }}
487
          loading={this.state.loading}
488
        />
489
        <DownloadTestResultsModal
490
          course_id={this.props.course_id}
491
          assignment_id={this.props.assignment_id}
492
          isOpen={this.state.showDownloadTestsModal}
493
          onRequestClose={() => this.setState({showDownloadTestsModal: false})}
×
494
          onSubmit={() => {}}
495
        />
496
        <LtiGradeModal
497
          isOpen={this.state.showLtiGradeModal}
498
          onRequestClose={() => this.setState({showLtiGradeModal: false})}
×
499
          lti_deployments={this.state.lti_deployments}
500
          assignment_id={this.props.assignment_id}
501
          course_id={this.props.course_id}
502
        />
503
      </div>
504
    );
505
  }
506
}
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