@nualang/nualang-ui-components 0.1.1401 → 0.1.1402

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,7 +56,8 @@ function GeneralForm({
56
56
  }) {
57
57
  const {
58
58
  email,
59
- preferred_username
59
+ given_name,
60
+ family_name
60
61
  } = values;
61
62
  const learnLang = values["custom:learnLang"] || "";
62
63
  const forLang = values["custom:forLang"] || "";
@@ -85,7 +86,7 @@ function GeneralForm({
85
86
  };
86
87
  getLearnLangLabel();
87
88
  }, [role, t]);
88
- const isPreferredUsernameDisabled = username && (username.toLowerCase().includes("wayside") || username.toLowerCase().includes("google"));
89
+ const isNameDisabled = username && (username.toLowerCase().includes("wayside") || username.toLowerCase().includes("google"));
89
90
  return /*#__PURE__*/_jsxs(Grid, {
90
91
  container: true,
91
92
  sx: {
@@ -102,46 +103,54 @@ function GeneralForm({
102
103
  },
103
104
  children: t("required_field")
104
105
  })
105
- }), !isGeneratedEmail && /*#__PURE__*/_jsx(Grid, {
106
+ }), /*#__PURE__*/_jsx(Grid, {
106
107
  size: {
107
108
  xs: 12,
108
109
  md: 6
109
110
  },
110
111
  children: /*#__PURE__*/_jsx(TextField, {
111
- id: "email",
112
- name: "email",
113
- label: t("email"),
114
- value: email,
112
+ id: "given_name",
113
+ name: "given_name",
114
+ label: t("first_name"),
115
+ title: t("first_name"),
116
+ value: given_name,
115
117
  onChange: handleChange,
116
118
  onBlur: handleBlur,
117
- helperText: touched.email ? t(errors.email) : "",
118
- error: touched.email && Boolean(errors.email),
119
- type: "email",
119
+ helperText: touched.given_name ? t(errors.given_name) : "",
120
+ error: touched.given_name && Boolean(errors.given_name),
121
+ type: "text",
120
122
  variant: "outlined",
121
- disabled: true,
122
123
  fullWidth: true,
123
- required: true
124
+ required: true,
125
+ disabled: isNameDisabled,
126
+ slotProps: {
127
+ htmlInput: {
128
+ "data-lpignore": "true",
129
+ autoComplete: "off",
130
+ maxLength: 32
131
+ }
132
+ }
124
133
  })
125
134
  }), /*#__PURE__*/_jsx(Grid, {
126
135
  size: {
127
136
  xs: 12,
128
- md: isGeneratedEmail ? 12 : 6
137
+ md: 6
129
138
  },
130
139
  children: /*#__PURE__*/_jsx(TextField, {
131
- id: "preferred_username",
132
- name: "preferred_username",
133
- label: t("name"),
134
- title: t("name"),
135
- value: preferred_username,
140
+ id: "family_name",
141
+ name: "family_name",
142
+ label: t("last_name"),
143
+ title: t("last_name"),
144
+ value: family_name,
136
145
  onChange: handleChange,
137
146
  onBlur: handleBlur,
138
- helperText: touched.preferred_username ? t(errors.preferred_username) : "",
139
- error: touched.preferred_username && Boolean(errors.preferred_username),
147
+ helperText: touched.family_name ? t(errors.family_name) : "",
148
+ error: touched.family_name && Boolean(errors.family_name),
140
149
  type: "text",
141
150
  variant: "outlined",
142
151
  fullWidth: true,
143
152
  required: true,
144
- disabled: isPreferredUsernameDisabled,
153
+ disabled: isNameDisabled,
145
154
  slotProps: {
146
155
  htmlInput: {
147
156
  "data-lpignore": "true",
@@ -150,6 +159,26 @@ function GeneralForm({
150
159
  }
151
160
  }
152
161
  })
162
+ }), !isGeneratedEmail && /*#__PURE__*/_jsx(Grid, {
163
+ size: {
164
+ xs: 12,
165
+ md: 6
166
+ },
167
+ children: /*#__PURE__*/_jsx(TextField, {
168
+ id: "email",
169
+ name: "email",
170
+ label: t("email"),
171
+ value: email,
172
+ onChange: handleChange,
173
+ onBlur: handleBlur,
174
+ helperText: touched.email ? t(errors.email) : "",
175
+ error: touched.email && Boolean(errors.email),
176
+ type: "email",
177
+ variant: "outlined",
178
+ disabled: true,
179
+ fullWidth: true,
180
+ required: true
181
+ })
153
182
  }), /*#__PURE__*/_jsx(Grid, {
154
183
  size: {
155
184
  xs: 12,
@@ -241,7 +270,8 @@ export default function General({
241
270
  t = text => text,
242
271
  initialValues = {
243
272
  picture: null,
244
- name: "Unknown",
273
+ given_name: "",
274
+ family_name: "",
245
275
  email: "Unknown",
246
276
  city: "",
247
277
  country: "",
@@ -261,8 +291,11 @@ export default function General({
261
291
  }) {
262
292
  const {
263
293
  email,
294
+ given_name,
295
+ family_name,
264
296
  preferred_username
265
297
  } = initialValues;
298
+ const displayName = [given_name, family_name].filter(Boolean).join(" ");
266
299
  const {
267
300
  classes
268
301
  } = useStyles();
@@ -271,7 +304,8 @@ export default function General({
271
304
  const [avatarOptionClicked, setAvatarOptionClicked] = useState(false);
272
305
  const [confirm] = useConfirm(t);
273
306
  const generalFormInitialValues = {
274
- preferred_username,
307
+ given_name,
308
+ family_name,
275
309
  email,
276
310
  "custom:learnLang": initialValues["custom:learnLang"],
277
311
  "custom:forLang": initialValues["custom:forLang"],
@@ -316,12 +350,12 @@ export default function General({
316
350
  display: "inline-block"
317
351
  },
318
352
  children: [values.picture ? /*#__PURE__*/_jsx(Avatar, {
319
- "aria-label": preferred_username,
353
+ "aria-label": displayName,
320
354
  src: values.picture,
321
355
  className: classes.avatar,
322
356
  alt: ""
323
357
  }) : /*#__PURE__*/_jsx(Avatar, {
324
- "aria-label": preferred_username,
358
+ "aria-label": displayName,
325
359
  sx: theme => ({
326
360
  backgroundColor: theme.palette.mode === "dark" ? "white" : "",
327
361
  color: theme.palette.mode === "dark" ? theme.palette.background.default : ""
@@ -378,7 +412,12 @@ export default function General({
378
412
  variant: "h5",
379
413
  component: "h5",
380
414
  gutterBottom: true,
381
- children: preferred_username
415
+ title: displayName || preferred_username,
416
+ noWrap: true,
417
+ sx: {
418
+ maxWidth: "100%"
419
+ },
420
+ children: displayName || preferred_username
382
421
  }), !isGeneratedEmail && /*#__PURE__*/_jsx(Typography, {
383
422
  color: "textSecondary",
384
423
  variant: "body1",
@@ -527,7 +566,8 @@ const isEmail = value => {
527
566
  return !emailRegex.test(value);
528
567
  };
529
568
  General.validationSchema = Yup.object().shape({
530
- preferred_username: Yup.string().required("required").test("noSpecialChars", "no_special_characters", value => !containsInvalidSymbols(value)).test("is-not-email", "Email addresses are not allowed", value => isEmail(value)),
569
+ given_name: Yup.string().required("required").test("noSpecialChars", "no_special_characters", value => !containsInvalidSymbols(value)).test("is-not-email", "Email addresses are not allowed", value => isEmail(value)),
570
+ family_name: Yup.string().required("required").test("noSpecialChars", "no_special_characters", value => !containsInvalidSymbols(value)).test("is-not-email", "Email addresses are not allowed", value => isEmail(value)),
531
571
  name: Yup.string().test("noSpecialChars", "no_special_characters", value => !containsInvalidSymbols(value)).nullable(),
532
572
  "custom:forLang": Yup.string().nullable(),
533
573
  "custom:learnLang": Yup.string().nullable()
@@ -1,15 +1,17 @@
1
- import { useEffect, useState, Fragment } from "react";
1
+ import { useEffect, useMemo, useState } from "react";
2
2
  import PropTypes from "prop-types";
3
- import { Avatar, Box, Button, IconButton, List, ListItem, ListItemAvatar, ListItemText, ListSubheader, Typography, Tooltip, Menu, MenuItem, Chip } from "@mui/material";
3
+ import { Avatar, Box, Button, IconButton, List, ListItem, ListItemAvatar, ListItemText, ListSubheader, TextField, Typography, Tooltip, Menu, MenuItem, Chip } from "@mui/material";
4
4
  import { makeStyles } from "tss-react/mui";
5
5
  import Person from "@mui/icons-material/Person";
6
6
  import CommentIcon from "@mui/icons-material/Comment";
7
7
  import MoreVertIcon from "@mui/icons-material/MoreVert";
8
+ import SortIcon from "@mui/icons-material/Sort";
8
9
  import Skeleton from "@mui/material/Skeleton";
9
10
  import MembersNotFound from "../../Misc/NotFound/Members";
10
11
  import { DateTime } from "luxon";
11
12
  import useConfirm from "../../hooks/useConfirm";
12
13
  import HtmlTooltipComponent from "../../Misc/HtmlTooltipComponent/HtmlTooltipComponent";
14
+ import { getStudentSortOrder, setStudentSortOrder, compareMembersBySortOrder, MEMBERS_SORT_ORDER_KEY } from "../../utils/studentSort";
13
15
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
14
16
  const useStyles = makeStyles()(theme => ({
15
17
  root: {
@@ -116,7 +118,7 @@ function Member(props) {
116
118
  })
117
119
  })
118
120
  }), /*#__PURE__*/_jsx(ListItemText, {
119
- primary: featureFlags.classroomModes.readWrite && featureFlags.classroomModes.challenge && member.assignedLabels?.isReadWriteModeStudent && member.assignedLabels?.isChallengeModeStudent ? /*#__PURE__*/_jsxs(Box, {
121
+ primary: featureFlags.classroomModes?.readWrite && featureFlags.classroomModes?.challenge && member.assignedLabels?.isReadWriteModeStudent && member.assignedLabels?.isChallengeModeStudent ? /*#__PURE__*/_jsxs(Box, {
120
122
  sx: {
121
123
  display: "flex",
122
124
  alignItems: "center",
@@ -143,7 +145,7 @@ function Member(props) {
143
145
  color: "primary"
144
146
  })
145
147
  })]
146
- }) : featureFlags.classroomModes.readWrite && member.assignedLabels?.isReadWriteModeStudent ? /*#__PURE__*/_jsxs(Box, {
148
+ }) : featureFlags.classroomModes?.readWrite && member.assignedLabels?.isReadWriteModeStudent ? /*#__PURE__*/_jsxs(Box, {
147
149
  sx: {
148
150
  display: "flex",
149
151
  alignItems: "center",
@@ -160,7 +162,7 @@ function Member(props) {
160
162
  color: "primary"
161
163
  })
162
164
  })]
163
- }) : featureFlags.classroomModes.challenge && member.assignedLabels?.isChallengeModeStudent ? /*#__PURE__*/_jsxs(Box, {
165
+ }) : featureFlags.classroomModes?.challenge && member.assignedLabels?.isChallengeModeStudent ? /*#__PURE__*/_jsxs(Box, {
164
166
  sx: {
165
167
  display: "flex",
166
168
  alignItems: "center",
@@ -193,7 +195,7 @@ function Member(props) {
193
195
  }), /*#__PURE__*/_jsxs(OverflowMenu, {
194
196
  anchorEl: anchorEl,
195
197
  handleClose: handleCloseMenu,
196
- children: [featureFlags.classroomModes.readWrite && (member.assignedLabels?.isReadWriteModeStudent ? /*#__PURE__*/_jsx(HtmlTooltipComponent, {
198
+ children: [featureFlags.classroomModes?.readWrite && (member.assignedLabels?.isReadWriteModeStudent ? /*#__PURE__*/_jsx(HtmlTooltipComponent, {
197
199
  titleBold: t("heritage_speaker"),
198
200
  titleDesc: t("heritage_speaker_desc"),
199
201
  placement: "right",
@@ -229,7 +231,7 @@ function Member(props) {
229
231
  primary: t("mark_as_heritage_speaker")
230
232
  })
231
233
  })
232
- })), featureFlags.classroomModes.challenge && (member.assignedLabels?.isChallengeModeStudent ? /*#__PURE__*/_jsx(HtmlTooltipComponent, {
234
+ })), featureFlags.classroomModes?.challenge && (member.assignedLabels?.isChallengeModeStudent ? /*#__PURE__*/_jsx(HtmlTooltipComponent, {
233
235
  titleBold: t("challenge_student"),
234
236
  titleDesc: t("challenge_student_desc"),
235
237
  placement: "right",
@@ -405,13 +407,56 @@ function Members({
405
407
  handleDeleteClassroomMember,
406
408
  handleUpdateClassroomMember,
407
409
  isClassroomArchived,
408
- featureFlags = {}
410
+ featureFlags = {},
411
+ showSortControl = false
409
412
  }) {
410
- return /*#__PURE__*/_jsx(_Fragment, {
411
- children: /*#__PURE__*/_jsx(MemberList, {
413
+ const [sortOrder, setSortOrder] = useState(() => getStudentSortOrder(MEMBERS_SORT_ORDER_KEY, "joinDate"));
414
+ const handleSortOrderChange = value => {
415
+ setSortOrder(value);
416
+ setStudentSortOrder(MEMBERS_SORT_ORDER_KEY, value);
417
+ };
418
+ const sortedMembers = useMemo(() => [...(members || [])].sort((a, b) => compareMembersBySortOrder(a, b, sortOrder)), [members, sortOrder]);
419
+ return /*#__PURE__*/_jsxs(_Fragment, {
420
+ children: [showSortControl && t && /*#__PURE__*/_jsxs(Box, {
421
+ sx: theme => ({
422
+ display: "flex",
423
+ alignItems: "center",
424
+ justifyContent: "flex-end",
425
+ gap: 1,
426
+ px: 2,
427
+ py: 1,
428
+ borderBottom: `1px solid ${theme.palette.divider}`,
429
+ backgroundColor: theme.palette.mode === "dark" ? theme.palette.background.default : theme.palette.grey[50]
430
+ }),
431
+ children: [/*#__PURE__*/_jsx(SortIcon, {
432
+ fontSize: "small",
433
+ color: "action"
434
+ }), /*#__PURE__*/_jsxs(TextField, {
435
+ select: true,
436
+ size: "small",
437
+ id: "members_sort_by",
438
+ "data-cy": "members_sort_by",
439
+ label: t("sort_by"),
440
+ value: sortOrder,
441
+ onChange: e => handleSortOrderChange(e.target.value),
442
+ sx: {
443
+ minWidth: 170
444
+ },
445
+ children: [/*#__PURE__*/_jsx(MenuItem, {
446
+ value: "joinDate",
447
+ children: t("join_date")
448
+ }), /*#__PURE__*/_jsx(MenuItem, {
449
+ value: "firstName",
450
+ children: t("first_name")
451
+ }), /*#__PURE__*/_jsx(MenuItem, {
452
+ value: "lastName",
453
+ children: t("last_name")
454
+ })]
455
+ })]
456
+ }), /*#__PURE__*/_jsx(MemberList, {
412
457
  t: t,
413
458
  placeholderImageUrl: placeholderImageUrl,
414
- members: members ? members.sort((a, b) => a.createdAt < b.createdAt ? 1 : -1) : [],
459
+ members: sortedMembers,
415
460
  handleMessage: handleMessage,
416
461
  disableSubheader: disableSubheader,
417
462
  getMemberDetails: getMemberDetails,
@@ -420,7 +465,7 @@ function Members({
420
465
  handleUpdateClassroomMember: handleUpdateClassroomMember,
421
466
  isClassroomArchived: isClassroomArchived,
422
467
  featureFlags: featureFlags
423
- })
468
+ })]
424
469
  });
425
470
  }
426
471
  Members.propTypes = {
@@ -1,20 +1,81 @@
1
- import { render } from "@testing-library/react";
2
- import { describe, it } from "vitest";
1
+ import { render, screen, cleanup } from "@testing-library/react";
2
+ import { describe, it, expect, afterEach } from "vitest";
3
3
  import { ThemeProvider, createTheme } from "@mui/material/styles";
4
4
  import Members from "./Members";
5
5
  import { props } from "./Members.stories";
6
+ import { MEMBERS_SORT_ORDER_KEY } from "../../utils/studentSort";
6
7
  import { jsx as _jsx } from "react/jsx-runtime";
8
+ const renderMembers = (extraProps = {}) => {
9
+ const theme = createTheme();
10
+ return render(/*#__PURE__*/_jsx(ThemeProvider, {
11
+ theme: theme,
12
+ children: /*#__PURE__*/_jsx(Members, {
13
+ ...props,
14
+ featureFlags: {
15
+ classroomModes: {}
16
+ },
17
+ ...extraProps
18
+ })
19
+ }));
20
+ };
21
+ const renderedUsernames = async () => {
22
+ await screen.findByText("Peter Parker");
23
+ const knownUsernames = new Set(props.members.map(m => m.username));
24
+ return Array.from(document.querySelectorAll(".MuiListItemText-primary")).map(el => el.textContent).filter(text => knownUsernames.has(text));
25
+ };
26
+ afterEach(() => {
27
+ cleanup();
28
+ localStorage.removeItem(MEMBERS_SORT_ORDER_KEY);
29
+ });
7
30
  describe("Members", () => {
8
31
  it("renders without crashing", () => {
9
- const theme = createTheme();
10
32
  const {
11
33
  unmount
12
- } = render(/*#__PURE__*/_jsx(ThemeProvider, {
13
- theme: theme,
14
- children: /*#__PURE__*/_jsx(Members, {
15
- ...props
16
- })
17
- }));
34
+ } = renderMembers();
35
+ unmount();
36
+ });
37
+ it("defaults to join date order (newest first)", async () => {
38
+ const {
39
+ unmount
40
+ } = renderMembers({
41
+ showSortControl: true
42
+ });
43
+ const usernames = await renderedUsernames();
44
+ expect(usernames).toEqual(["Indigo Young", "Indigo Garland", "Cellophane", "Spider", "Evelyn Tool", "Oran Tool", "Peter Parker", "Mary Jane", "Giles Posture", "Indigo Violet", "Norman Gordon", "Penny Tool"]);
45
+ unmount();
46
+ });
47
+ it("sorts by last name when the stored preference is lastName", async () => {
48
+ localStorage.setItem(MEMBERS_SORT_ORDER_KEY, "lastName");
49
+ const {
50
+ unmount
51
+ } = renderMembers({
52
+ showSortControl: true
53
+ });
54
+ const usernames = await renderedUsernames();
55
+ expect(usernames).toEqual(["Cellophane", "Indigo Garland", "Norman Gordon", "Mary Jane", "Peter Parker", "Giles Posture", "Spider", "Evelyn Tool", "Oran Tool", "Penny Tool", "Indigo Violet", "Indigo Young"]);
56
+ unmount();
57
+ });
58
+ it("sorts by first name when the stored preference is firstName", async () => {
59
+ localStorage.setItem(MEMBERS_SORT_ORDER_KEY, "firstName");
60
+ const {
61
+ unmount
62
+ } = renderMembers({
63
+ showSortControl: true
64
+ });
65
+ const usernames = await renderedUsernames();
66
+ expect(usernames).toEqual(["Cellophane", "Evelyn Tool", "Giles Posture", "Indigo Garland", "Indigo Violet", "Indigo Young", "Mary Jane", "Norman Gordon", "Oran Tool", "Penny Tool", "Peter Parker", "Spider"]);
67
+ unmount();
68
+ });
69
+ it("does not mutate the members prop", async () => {
70
+ localStorage.setItem(MEMBERS_SORT_ORDER_KEY, "lastName");
71
+ const originalOrder = props.members.map(m => m.memberId);
72
+ const {
73
+ unmount
74
+ } = renderMembers({
75
+ showSortControl: true
76
+ });
77
+ await screen.findByText("Peter Parker");
78
+ expect(props.members.map(m => m.memberId)).toEqual(originalOrder);
18
79
  unmount();
19
80
  });
20
81
  });
@@ -409,12 +409,13 @@ function ResponsiveDrawer({
409
409
  }), !isCollapsed && authenticated && /*#__PURE__*/_jsxs(_Fragment, {
410
410
  children: [/*#__PURE__*/_jsx(Typography, {
411
411
  component: "div",
412
- sx: {
413
- whiteSpace: "normal"
414
- },
412
+ title: preferred_username,
415
413
  children: /*#__PURE__*/_jsx(Box, {
416
414
  sx: {
417
- fontWeight: "fontWeightBold"
415
+ fontWeight: "fontWeightBold",
416
+ whiteSpace: "nowrap",
417
+ overflow: "hidden",
418
+ textOverflow: "ellipsis"
418
419
  },
419
420
  children: preferred_username
420
421
  })
@@ -18,6 +18,7 @@ import ProgressList from "./ProgressList";
18
18
  import { DateTime } from "luxon";
19
19
  import useProgressUrlParams from "./useProgressUrlParams";
20
20
  import { deriveCurrentView } from "./viewState";
21
+ import { getStudentSortOrder, setStudentSortOrder, compareMembersBySortOrder, PROGRESS_SORT_ORDER_KEY } from "../../utils/studentSort";
21
22
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
22
23
  function Progress({
23
24
  t = text => text,
@@ -55,6 +56,11 @@ function Progress({
55
56
  const [bots, setBots] = useState([]);
56
57
  const reportFilters = ["percentage_complete", "correct_answer_percentage", "avg_pronunciation_score"];
57
58
  const [filter, setFilter] = useState(reportFilters[0]);
59
+ const [studentSortOrder, setStudentSortOrderState] = useState(() => getStudentSortOrder(PROGRESS_SORT_ORDER_KEY));
60
+ const handleStudentSortOrderChange = value => {
61
+ setStudentSortOrderState(value);
62
+ setStudentSortOrder(PROGRESS_SORT_ORDER_KEY, value);
63
+ };
58
64
  const reportTypes = useMemo(() => isVideoChatEnabled ? ["courses", "assignments", "discuss"] : ["courses", "assignments"], [isVideoChatEnabled]);
59
65
  const availableReportTypes = useMemo(() => memberId ? ["courses", "assignments"] : reportTypes, [memberId, reportTypes]);
60
66
  const [reportType, setReportType] = useState(availableReportTypes[0]);
@@ -150,6 +156,9 @@ function Progress({
150
156
  return true;
151
157
  });
152
158
  }, [members, memberId, selectedMembers, reportType, selectedAssignment, assignedStudents]);
159
+
160
+ // ProgressList (mobile) has no internal sorting, so sort its members here
161
+ const sortedListMembers = useMemo(() => [...classroomMembers].sort((a, b) => compareMembersBySortOrder(a, b, studentSortOrder)), [classroomMembers, studentSortOrder]);
153
162
  const currentView = deriveCurrentView({
154
163
  reportType,
155
164
  selectedAssignment,
@@ -358,7 +367,9 @@ function Progress({
358
367
  setReportType: setReportType,
359
368
  reportType: reportType,
360
369
  currentView: currentView,
361
- memberId: memberId
370
+ memberId: memberId,
371
+ studentSortOrder: studentSortOrder,
372
+ setStudentSortOrder: handleStudentSortOrderChange
362
373
  }), reportType === "discuss" ? /*#__PURE__*/_jsxs(Grid, {
363
374
  container: true,
364
375
  spacing: 2,
@@ -407,7 +418,7 @@ function Progress({
407
418
  setSelectedAssignment: setSelectedAssignment,
408
419
  assignments: assignments,
409
420
  courses: coursesWithSections,
410
- members: classroomMembers,
421
+ members: sortedListMembers,
411
422
  courseIds: courseIds,
412
423
  fetchMemberCourseCompletions: fetchMemberCourseCompletions,
413
424
  hideSorting: memberId ? true : false,
@@ -434,7 +445,9 @@ function Progress({
434
445
  setReportType: setReportType,
435
446
  reportType: reportType,
436
447
  currentView: currentView,
437
- memberId: memberId
448
+ memberId: memberId,
449
+ studentSortOrder: studentSortOrder,
450
+ setStudentSortOrder: handleStudentSortOrderChange
438
451
  }), reportType === "discuss" ? /*#__PURE__*/_jsxs(Grid, {
439
452
  container: true,
440
453
  spacing: 2,
@@ -490,6 +503,7 @@ function Progress({
490
503
  fetchMemberCourseCompletions: fetchMemberCourseCompletions,
491
504
  hideSorting: memberId ? true : false,
492
505
  filter: filter,
506
+ studentSortOrder: studentSortOrder,
493
507
  roleplays: roleplays,
494
508
  bots: bots,
495
509
  courseIds: courseIds,
@@ -28,6 +28,7 @@ function ProgressTable({
28
28
  fetchMemberCourseCompletions = () => {},
29
29
  currentView,
30
30
  filter,
31
+ studentSortOrder,
31
32
  selectedCourse,
32
33
  setSelectedCourse,
33
34
  deleteCourseId,
@@ -74,6 +75,7 @@ function ProgressTable({
74
75
  useEffect(() => {
75
76
  const opts = {
76
77
  filter,
78
+ studentSortOrder,
77
79
  queryClient,
78
80
  currentCourseIds,
79
81
  currentView,
@@ -87,13 +89,14 @@ function ProgressTable({
87
89
  formatMemberAssignmentCompletions
88
90
  };
89
91
  setMembersState(stableSort(members, sortColumn, getComparator(sortDirection, opts))); // sort alphabetically by default
90
- }, [filter, members]);
92
+ }, [filter, members, studentSortOrder]);
91
93
  const handleSort = property => {
92
94
  const isAsc = sortColumn === property && sortDirection === "asc";
93
95
  setSortDirection(isAsc ? "desc" : "asc");
94
96
  setSortColumn(property);
95
97
  const opts = {
96
98
  filter,
99
+ studentSortOrder,
97
100
  queryClient,
98
101
  currentCourseIds,
99
102
  currentView,
@@ -44,6 +44,30 @@ describe("ProgressTable (smoke)", () => {
44
44
  }));
45
45
  unmount();
46
46
  });
47
+ it("orders rows by last name when studentSortOrder is lastName", () => {
48
+ const {
49
+ container,
50
+ unmount
51
+ } = renderWithProviders(/*#__PURE__*/_jsx(ProgressTable, {
52
+ t: t => t,
53
+ courses: courses,
54
+ members: members,
55
+ currentView: "all_courses",
56
+ filter: "percentage_complete",
57
+ studentSortOrder: "lastName",
58
+ fetchMemberCourseCompletions: async () => ({
59
+ Items: []
60
+ }),
61
+ reportType: "courses",
62
+ courseIds: courses.map(c => c.courseId),
63
+ assignments: assignments,
64
+ roleplays: [],
65
+ bots: []
66
+ }));
67
+ const rowUsernames = Array.from(container.querySelectorAll('th[scope="row"]')).map(el => members.find(m => el.textContent.includes(m.username))?.username).filter(Boolean);
68
+ expect(rowUsernames).toEqual(["Cellophane", "Indigo Garland", "Norman Gordon", "Mary Jane", "Peter Parker", "Giles Posture", "Spider", "Evelyn Tool", "Oran Tool", "Penny Tool", "Indigo Violet", "Indigo Young"]);
69
+ unmount();
70
+ });
47
71
  it("renders course view without crashing", () => {
48
72
  const {
49
73
  unmount
@@ -19,7 +19,9 @@ const TableToolbar = ({
19
19
  setReportType,
20
20
  reportType,
21
21
  currentView,
22
- memberId
22
+ memberId,
23
+ studentSortOrder,
24
+ setStudentSortOrder
23
25
  }) => {
24
26
  const {
25
27
  useQueryClient
@@ -79,6 +81,28 @@ const TableToolbar = ({
79
81
  },
80
82
  children: reportFilterOptions
81
83
  })
84
+ }), reportType !== "discuss" && !memberId && setStudentSortOrder && /*#__PURE__*/_jsx(Grid, {
85
+ children: /*#__PURE__*/_jsxs(TextField, {
86
+ select: true,
87
+ id: "sort_by",
88
+ name: t("sort_by"),
89
+ "data-cy": "sort_by",
90
+ label: t("sort_by"),
91
+ value: studentSortOrder,
92
+ onChange: e => setStudentSortOrder(e.target.value),
93
+ margin: "dense",
94
+ variant: "outlined",
95
+ sx: {
96
+ minWidth: 180
97
+ },
98
+ children: [/*#__PURE__*/_jsx(MenuItem, {
99
+ value: "firstName",
100
+ children: t("first_name")
101
+ }), /*#__PURE__*/_jsx(MenuItem, {
102
+ value: "lastName",
103
+ children: t("last_name")
104
+ })]
105
+ })
82
106
  }), reportType !== "discuss" && !memberId && /*#__PURE__*/_jsx(Grid, {
83
107
  sx: {
84
108
  mt: 0.5,
@@ -66,4 +66,66 @@ describe("TableToolbar", () => {
66
66
  });
67
67
  expect(defaultProps.setReportType).toHaveBeenCalledWith("type2");
68
68
  });
69
+ it("renders sort selector when setStudentSortOrder is provided", () => {
70
+ const {
71
+ container,
72
+ unmount
73
+ } = renderWithProviders(/*#__PURE__*/_jsx(TableToolbar, {
74
+ ...defaultProps,
75
+ studentSortOrder: "firstName",
76
+ setStudentSortOrder: vi.fn()
77
+ }));
78
+ expect(container.querySelector('input[name="sort_by"]')).not.toBeNull();
79
+ unmount();
80
+ });
81
+ it("calls setStudentSortOrder when the sort selector changes", () => {
82
+ const setStudentSortOrder = vi.fn();
83
+ const {
84
+ container,
85
+ unmount
86
+ } = renderWithProviders(/*#__PURE__*/_jsx(TableToolbar, {
87
+ ...defaultProps,
88
+ studentSortOrder: "firstName",
89
+ setStudentSortOrder: setStudentSortOrder
90
+ }));
91
+ const nativeInput = container.querySelector('input[name="sort_by"]');
92
+ expect(nativeInput).not.toBeNull();
93
+ fireEvent.change(nativeInput, {
94
+ target: {
95
+ value: "lastName"
96
+ }
97
+ });
98
+ expect(setStudentSortOrder).toHaveBeenCalledWith("lastName");
99
+ unmount();
100
+ });
101
+ it("does not include a join date option in the sort selector", () => {
102
+ const {
103
+ getByRole,
104
+ unmount
105
+ } = renderWithProviders(/*#__PURE__*/_jsx(TableToolbar, {
106
+ ...defaultProps,
107
+ studentSortOrder: "firstName",
108
+ setStudentSortOrder: vi.fn()
109
+ }));
110
+ fireEvent.mouseDown(getByRole("combobox", {
111
+ name: "sort_by"
112
+ }));
113
+ expect(screen.queryByRole("option", {
114
+ name: "join_date"
115
+ })).toBeNull();
116
+ unmount();
117
+ });
118
+ it("hides sort selector for a single-student view", () => {
119
+ const {
120
+ container,
121
+ unmount
122
+ } = renderWithProviders(/*#__PURE__*/_jsx(TableToolbar, {
123
+ ...defaultProps,
124
+ memberId: "member-1",
125
+ studentSortOrder: "firstName",
126
+ setStudentSortOrder: vi.fn()
127
+ }));
128
+ expect(container.querySelector('input[name="sort_by"]')).toBeNull();
129
+ unmount();
130
+ });
69
131
  });
@@ -1,3 +1,4 @@
1
+ import { getStudentSortKey } from "../../utils/studentSort";
1
2
  export function descendingComparator(a, b, property, opts = {}) {
2
3
  const {
3
4
  assignments,
@@ -24,8 +25,8 @@ export function descendingComparator(a, b, property, opts = {}) {
24
25
  percentageAccessor = "percentComplete";
25
26
  }
26
27
  if (property === "student") {
27
- a1 = a.username.toLowerCase();
28
- b1 = b.username.toLowerCase();
28
+ a1 = getStudentSortKey(a.username || "", opts.studentSortOrder);
29
+ b1 = getStudentSortKey(b.username || "", opts.studentSortOrder);
29
30
  } else {
30
31
  const aCompletions = queryClient.getQueryData(["memberCourseProgress", a.memberId, currentCourseIds])?.Items ?? [];
31
32
  const bCompletions = queryClient.getQueryData(["memberCourseProgress", b.memberId, currentCourseIds])?.Items ?? [];
@@ -13,6 +13,42 @@ describe("sorting helpers", () => {
13
13
  const sorted = stableSort(members, "student", getComparator("asc", opts));
14
14
  expect(sorted.map(m => m.username)).toEqual(["alice", "bob"]);
15
15
  });
16
+ it("sorts by last name when studentSortOrder is lastName", () => {
17
+ const members = [{
18
+ memberId: "1",
19
+ username: "Zoe Adams"
20
+ }, {
21
+ memberId: "2",
22
+ username: "Adam Zebra"
23
+ }];
24
+ const opts = {
25
+ studentSortOrder: "lastName"
26
+ };
27
+ const sorted = stableSort(members, "student", getComparator("asc", opts));
28
+ expect(sorted.map(m => m.username)).toEqual(["Zoe Adams", "Adam Zebra"]);
29
+ });
30
+ it("keeps first-name order without a studentSortOrder", () => {
31
+ const members = [{
32
+ memberId: "1",
33
+ username: "Zoe Adams"
34
+ }, {
35
+ memberId: "2",
36
+ username: "Adam Zebra"
37
+ }];
38
+ const sorted = stableSort(members, "student", getComparator("asc", {}));
39
+ expect(sorted.map(m => m.username)).toEqual(["Adam Zebra", "Zoe Adams"]);
40
+ });
41
+ it("does not throw when a username is missing", () => {
42
+ const members = [{
43
+ memberId: "1",
44
+ username: null
45
+ }, {
46
+ memberId: "2",
47
+ username: "Adam Zebra"
48
+ }];
49
+ const sorted = stableSort(members, "student", getComparator("asc", {}));
50
+ expect(sorted.map(m => m.memberId)).toEqual(["1", "2"]);
51
+ });
16
52
  it("sorts by course percentage in all_courses view", () => {
17
53
  const members = [{
18
54
  memberId: "1",
@@ -0,0 +1,35 @@
1
+ // Members list and Progress table each keep their own independent sort
2
+ // preference, so choosing a sort order in one never changes the other.
3
+ export const MEMBERS_SORT_ORDER_KEY = "membersSortOrder";
4
+ export const PROGRESS_SORT_ORDER_KEY = "progressSortOrder";
5
+ export const getStudentSortOrder = (key, fallback = "firstName") => {
6
+ try {
7
+ return localStorage.getItem(key) || fallback;
8
+ } catch {
9
+ return fallback;
10
+ }
11
+ };
12
+ export const setStudentSortOrder = (key, value) => {
13
+ try {
14
+ localStorage.setItem(key, value);
15
+ } catch {
16
+ // localStorage unavailable (e.g. blocked storage) — preference just won't persist
17
+ }
18
+ };
19
+ export const getLastName = (name = "") => {
20
+ const parts = name.trim().split(/\s+/);
21
+ return parts[parts.length - 1] || name;
22
+ };
23
+
24
+ // Sort key honoring the preference; ties on last name broken by full name.
25
+ export const getStudentSortKey = (name = "", order = "firstName") => order === "lastName" ? `${getLastName(name)} ${name}`.toLowerCase() : (name || "").toLowerCase();
26
+ export const compareStudentNames = (a, b, order) => getStudentSortKey(a, order).localeCompare(getStudentSortKey(b, order));
27
+
28
+ // Compares member objects ({ username, createdAt }) by the shared sort
29
+ // preference; joinDate (or any other value) falls back to most-recently-joined first.
30
+ export const compareMembersBySortOrder = (a = {}, b = {}, order) => {
31
+ if (order === "firstName" || order === "lastName") {
32
+ return compareStudentNames(a.username || "", b.username || "", order);
33
+ }
34
+ return (b.createdAt || 0) - (a.createdAt || 0);
35
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nualang/nualang-ui-components",
3
- "version": "0.1.1401",
3
+ "version": "0.1.1402",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [