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

MarkUsProject / Markus / 29673128555

19 Jul 2026 04:18AM UTC coverage: 90.37% (-0.04%) from 90.411%
29673128555

Pull #8068

github

web-flow
Merge dccb0480f into 59d4c37c6
Pull Request #8068: Groups tab tables refactor

1219 of 2484 branches covered (49.07%)

Branch coverage included in aggregate %.

38 of 78 new or added lines in 3 files covered. (48.72%)

18 existing lines in 1 file now uncovered.

47355 of 51266 relevant lines covered (92.37%)

127.93 hits per line

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

51.17
/app/javascript/Components/groups_manager.jsx
1
import React from "react";
2
import {createRoot} from "react-dom/client";
3
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
4

5
import consumer from "../channels/consumer";
6
import {renderFlashMessages} from "../common/flash";
7
import ExtensionModal from "./Modals/extension_modal";
8
import {
9
  caseSensitiveStringFilterMethod,
10
  durationSort,
11
  getTimeExtension,
12
} from "./Helpers/table_helpers";
13
import AutoMatchModal from "./Modals/auto_match_modal";
14
import CreateGroupModal from "./Modals/create_group_modal";
15
import RenameGroupModal from "./Modals/rename_group_modal";
16
import AssignmentGroupUseModal from "./Modals/assignment_group_use_modal";
17

18
import Table from "./table/table";
19
import {createColumnHelper} from "@tanstack/react-table";
20

21
class GroupsManager extends React.Component {
22
  constructor(props) {
23
    super(props);
13✔
24
    this.state = {
13✔
25
      groups: [],
26
      students: [],
27
      show_hidden: false,
28
      hidden_students_count: 0,
29
      inactive_groups_count: 0,
30
      renameGroupingId: null,
31
      renameGroupName: "",
32
      show_modal: false,
33
      selected_extension_data: {},
34
      updating_extension: false,
35
      isAutoMatchModalOpen: false,
36
      isAssignmentGroupUseModalOpen: false,
37
      isCreateGroupModalOpen: false,
38
      isRenameGroupDialogOpen: false,
39
      examTemplates: [],
40
      loading: true,
41
      cloneAssignments: [],
42
    };
43
  }
44

45
  componentDidMount() {
46
    this.fetchData();
13✔
47
    this.createChannelSubscriptions();
13✔
48
  }
49

50
  fetchData = () => {
13✔
51
    fetch(Routes.course_assignment_groups_path(this.props.course_id, this.props.assignment_id), {
13✔
52
      headers: {
53
        Accept: "application/json",
54
      },
55
    })
56
      .then(response => {
57
        if (response.ok) {
13!
58
          return response.json();
13✔
59
        }
60
      })
61
      .then(res => {
62
        this.studentsTable.resetSelection();
13✔
63
        this.groupsTable.resetSelection();
13✔
64
        var inactive_groups_count = 0;
13✔
65
        res.groups.forEach(group => {
13✔
66
          if (group.members.length && group.members.every(member => member[2])) {
39!
67
            group.inactive = true;
×
68
            inactive_groups_count += 1;
×
69
          } else {
70
            group.inactive = false;
39✔
71
          }
72
          group.members.forEach(member => {
39✔
73
            member.display_label = `(${member[1]}${
39✔
74
              member[2] ? `, ${I18n.t("activerecord.attributes.user.hidden")}` : ""
39!
75
            })`;
76
          });
77
        });
78
        this.setState({
13✔
79
          groups: res.groups,
80
          students: res.students || [],
13!
81
          loading: false,
82
          hidden_students_count: res.students.filter(student => student.hidden).length,
13✔
83
          inactive_groups_count: inactive_groups_count,
84
          examTemplates: res.exam_templates,
85
          cloneAssignments: res.clone_assignments || [],
13!
86
        });
87
      });
88
  };
89

90
  updateShowHidden = event => {
13✔
91
    let show_hidden = event.target.checked;
×
92
    this.setState({show_hidden});
×
93
  };
94

95
  createGroup = () => {
13✔
96
    if (this.props.group_name_autogenerated) {
×
97
      fetch(
×
98
        Routes.new_course_assignment_group_path(this.props.course_id, this.props.assignment_id)
99
      ).then(this.fetchData);
100
    } else {
101
      this.setState({isCreateGroupModalOpen: true});
×
102
    }
103
  };
104

105
  createAllGroups = () => {
13✔
106
    $.get({
1✔
107
      url: Routes.create_groups_when_students_work_alone_course_assignment_groups_path(
108
        this.props.course_id,
109
        this.props.assignment_id
110
      ),
111
    });
112
  };
113

114
  createChannelSubscriptions = () => {
13✔
115
    consumer.subscriptions.create(
13✔
116
      {
117
        channel: "GroupsChannel",
118
        course_id: this.props.course_id,
119
        assignment_id: this.props.assignment_id,
120
      },
121
      {
122
        connected: () => {},
123
        disconnected: () => {},
124
        received: data => {
125
          if (data["status"] != null) {
6✔
126
            renderFlashMessages(generateMessage(data));
5✔
127
          }
128
          if (data["update_table"] != null) {
6✔
129
            this.fetchData();
1✔
130
          }
131
        },
132
      }
133
    );
134
  };
135

136
  deleteGroups = () => {
13✔
NEW
137
    let groupings = this.groupsTable.getSelectedRows();
×
138
    if (groupings.length === 0) {
×
139
      alert(I18n.t("groups.select_a_group"));
×
140
      return;
×
141
    } else if (!confirm(I18n.t("groups.delete_confirm"))) {
×
142
      return;
×
143
    }
144

145
    $.ajax(
×
146
      Routes.remove_group_course_assignment_groups_path(
147
        this.props.course_id,
148
        this.props.assignment_id
149
      ),
150
      {
151
        method: "DELETE",
152
        data: {
153
          // TODO: change param to grouping_ids
154
          grouping_id: groupings,
155
        },
156
      }
157
    ).then(this.fetchData);
158
  };
159

160
  renameGroup = (grouping_id, group_name) => {
13✔
161
    this.setState({
×
162
      isRenameGroupDialogOpen: true,
163
      renameGroupingId: grouping_id,
164
      renameGroupName: group_name,
165
    });
166
  };
167

168
  handleRenameGroupDialog = newGroupName => {
13✔
169
    $.post({
×
170
      url: Routes.rename_group_course_group_path(this.props.course_id, this.state.renameGroupingId),
171
      data: {
172
        new_groupname: newGroupName,
173
      },
174
    }).then(() => {
175
      this.setState({isRenameGroupDialogOpen: false});
×
176
      this.fetchData();
×
177
    });
178
  };
179

180
  handleCloseRenameGroupDialog = () => {
13✔
181
    this.setState({
×
182
      isRenameGroupDialogOpen: false,
183
      renameGroupingId: null,
184
      renameGroupName: "",
185
    });
186
  };
187

188
  unassign = (grouping_id, student_user_name) => {
13✔
189
    $.post({
×
190
      url: Routes.global_actions_course_assignment_groups_path(
191
        this.props.course_id,
192
        this.props.assignment_id
193
      ),
194
      data: {
195
        global_actions: "unassign",
196
        groupings: [grouping_id],
197
        students: [], // Not necessary for 'unassign'
198
        students_to_remove: [student_user_name],
199
      },
200
    }).then(this.fetchData);
201
  };
202

203
  assign = () => {
13✔
NEW
204
    if (this.studentsTable.getSelectedRows().length === 0) {
×
205
      alert(I18n.t("groups.select_a_student"));
×
206
      return;
×
NEW
207
    } else if (this.groupsTable.getSelectedRows().length === 0) {
×
208
      alert(I18n.t("groups.select_a_group"));
×
209
      return;
×
NEW
210
    } else if (this.groupsTable.getSelectedRows().length > 1) {
×
211
      alert(I18n.t("groups.select_only_one_group"));
×
212
      return;
×
213
    }
214

NEW
215
    let students = this.studentsTable.getSelectedRows();
×
NEW
216
    let grouping_id = this.groupsTable.getSelectedRows()[0];
×
217

218
    $.post({
×
219
      url: Routes.global_actions_course_assignment_groups_path(
220
        this.props.course_id,
221
        this.props.assignment_id
222
      ),
223
      data: {
224
        global_actions: "assign",
225
        groupings: [grouping_id],
226
        students: students,
227
      },
228
    }).then(this.fetchData);
229
  };
230

231
  handleCloseCreateGroupModal = () => {
13✔
232
    this.setState({
×
233
      isCreateGroupModalOpen: false,
234
    });
235
  };
236

237
  handleSubmitCreateGroup = groupName => {
13✔
238
    $.get({
×
239
      url: Routes.new_course_assignment_group_path(this.props.course_id, this.props.assignment_id),
240
      data: {new_group_name: groupName},
241
    }).then(() => {
242
      this.setState({isCreateGroupModalOpen: false});
×
243
      this.fetchData();
×
244
    });
245
  };
246

247
  handleShowAssignmentGroupUseModal = () => {
13✔
248
    this.setState({
×
249
      isAssignmentGroupUseModalOpen: true,
250
    });
251
  };
252

253
  handleCloseAssignmentGroupUseModal = () => {
13✔
254
    this.setState({
×
255
      isAssignmentGroupUseModalOpen: false,
256
    });
257
  };
258

259
  handleSubmitAssignmentGroupUseModal = selectedAssignmentId => {
13✔
260
    $.post({
×
261
      url: Routes.use_another_assignment_groups_course_assignment_groups_path(
262
        this.props.course_id,
263
        this.props.assignment_id
264
      ),
265
      data: {
266
        clone_assignment_id: selectedAssignmentId,
267
      },
268
    }).then(() => {
269
      this.setState({isAssignmentGroupUseModalOpen: false});
×
270
      this.fetchData();
×
271
    });
272
  };
273

274
  handleShowAutoMatchModal = () => {
13✔
NEW
275
    if (this.groupsTable.getSelectedRows().length === 0) {
×
276
      alert(I18n.t("groups.select_a_group"));
×
277
      return;
×
278
    }
279

280
    this.setState({
×
281
      isAutoMatchModalOpen: true,
282
    });
283
  };
284

285
  handleCloseAutoMatchModal = () => {
13✔
286
    this.setState({
×
287
      isAutoMatchModalOpen: false,
288
    });
289
  };
290

291
  autoMatch = examTemplate => {
13✔
292
    $.post({
×
293
      url: Routes.auto_match_course_assignment_groups_path(
294
        this.props.course_id,
295
        this.props.assignment_id
296
      ),
297
      data: {
298
        groupings: this.groupsTable.getSelectedRows(),
299
        exam_template_id: examTemplate,
300
      },
301
    });
302
  };
303

304
  validate = grouping_id => {
13✔
305
    if (!confirm(I18n.t("groups.validate_confirm"))) {
×
306
      return;
×
307
    }
308

309
    const url = Routes.valid_grouping_course_assignment_groups_path(
×
310
      this.props.course_id,
311
      this.props.assignment_id,
312
      {grouping_id: grouping_id}
313
    );
314

315
    fetch(url).then(this.fetchData);
×
316
  };
317

318
  invalidate = grouping_id => {
13✔
319
    if (!confirm(I18n.t("groups.invalidate_confirm"))) {
×
320
      return;
×
321
    }
322

323
    const url = Routes.invalid_grouping_course_assignment_groups_path(
×
324
      this.props.course_id,
325
      this.props.assignment_id,
326
      {grouping_id: grouping_id}
327
    );
328
    fetch(url).then(this.fetchData);
×
329
  };
330

331
  handleShowModal = (extension_data, updating) => {
13✔
332
    this.setState({
×
333
      show_modal: true,
334
      selected_extension_data: extension_data,
335
      updating_extension: updating,
336
    });
337
  };
338

339
  handleCloseModal = updated => {
13✔
340
    this.setState({show_modal: false}, () => {
×
341
      if (updated) {
×
342
        this.fetchData();
×
343
      }
344
    });
345
  };
346

347
  extraModalInfo = () => {
13✔
348
    // Render extra modal info for timed assignments only
349
    if (this.props.timed) {
26!
350
      return I18n.t("assignments.timed.modal_current_duration", {
×
351
        duration: this.props.current_duration,
352
      });
353
    }
354
  };
355

356
  render() {
357
    const times = !!this.props.timed ? ["hours", "minutes"] : ["weeks", "days", "hours", "minutes"];
26!
358
    const title = !!this.props.timed
26!
359
      ? I18n.t("groups.duration_extension")
360
      : I18n.t("groups.due_date_extension");
361
    return (
26✔
362
      <div>
363
        <GroupsActionBox
364
          assign={this.assign}
365
          can_create_all_groups={this.props.can_create_all_groups}
366
          createAllGroups={this.createAllGroups}
367
          createGroup={this.createGroup}
368
          deleteGroups={this.deleteGroups}
369
          handleShowAutoMatchModal={this.handleShowAutoMatchModal}
370
          handleShowAssignmentGroupUseModal={this.handleShowAssignmentGroupUseModal}
371
          hiddenStudentsCount={this.state.loading ? null : this.state.hidden_students_count}
26✔
372
          hiddenGroupsCount={this.state.loading ? null : this.state.inactive_groups_count}
26✔
373
          scanned_exam={this.props.scanned_exam}
374
          showHidden={this.state.show_hidden}
375
          updateShowHidden={this.updateShowHidden}
376
          vcs_submit={this.props.vcs_submit}
377
        />
378
        <div className="mapping-tables">
379
          <div className="mapping-table">
380
            <StudentsTable
381
              ref={r => (this.studentsTable = r)}
52✔
382
              students={this.state.students}
383
              loading={this.state.loading}
384
              showHidden={this.state.show_hidden}
385
            />
386
          </div>
387
          <div className="mapping-table">
388
            <GroupsTable
389
              ref={r => (this.groupsTable = r)}
52✔
390
              course_id={this.props.course_id}
391
              groups={this.state.groups}
392
              loading={this.state.loading}
393
              unassign={this.unassign}
394
              renameGroup={this.renameGroup}
395
              groupMin={this.props.groupMin}
396
              validate={this.validate}
397
              invalidate={this.invalidate}
398
              scanned_exam={this.props.scanned_exam}
399
              assignment_id={this.props.assignment_id}
400
              onExtensionModal={this.handleShowModal}
401
              extensionColumnHeader={title}
402
              times={times}
403
              showInactive={this.state.show_hidden}
404
            />
405
          </div>
406
        </div>
407
        <ExtensionModal
408
          course_id={this.props.course_id}
409
          isOpen={this.state.show_modal}
410
          onRequestClose={this.handleCloseModal}
411
          weeks={this.state.selected_extension_data.weeks}
412
          days={this.state.selected_extension_data.days}
413
          hours={this.state.selected_extension_data.hours}
414
          minutes={this.state.selected_extension_data.minutes}
415
          note={this.state.selected_extension_data.note}
416
          penalty={this.state.selected_extension_data.apply_penalty}
417
          grouping_id={this.state.selected_extension_data.grouping_id}
418
          extension_id={this.state.selected_extension_data.id}
419
          updating={this.state.updating_extension}
420
          times={times}
421
          title={title}
422
          extra_info={this.extraModalInfo()}
423
          key={this.state.selected_extension_data.id} // this causes the ExtensionModal to be recreated if this value changes
424
        />
425
        <AutoMatchModal
426
          isOpen={this.state.isAutoMatchModalOpen}
427
          onRequestClose={this.handleCloseAutoMatchModal}
428
          examTemplates={this.state.examTemplates}
429
          onSubmit={this.autoMatch}
430
        />
431
        <CreateGroupModal
432
          isOpen={this.state.isCreateGroupModalOpen}
433
          onRequestClose={this.handleCloseCreateGroupModal}
434
          onSubmit={this.handleSubmitCreateGroup}
435
        />
436
        <RenameGroupModal
437
          isOpen={this.state.isRenameGroupDialogOpen}
438
          onRequestClose={this.handleCloseRenameGroupDialog}
439
          onSubmit={this.handleRenameGroupDialog}
440
          initialGroupName={this.state.renameGroupName}
441
        />
442
        <AssignmentGroupUseModal
443
          isOpen={this.state.isAssignmentGroupUseModalOpen}
444
          onRequestClose={this.handleCloseAssignmentGroupUseModal}
445
          onSubmit={this.handleSubmitAssignmentGroupUseModal}
446
          cloneAssignments={this.state.cloneAssignments}
447
        />
448
      </div>
449
    );
450
  }
451
}
452

