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

MarkUsProject / Markus / 20143075828

11 Dec 2025 06:18PM UTC coverage: 91.513%. Remained the same
20143075828

Pull #7763

github

web-flow
Merge 9f55e660a into 3421ef3b2
Pull Request #7763: Release 2.9.0

914 of 1805 branches covered (50.64%)

Branch coverage included in aggregate %.

1584 of 1666 new or added lines in 108 files covered. (95.08%)

573 existing lines in 35 files now uncovered.

43650 of 46892 relevant lines covered (93.09%)

121.63 hits per line

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

50.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 {withSelection, CheckboxTable} from "./markus_with_selection_hoc";
6
import ExtensionModal from "./Modals/extension_modal";
7
import {durationSort, selectFilter, getTimeExtension} from "./Helpers/table_helpers";
8
import AutoMatchModal from "./Modals/auto_match_modal";
9
import CreateGroupModal from "./Modals/create_group_modal";
10
import RenameGroupModal from "./Modals/rename_group_modal";
11
import AssignmentGroupUseModal from "./Modals/assignment_group_use_modal";
12

13
class GroupsManager extends React.Component {
14
  constructor(props) {
15
    super(props);
8✔
16
    this.state = {
8✔
17
      groups: [],
18
      students: [],
19
      show_hidden: false,
20
      hidden_students_count: 0,
21
      inactive_groups_count: 0,
22
      renameGroupingId: null,
23
      renameGroupName: "",
24
      show_modal: false,
25
      selected_extension_data: {},
26
      updating_extension: false,
27
      isAutoMatchModalOpen: false,
28
      isAssignmentGroupUseModalOpen: false,
29
      isCreateGroupModalOpen: false,
30
      isRenameGroupDialogOpen: false,
31
      examTemplates: [],
32
      loading: true,
33
      cloneAssignments: [],
34
    };
35
  }
36

37
  componentDidMount() {
38
    this.fetchData();
8✔
39
  }
40

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

81
  updateShowHidden = event => {
8✔
UNCOV
82
    let show_hidden = event.target.checked;
×
UNCOV
83
    this.setState({show_hidden});
×
84
  };
85

86
  createGroup = () => {
8✔
UNCOV
87
    if (this.props.group_name_autogenerated) {
×
UNCOV
88
      fetch(
×
89
        Routes.new_course_assignment_group_path(this.props.course_id, this.props.assignment_id)
90
      ).then(this.fetchData);
91
    } else {
NEW
92
      this.setState({isCreateGroupModalOpen: true});
×
93
    }
94
  };
95

96
  createAllGroups = () => {
8✔
UNCOV
97
    $.get({
×
98
      url: Routes.create_groups_when_students_work_alone_course_assignment_groups_path(
99
        this.props.course_id,
100
        this.props.assignment_id
101
      ),
102
    }).then(this.fetchData);
103
  };
104

105
  deleteGroups = () => {
8✔
UNCOV
106
    let groupings = this.groupsTable.state.selection;
×
UNCOV
107
    if (groupings.length === 0) {
×
UNCOV
108
      alert(I18n.t("groups.select_a_group"));
×
109
      return;
×
UNCOV
110
    } else if (!confirm(I18n.t("groups.delete_confirm"))) {
×
UNCOV
111
      return;
×
112
    }
113

UNCOV
114
    $.ajax(
×
115
      Routes.remove_group_course_assignment_groups_path(
116
        this.props.course_id,
117
        this.props.assignment_id
118
      ),
119
      {
120
        method: "DELETE",
121
        data: {
122
          // TODO: change param to grouping_ids
123
          grouping_id: groupings,
124
        },
125
      }
126
    ).then(this.fetchData);
127
  };
128

129
  renameGroup = (grouping_id, group_name) => {
8✔
NEW
130
    this.setState({
×
131
      isRenameGroupDialogOpen: true,
132
      renameGroupingId: grouping_id,
133
      renameGroupName: group_name,
134
    });
135
  };
136

137
  handleRenameGroupDialog = newGroupName => {
8✔
NEW
138
    $.post({
×
139
      url: Routes.rename_group_course_group_path(this.props.course_id, this.state.renameGroupingId),
140
      data: {
141
        new_groupname: newGroupName,
142
      },
143
    }).then(() => {
NEW
144
      this.setState({isRenameGroupDialogOpen: false});
×
NEW
145
      this.fetchData();
×
146
    });
147
  };
148

149
  handleCloseRenameGroupDialog = () => {
8✔
NEW
150
    this.setState({
×
151
      isRenameGroupDialogOpen: false,
152
      renameGroupingId: null,
153
      renameGroupName: "",
154
    });
155
  };
156

157
  unassign = (grouping_id, student_user_name) => {
8✔
UNCOV
158
    $.post({
×
159
      url: Routes.global_actions_course_assignment_groups_path(
160
        this.props.course_id,
161
        this.props.assignment_id
162
      ),
163
      data: {
164
        global_actions: "unassign",
165
        groupings: [grouping_id],
166
        students: [], // Not necessary for 'unassign'
167
        students_to_remove: [student_user_name],
168
      },
169
    }).then(this.fetchData);
170
  };
171

172
  assign = () => {
8✔
173
    if (this.studentsTable.state.selection.length === 0) {
×
174
      alert(I18n.t("groups.select_a_student"));
×
175
      return;
×
176
    } else if (this.groupsTable.state.selection.length === 0) {
×
177
      alert(I18n.t("groups.select_a_group"));
×
178
      return;
×
179
    } else if (this.groupsTable.state.selection.length > 1) {
×
UNCOV
180
      alert(I18n.t("groups.select_only_one_group"));
×
UNCOV
181
      return;
×
182
    }
183

UNCOV
184
    let students = this.studentsTable.state.selection;
×
185
    let grouping_id = this.groupsTable.state.selection[0];
×
186

UNCOV
187
    $.post({
×
188
      url: Routes.global_actions_course_assignment_groups_path(
189
        this.props.course_id,
190
        this.props.assignment_id
191
      ),
192
      data: {
193
        global_actions: "assign",
194
        groupings: [grouping_id],
195
        students: students,
196
      },
197
    }).then(this.fetchData);
198
  };
199

200
  handleCloseCreateGroupModal = () => {
8✔
NEW
201
    this.setState({
×
202
      isCreateGroupModalOpen: false,
203
    });
204
  };
205

206
  handleSubmitCreateGroup = groupName => {
8✔
NEW
207
    $.get({
×
208
      url: Routes.new_course_assignment_group_path(this.props.course_id, this.props.assignment_id),
209
      data: {new_group_name: groupName},
210
    }).then(() => {
NEW
211
      this.setState({isCreateGroupModalOpen: false});
×
NEW
212
      this.fetchData();
×
213
    });
214
  };
215

216
  handleShowAssignmentGroupUseModal = () => {
8✔
NEW
217
    this.setState({
×
218
      isAssignmentGroupUseModalOpen: true,
219
    });
220
  };
221

222
  handleCloseAssignmentGroupUseModal = () => {
8✔
NEW
223
    this.setState({
×
224
      isAssignmentGroupUseModalOpen: false,
225
    });
226
  };
227

228
  handleSubmitAssignmentGroupUseModal = selectedAssignmentId => {
8✔
NEW
229
    $.post({
×
230
      url: Routes.use_another_assignment_groups_course_assignment_groups_path(
231
        this.props.course_id,
232
        this.props.assignment_id
233
      ),
234
      data: {
235
        clone_assignment_id: selectedAssignmentId,
236
      },
237
    }).then(() => {
NEW
238
      this.setState({isAssignmentGroupUseModalOpen: false});
×
NEW
239
      this.fetchData();
×
240
    });
241
  };
242

243
  handleShowAutoMatchModal = () => {
8✔
UNCOV
244
    if (this.groupsTable.state.selection.length === 0) {
×
UNCOV
245
      alert(I18n.t("groups.select_a_group"));
×
UNCOV
246
      return;
×
247
    }
248

UNCOV
249
    this.setState({
×
250
      isAutoMatchModalOpen: true,
251
    });
252
  };
253

254
  handleCloseAutoMatchModal = () => {
8✔
UNCOV
255
    this.setState({
×
256
      isAutoMatchModalOpen: false,
257
    });
258
  };
259

260
  autoMatch = examTemplate => {
8✔
UNCOV
261
    $.post({
×
262
      url: Routes.auto_match_course_assignment_groups_path(
263
        this.props.course_id,
264
        this.props.assignment_id
265
      ),
266
      data: {
267
        groupings: this.groupsTable.state.selection,
268
        exam_template_id: examTemplate,
269
      },
270
    });
271
  };
272

273
  validate = grouping_id => {
8✔
UNCOV
274
    if (!confirm(I18n.t("groups.validate_confirm"))) {
×
UNCOV
275
      return;
×
276
    }
277

UNCOV
278
    const url = Routes.valid_grouping_course_assignment_groups_path(
×
279
      this.props.course_id,
280
      this.props.assignment_id,
281
      {grouping_id: grouping_id}
282
    );
283

UNCOV
284
    fetch(url).then(this.fetchData);
×
285
  };
286

287
  invalidate = grouping_id => {
8✔
UNCOV
288
    if (!confirm(I18n.t("groups.invalidate_confirm"))) {
×
UNCOV
289
      return;
×
290
    }
291

UNCOV
292
    const url = Routes.invalid_grouping_course_assignment_groups_path(
×
293
      this.props.course_id,
294
      this.props.assignment_id,
295
      {grouping_id: grouping_id}
296
    );
UNCOV
297
    fetch(url).then(this.fetchData);
×
298
  };
299

300
  handleShowModal = (extension_data, updating) => {
8✔
UNCOV
301
    this.setState({
×
302
      show_modal: true,
303
      selected_extension_data: extension_data,
304
      updating_extension: updating,
305
    });
306
  };
307

308
  handleCloseModal = updated => {
8✔
UNCOV
309
    this.setState({show_modal: false}, () => {
×
UNCOV
310
      if (updated) {
×
UNCOV
311
        this.fetchData();
×
312
      }
313
    });
314
  };
315

316
  extraModalInfo = () => {
8✔
317
    // Render extra modal info for timed assignments only
318
    if (this.props.timed) {
16!
319
      return I18n.t("assignments.timed.modal_current_duration", {
×
320
        duration: this.props.current_duration,
321
      });
322
    }
323
  };
324

325
  render() {
326
    const times = !!this.props.timed ? ["hours", "minutes"] : ["weeks", "days", "hours", "minutes"];
16!
327
    const title = !!this.props.timed
16!
328
      ? I18n.t("groups.duration_extension")
329
      : I18n.t("groups.due_date_extension");
330
    return (
16✔
331
      <div>
332
        <GroupsActionBox
333
          assign={this.assign}
334
          can_create_all_groups={this.props.can_create_all_groups}
335
          createAllGroups={this.createAllGroups}
336
          createGroup={this.createGroup}
337
          deleteGroups={this.deleteGroups}
338
          handleShowAutoMatchModal={this.handleShowAutoMatchModal}
339
          handleShowAssignmentGroupUseModal={this.handleShowAssignmentGroupUseModal}
340
          hiddenStudentsCount={this.state.loading ? null : this.state.hidden_students_count}
16✔
341
          hiddenGroupsCount={this.state.loading ? null : this.state.inactive_groups_count}
16✔
342
          scanned_exam={this.props.scanned_exam}
343
          showHidden={this.state.show_hidden}
344
          updateShowHidden={this.updateShowHidden}
345
          vcs_submit={this.props.vcs_submit}
346
        />
347
        <div className="mapping-tables">
348
          <div className="mapping-table">
349
            <StudentsTable
350
              ref={r => (this.studentsTable = r)}
32✔
351
              students={this.state.students}
352
              loading={this.state.loading}
353
              showHidden={this.state.show_hidden}
354
            />
355
          </div>
356
          <div className="mapping-table">
357
            <GroupsTable
358
              ref={r => (this.groupsTable = r)}
32✔
359
              course_id={this.props.course_id}
360
              groups={this.state.groups}
361
              loading={this.state.loading}
362
              unassign={this.unassign}
363
              renameGroup={this.renameGroup}
364
              groupMin={this.props.groupMin}
365
              validate={this.validate}
366
              invalidate={this.invalidate}
367
              scanned_exam={this.props.scanned_exam}
368
              assignment_id={this.props.assignment_id}
369
              onExtensionModal={this.handleShowModal}
370
              extensionColumnHeader={title}
371
              times={times}
372
              showInactive={this.state.show_hidden}
373
            />
374
          </div>
375
        </div>
376
        <ExtensionModal
377
          course_id={this.props.course_id}
378
          isOpen={this.state.show_modal}
379
          onRequestClose={this.handleCloseModal}
380
          weeks={this.state.selected_extension_data.weeks}
381
          days={this.state.selected_extension_data.days}
382
          hours={this.state.selected_extension_data.hours}
383
          minutes={this.state.selected_extension_data.minutes}
384
          note={this.state.selected_extension_data.note}
385
          penalty={this.state.selected_extension_data.apply_penalty}
386
          grouping_id={this.state.selected_extension_data.grouping_id}
387
          extension_id={this.state.selected_extension_data.id}
388
          updating={this.state.updating_extension}
389
          times={times}
390
          title={title}
391
          extra_info={this.extraModalInfo()}
392
          key={this.state.selected_extension_data.id} // this causes the ExtensionModal to be recreated if this value changes
393
        />
394
        <AutoMatchModal
395
          isOpen={this.state.isAutoMatchModalOpen}
396
          onRequestClose={this.handleCloseAutoMatchModal}
397
          examTemplates={this.state.examTemplates}
398
          onSubmit={this.autoMatch}
399
        />
400
        <CreateGroupModal
401
          isOpen={this.state.isCreateGroupModalOpen}
402
          onRequestClose={this.handleCloseCreateGroupModal}
403
          onSubmit={this.handleSubmitCreateGroup}
404
        />
405
        <RenameGroupModal
406
          isOpen={this.state.isRenameGroupDialogOpen}
407
          onRequestClose={this.handleCloseRenameGroupDialog}
408
          onSubmit={this.handleRenameGroupDialog}
409
          initialGroupName={this.state.renameGroupName}
410
        />
411
        <AssignmentGroupUseModal
412
          isOpen={this.state.isAssignmentGroupUseModalOpen}
413
          onRequestClose={this.handleCloseAssignmentGroupUseModal}
414
          onSubmit={this.handleSubmitAssignmentGroupUseModal}
415
          cloneAssignments={this.state.cloneAssignments}
416
        />
417
      </div>
418
    );
419
  }
420
}
421

