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

MarkUsProject / Markus / 28308636107

28 Jun 2026 02:17AM UTC coverage: 90.198% (-0.05%) from 90.245%
28308636107

Pull #8020

github

web-flow
Merge 01d245ede into 1abc7e1c4
Pull Request #8020: WIP: feat: refactor polling to websocket

1163 of 2429 branches covered (47.88%)

Branch coverage included in aggregate %.

116 of 140 new or added lines in 7 files covered. (82.86%)

1 existing line in 1 file now uncovered.

47021 of 50991 relevant lines covered (92.21%)

127.08 hits per line

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

45.26
/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 {withSelection, CheckboxTable} from "./markus_with_selection_hoc";
8
import ExtensionModal from "./Modals/extension_modal";
9
import {
10
  caseSensitiveStringFilterMethod,
11
  caseSensitiveTextFilter,
12
  durationSort,
13
  getTimeExtension,
14
  selectFilter,
15
} from "./Helpers/table_helpers";
16
import AutoMatchModal from "./Modals/auto_match_modal";
17
import CreateGroupModal from "./Modals/create_group_modal";
18
import RenameGroupModal from "./Modals/rename_group_modal";
19
import AssignmentGroupUseModal from "./Modals/assignment_group_use_modal";
20

21
class GroupsManager extends React.Component {
22
  constructor(props) {
23
    super(props);
12✔
24
    this.state = {
12✔
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();
12✔
47
    this.createChannelSubscriptions();
12✔
48
  }
49

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

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

95
  createGroup = () => {
12✔
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 = () => {
12✔
106
    $.get({
×
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 = () => {
12✔
115
    consumer.subscriptions.create(
12✔
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 => {
NEW
125
          if (data["status"] != null) {
×
NEW
126
            renderFlashMessages(generateMessage(data));
×
127
          }
NEW
128
          if (data["update_table"] != null) {
×
NEW
129
            this.fetchData();
×
130
          }
131
        },
132
      }
133
    );
134
  };
135

136
  deleteGroups = () => {
12✔
137
    let groupings = this.groupsTable.state.selection;
×
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) => {
12✔
161
    this.setState({
×
162
      isRenameGroupDialogOpen: true,
163
      renameGroupingId: grouping_id,
164
      renameGroupName: group_name,
165
    });
166
  };
167

168
  handleRenameGroupDialog = newGroupName => {
12✔
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 = () => {
12✔
181
    this.setState({
×
182
      isRenameGroupDialogOpen: false,
183
      renameGroupingId: null,
184
      renameGroupName: "",
185
    });
186
  };
187

188
  unassign = (grouping_id, student_user_name) => {
12✔
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 = () => {
12✔
204
    if (this.studentsTable.state.selection.length === 0) {
×
205
      alert(I18n.t("groups.select_a_student"));
×
206
      return;
×
207
    } else if (this.groupsTable.state.selection.length === 0) {
×
208
      alert(I18n.t("groups.select_a_group"));
×
209
      return;
×
210
    } else if (this.groupsTable.state.selection.length > 1) {
×
211
      alert(I18n.t("groups.select_only_one_group"));
×
212
      return;
×
213
    }
214

215
    let students = this.studentsTable.state.selection;
×
216
    let grouping_id = this.groupsTable.state.selection[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 = () => {
12✔
232
    this.setState({
×
233
      isCreateGroupModalOpen: false,
234
    });
235
  };
236

237
  handleSubmitCreateGroup = groupName => {
12✔
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 = () => {
12✔
248
    this.setState({
×
249
      isAssignmentGroupUseModalOpen: true,
250
    });
251
  };
252

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

259
  handleSubmitAssignmentGroupUseModal = selectedAssignmentId => {
12✔
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 = () => {
12✔
275
    if (this.groupsTable.state.selection.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 = () => {
12✔
286
    this.setState({
×
287
      isAutoMatchModalOpen: false,
288
    });
289
  };
290

291
  autoMatch = examTemplate => {
12✔
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.state.selection,
299
        exam_template_id: examTemplate,
300
      },
301
    });
302
  };
303

304
  validate = grouping_id => {
12✔
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 => {
12✔
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) => {
12✔
332
    this.setState({
×
333
      show_modal: true,
334
      selected_extension_data: extension_data,
335
      updating_extension: updating,
336
    });
337
  };
338

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

347
  extraModalInfo = () => {
12✔
348
    // Render extra modal info for timed assignments only
349
    if (this.props.timed) {
24!
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"];
24!
358
    const title = !!this.props.timed
24!
359
      ? I18n.t("groups.duration_extension")
360
      : I18n.t("groups.due_date_extension");
361
    return (
24✔
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}
24✔
372
          hiddenGroupsCount={this.state.loading ? null : this.state.inactive_groups_count}
24✔
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)}
48✔
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)}
48✔
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
class RawGroupsTable extends React.Component {
454
  constructor(props) {
455
    super(props);
12✔
456
    this.state = {
12✔
457
      filtered: [],
458
      columns: [
459
        {
460
          accessor: "inactive",
461
          id: "inactive",
462
          width: 0,
463
          className: "rt-hidden",
464
          headerClassName: "rt-hidden",
465
          resizable: false,
466
        },
467
        {
468
          show: false,
469
          accessor: "id",
470
          id: "_id",
471
        },
472
        {
473
          Header: I18n.t("activerecord.models.group.one"),
474
          accessor: "group_name",
475
          id: "group_name",
476
          Cell: row => {
477
            return (
51✔
478
              <span>
479
                <span>{row.value}</span>
480
                <a
481
                  href="#"
482
                  onClick={() => this.props.renameGroup(row.original._id, row.value)}
×
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
          Filter: caseSensitiveTextFilter,
491
          filterMethod: caseSensitiveStringFilterMethod,
492
        },
493
        {
494
          Header: I18n.t("activerecord.attributes.group.student_memberships"),
495
          accessor: "members",
496
          Cell: row => {
497
            if (row.value.length > 0 || !this.props.scanned_exam) {
51!
498
              return row.value.map(member => {
51✔
499
                let status;
500
                if (member[1] === "pending") {
51!
501
                  status = <strong>({member[1]})</strong>;
×
502
                } else {
503
                  status = member.display_label;
51✔
504
                }
505
                return (
51✔
506
                  <div key={`${row.original._id}-${member[0]}`}>
507
                    {member[0]} {status}
508
                    <a
509
                      href="#"
510
                      onClick={() => this.props.unassign(row.original._id, member[0])}
×
511
                      title={I18n.t("delete")}
512
                    >
513
                      <FontAwesomeIcon icon="fa-solid fa-trash" className="icon-right" />
514
                    </a>
515
                  </div>
516
                );
517
              });
518
            } else {
519
              // Link to assigning a student to this scanned exam
520
              const assign_url = Routes.assign_scans_course_assignment_groups_path(
×
521
                this.props.course_id,
522
                this.props.assignment_id,
523
                {grouping_id: row.original._id}
524
              );
525
              return <a href={assign_url}>{I18n.t("exam_templates.assign_scans.title")}</a>;
×
526
            }
527
          },
528
          filterMethod: (filter, row) => {
529
            if (filter.value) {
×
530
              return row._original.members.some(member => member[0].includes(filter.value));
×
531
            } else {
532
              return true;
×
533
            }
534
          },
535
          sortable: false,
536
        },
537
        {
538
          Header: I18n.t("groups.valid"),
539
          Cell: row => {
540
            let isValid =
541
              row.original.instructor_approved ||
51!
542
              row.original.members.length >= this.props.groupMin;
543
            if (isValid) {
51!
544
              return (
51✔
545
                <a
546
                  href="#"
547
                  title={I18n.t("groups.is_valid")}
548
                  onClick={() => this.props.invalidate(row.original._id)}
×
549
                >
550
                  ✔
551
                </a>
552
              );
553
            } else {
554
              return (
×
555
                <a
556
                  href="#"
557
                  title={I18n.t("groups.is_not_valid")}
558
                  onClick={() => this.props.validate(row.original._id)}
×
559
                >
560
                  <FontAwesomeIcon icon="fa-solid fa-close" />
561
                </a>
562
              );
563
            }
564
          },
565
          filterMethod: (filter, row) => {
566
            if (filter.value === "all") {
×
567
              return true;
×
568
            } else {
569
              // Either 'true' or 'false'
570
              const val = filter.value === "true";
×
571
              let isValid =
572
                row._original.instructor_approved ||
×
573
                row._original.members.length >= this.props.groupMin;
574
              return isValid === val;
×
575
            }
576
          },
577
          Filter: selectFilter,
578
          filterOptions: [
579
            {value: "true", text: I18n.t("groups.is_valid")},
580
            {value: "false", text: I18n.t("groups.is_not_valid")},
581
          ],
582
          minWidth: 30,
583
          sortable: false,
584
        },
585
        {
586
          Header: props.extensionColumnHeader,
587
          accessor: "extension",
588
          show: !props.scanned_exam,
589
          Cell: row => {
590
            const timeExtension = getTimeExtension(row.original.extension, this.props.times);
51✔
591
            const lateSubmissionText = row.original.extension.apply_penalty
51✔
592
              ? `(${I18n.t("groups.late_submissions_accepted")})`
593
              : "";
594
            const extension = `${timeExtension} ${lateSubmissionText}`;
51✔
595

596
            if (!!timeExtension) {
51✔
597
              return (
21✔
598
                <div>
599
                  <a
600
                    href={"#"}
601
                    onClick={() => this.props.onExtensionModal(row.original.extension, true)}
×
602
                  >
603
                    {extension}
604
                  </a>
605
                </div>
606
              );
607
            } else {
608
              return (
30✔
609
                <a
610
                  href="#"
611
                  onClick={() => this.props.onExtensionModal(row.original.extension, false)}
×
612
                  title={I18n.t("add")}
613
                >
614
                  <FontAwesomeIcon icon="fa-solid fa-add" />
615
                </a>
616
              );
617
            }
618
          },
619
          sortMethod: durationSort,
620
          Filter: selectFilter,
621
          filterMethod: (filter, row) => {
622
            if (filter.value === "all") {
7✔
623
              return true;
1✔
624
            }
625
            const applyPenalty = row._original.extension.apply_penalty;
6✔
626
            const {withExtension, withLateSubmission} = JSON.parse(filter.value);
6✔
627
            // If there is an extension applied, the extension object will contain a property called hours
628
            const hasExtension = Object.hasOwn(row._original.extension, "hours");
6✔
629

630
            if (!withExtension) {
6✔
631
              return !hasExtension;
2✔
632
            }
633
            if (withLateSubmission) {
4✔
634
              return hasExtension && applyPenalty;
2✔
635
            }
636
            return hasExtension && !applyPenalty;
2✔
637
          },
638
          filterOptions: [
639
            {
640
              value: JSON.stringify({withExtension: false}),
641
              text: I18n.t("groups.groups_without_extension"),
642
            },
643
            {
644
              value: JSON.stringify({withExtension: true, withLateSubmission: true}),
645
              text: I18n.t("groups.groups_with_extension.with_late_submission"),
646
            },
647
            {
648
              value: JSON.stringify({withExtension: true, withLateSubmission: false}),
649
              text: I18n.t("groups.groups_with_extension.without_late_submission"),
650
            },
651
          ],
652
        },
653
      ],
654
    };
655
  }
656

657
  static getDerivedStateFromProps(props, state) {
658
    let filtered = state.filtered.filter(group => group.id !== "inactive");
31✔
659

660
    if (!props.showInactive) {
31!
661
      filtered.push({id: "inactive", value: false});
31✔
662
    }
663
    return {filtered};
31✔
664
  }
665

666
  onFilteredChange = filtered => {
12✔
667
    this.setState({filtered});
7✔
668
  };
669

670
  render() {
671
    return (
31✔
672
      <CheckboxTable
673
        ref={r => (this.checkboxTable = r)}
62✔
674
        data={this.props.groups}
675
        columns={this.state.columns}
676
        defaultSorted={[
677
          {
678
            id: "group_name",
679
          },
680
        ]}
681
        loading={this.props.loading}
682
        filterable
683
        filtered={this.state.filtered}
684
        onFilteredChange={this.onFilteredChange}
685
        {...this.props.getCheckboxProps()}
686
      />
687
    );
688
  }
689
}
690

691
class RawStudentsTable extends React.Component {
692
  constructor(props) {
693
    super(props);
12✔
694
    this.state = {
12✔
695
      filtered: [],
696
      columns: [
697
        {
698
          accessor: "hidden",
699
          id: "hidden",
700
          width: 0,
701
          className: "rt-hidden",
702
          headerClassName: "rt-hidden",
703
          resizable: false,
704
        },
705
        {
706
          show: false,
707
          accessor: "_id",
708
          id: "_id",
709
        },
710
        {
711
          Header: I18n.t("activerecord.attributes.user.user_name"),
712
          accessor: "user_name",
713
          id: "user_name",
714
          Cell: props =>
715
            props.original.hidden
12!
716
              ? `${props.value} (${I18n.t("activerecord.attributes.user.hidden")})`
717
              : props.value,
718
          filterMethod: (filter, row) => {
719
            if (filter.value) {
×
720
              return `${row._original.user_name}${
×
721
                row._original.hidden ? `, ${I18n.t("activerecord.attributes.user.hidden")}` : ""
×
722
              }`.includes(filter.value);
723
            } else {
724
              return true;
×
725
            }
726
          },
727
          sortable: true,
728
          minWidth: 90,
729
        },
730
        {
731
          Header: I18n.t("activerecord.attributes.user.last_name"),
732
          accessor: "last_name",
733
          id: "last_name",
734
        },
735
        {
736
          Header: I18n.t("activerecord.attributes.user.first_name"),
737
          accessor: "first_name",
738
          id: "first_name",
739
        },
740
        {
741
          Header: I18n.t("groups.assigned_students") + "?",
742
          accessor: "assigned",
743
          Cell: ({value}) => (value ? "✔" : ""),
12!
744
          sortable: false,
745
          minWidth: 60,
746
          filterMethod: (filter, row) => {
747
            if (filter.value === "all") {
×
748
              return true;
×
749
            } else {
750
              // Either 'true' or 'false'
751
              const assigned = filter.value === "true";
×
752
              return row._original.assigned === assigned;
×
753
            }
754
          },
755
          Filter: selectFilter,
756
          filterOptions: [
757
            {value: "true", text: I18n.t("groups.assigned_students")},
758
            {value: "false", text: I18n.t("groups.unassigned_students")},
759
          ],
760
        },
761
      ],
762
    };
763
  }
764

765
  static getDerivedStateFromProps(props, state) {
766
    let filtered = [];
24✔
767
    for (let i = 0; i < state.filtered.length; i++) {
24✔
768
      if (state.filtered[i].id !== "hidden") {
12!
769
        filtered.push(state.filtered[i]);
×
770
      }
771
    }
772
    if (!props.showHidden) {
24!
773
      filtered.push({id: "hidden", value: false});
24✔
774
    }
775
    return {filtered};
24✔
776
  }
777

778
  onFilteredChange = filtered => {
12✔
779
    this.setState({filtered});
×
780
  };
781

782
  render() {
783
    return (
24✔
784
      <CheckboxTable
785
        ref={r => (this.checkboxTable = r)}
48✔
786
        data={this.props.students}
787
        columns={this.state.columns}
788
        defaultSorted={[
789
          {
790
            id: "user_name",
791
          },
792
        ]}
793
        loading={this.props.loading}
794
        filterable
795
        filtered={this.state.filtered}
796
        onFilteredChange={this.onFilteredChange}
797
        {...this.props.getCheckboxProps()}
798
      />
799
    );
800
  }
801
}
802

803
const GroupsTable = withSelection(RawGroupsTable);
1✔
804
const StudentsTable = withSelection(RawStudentsTable);
1✔
805

806
class GroupsActionBox extends React.Component {
807
  render = () => {
12✔
808
    var showHiddenTooltip = null;
24✔
809
    if (this.props.hiddenStudentsCount !== null && this.props.hiddenGroupsCount !== null) {
24✔
810
      showHiddenTooltip = `${I18n.t("activerecord.attributes.grouping.inactive_students", {
12✔
811
        count: this.props.hiddenStudentsCount,
812
      })}, ${I18n.t("activerecord.attributes.grouping.inactive_groups", {
813
        count: this.props.hiddenGroupsCount,
814
      })}`;
815
    }
816

817
    return (
24✔
818
      <div className="rt-action-box">
819
        <span>
820
          <input
821
            id="show_hidden"
822
            name="show_hidden"
823
            type="checkbox"
824
            checked={this.props.showHidden}
825
            onChange={this.props.updateShowHidden}
826
            style={{marginLeft: "5px", marginRight: "5px"}}
827
          />
828
          <label title={showHiddenTooltip} htmlFor="show_hidden">
829
            {I18n.t("students.display_inactive")}
830
          </label>
831
        </span>
832
        {this.props.vcs_submit && (
24!
833
          <button onClick={this.props.handleShowAssignmentGroupUseModal}>
834
            <FontAwesomeIcon icon="fa-solid fa-recycle" />
835
            {I18n.t("groups.reuse_groups")}
836
          </button>
837
        )}
838
        <button className="" onClick={this.props.assign}>
839
          <FontAwesomeIcon icon="fa-solid fa-user-plus" />
840
          {I18n.t("groups.add_to_group")}
841
        </button>
842
        {this.props.scanned_exam && (
24!
843
          <button onClick={this.props.handleShowAutoMatchModal}>
844
            <FontAwesomeIcon icon="fa-solid fa-file-import" />
845
            {I18n.t("groups.auto_match")}
846
          </button>
847
        )}
848
        {this.props.can_create_all_groups ? (
24!
849
          <button className="" onClick={this.props.createAllGroups}>
850
            <FontAwesomeIcon icon="fa-solid fa-people-group" />
851
            {I18n.t("groups.add_all_groups")}
852
          </button>
853
        ) : undefined}
854
        <button className="" onClick={this.props.createGroup}>
855
          <FontAwesomeIcon icon="fa-solid fa-circle-plus" />
856
          {I18n.t("helpers.submit.create", {
857
            model: I18n.t("activerecord.models.group.one"),
858
          })}
859
        </button>
860
        <button className="" onClick={this.props.deleteGroups}>
861
          <FontAwesomeIcon icon="fa-solid fa-trash" />
862
          {I18n.t("groups.delete")}
863
        </button>
864
      </div>
865
    );
866
  };
867
}
868

869
export function makeGroupsManager(elem, props) {
870
  const root = createRoot(elem);
×
871
  const component = React.createRef();
×
872
  root.render(<GroupsManager {...props} ref={component} />);
×
873
  return component;
×
874
}
875

876
function generateMessage(status_data) {
NEW
877
  let message_data = {};
×
NEW
878
  switch (status_data["status"]) {
×
879
    case "failed":
NEW
880
      if (!status_data["exception"] || !status_data["exception"]["message"]) {
×
NEW
881
        message_data["error"] = I18n.t("job.status.failed.no_message");
×
882
      } else {
NEW
883
        message_data["error"] = I18n.t("job.status.failed.message", {
×
884
          error: status_data["exception"]["message"],
885
        });
886
      }
NEW
887
      break;
×
888
    case "completed":
NEW
889
      message_data["success"] = I18n.t("job.status.completed");
×
NEW
890
      break;
×
891
    case "queued":
NEW
892
      message_data["notice"] = I18n.t("job.status.queued");
×
NEW
893
      break;
×
894
    default: {
NEW
895
      let progress = status_data["progress"];
×
NEW
896
      let total = status_data["total"];
×
NEW
897
      message_data["notice"] = I18n.t("poll_job.create_groups_job", {progress, total});
×
898
    }
899
  }
NEW
900
  if (status_data["warning_message"]) {
×
NEW
901
    message_data["warning"] = status_data["warning_message"];
×
902
  }
NEW
903
  return message_data;
×
904
}
905

906
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