453
const columnHelper = createColumnHelper();
1✔
454
class GroupsTable extends React.Component {
455
  constructor(props) {
456
    super(props);
13✔
457
    this.state = {
13✔
458
      columnFilters: [{id: "inactive", value: false}],
459
      rowSelection: {},
460
      columns: [
461
        columnHelper.accessor("inactive", {
462
          id: "inactive",
463
          size: 0,
464
          meta: {
465
            className: "rt-hidden",
466
            headerClassName: "rt-hidden",
467
          },
468
          enableResizing: false,
469
        }),
470
        columnHelper.accessor("_id", {
471
          id: "_id",
472
        }),
473
        columnHelper.accessor("group_name", {
474
          header: I18n.t("activerecord.models.group.one"),
475
          id: "group_name",
476
          cell: props => {
477
            return (
39✔
478
              <span>
479
                <span>{props.getValue()}</span>
480
                <a
481
                  href="#"
NEW
482
                  onClick={() => this.props.renameGroup(props.row.original._id, props.getValue())}
×
483
                  title={I18n.t("groups.rename_group")}
484
                >
485
                  <FontAwesomeIcon icon="fa-solid fa-pen" className="icon-right" />
486
                </a>
487
              </span>
488
            );
489
          },
490
          meta: {
491
            filterVariant: "case-sensitive-text",
492
          },
493
          filterFn: caseSensitiveStringFilterMethod,
494
        }),
495
        columnHelper.accessor("members", {
496
          header: I18n.t("activerecord.attributes.group.student_memberships"),
497
          cell: props => {
498
            if (props.getValue().length > 0 || !this.props.scanned_exam) {
39!
499
              return props.getValue().map(member => {
39✔
500
                let status;
501
                if (member[1] === "pending") {
39!
502
                  status = <strong>({member[1]})</strong>;
×
503
                } else {
504
                  status = member.display_label;
39✔
505
                }
506
                return (
39✔
507
                  <div key={`${props.row.original._id}-${member[0]}`}>
508
                    {member[0]} {status}
509
                    <a
510
                      href="#"
NEW
511
                      onClick={() => this.props.unassign(props.row.original._id, member[0])}
×
512
                      title={I18n.t("delete")}
513
                    >
514
                      <FontAwesomeIcon icon="fa-solid fa-trash" className="icon-right" />
515
                    </a>
516
                  </div>
517
                );
518
              });
519
            } else {
520
              // Link to assigning a student to this scanned exam
521
              const assign_url = Routes.assign_scans_course_assignment_groups_path(
×
522
                this.props.course_id,
523
                this.props.assignment_id,
524
                {grouping_id: props.row.original._id}
525
              );
526
              return <a href={assign_url}>{I18n.t("exam_templates.assign_scans.title")}</a>;
×
527
            }
528
          },
529
          filterFn: (row, columnId, filterValue) => {
NEW
530
            if (filterValue) {
×
NEW
531
              return row.original.members.some(member => member[0].includes(filterValue));
×
532
            } else {
533
              return true;
×
534
            }
535
          },
536
          enableSorting: false,
537
        }),
538
        columnHelper.accessor(
539
          row =>
540
            row.instructor_approved || row.members.length >= this.props.groupMin
39!
541
              ? I18n.t("groups.is_valid")
542
              : I18n.t("groups.is_not_valid"),
543
          {
544
            id: "valid",
545
            header: I18n.t("groups.valid"),
546
            cell: props => {
547
              let isValid =
548
                props.row.original.instructor_approved ||
39!
549
                props.row.original.members.length >= this.props.groupMin;
550
              if (isValid) {
39!
551
                return (
39✔
552
                  <a
553
                    href="#"
554
                    title={I18n.t("groups.is_valid")}
NEW
555
                    onClick={() => this.props.invalidate(props.row.original._id)}
×
556
                  >
557
                    ✔
558
                  </a>
559
                );
560
              } else {
NEW
561
                return (
×
562
                  <a
563
                    href="#"
564
                    title={I18n.t("groups.is_not_valid")}
NEW
565
                    onClick={() => this.props.validate(props.row.original._id)}
×
566
                  >
567
                    <FontAwesomeIcon icon="fa-solid fa-close" />
568
                  </a>
569
                );
570
              }
571
            },
572
            filterFn: "equals",
573
            meta: {
574
              filterVariant: "select",
575
            },
576
            minSize: 30,
577
            enableSorting: false,
578
          }
579
        ),
580
        ...(!props.scanned_exam
13!
581
          ? [
582
              columnHelper.accessor(
583
                row => {
584
                  const hasExtension = Object.hasOwn(row.extension, "hours");
39✔
585
                  if (!hasExtension) return I18n.t("groups.groups_without_extension");
39✔
586
                  if (row.extension.apply_penalty) {
26✔
587
                    return I18n.t("groups.groups_with_extension.with_late_submission");
13✔
588
                  }
589
                  return I18n.t("groups.groups_with_extension.without_late_submission");
13✔
590
                },
591
                {
592
                  id: "extension",
593
                  header: props.extensionColumnHeader,
594
                  cell: props => {
595
                    const timeExtension = getTimeExtension(
39✔
596
                      props.row.original.extension,
597
                      this.props.times
598
                    );
599
                    const lateSubmissionText = props.row.original.extension.apply_penalty
39✔
600
                      ? `(${I18n.t("groups.late_submissions_accepted")})`
601
                      : "";
602
                    const extension = `${timeExtension} ${lateSubmissionText}`;
39✔
603

604
                    if (!!timeExtension) {
39✔
605
                      return (
26✔
606
                        <div>
607
                          <a
608
                            href={"#"}
609
                            onClick={() =>
NEW
610
                              this.props.onExtensionModal(props.row.original.extension, true)
×
611
                            }
612
                          >
613
                            {extension}
614
                          </a>
615
                        </div>
616
                      );
617
                    } else {
618
                      return (
13✔
619
                        <a
620
                          href="#"
621
                          onClick={() =>
NEW
622
                            this.props.onExtensionModal(props.row.original.extension, false)
×
623
                          }
624
                          title={I18n.t("add")}
625
                        >
626
                          <FontAwesomeIcon icon="fa-solid fa-add" />
627
                        </a>
628
                      );
629
                    }
630
                  },
631
                  sortingFn: (rowA, rowB) =>
NEW
632
                    durationSort(rowA.original.extension, rowB.original.extension),
×
633
                  filterFn: "equals",
634
                  meta: {filterVariant: "select"},
635
                }
636
              ),
637
            ]
638
          : []),
639
      ],
640
    };
641
  }
642

643
  resetSelection = () => {
13✔
644
    this.setState({rowSelection: {}});
13✔
645
  };
646

647
  getSelectedRows = () => {
13✔
NEW
648
    return Object.keys(this.state.rowSelection).map(id => Number(id));
×
649
  };
650

651
  componentDidUpdate(prevProps, prevState, snapshot) {
652
    if (prevProps.showInactive !== this.props.showInactive) {
23!
NEW
653
      this.setState(prevState => {
×
NEW
654
        let newFilters = prevState.columnFilters;
×
655

NEW
656
        if (this.props.showInactive) {
×
NEW
657
          newFilters = newFilters.filter(f => f.id !== "inactive");
×
658
        } else {
NEW
659
          if (!newFilters.some(f => f.id === "inactive")) {
×
NEW
660
            newFilters = [...newFilters, {id: "inactive", value: false}];
×
661
          }
662
        }
NEW
663
        return {columnFilters: newFilters};
×
664
      });
665
    }
666
  }
667

668
  render() {
669
    return (
36✔
670
      <Table
671
        loading={this.props.loading}
672
        data={this.props.groups}
673
        columns={this.state.columns}
674
        initialState={{
675
          sorting: [{id: "group_name"}],
676
          columnVisibility: {
677
            _id: false,
678
            inactive: false,
679
          },
680
        }}
681
        columnFilters={this.state.columnFilters}
682
        onColumnFiltersChange={updaterOrValue => {
683
          this.setState(prevState => {
10✔
684
            let newFilters =
685
              typeof updaterOrValue === "function"
10!
686
                ? updaterOrValue(prevState.columnFilters)
687
                : updaterOrValue;
688
            return {columnFilters: newFilters};
10✔
689
          });
690
        }}
691
        enableRowSelection={true}
692
        rowSelection={this.state.rowSelection}
693
        onRowSelectionChange={updater => {
NEW
694
          this.setState(prevState => ({
×
695
            rowSelection: typeof updater === "function" ? updater(prevState.rowSelection) : updater,
×
696
          }));
697
        }}
698
        getRowId={row => row._id}
39✔
699
      />
700
    );
701
  }
702
}
703

