@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.
- package/dist/Forms/Settings/Tabs/General/General.js +67 -27
- package/dist/Lists/Members/Members.js +57 -12
- package/dist/Lists/Members/Members.test.js +70 -9
- package/dist/Navigation/ResponsiveDrawer/ResponsiveDrawer.js +5 -4
- package/dist/Tables/Progress/Progress.js +17 -3
- package/dist/Tables/Progress/ProgressTable.js +4 -1
- package/dist/Tables/Progress/ProgressTable.test.js +24 -0
- package/dist/Tables/Progress/TableToolbar.js +25 -1
- package/dist/Tables/Progress/TableToolbar.test.js +62 -0
- package/dist/Tables/Progress/sorting.js +3 -2
- package/dist/Tables/Progress/sorting.test.js +36 -0
- package/dist/utils/studentSort.js +35 -0
- package/package.json +1 -1
|
@@ -56,7 +56,8 @@ function GeneralForm({
|
|
|
56
56
|
}) {
|
|
57
57
|
const {
|
|
58
58
|
email,
|
|
59
|
-
|
|
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
|
|
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
|
-
}),
|
|
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: "
|
|
112
|
-
name: "
|
|
113
|
-
label: t("
|
|
114
|
-
|
|
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.
|
|
118
|
-
error: touched.
|
|
119
|
-
type: "
|
|
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:
|
|
137
|
+
md: 6
|
|
129
138
|
},
|
|
130
139
|
children: /*#__PURE__*/_jsx(TextField, {
|
|
131
|
-
id: "
|
|
132
|
-
name: "
|
|
133
|
-
label: t("
|
|
134
|
-
title: t("
|
|
135
|
-
value:
|
|
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.
|
|
139
|
-
error: touched.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
411
|
-
|
|
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:
|
|
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
|
-
} =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
28
|
-
b1 = b.username.
|
|
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
|
+
};
|