422
class RawGroupsTable extends React.Component {
423
  constructor(props) {
424
    super(props);
8✔
425
    this.state = {
8✔
426
      filtered: [],
427
    };
428
  }
429

430
  getColumns = () => [
16✔
431
    {
432
      accessor: "inactive",
433
      id: "inactive",
434
      width: 0,
435
      className: "rt-hidden",
436
      headerClassName: "rt-hidden",
437
      resizable: false,
438
    },
439
    {
440
      show: false,
441
      accessor: "id",
442
      id: "_id",
443
    },
444
    {
445
      Header: I18n.t("activerecord.models.group.one"),
446
      accessor: "group_name",
447
      id: "group_name",
448
      Cell: row => {
449
        return (
16✔
450
          <span>
451
            <span>{row.value}</span>
452
            <a
453
              href="#"
NEW
454
              onClick={() => this.props.renameGroup(row.original._id, row.value)}
×
455
              title={I18n.t("groups.rename_group")}
456
            >
457
              <FontAwesomeIcon icon="fa-solid fa-pen" className="icon-right" />
458
            </a>
459
          </span>
460
        );
461
      },
462
    },
463
    {
464
      Header: I18n.t("activerecord.attributes.group.student_memberships"),
465
      accessor: "members",
466
      Cell: row => {
467
        if (row.value.length > 0 || !this.props.scanned_exam) {
16!
468
          return row.value.map(member => {
16✔
469
            let status;
470
            if (member[1] === "pending") {
16!
UNCOV
471
              status = <strong>({member[1]})</strong>;
×
472
            } else {
473
              status = member.display_label;
16✔
474
            }
475
            return (
16✔
476
              <div key={`${row.original._id}-${member[0]}`}>
477
                {member[0]} {status}
478
                <a
479
                  href="#"
UNCOV
480
                  onClick={() => this.props.unassign(row.original._id, member[0])}
×
481
                  title={I18n.t("delete")}
482
                >
483
                  <FontAwesomeIcon icon="fa-solid fa-trash" className="icon-right" />
484
                </a>
485
              </div>
486
            );
487
          });
488
        } else {
489
          // Link to assigning a student to this scanned exam
UNCOV
490
          const assign_url = Routes.assign_scans_course_assignment_groups_path(
×
491
            this.props.course_id,
492
            this.props.assignment_id,
493
            {grouping_id: row.original._id}
494
          );
UNCOV
495
          return <a href={assign_url}>{I18n.t("exam_templates.assign_scans.title")}</a>;
×
496
        }
497
      },
498
      filterMethod: (filter, row) => {
UNCOV
499
        if (filter.value) {
×
UNCOV
500
          return row._original.members.some(member => member[0].includes(filter.value));
×
501
        } else {
UNCOV
502
          return true;
×
503
        }
504
      },
505
      sortable: false,
506
    },
507
    {
508
      Header: I18n.t("groups.valid"),
509
      Cell: row => {
510
        let isValid =
511
          row.original.instructor_approved || row.original.members.length >= this.props.groupMin;
16!
512
        if (isValid) {
16!
513
          return (
16✔
514
            <a
515
              href="#"
516
              title={I18n.t("groups.is_valid")}
UNCOV
517
              onClick={() => this.props.invalidate(row.original._id)}
×
518
            >
519
              ✔
520
            </a>
521
          );
522
        } else {
UNCOV
523
          return (
×
524
            <a
525
              href="#"
526
              title={I18n.t("groups.is_not_valid")}
UNCOV
527
              onClick={() => this.props.validate(row.original._id)}
×
528
            >
529
              <FontAwesomeIcon icon="fa-solid fa-close" />
530
            </a>
531
          );
532
        }
533
      },
534
      filterMethod: (filter, row) => {
UNCOV
535
        if (filter.value === "all") {
×
UNCOV
536
          return true;
×
537
        } else {
538
          // Either 'true' or 'false'
UNCOV
539
          const val = filter.value === "true";
×
540
          let isValid =
UNCOV
541
            row._original.instructor_approved ||
×
542
            row._original.members.length >= this.props.groupMin;
UNCOV
543
          return isValid === val;
×
544
        }
545
      },
546
      Filter: selectFilter,
547
      filterOptions: [
548
        {value: "true", text: I18n.t("groups.is_valid")},
549
        {value: "false", text: I18n.t("groups.is_not_valid")},
550
      ],
551
      minWidth: 30,
552
      sortable: false,
553
    },
554
    {
555
      Header: this.props.extensionColumnHeader,
556
      accessor: "extension",
557
      show: !this.props.scanned_exam,
558
      Cell: row => {
559
        const timeExtension = getTimeExtension(row.original.extension, this.props.times);
16✔
560
        const lateSubmissionText = row.original.extension.apply_penalty
16✔
561
          ? `(${I18n.t("groups.late_submissions_accepted")})`
562
          : "";
563
        const extension = `${timeExtension} ${lateSubmissionText}`;
16✔
564

565
        if (!!timeExtension) {
16✔
566
          return (
8✔
567
            <div>
568
              <a
569
                href={"#"}
UNCOV
570
                onClick={() => this.props.onExtensionModal(row.original.extension, true)}
×
571
              >
572
                {extension}
573
              </a>
574
            </div>
575
          );
576
        } else {
577
          return (
8✔
578
            <a
579
              href="#"
UNCOV
580
              onClick={() => this.props.onExtensionModal(row.original.extension, false)}
×
581
              title={I18n.t("add")}
582
            >
583
              <FontAwesomeIcon icon="fa-solid fa-add" />
584
            </a>
585
          );
586
        }
587
      },
588
      sortMethod: durationSort,
589
      Filter: selectFilter,
590
      filterMethod: (filter, row) => {
591
        if (filter.value === "all") {
7✔
592
          return true;
1✔
593
        }
594
        const applyPenalty = row._original.extension.apply_penalty;
6✔
595
        const {withExtension, withLateSubmission} = JSON.parse(filter.value);
6✔
596
        // If there is an extension applied, the extension object will contain a property called hours
597
        const hasExtension = Object.hasOwn(row._original.extension, "hours");
6✔
598

599
        if (!withExtension) {
6✔
600
          return !hasExtension;
2✔
601
        }
602
        if (withLateSubmission) {
4✔
603
          return hasExtension && applyPenalty;
2✔
604
        }
605
        return hasExtension && !applyPenalty;
2✔
606
      },
607
      filterOptions: [
608
        {
609
          value: JSON.stringify({withExtension: false}),
610
          text: I18n.t("groups.groups_without_extension"),
611
        },
612
        {
613
          value: JSON.stringify({withExtension: true, withLateSubmission: true}),
614
          text: I18n.t("groups.groups_with_extension.with_late_submission"),
615
        },
616
        {
617
          value: JSON.stringify({withExtension: true, withLateSubmission: false}),
618
          text: I18n.t("groups.groups_with_extension.without_late_submission"),
619
        },
620
      ],
621
    },
622
  ];
623

624
  static getDerivedStateFromProps(props, state) {
625
    let filtered = state.filtered.filter(group => group.id !== "inactive");
16✔
626

627
    if (!props.showInactive) {
16!
628
      filtered.push({id: "inactive", value: false});
16✔
629
    }
630
    return {filtered};
16✔
631
  }
632

633
  onFilteredChange = filtered => {
8✔
UNCOV
634
    this.setState({filtered});
×
635
  };
636

637
  render() {
638
    return (
16✔
639
      <CheckboxTable
640
        ref={r => (this.checkboxTable = r)}
32✔
641
        data={this.props.groups}
642
        columns={this.getColumns()}
643
        defaultSorted={[
644
          {
645
            id: "group_name",
646
          },
647
        ]}
648
        loading={this.props.loading}
649
        filterable
650
        filtered={this.state.filtered}
651
        onFilteredChange={this.onFilteredChange}
652
        {...this.props.getCheckboxProps()}
653
      />
654
    );
655
  }
656
}
657