704
class StudentsTable extends React.Component {
705
  constructor(props) {
706
    super(props);
13✔
707
    this.state = {
13✔
708
      columnFilters: [{id: "hidden", value: false}],
709
      rowSelection: {},
710
      columns: [
711
        columnHelper.accessor("hidden", {
712
          id: "hidden",
713
          size: 0,
714
          meta: {
715
            className: "rt-hidden",
716
            headerClassName: "rt-hidden",
717
          },
718
          enableResizing: false,
719
        }),
720
        columnHelper.accessor("_id", {
721
          id: "_id",
722
        }),
723
        columnHelper.accessor("user_name", {
724
          header: I18n.t("activerecord.attributes.user.user_name"),
725
          id: "user_name",
726
          cell: props =>
727
            props.row.original.hidden
13!
728
              ? `${props.getValue()} (${I18n.t("activerecord.attributes.user.hidden")})`
729
              : props.getValue(),
730
          filterFn: (row, columnID, filterValue) => {
NEW
731
            if (filterValue) {
×
NEW
732
              return `${row.original.user_name}${
×
733
                row.original.hidden ? `, ${I18n.t("activerecord.attributes.user.hidden")}` : ""
×
734
              }`.includes(filterValue);
735
            } else {
736
              return true;
×
737
            }
738
          },
739
          enableSorting: true,
740
          minSize: 90,
741
        }),
742
        columnHelper.accessor("last_name", {
743
          header: I18n.t("activerecord.attributes.user.last_name"),
744
          id: "last_name",
745
        }),
746
        columnHelper.accessor("first_name", {
747
          header: I18n.t("activerecord.attributes.user.first_name"),
748
          id: "first_name",
749
        }),
750
        columnHelper.accessor(
751
          row =>
752
            row.assigned
13!
753
              ? I18n.t("groups.assigned_students")
754
              : I18n.t("groups.unassigned_students"),
755
          {
756
            id: "assigned",
757
            header: I18n.t("groups.assigned_students") + "?",
758
            cell: ({row}) => (row.original.assigned ? "✔" : ""),
13!
759
            enableSorting: false,
760
            minSize: 60,
761
            filterFn: "equals",
762
            meta: {filterVariant: "select"},
763
          }
764
        ),
765
      ],
766
    };
767
  }
768

769
  resetSelection = () => {
13✔
770
    this.setState({rowSelection: {}});
13✔
771
  };
772

773
  getSelectedRows = () => {
13✔
NEW
774
    return Object.keys(this.state.rowSelection).map(id => Number(id));
×
775
  };
776

777
  componentDidUpdate(prevProps, prevState, snapshot) {
778
    if (prevProps.showHidden !== this.props.showHidden) {
13!
NEW
779
      this.setState(prevState => {
×
NEW
780
        let newFilters = prevState.columnFilters;
×
781

NEW
782
        if (this.props.showHidden) {
×
NEW
783
          newFilters = newFilters.filter(f => f.id !== "hidden");
×
784
        } else {
NEW
785
          if (!newFilters.some(f => f.id === "hidden")) {
×
NEW
786
            newFilters = [...newFilters, {id: "hidden", value: false}];
×
787
          }
788
        }
NEW
789
        return {columnFilters: newFilters};
×
790
      });
791
    }
792
  }
793

794
  render() {
795
    return (
26✔
796
      <Table
797
        loading={this.props.loading}
798
        data={this.props.students}
799
        columns={this.state.columns}
800
        initialState={{
801
          sorting: [{id: "user_name"}],
802
          columnVisibility: {
803
            hidden: false,
804
            _id: false,
805
          },
806
        }}
807
        columnFilters={this.state.columnFilters}
808
        onColumnFiltersChange={updaterOrValue => {
NEW
809
          this.setState(prevState => {
×
810
            let newFilters =
NEW
811
              typeof updaterOrValue === "function"
×
812
                ? updaterOrValue(prevState.columnFilters)
813
                : updaterOrValue;
NEW
814
            return {columnFilters: newFilters};
×
815
          });
816
        }}
817
        enableRowSelection={true}
818
        rowSelection={this.state.rowSelection}
819
        onRowSelectionChange={updater => {
NEW
820
          this.setState(prevState => ({
×
821
            rowSelection: typeof updater === "function" ? updater(prevState.rowSelection) : updater,
×
822
          }));
823
        }}
824
        getRowId={row => row._id}
13✔
825
      />
826
    );
827
  }
828
}
829

