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

MarkUsProject / Markus / 26667085869

29 May 2026 11:04PM UTC coverage: 90.271% (-0.006%) from 90.277%
26667085869

Pull #7967

github

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

965 of 2149 branches covered (44.9%)

Branch coverage included in aggregate %.

13 of 14 new or added lines in 1 file covered. (92.86%)

5 existing lines in 1 file now uncovered.

46019 of 49899 relevant lines covered (92.22%)

122.02 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

364
  onLtiGradeModal = () => {
12✔
365
    this.setState({showLtiGradeModal: true});
×
366
  };
367

368
  render() {
369
    const {data} = this.state;
37✔
370
    let ltiButton;
371
    if (this.state.lti_deployments.length > 0) {
37!
372
      ltiButton = (
×
373
        <button type="submit" name="sync_grades" onClick={this.onLtiGradeModal}>
374
          {I18n.t("lti.sync_grades_lms")}
375
        </button>
376
      );
377
    }
378

379
    let displayInactiveGroupsTooltip = "";
37✔
380

381
    if (this.state.inactiveGroupsCount !== null) {
37!
382
      displayInactiveGroupsTooltip = `${I18n.t("activerecord.attributes.grouping.inactive_groups", {
37✔
383
        count: this.state.inactiveGroupsCount,
384
      })}`;
385
    }
386

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