658
class RawStudentsTable extends React.Component {
659
  constructor(props) {
660
    super(props);
8✔
661
    this.state = {
8✔
662
      filtered: [],
663
    };
664
  }
665

666
  getColumns = () => {
8✔
667
    return [
16✔
668
      {
669
        accessor: "hidden",
670
        id: "hidden",
671
        width: 0,
672
        className: "rt-hidden",
673
        headerClassName: "rt-hidden",
674
        resizable: false,
675
      },
676
      {
677
        show: false,
678
        accessor: "_id",
679
        id: "_id",
680
      },
681
      {
682
        Header: I18n.t("activerecord.attributes.user.user_name"),
683
        accessor: "user_name",
684
        id: "user_name",
685
        Cell: props =>
686
          props.original.hidden
8!
687
            ? `${props.value} (${I18n.t("activerecord.attributes.user.hidden")})`
688
            : props.value,
689
        filterMethod: (filter, row) => {
UNCOV
690
          if (filter.value) {
×
UNCOV
691
            return `${row._original.user_name}${
×
692
              row._original.hidden ? `, ${I18n.t("activerecord.attributes.user.hidden")}` : ""
×
693
            }`.includes(filter.value);
694
          } else {
UNCOV
695
            return true;
×
696
          }
697
        },
698
        sortable: true,
699
        minWidth: 90,
700
      },
701
      {
702
        Header: I18n.t("activerecord.attributes.user.last_name"),
703
        accessor: "last_name",
704
        id: "last_name",
705
      },
706
      {
707
        Header: I18n.t("activerecord.attributes.user.first_name"),
708
        accessor: "first_name",
709
        id: "first_name",
710
      },
711
      {
712
        Header: I18n.t("groups.assigned_students") + "?",
713
        accessor: "assigned",
714
        Cell: ({value}) => (value ? "✔" : ""),
8!
715
        sortable: false,
716
        minWidth: 60,
717
        filterMethod: (filter, row) => {
UNCOV
718
          if (filter.value === "all") {
×
UNCOV
719
            return true;
×
720
          } else {
721
            // Either 'true' or 'false'
UNCOV
722
            const assigned = filter.value === "true";
×
UNCOV
723
            return row._original.assigned === assigned;
×
724
          }
725
        },
726
        Filter: selectFilter,
727
        filterOptions: [
728
          {value: "true", text: I18n.t("groups.assigned_students")},
729
          {value: "false", text: I18n.t("groups.unassigned_students")},
730
        ],
731
      },
732
    ];
733
  };
734

735
  static getDerivedStateFromProps(props, state) {
736
    let filtered = [];
16✔
737
    for (let i = 0; i < state.filtered.length; i++) {
16✔
738
      if (state.filtered[i].id !== "hidden") {
8!
UNCOV
739
        filtered.push(state.filtered[i]);
×
740
      }
741
    }
742
    if (!props.showHidden) {
16!
743
      filtered.push({id: "hidden", value: false});
16✔
744
    }
745
    return {filtered};
16✔
746
  }
747

748
  onFilteredChange = filtered => {
8✔
UNCOV
749
    this.setState({filtered});
×
750
  };
751

752
  render() {
753
    return (
16✔
754
      <CheckboxTable
755
        ref={r => (this.checkboxTable = r)}
32✔
756
        data={this.props.students}
757
        columns={this.getColumns()}
758
        defaultSorted={[
759
          {
760
            id: "user_name",
761
          },
762
        ]}
763
        loading={this.props.loading}
764
        filterable
765
        filtered={this.state.filtered}
766
        onFilteredChange={this.onFilteredChange}
767
        {...this.props.getCheckboxProps()}
768
      />
769
    );
770
  }
771
}
772