830
class GroupsActionBox extends React.Component {
831
  render = () => {
13✔
832
    var showHiddenTooltip = null;
26✔
833
    if (this.props.hiddenStudentsCount !== null && this.props.hiddenGroupsCount !== null) {
26✔
834
      showHiddenTooltip = `${I18n.t("activerecord.attributes.grouping.inactive_students", {
13✔
835
        count: this.props.hiddenStudentsCount,
836
      })}, ${I18n.t("activerecord.attributes.grouping.inactive_groups", {
837
        count: this.props.hiddenGroupsCount,
838
      })}`;
839
    }
840

841
    return (
26✔
842
      <div className="rt-action-box">
843
        <span>
844
          <input
845
            id="show_hidden"
846
            name="show_hidden"
847
            type="checkbox"
848
            checked={this.props.showHidden}
849
            onChange={this.props.updateShowHidden}
850
            style={{marginLeft: "5px", marginRight: "5px"}}
851
          />
852
          <label title={showHiddenTooltip} htmlFor="show_hidden">
853
            {I18n.t("students.display_inactive")}
854
          </label>
855
        </span>
856
        {this.props.vcs_submit && (
26!
857
          <button onClick={this.props.handleShowAssignmentGroupUseModal}>
858
            <FontAwesomeIcon icon="fa-solid fa-recycle" />
859
            {I18n.t("groups.reuse_groups")}
860
          </button>
861
        )}
862
        <button className="" onClick={this.props.assign}>
863
          <FontAwesomeIcon icon="fa-solid fa-user-plus" />
864
          {I18n.t("groups.add_to_group")}
865
        </button>
866
        {this.props.scanned_exam && (
26!
867
          <button onClick={this.props.handleShowAutoMatchModal}>
868
            <FontAwesomeIcon icon="fa-solid fa-file-import" />
869
            {I18n.t("groups.auto_match")}
870
          </button>
871
        )}
872
        {this.props.can_create_all_groups ? (
26!
873
          <button className="" onClick={this.props.createAllGroups}>
874
            <FontAwesomeIcon icon="fa-solid fa-people-group" />
875
            {I18n.t("groups.add_all_groups")}
876
          </button>
877
        ) : undefined}
878
        <button className="" onClick={this.props.createGroup}>
879
          <FontAwesomeIcon icon="fa-solid fa-circle-plus" />
880
          {I18n.t("helpers.submit.create", {
881
            model: I18n.t("activerecord.models.group.one"),
882
          })}
883
        </button>
884
        <button className="" onClick={this.props.deleteGroups}>
885
          <FontAwesomeIcon icon="fa-solid fa-trash" />
886
          {I18n.t("groups.delete")}
887
        </button>
888
      </div>
889
    );
890
  };
891
}
892