773
const GroupsTable = withSelection(RawGroupsTable);
1✔
774
const StudentsTable = withSelection(RawStudentsTable);
1✔
775

776
class GroupsActionBox extends React.Component {
777
  render = () => {
8✔
778
    var showHiddenTooltip = null;
16✔
779
    if (this.props.hiddenStudentsCount !== null && this.props.hiddenGroupsCount !== null) {
16✔
780
      showHiddenTooltip = `${I18n.t("activerecord.attributes.grouping.inactive_students", {
8✔
781
        count: this.props.hiddenStudentsCount,
782
      })}, ${I18n.t("activerecord.attributes.grouping.inactive_groups", {
783
        count: this.props.hiddenGroupsCount,
784
      })}`;
785
    }
786

787
    return (
16✔
788
      <div className="rt-action-box">
789
        <span>
790
          <input
791
            id="show_hidden"
792
            name="show_hidden"
793
            type="checkbox"
794
            checked={this.props.showHidden}
795
            onChange={this.props.updateShowHidden}
796
            style={{marginLeft: "5px", marginRight: "5px"}}
797
          />
798
          <label title={showHiddenTooltip} htmlFor="show_hidden">
799
            {I18n.t("students.display_inactive")}
800
          </label>
801
        </span>
802
        {this.props.vcs_submit && (
16!
803
          <button onClick={this.props.handleShowAssignmentGroupUseModal}>
804
            <FontAwesomeIcon icon="fa-solid fa-recycle" />
805
            {I18n.t("groups.reuse_groups")}
806
          </button>
807
        )}
808
        <button className="" onClick={this.props.assign}>
809
          <FontAwesomeIcon icon="fa-solid fa-user-plus" />
810
          {I18n.t("groups.add_to_group")}
811
        </button>
812
        {this.props.scanned_exam && (
16!
813
          <button onClick={this.props.handleShowAutoMatchModal}>
814
            <FontAwesomeIcon icon="fa-solid fa-file-import" />
815
            {I18n.t("groups.auto_match")}
816
          </button>
817
        )}
818
        {this.props.can_create_all_groups ? (
16!
819
          <button className="" onClick={this.props.createAllGroups}>
820
            <FontAwesomeIcon icon="fa-solid fa-people-group" />
821
            {I18n.t("groups.add_all_groups")}
822
          </button>
823
        ) : undefined}
824
        <button className="" onClick={this.props.createGroup}>
825
          <FontAwesomeIcon icon="fa-solid fa-circle-plus" />
826
          {I18n.t("helpers.submit.create", {
827
            model: I18n.t("activerecord.models.group.one"),
828
          })}
829
        </button>
830
        <button className="" onClick={this.props.deleteGroups}>
831
          <FontAwesomeIcon icon="fa-solid fa-trash" />
832
          {I18n.t("groups.delete")}
833
        </button>
834
      </div>
835
    );
836
  };
837
}
838

839
export function makeGroupsManager(elem, props) {
UNCOV
840
  const root = createRoot(elem);
×
UNCOV
841
  const component = React.createRef();
×
UNCOV
842
  root.render(<GroupsManager {...props} ref={component} />);
×
UNCOV
843
  return component;
×
844
}
845

846
export {GroupsManager};
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