893
export function makeGroupsManager(elem, props) {
894
  const root = createRoot(elem);
×
895
  const component = React.createRef();
×
896
  root.render(<GroupsManager {...props} ref={component} />);
×
897
  return component;
×
898
}
899

900
function generateMessage(status_data) {
901
  let message_data = {};
5✔
902
  switch (status_data["status"]) {
5✔
903
    case "failed":
904
      if (!status_data["exception"] || !status_data["exception"]["message"]) {
2✔
905
        message_data["error"] = I18n.t("job.status.failed.no_message");
1✔
906
      } else {
907
        message_data["error"] = I18n.t("job.status.failed.message", {
1✔
908
          error: status_data["exception"]["message"],
909
        });
910
      }
911
      break;
2✔
912
    case "completed":
913
      message_data["success"] = I18n.t("job.status.completed");
1✔
914
      break;
1✔
915
    case "queued":
916
      message_data["notice"] = I18n.t("job.status.queued");
1✔
917
      break;
1✔
918
    default: {
919
      let progress = status_data["progress"];
1✔
920
      let total = status_data["total"];
1✔
921
      message_data["notice"] = I18n.t("poll_job.create_groups_job", {progress, total});
1✔
922
    }
923
  }
924
  if (status_data["warning_message"]) {
5✔
925
    message_data["warning"] = status_data["warning_message"];
1✔
926
  }
927
  return message_data;
5✔
928
}
929

930
export {GroupsManager};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc