@nualang/nualang-ui-components 0.1.1436 → 0.1.1437
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/Assignments/CreateAssignmentDialog/CreateAssignmentDialog.js +3 -1
- package/dist/Cards/Course/Course.js +2 -2
- package/dist/Chat/Messages/Messages.js +1 -1
- package/dist/Chat/Messages/Question/Question.js +2 -2
- package/dist/Forms/CreateClassroom/Steps/ClassroomSettings/ClassroomSettings.js +20 -15
- package/dist/Lists/CourseOutline/CourseOutline.js +363 -69
- package/dist/Lists/CourseOutline/CourseOutline.test.js +209 -2
- package/dist/Lists/Timeline/EventItem.js +15 -0
- package/dist/Tables/Attempt/Attempt.js +2 -2
- package/dist/hooks/useExerciseState.js +8 -6
- package/dist/hooks/useRecognition.js +71 -22
- package/package.json +1 -1
|
@@ -83,7 +83,9 @@ export default function CreateAssignmentDialog({
|
|
|
83
83
|
return response;
|
|
84
84
|
}, {
|
|
85
85
|
filters: {
|
|
86
|
-
courseIds: selectedClassroomCourses?.toString()
|
|
86
|
+
courseIds: selectedClassroomCourses?.toString(),
|
|
87
|
+
// Sections/topics are only returned when verbose; needed for the title autofill
|
|
88
|
+
verbose: true
|
|
87
89
|
}
|
|
88
90
|
}, {
|
|
89
91
|
enabled: !!assignment.classroomId
|
|
@@ -862,7 +862,7 @@ function CourseCard({
|
|
|
862
862
|
handleViewCourse(parentId);
|
|
863
863
|
}
|
|
864
864
|
};
|
|
865
|
-
const courseSections = sectionsQuery.isSuccess
|
|
865
|
+
const courseSections = sectionsQuery.isSuccess && sectionsQuery.data?.Items || [];
|
|
866
866
|
const topicDifficulties = sectionsQuery.isSuccess ? [...new Set(courseSections.flatMap(section => section.topics || []).map(topic => topic.difficulty).filter(Boolean))] : [];
|
|
867
867
|
const deleteCourse = async () => {
|
|
868
868
|
const confirmed = await confirm(t("delete_course"), t("delete_course_confirmation"), "", true, t("course_deletion_note", {
|
|
@@ -1263,7 +1263,7 @@ function CourseCard({
|
|
|
1263
1263
|
dialogTitle: courseName,
|
|
1264
1264
|
open: isOutlineOpen,
|
|
1265
1265
|
handleClose: handleCloseOutline,
|
|
1266
|
-
sections:
|
|
1266
|
+
sections: courseSections,
|
|
1267
1267
|
isLoading: sectionsQuery.isLoading,
|
|
1268
1268
|
isMember: isMember,
|
|
1269
1269
|
completions: completions,
|
|
@@ -100,11 +100,11 @@ function Answers({
|
|
|
100
100
|
if (!isLiveGame) {
|
|
101
101
|
if (value.correct) {
|
|
102
102
|
setAnsweredCorrectly(true);
|
|
103
|
-
handleCorrectQuestionAnswer(question, value);
|
|
103
|
+
handleCorrectQuestionAnswer(question, value, answers);
|
|
104
104
|
}
|
|
105
105
|
if (!value.correct) {
|
|
106
106
|
setAnsweredCorrectly(false);
|
|
107
|
-
handleIncorrectQuestionAnswer(question, value);
|
|
107
|
+
handleIncorrectQuestionAnswer(question, value, answers);
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
};
|
|
@@ -50,6 +50,24 @@ function ClassroomSettings({
|
|
|
50
50
|
}
|
|
51
51
|
}, [scrollToDiscuss]);
|
|
52
52
|
const isInviteFeatureDisabled = classroomId && classroomId.toLowerCase().includes("wayside");
|
|
53
|
+
|
|
54
|
+
// Returns the translation key explaining why "public" is disabled, or null if allowed
|
|
55
|
+
const getPublicVisibilityDisabledReason = () => {
|
|
56
|
+
if (isUpdateClassroom && isInviteFeatureDisabled) {
|
|
57
|
+
return "public_visibility_disabled_wayside";
|
|
58
|
+
}
|
|
59
|
+
if (subscription.isSubscriptionLoaded === false) {
|
|
60
|
+
return "public_visibility_disabled_loading";
|
|
61
|
+
}
|
|
62
|
+
if (subscription.subscriptionStatus === "trialing") {
|
|
63
|
+
return "public_visibility_disabled_trial";
|
|
64
|
+
}
|
|
65
|
+
if (!subscription.isPaidUser) {
|
|
66
|
+
return subscription.isUpgradePossible ? "upgrade_plan_explanation" : "upgrade_subscription_student";
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
const publicVisibilityDisabledReason = getPublicVisibilityDisabledReason();
|
|
53
71
|
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
54
72
|
children: [/*#__PURE__*/_jsx(Typography, {
|
|
55
73
|
children: t("limit_access_classroom_visibility_private_including_enrolment_key")
|
|
@@ -85,21 +103,8 @@ function ClassroomSettings({
|
|
|
85
103
|
onBlur: handleBlur,
|
|
86
104
|
helperText: touched.visibility ? errors.visibility : "",
|
|
87
105
|
error: touched.visibility && Boolean(errors.visibility),
|
|
88
|
-
children: [
|
|
89
|
-
title:
|
|
90
|
-
placement: "top",
|
|
91
|
-
children: /*#__PURE__*/_jsx(FormControlLabel, {
|
|
92
|
-
value: "public",
|
|
93
|
-
control: /*#__PURE__*/_jsx(Radio, {}),
|
|
94
|
-
label: t("public"),
|
|
95
|
-
disabled: true
|
|
96
|
-
})
|
|
97
|
-
}) : /*#__PURE__*/_jsx(FormControlLabel, {
|
|
98
|
-
value: "public",
|
|
99
|
-
control: /*#__PURE__*/_jsx(Radio, {}),
|
|
100
|
-
label: t("public")
|
|
101
|
-
}) : !subscription.isPaidUser || subscription.subscriptionStatus === "trialing" ? /*#__PURE__*/_jsx(Tooltip, {
|
|
102
|
-
title: subscription.isUpgradePossible ? t("upgrade_plan_explanation") : t("upgrade_subscription_student"),
|
|
106
|
+
children: [publicVisibilityDisabledReason ? /*#__PURE__*/_jsx(Tooltip, {
|
|
107
|
+
title: t(publicVisibilityDisabledReason),
|
|
103
108
|
placement: "top",
|
|
104
109
|
children: /*#__PURE__*/_jsx(FormControlLabel, {
|
|
105
110
|
value: "public",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
|
2
2
|
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
|
3
|
-
import { ListSubheader, List, ListItem, ListItemIcon, ListItemText, ListItemAvatar, ListItemSecondaryAction, IconButton, Button, Collapse, Typography, Box, Tooltip, Menu, MenuItem, Avatar, Chip } from "@mui/material";
|
|
3
|
+
import { ListSubheader, List, ListItem, ListItemIcon, ListItemText, ListItemAvatar, ListItemSecondaryAction, IconButton, Button, Collapse, Typography, Box, Tooltip, Menu, MenuItem, Avatar, Chip, TextField, InputAdornment, FormControl, InputLabel, Select, Divider } from "@mui/material";
|
|
4
4
|
import { useTheme, alpha } from "@mui/material/styles";
|
|
5
5
|
import { makeStyles } from "tss-react/mui";
|
|
6
6
|
import CheckBox from "@mui/icons-material/CheckBox";
|
|
@@ -27,6 +27,8 @@ import ImageSearchIcon from "@mui/icons-material/ImageSearch";
|
|
|
27
27
|
import Skeleton from "@mui/material/Skeleton";
|
|
28
28
|
import OndemandVideoIcon from "@mui/icons-material/OndemandVideo";
|
|
29
29
|
import DifficultyIcon from "@mui/icons-material/School";
|
|
30
|
+
import Close from "@mui/icons-material/Close";
|
|
31
|
+
import SearchOff from "@mui/icons-material/SearchOff";
|
|
30
32
|
import ColorLinearProgress from "../../Misc/ColorLinearProgress/ColorLinearProgress";
|
|
31
33
|
import { calcCompletions, calcPercentageCompletion } from "../../utils/index";
|
|
32
34
|
import SelectClassroom from "../../Dialogs/SelectClassroom/SelectClassroom";
|
|
@@ -47,6 +49,56 @@ const useStyles = makeStyles()(theme => ({
|
|
|
47
49
|
color: theme.palette.success ? theme.palette.success.main : ""
|
|
48
50
|
}
|
|
49
51
|
}));
|
|
52
|
+
const ALL_DIFFICULTIES = "all";
|
|
53
|
+
const DIFFICULTY_ORDER = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
|
54
|
+
const SEARCH_DEBOUNCE_MS = 200;
|
|
55
|
+
const difficultyRank = difficulty => {
|
|
56
|
+
const rank = DIFFICULTY_ORDER.indexOf(difficulty);
|
|
57
|
+
return rank < 0 ? DIFFICULTY_ORDER.length : rank;
|
|
58
|
+
};
|
|
59
|
+
const normaliseText = text => (text || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
|
60
|
+
const isSectionVisible = (section, isCreator) => !section.isHidden || isCreator;
|
|
61
|
+
const isTopicVisible = (topic, isCreator) => !topic.isTopicHidden || isCreator;
|
|
62
|
+
function filterOutline(sections, {
|
|
63
|
+
query,
|
|
64
|
+
difficulty,
|
|
65
|
+
isCreator
|
|
66
|
+
}) {
|
|
67
|
+
const normalisedQuery = normaliseText(query.trim());
|
|
68
|
+
const isDifficultyFiltered = difficulty !== ALL_DIFFICULTIES;
|
|
69
|
+
let topicCount = 0;
|
|
70
|
+
const filteredSections = [];
|
|
71
|
+
sections.forEach(section => {
|
|
72
|
+
if (!isSectionVisible(section, isCreator)) return;
|
|
73
|
+
const visibleTopics = (section.topics || []).filter(topic => isTopicVisible(topic, isCreator));
|
|
74
|
+
const isSectionMatch = !!normalisedQuery && normaliseText(section.sectionName).includes(normalisedQuery);
|
|
75
|
+
const topics = visibleTopics.filter(topic => (!normalisedQuery || isSectionMatch || normaliseText(topic.topicName).includes(normalisedQuery) || normaliseText(topic.description).includes(normalisedQuery)) && (!isDifficultyFiltered || topic.difficulty === difficulty));
|
|
76
|
+
const isKeptForSectionMatch = isSectionMatch && !isDifficultyFiltered;
|
|
77
|
+
if (!topics.length && !isKeptForSectionMatch) return;
|
|
78
|
+
topicCount += topics.length;
|
|
79
|
+
filteredSections.push({
|
|
80
|
+
...section,
|
|
81
|
+
topics,
|
|
82
|
+
filterCount: {
|
|
83
|
+
shown: topics.length,
|
|
84
|
+
total: visibleTopics.length
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
sections: filteredSections,
|
|
90
|
+
topicCount
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const activeFilterOutlineSx = {
|
|
94
|
+
"& .MuiOutlinedInput-notchedOutline": {
|
|
95
|
+
borderColor: "primary.main",
|
|
96
|
+
borderWidth: 2
|
|
97
|
+
},
|
|
98
|
+
"& .MuiInputLabel-root": {
|
|
99
|
+
color: "primary.main"
|
|
100
|
+
}
|
|
101
|
+
};
|
|
50
102
|
function OverflowMenu({
|
|
51
103
|
children,
|
|
52
104
|
anchorEl,
|
|
@@ -346,10 +398,12 @@ function Topic({
|
|
|
346
398
|
courseSettings,
|
|
347
399
|
showViewTopicButton,
|
|
348
400
|
isClassroomArchived = false,
|
|
401
|
+
highlightDifficulty,
|
|
349
402
|
...otherProps
|
|
350
403
|
}) {
|
|
351
404
|
const theme = useTheme();
|
|
352
405
|
const isLargeScreen = useMediaQuery(theme.breakpoints.up("sm"));
|
|
406
|
+
const isDifficultyHighlighted = !!highlightDifficulty && topic?.difficulty === highlightDifficulty;
|
|
353
407
|
const [open] = useState(false);
|
|
354
408
|
const [anchorEl, setAnchorEl] = useState(null);
|
|
355
409
|
const handleClickMenu = event => {
|
|
@@ -571,8 +625,10 @@ function Topic({
|
|
|
571
625
|
size: "small",
|
|
572
626
|
sx: theme => ({
|
|
573
627
|
height: 24,
|
|
574
|
-
backgroundColor: theme.palette.action.selected,
|
|
575
|
-
color: theme.palette.text.secondary,
|
|
628
|
+
backgroundColor: isDifficultyHighlighted ? alpha(theme.palette.primary.main, 0.08) : theme.palette.action.selected,
|
|
629
|
+
color: isDifficultyHighlighted ? theme.palette.primary.dark : theme.palette.text.secondary,
|
|
630
|
+
border: "1px solid",
|
|
631
|
+
borderColor: isDifficultyHighlighted ? theme.palette.primary.light : "transparent",
|
|
576
632
|
"& .MuiChip-label": {
|
|
577
633
|
fontSize: "0.75rem",
|
|
578
634
|
px: 0.75
|
|
@@ -707,14 +763,16 @@ function Topic({
|
|
|
707
763
|
})
|
|
708
764
|
}), isCreator && /*#__PURE__*/_jsx(Tooltip, {
|
|
709
765
|
title: t("move_topic"),
|
|
710
|
-
children: /*#__PURE__*/_jsx(
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
766
|
+
children: /*#__PURE__*/_jsx("span", {
|
|
767
|
+
children: /*#__PURE__*/_jsx(IconButton, {
|
|
768
|
+
"data-handler-id": handlerId,
|
|
769
|
+
style: {
|
|
770
|
+
cursor: disableDrag ? "default" : "move"
|
|
771
|
+
},
|
|
772
|
+
"aria-label": "Move Topic",
|
|
773
|
+
disabled: !isCreator || !!disableDrag,
|
|
774
|
+
children: /*#__PURE__*/_jsx(DragHandle, {})
|
|
775
|
+
})
|
|
718
776
|
})
|
|
719
777
|
})]
|
|
720
778
|
})]
|
|
@@ -887,6 +945,8 @@ function Section({
|
|
|
887
945
|
coursePictureBuilderColor,
|
|
888
946
|
coursePictureBuilderIcon,
|
|
889
947
|
openSnackbar,
|
|
948
|
+
filterCount,
|
|
949
|
+
hideAddTopic = false,
|
|
890
950
|
...otherProps
|
|
891
951
|
}) {
|
|
892
952
|
const {
|
|
@@ -1068,12 +1128,26 @@ function Section({
|
|
|
1068
1128
|
sx: {
|
|
1069
1129
|
display: "flex"
|
|
1070
1130
|
},
|
|
1071
|
-
children: [/*#__PURE__*/
|
|
1131
|
+
children: [/*#__PURE__*/_jsxs(Box, {
|
|
1072
1132
|
sx: {
|
|
1073
1133
|
fontWeight: "fontWeightBold",
|
|
1074
|
-
flexGrow: 1
|
|
1134
|
+
flexGrow: 1,
|
|
1135
|
+
display: "flex",
|
|
1136
|
+
alignItems: "center",
|
|
1137
|
+
gap: 1.5
|
|
1075
1138
|
},
|
|
1076
|
-
children: sectionName
|
|
1139
|
+
children: [sectionName, filterCount && /*#__PURE__*/_jsx(Typography, {
|
|
1140
|
+
variant: "caption",
|
|
1141
|
+
color: "textSecondary",
|
|
1142
|
+
sx: {
|
|
1143
|
+
whiteSpace: "nowrap"
|
|
1144
|
+
},
|
|
1145
|
+
"data-cy": "section-filter-count",
|
|
1146
|
+
children: t("x_of_y", {
|
|
1147
|
+
shown: filterCount.shown,
|
|
1148
|
+
total: filterCount.total
|
|
1149
|
+
})
|
|
1150
|
+
})]
|
|
1077
1151
|
}), !isLargeScreen ? /*#__PURE__*/_jsxs(_Fragment, {
|
|
1078
1152
|
children: [/*#__PURE__*/_jsx(Box, {
|
|
1079
1153
|
children: isCreator ? /*#__PURE__*/_jsx(Tooltip, {
|
|
@@ -1091,7 +1165,7 @@ function Section({
|
|
|
1091
1165
|
}), /*#__PURE__*/_jsxs(OverflowMenu, {
|
|
1092
1166
|
anchorEl: anchorEl,
|
|
1093
1167
|
handleClose: handleCloseMenu,
|
|
1094
|
-
children: [/*#__PURE__*/_jsxs(MenuItem, {
|
|
1168
|
+
children: [!hideAddTopic && /*#__PURE__*/_jsxs(MenuItem, {
|
|
1095
1169
|
onClick: () => {
|
|
1096
1170
|
setIsQuickCreateTopicOpen(true);
|
|
1097
1171
|
handleCloseMenu();
|
|
@@ -1103,7 +1177,7 @@ function Section({
|
|
|
1103
1177
|
}), /*#__PURE__*/_jsx(ListItemText, {
|
|
1104
1178
|
primary: t("create_topic")
|
|
1105
1179
|
})]
|
|
1106
|
-
}), /*#__PURE__*/_jsxs(MenuItem, {
|
|
1180
|
+
}), !hideAddTopic && /*#__PURE__*/_jsxs(MenuItem, {
|
|
1107
1181
|
onClick: () => {
|
|
1108
1182
|
handleFindTopic();
|
|
1109
1183
|
handleCloseMenu();
|
|
@@ -1156,14 +1230,16 @@ function Section({
|
|
|
1156
1230
|
}), /*#__PURE__*/_jsx(Box, {
|
|
1157
1231
|
children: isCreator ? /*#__PURE__*/_jsx(Tooltip, {
|
|
1158
1232
|
title: t("move_section"),
|
|
1159
|
-
children: /*#__PURE__*/_jsx(
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1233
|
+
children: /*#__PURE__*/_jsx("span", {
|
|
1234
|
+
children: /*#__PURE__*/_jsx(IconButton, {
|
|
1235
|
+
"data-handler-id": handlerId,
|
|
1236
|
+
style: {
|
|
1237
|
+
cursor: disableDrag ? "default" : "move"
|
|
1238
|
+
},
|
|
1239
|
+
"aria-label": "Move Section",
|
|
1240
|
+
disabled: !isCreator || !!disableDrag,
|
|
1241
|
+
children: /*#__PURE__*/_jsx(DragHandle, {})
|
|
1242
|
+
})
|
|
1167
1243
|
})
|
|
1168
1244
|
}) : null
|
|
1169
1245
|
})]
|
|
@@ -1223,14 +1299,16 @@ function Section({
|
|
|
1223
1299
|
}), /*#__PURE__*/_jsx(Box, {
|
|
1224
1300
|
children: isCreator ? /*#__PURE__*/_jsx(Tooltip, {
|
|
1225
1301
|
title: t("move_section"),
|
|
1226
|
-
children: /*#__PURE__*/_jsx(
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1302
|
+
children: /*#__PURE__*/_jsx("span", {
|
|
1303
|
+
children: /*#__PURE__*/_jsx(IconButton, {
|
|
1304
|
+
"data-handler-id": handlerId,
|
|
1305
|
+
style: {
|
|
1306
|
+
cursor: disableDrag ? "default" : "move"
|
|
1307
|
+
},
|
|
1308
|
+
"aria-label": "Move Section",
|
|
1309
|
+
disabled: !isCreator || !!disableDrag,
|
|
1310
|
+
children: /*#__PURE__*/_jsx(DragHandle, {})
|
|
1311
|
+
})
|
|
1234
1312
|
})
|
|
1235
1313
|
}) : null
|
|
1236
1314
|
})]
|
|
@@ -1257,7 +1335,7 @@ function Section({
|
|
|
1257
1335
|
showViewTopicButton: showViewTopicButton,
|
|
1258
1336
|
isClassroomArchived: isClassroomArchived,
|
|
1259
1337
|
...otherProps
|
|
1260
|
-
}), isCreator && /*#__PURE__*/_jsxs(_Fragment, {
|
|
1338
|
+
}), isCreator && !hideAddTopic && /*#__PURE__*/_jsxs(_Fragment, {
|
|
1261
1339
|
children: [/*#__PURE__*/_jsxs(ListItem, {
|
|
1262
1340
|
button: true,
|
|
1263
1341
|
onClick: handleOpenAddTopicMenu,
|
|
@@ -1469,6 +1547,7 @@ function CourseOutline({
|
|
|
1469
1547
|
classroomId,
|
|
1470
1548
|
isChallengeModeStudent = false,
|
|
1471
1549
|
isReadWriteModeStudent = false,
|
|
1550
|
+
enableFilters = false,
|
|
1472
1551
|
...otherProps
|
|
1473
1552
|
}) {
|
|
1474
1553
|
const [percentageCompletion, setPercentageCompletion] = useState({
|
|
@@ -1632,6 +1711,50 @@ function CourseOutline({
|
|
|
1632
1711
|
completed,
|
|
1633
1712
|
total
|
|
1634
1713
|
} = percentageCompletion;
|
|
1714
|
+
const [searchInput, setSearchInput] = useState("");
|
|
1715
|
+
const [query, setQuery] = useState("");
|
|
1716
|
+
const [difficulty, setDifficulty] = useState(ALL_DIFFICULTIES);
|
|
1717
|
+
useEffect(() => {
|
|
1718
|
+
const timeout = setTimeout(() => setQuery(searchInput), SEARCH_DEBOUNCE_MS);
|
|
1719
|
+
return () => clearTimeout(timeout);
|
|
1720
|
+
}, [searchInput]);
|
|
1721
|
+
const clearSearch = () => {
|
|
1722
|
+
setSearchInput("");
|
|
1723
|
+
setQuery("");
|
|
1724
|
+
};
|
|
1725
|
+
const clearFilters = () => {
|
|
1726
|
+
clearSearch();
|
|
1727
|
+
setDifficulty(ALL_DIFFICULTIES);
|
|
1728
|
+
};
|
|
1729
|
+
const trimmedQuery = query.trim();
|
|
1730
|
+
const isDifficultyFiltered = difficulty !== ALL_DIFFICULTIES;
|
|
1731
|
+
const isFiltering = enableFilters && (!!trimmedQuery || isDifficultyFiltered);
|
|
1732
|
+
const difficultyOptions = useMemo(() => {
|
|
1733
|
+
const difficulties = new Set();
|
|
1734
|
+
sects.forEach(section => {
|
|
1735
|
+
if (!isSectionVisible(section, isCreator)) return;
|
|
1736
|
+
(section.topics || []).forEach(topic => {
|
|
1737
|
+
if (isTopicVisible(topic, isCreator) && topic.difficulty) {
|
|
1738
|
+
difficulties.add(topic.difficulty);
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
});
|
|
1742
|
+
if (difficulty !== ALL_DIFFICULTIES) difficulties.add(difficulty);
|
|
1743
|
+
return [...difficulties].sort((a, b) => difficultyRank(a) - difficultyRank(b) || a.localeCompare(b));
|
|
1744
|
+
}, [sects, isCreator, difficulty]);
|
|
1745
|
+
const filtered = useMemo(() => isFiltering ? filterOutline(sects, {
|
|
1746
|
+
query: trimmedQuery,
|
|
1747
|
+
difficulty,
|
|
1748
|
+
isCreator
|
|
1749
|
+
}) : null, [isFiltering, sects, trimmedQuery, difficulty, isCreator]);
|
|
1750
|
+
const filterCountText = filtered ? t("outline_filter_count", {
|
|
1751
|
+
topics: t("topic_count", {
|
|
1752
|
+
count: filtered.topicCount
|
|
1753
|
+
}),
|
|
1754
|
+
sections: t("section_count", {
|
|
1755
|
+
count: filtered.sections.length
|
|
1756
|
+
})
|
|
1757
|
+
}) : "";
|
|
1635
1758
|
if (isLoading) {
|
|
1636
1759
|
return /*#__PURE__*/_jsx(_Fragment, {
|
|
1637
1760
|
children: Array(3).fill("").map((_, idx) => /*#__PURE__*/_jsxs("div", {
|
|
@@ -1684,55 +1807,224 @@ function CourseOutline({
|
|
|
1684
1807
|
}, `section_${idx}`))
|
|
1685
1808
|
});
|
|
1686
1809
|
}
|
|
1810
|
+
const isEmptyResult = !!filtered && filtered.sections.length === 0;
|
|
1811
|
+
const isProgressShown = isMember && !isCreator;
|
|
1687
1812
|
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
1688
|
-
children: [/*#__PURE__*/
|
|
1689
|
-
|
|
1690
|
-
|
|
1813
|
+
children: [/*#__PURE__*/_jsxs(Box, {
|
|
1814
|
+
sx: {
|
|
1815
|
+
display: "flex",
|
|
1816
|
+
alignItems: "center",
|
|
1817
|
+
flexWrap: "wrap",
|
|
1818
|
+
gap: 2
|
|
1819
|
+
},
|
|
1820
|
+
children: [/*#__PURE__*/_jsx(Typography, {
|
|
1821
|
+
variant: "subtitle1",
|
|
1822
|
+
sx: {
|
|
1823
|
+
fontWeight: "fontWeightBold",
|
|
1824
|
+
whiteSpace: "nowrap"
|
|
1825
|
+
},
|
|
1826
|
+
children: isProgressShown ? t("course_progress") : t(isCreator || isCreator && isMember ? "course_outline" : "course_progress")
|
|
1827
|
+
}), isProgressShown ? /*#__PURE__*/_jsxs(Box, {
|
|
1691
1828
|
sx: {
|
|
1692
1829
|
display: "flex",
|
|
1693
1830
|
alignItems: "center",
|
|
1694
|
-
|
|
1831
|
+
flexGrow: 1,
|
|
1832
|
+
minWidth: 120,
|
|
1833
|
+
ml: 4
|
|
1695
1834
|
},
|
|
1696
1835
|
children: [/*#__PURE__*/_jsx(Box, {
|
|
1697
1836
|
sx: {
|
|
1698
|
-
|
|
1837
|
+
flexGrow: 1
|
|
1699
1838
|
},
|
|
1700
|
-
children:
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
flexGrow: 1,
|
|
1706
|
-
ml: 6
|
|
1707
|
-
},
|
|
1708
|
-
children: /*#__PURE__*/_jsx(Tooltip, {
|
|
1709
|
-
title: `Completed ${completed} / ${total} exercises`,
|
|
1710
|
-
children: /*#__PURE__*/_jsx(ColorLinearProgress, {
|
|
1711
|
-
variant: "determinate",
|
|
1712
|
-
value: percentage
|
|
1713
|
-
})
|
|
1839
|
+
children: /*#__PURE__*/_jsx(Tooltip, {
|
|
1840
|
+
title: `Completed ${completed} / ${total} exercises`,
|
|
1841
|
+
children: /*#__PURE__*/_jsx(ColorLinearProgress, {
|
|
1842
|
+
variant: "determinate",
|
|
1843
|
+
value: percentage
|
|
1714
1844
|
})
|
|
1715
|
-
})
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1845
|
+
})
|
|
1846
|
+
}), /*#__PURE__*/_jsxs(Box, {
|
|
1847
|
+
sx: {
|
|
1848
|
+
ml: 1,
|
|
1849
|
+
fontSize: 14
|
|
1850
|
+
},
|
|
1851
|
+
children: [percentage, "%"]
|
|
1852
|
+
})]
|
|
1853
|
+
}) : /*#__PURE__*/_jsx(Box, {
|
|
1854
|
+
sx: {
|
|
1855
|
+
flexGrow: 1
|
|
1856
|
+
}
|
|
1857
|
+
}), enableFilters && /*#__PURE__*/_jsxs(_Fragment, {
|
|
1858
|
+
children: [/*#__PURE__*/_jsx(TextField, {
|
|
1859
|
+
size: "small",
|
|
1860
|
+
value: searchInput,
|
|
1861
|
+
onChange: event => setSearchInput(event.target.value),
|
|
1862
|
+
onKeyDown: event => {
|
|
1863
|
+
if (event.key === "Escape" && searchInput) {
|
|
1864
|
+
event.stopPropagation();
|
|
1865
|
+
clearSearch();
|
|
1866
|
+
}
|
|
1867
|
+
},
|
|
1868
|
+
placeholder: t("search_sections_and_topics"),
|
|
1869
|
+
sx: {
|
|
1870
|
+
width: {
|
|
1871
|
+
xs: "100%",
|
|
1872
|
+
sm: 300
|
|
1873
|
+
},
|
|
1874
|
+
...(trimmedQuery ? activeFilterOutlineSx : {})
|
|
1875
|
+
},
|
|
1876
|
+
slotProps: {
|
|
1877
|
+
htmlInput: {
|
|
1878
|
+
"aria-label": t("search_sections_and_topics"),
|
|
1879
|
+
"data-cy": "outline-search"
|
|
1880
|
+
},
|
|
1881
|
+
input: {
|
|
1882
|
+
startAdornment: /*#__PURE__*/_jsx(InputAdornment, {
|
|
1883
|
+
position: "start",
|
|
1884
|
+
children: /*#__PURE__*/_jsx(Search, {
|
|
1885
|
+
fontSize: "small"
|
|
1886
|
+
})
|
|
1887
|
+
}),
|
|
1888
|
+
endAdornment: searchInput ? /*#__PURE__*/_jsx(InputAdornment, {
|
|
1889
|
+
position: "end",
|
|
1890
|
+
children: /*#__PURE__*/_jsx(IconButton, {
|
|
1891
|
+
size: "small",
|
|
1892
|
+
edge: "end",
|
|
1893
|
+
"aria-label": t("clear_search"),
|
|
1894
|
+
onClick: clearSearch,
|
|
1895
|
+
children: /*#__PURE__*/_jsx(Close, {
|
|
1896
|
+
fontSize: "small"
|
|
1897
|
+
})
|
|
1898
|
+
})
|
|
1899
|
+
}) : null
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}), difficultyOptions.length > 0 && /*#__PURE__*/_jsxs(FormControl, {
|
|
1903
|
+
size: "small",
|
|
1904
|
+
sx: {
|
|
1905
|
+
width: {
|
|
1906
|
+
xs: "100%",
|
|
1907
|
+
sm: 200
|
|
1719
1908
|
},
|
|
1720
|
-
|
|
1909
|
+
...(isDifficultyFiltered ? activeFilterOutlineSx : {})
|
|
1910
|
+
},
|
|
1911
|
+
children: [/*#__PURE__*/_jsx(InputLabel, {
|
|
1912
|
+
id: "course-outline-difficulty-label",
|
|
1913
|
+
children: t("difficulty")
|
|
1914
|
+
}), /*#__PURE__*/_jsxs(Select, {
|
|
1915
|
+
labelId: "course-outline-difficulty-label",
|
|
1916
|
+
label: t("difficulty"),
|
|
1917
|
+
value: difficulty,
|
|
1918
|
+
onChange: event => setDifficulty(event.target.value),
|
|
1919
|
+
"data-cy": "outline-difficulty-filter",
|
|
1920
|
+
children: [/*#__PURE__*/_jsx(MenuItem, {
|
|
1921
|
+
value: ALL_DIFFICULTIES,
|
|
1922
|
+
children: t("all")
|
|
1923
|
+
}), difficultyOptions.map(option => /*#__PURE__*/_jsx(MenuItem, {
|
|
1924
|
+
value: option,
|
|
1925
|
+
children: t(`card_${option}`)
|
|
1926
|
+
}, option))]
|
|
1721
1927
|
})]
|
|
1722
|
-
}), /*#__PURE__*/_jsx(Box, {
|
|
1723
|
-
children: isCreator && /*#__PURE__*/_jsx(Button, {
|
|
1724
|
-
variant: "contained",
|
|
1725
|
-
color: "primary",
|
|
1726
|
-
onClick: handleClickOpenCreateSection,
|
|
1727
|
-
size: "small",
|
|
1728
|
-
"data-cy": "add-section",
|
|
1729
|
-
children: t("add_section")
|
|
1730
|
-
})
|
|
1731
1928
|
})]
|
|
1732
|
-
})
|
|
1929
|
+
}), isCreator && /*#__PURE__*/_jsx(Button, {
|
|
1930
|
+
variant: "contained",
|
|
1931
|
+
color: "primary",
|
|
1932
|
+
onClick: handleClickOpenCreateSection,
|
|
1933
|
+
size: "small",
|
|
1934
|
+
"data-cy": "add-section",
|
|
1935
|
+
children: t("add_section")
|
|
1936
|
+
})]
|
|
1937
|
+
}), enableFilters && /*#__PURE__*/_jsx(Box, {
|
|
1938
|
+
role: "status",
|
|
1939
|
+
"aria-live": "polite",
|
|
1940
|
+
sx: {
|
|
1941
|
+
position: "absolute",
|
|
1942
|
+
width: 1,
|
|
1943
|
+
height: 1,
|
|
1944
|
+
overflow: "hidden",
|
|
1945
|
+
clip: "rect(0 0 0 0)",
|
|
1946
|
+
whiteSpace: "nowrap"
|
|
1947
|
+
},
|
|
1948
|
+
children: isEmptyResult ? t("no_sections_or_topics_match") : filterCountText
|
|
1949
|
+
}), isFiltering && /*#__PURE__*/_jsxs(_Fragment, {
|
|
1950
|
+
children: [/*#__PURE__*/_jsx(Divider, {
|
|
1951
|
+
sx: {
|
|
1952
|
+
mt: 2
|
|
1953
|
+
}
|
|
1954
|
+
}), /*#__PURE__*/_jsxs(Box, {
|
|
1955
|
+
sx: {
|
|
1956
|
+
display: "flex",
|
|
1957
|
+
alignItems: "center",
|
|
1958
|
+
flexWrap: "wrap",
|
|
1959
|
+
gap: 1,
|
|
1960
|
+
pt: 1.25,
|
|
1961
|
+
pb: 0.5
|
|
1962
|
+
},
|
|
1963
|
+
"data-cy": "outline-filter-bar",
|
|
1964
|
+
children: [/*#__PURE__*/_jsx(Typography, {
|
|
1965
|
+
variant: "body2",
|
|
1966
|
+
sx: {
|
|
1967
|
+
fontWeight: 600,
|
|
1968
|
+
mr: 0.5,
|
|
1969
|
+
whiteSpace: "nowrap"
|
|
1970
|
+
},
|
|
1971
|
+
children: filterCountText
|
|
1972
|
+
}), trimmedQuery && /*#__PURE__*/_jsx(Chip, {
|
|
1973
|
+
size: "small",
|
|
1974
|
+
icon: /*#__PURE__*/_jsx(Search, {}),
|
|
1975
|
+
label: `“${trimmedQuery}”`,
|
|
1976
|
+
onDelete: clearSearch
|
|
1977
|
+
}), isDifficultyFiltered && /*#__PURE__*/_jsx(Chip, {
|
|
1978
|
+
size: "small",
|
|
1979
|
+
icon: /*#__PURE__*/_jsx(DifficultyIcon, {}),
|
|
1980
|
+
label: t(`card_${difficulty}`),
|
|
1981
|
+
onDelete: () => setDifficulty(ALL_DIFFICULTIES)
|
|
1982
|
+
}), /*#__PURE__*/_jsx(Button, {
|
|
1983
|
+
size: "small",
|
|
1984
|
+
onClick: clearFilters,
|
|
1985
|
+
children: t("clear_all")
|
|
1986
|
+
})]
|
|
1987
|
+
})]
|
|
1988
|
+
}), isEmptyResult && /*#__PURE__*/_jsxs(Box, {
|
|
1989
|
+
sx: {
|
|
1990
|
+
display: "flex",
|
|
1991
|
+
flexDirection: "column",
|
|
1992
|
+
alignItems: "center",
|
|
1993
|
+
gap: 1,
|
|
1994
|
+
pt: 6,
|
|
1995
|
+
pb: 5,
|
|
1996
|
+
textAlign: "center"
|
|
1997
|
+
},
|
|
1998
|
+
"data-cy": "outline-empty-state",
|
|
1999
|
+
children: [/*#__PURE__*/_jsx(SearchOff, {
|
|
2000
|
+
sx: {
|
|
2001
|
+
fontSize: 40,
|
|
2002
|
+
color: "action.disabled"
|
|
2003
|
+
}
|
|
2004
|
+
}), /*#__PURE__*/_jsx(Typography, {
|
|
2005
|
+
variant: "subtitle1",
|
|
2006
|
+
sx: {
|
|
2007
|
+
fontWeight: "fontWeightBold"
|
|
2008
|
+
},
|
|
2009
|
+
children: t("no_sections_or_topics_match")
|
|
2010
|
+
}), /*#__PURE__*/_jsx(Typography, {
|
|
2011
|
+
variant: "body2",
|
|
2012
|
+
color: "textSecondary",
|
|
2013
|
+
children: trimmedQuery && isDifficultyFiltered ? t("nothing_matches_keyword_at_difficulty", {
|
|
2014
|
+
keyword: trimmedQuery,
|
|
2015
|
+
difficulty: t(`card_${difficulty}`)
|
|
2016
|
+
}) : t("try_different_keyword_or_difficulty")
|
|
2017
|
+
}), /*#__PURE__*/_jsx(Button, {
|
|
2018
|
+
variant: "outlined",
|
|
2019
|
+
sx: {
|
|
2020
|
+
mt: 1
|
|
2021
|
+
},
|
|
2022
|
+
onClick: clearFilters,
|
|
2023
|
+
children: t("clear_filters")
|
|
2024
|
+
})]
|
|
1733
2025
|
}), /*#__PURE__*/_jsx(SectionList, {
|
|
1734
2026
|
t: t,
|
|
1735
|
-
sections: sects,
|
|
2027
|
+
sections: filtered ? filtered.sections : sects,
|
|
1736
2028
|
completions: completions,
|
|
1737
2029
|
isMember: isMember,
|
|
1738
2030
|
courseId: courseId,
|
|
@@ -1771,7 +2063,9 @@ function CourseOutline({
|
|
|
1771
2063
|
teachClassrooms: teachClassrooms,
|
|
1772
2064
|
...otherProps,
|
|
1773
2065
|
loading: loading,
|
|
1774
|
-
disableDrag: disableDrag,
|
|
2066
|
+
disableDrag: disableDrag || isFiltering,
|
|
2067
|
+
highlightDifficulty: isFiltering && isDifficultyFiltered ? difficulty : undefined,
|
|
2068
|
+
hideAddTopic: isFiltering,
|
|
1775
2069
|
courseSettings: courseSettings,
|
|
1776
2070
|
showViewTopicButton: showViewTopicButton,
|
|
1777
2071
|
isClassroomArchived: isClassroomArchived,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { render } from "@testing-library/react";
|
|
2
|
-
import { describe, it, expect, vi } from "vitest";
|
|
1
|
+
import { render, screen, fireEvent, within, waitFor, cleanup } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
3
3
|
import CourseOutline from "./CourseOutline";
|
|
4
4
|
import { props } from "./CourseOutline.stories";
|
|
5
5
|
import { DndProvider } from "react-dnd";
|
|
@@ -86,4 +86,211 @@ describe("CourseOutline", () => {
|
|
|
86
86
|
}));
|
|
87
87
|
unmount();
|
|
88
88
|
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Minimal i18next-like `t`: resolves `_one`/`_other` plurals and `{{var}}`
|
|
92
|
+
// interpolation so assertions can read real copy.
|
|
93
|
+
const strings = {
|
|
94
|
+
topic_count_one: "{{count}} topic",
|
|
95
|
+
topic_count_other: "{{count}} topics",
|
|
96
|
+
section_count_one: "{{count}} section",
|
|
97
|
+
section_count_other: "{{count}} sections",
|
|
98
|
+
outline_filter_count: "{{topics}} in {{sections}}",
|
|
99
|
+
x_of_y: "{{shown}} of {{total}}",
|
|
100
|
+
search_sections_and_topics: "Search sections and topics",
|
|
101
|
+
all: "All",
|
|
102
|
+
difficulty: "Difficulty",
|
|
103
|
+
clear_all: "Clear all",
|
|
104
|
+
clear_filters: "Clear filters",
|
|
105
|
+
no_sections_or_topics_match: "No sections or topics match",
|
|
106
|
+
try_different_keyword_or_difficulty: "Try a different keyword or difficulty.",
|
|
107
|
+
nothing_matches_keyword_at_difficulty: "Nothing matches “{{keyword}}” at {{difficulty}} difficulty. Try another keyword or difficulty."
|
|
108
|
+
};
|
|
109
|
+
const t = (key, options = {}) => {
|
|
110
|
+
const pluralKey = options.count !== undefined ? `${key}_${options.count === 1 ? "one" : "other"}` : key;
|
|
111
|
+
const template = strings[pluralKey] ?? strings[key] ?? key;
|
|
112
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, name) => options[name] ?? "");
|
|
113
|
+
};
|
|
114
|
+
const filterTopic = (id, name, description, difficulty, sectionId, idx, extra = {}) => ({
|
|
115
|
+
topicId: id,
|
|
116
|
+
topicName: name,
|
|
117
|
+
description,
|
|
118
|
+
difficulty,
|
|
119
|
+
sectionId,
|
|
120
|
+
idx,
|
|
121
|
+
roleplays: [],
|
|
122
|
+
bots: [],
|
|
123
|
+
completions: [],
|
|
124
|
+
...extra
|
|
125
|
+
});
|
|
126
|
+
const filterProps = (overrides = {}) => ({
|
|
127
|
+
...props,
|
|
128
|
+
t,
|
|
129
|
+
enableFilters: true,
|
|
130
|
+
isCreator: true,
|
|
131
|
+
isMember: false,
|
|
132
|
+
courseId: "course-1",
|
|
133
|
+
sections: [{
|
|
134
|
+
sectionId: "s1",
|
|
135
|
+
sectionName: "Unidad 1 · Identidad",
|
|
136
|
+
idx: 0,
|
|
137
|
+
topics: [filterTopic("t1", "Una canción", "Escucha la canción", "A2", "s1", 0), filterTopic("t2", "Perífrasis verbales", "Practica", "B1", "s1", 1)]
|
|
138
|
+
}, {
|
|
139
|
+
sectionId: "s2",
|
|
140
|
+
sectionName: "Unidad 3 · Viajes",
|
|
141
|
+
idx: 1,
|
|
142
|
+
topics: [filterTopic("t3", "En el aeropuerto", "Vocabulario del aeropuerto", "A2", "s2", 0), filterTopic("t4", "¿Adónde vas?", "Planes", "B1", "s2", 1), filterTopic("t5", "Secreto", "Vocabulario oculto", "C1", "s2", 2, {
|
|
143
|
+
isTopicHidden: true
|
|
144
|
+
})]
|
|
145
|
+
}],
|
|
146
|
+
...overrides
|
|
147
|
+
});
|
|
148
|
+
const topicNames = container => [...container.querySelectorAll('[data-cy="topic-list-item"]')].map(item => item.querySelector(".MuiListItemText-primary").textContent);
|
|
149
|
+
const search = value => fireEvent.change(screen.getByLabelText("Search sections and topics"), {
|
|
150
|
+
target: {
|
|
151
|
+
value
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
const selectDifficulty = label => {
|
|
155
|
+
fireEvent.mouseDown(screen.getByRole("combobox", {
|
|
156
|
+
name: "Difficulty"
|
|
157
|
+
}));
|
|
158
|
+
fireEvent.click(within(screen.getByRole("listbox")).getByText(label));
|
|
159
|
+
};
|
|
160
|
+
describe("CourseOutline filters", () => {
|
|
161
|
+
afterEach(cleanup);
|
|
162
|
+
it("does not render the filters unless enableFilters is set", () => {
|
|
163
|
+
renderOutline(filterProps({
|
|
164
|
+
enableFilters: false
|
|
165
|
+
}));
|
|
166
|
+
expect(screen.queryByLabelText("Search sections and topics")).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
it("only offers difficulties that exist in the course, in CEFR order", () => {
|
|
169
|
+
renderOutline(filterProps({
|
|
170
|
+
isCreator: false,
|
|
171
|
+
isMember: false
|
|
172
|
+
}));
|
|
173
|
+
fireEvent.mouseDown(screen.getByRole("combobox", {
|
|
174
|
+
name: "Difficulty"
|
|
175
|
+
}));
|
|
176
|
+
const options = within(screen.getByRole("listbox")).getAllByRole("option").map(option => option.textContent);
|
|
177
|
+
// C1 only exists on a hidden topic, so a non-creator never sees it.
|
|
178
|
+
expect(options).toEqual(["All", "card_A2", "card_B1"]);
|
|
179
|
+
});
|
|
180
|
+
it("keeps every topic of a section whose name matches the keyword", async () => {
|
|
181
|
+
const {
|
|
182
|
+
container
|
|
183
|
+
} = renderOutline(filterProps());
|
|
184
|
+
search("viaje");
|
|
185
|
+
await waitFor(() => expect(topicNames(container)).toEqual(["En el aeropuerto", "¿Adónde vas?", "Secreto"]));
|
|
186
|
+
expect(container.querySelector('[data-cy="outline-filter-bar"]').textContent).toContain("3 topics in 1 section");
|
|
187
|
+
expect(screen.getByText("3 of 3")).toBeTruthy();
|
|
188
|
+
});
|
|
189
|
+
it("keeps a section whose name matches even when it has no visible topics", async () => {
|
|
190
|
+
const base = filterProps();
|
|
191
|
+
const sections = [...base.sections, {
|
|
192
|
+
sectionId: "s3",
|
|
193
|
+
sectionName: "Unidad 4 · Repaso",
|
|
194
|
+
idx: 2,
|
|
195
|
+
topics: []
|
|
196
|
+
}, {
|
|
197
|
+
sectionId: "s4",
|
|
198
|
+
sectionName: "Unidad 5 · Repaso final",
|
|
199
|
+
idx: 3,
|
|
200
|
+
topics: [filterTopic("t6", "Examen", "Oculto", "A2", "s4", 0, {
|
|
201
|
+
isTopicHidden: true
|
|
202
|
+
})]
|
|
203
|
+
}];
|
|
204
|
+
// Students cannot see t6, so both matching sections have no visible topics.
|
|
205
|
+
const {
|
|
206
|
+
container
|
|
207
|
+
} = renderOutline(filterProps({
|
|
208
|
+
sections,
|
|
209
|
+
isCreator: false,
|
|
210
|
+
isMember: false
|
|
211
|
+
}));
|
|
212
|
+
search("repaso");
|
|
213
|
+
await waitFor(() => expect(container.querySelectorAll('[data-cy="section-drop-zone"]')).toHaveLength(2));
|
|
214
|
+
expect(container.querySelector('[data-cy="outline-empty-state"]')).toBeNull();
|
|
215
|
+
expect(container.querySelector('[data-cy="outline-filter-bar"]').textContent).toContain("0 topics in 2 sections");
|
|
216
|
+
expect(screen.getAllByText("0 of 0")).toHaveLength(2);
|
|
217
|
+
|
|
218
|
+
// Difficulty only applies to topics, so with one selected the sections
|
|
219
|
+
// have nothing that matches.
|
|
220
|
+
selectDifficulty("card_A2");
|
|
221
|
+
expect(container.querySelector('[data-cy="outline-empty-state"]')).toBeTruthy();
|
|
222
|
+
});
|
|
223
|
+
it("matches topic descriptions and ignores accents", async () => {
|
|
224
|
+
const {
|
|
225
|
+
container
|
|
226
|
+
} = renderOutline(filterProps());
|
|
227
|
+
search("cancion");
|
|
228
|
+
await waitFor(() => expect(topicNames(container)).toEqual(["Una canción"]));
|
|
229
|
+
expect(screen.getByText("1 of 2")).toBeTruthy();
|
|
230
|
+
});
|
|
231
|
+
it("filters by difficulty across sections and combines with the keyword", async () => {
|
|
232
|
+
const {
|
|
233
|
+
container
|
|
234
|
+
} = renderOutline(filterProps());
|
|
235
|
+
selectDifficulty("card_A2");
|
|
236
|
+
expect(topicNames(container)).toEqual(["Una canción", "En el aeropuerto"]);
|
|
237
|
+
search("vocabulario");
|
|
238
|
+
await waitFor(() => expect(topicNames(container)).toEqual(["En el aeropuerto"]));
|
|
239
|
+
});
|
|
240
|
+
it("shows an empty state that clears both filters", async () => {
|
|
241
|
+
const {
|
|
242
|
+
container
|
|
243
|
+
} = renderOutline(filterProps());
|
|
244
|
+
selectDifficulty("card_B1");
|
|
245
|
+
search("subjuntivo");
|
|
246
|
+
await waitFor(() => expect(container.querySelector('[data-cy="outline-empty-state"]')).toBeTruthy());
|
|
247
|
+
expect(screen.getByText("Nothing matches “subjuntivo” at card_B1 difficulty. Try another keyword or difficulty.")).toBeTruthy();
|
|
248
|
+
fireEvent.click(screen.getByRole("button", {
|
|
249
|
+
name: "Clear filters"
|
|
250
|
+
}));
|
|
251
|
+
expect(topicNames(container)).toHaveLength(5);
|
|
252
|
+
expect(container.querySelector('[data-cy="outline-filter-bar"]')).toBeNull();
|
|
253
|
+
});
|
|
254
|
+
it("clears the keyword on Escape and through its chip", async () => {
|
|
255
|
+
const {
|
|
256
|
+
container
|
|
257
|
+
} = renderOutline(filterProps());
|
|
258
|
+
const input = screen.getByLabelText("Search sections and topics");
|
|
259
|
+
search("viaje");
|
|
260
|
+
await waitFor(() => expect(topicNames(container)).toHaveLength(3));
|
|
261
|
+
fireEvent.keyDown(input, {
|
|
262
|
+
key: "Escape"
|
|
263
|
+
});
|
|
264
|
+
expect(input.value).toBe("");
|
|
265
|
+
expect(topicNames(container)).toHaveLength(5);
|
|
266
|
+
search("viaje");
|
|
267
|
+
const chip = await screen.findByText("“viaje”");
|
|
268
|
+
fireEvent.click(chip.closest(".MuiChip-root").querySelector(".MuiChip-deleteIcon"));
|
|
269
|
+
expect(topicNames(container)).toHaveLength(5);
|
|
270
|
+
});
|
|
271
|
+
it("hides each section's Add new topic button while filtering", async () => {
|
|
272
|
+
const {
|
|
273
|
+
container
|
|
274
|
+
} = renderOutline(filterProps());
|
|
275
|
+
const addTopicButtons = () => container.querySelectorAll('[data-cy="create-topic"]');
|
|
276
|
+
expect(addTopicButtons()).toHaveLength(2);
|
|
277
|
+
search("viaje");
|
|
278
|
+
await waitFor(() => expect(addTopicButtons()).toHaveLength(0));
|
|
279
|
+
fireEvent.keyDown(screen.getByLabelText("Search sections and topics"), {
|
|
280
|
+
key: "Escape"
|
|
281
|
+
});
|
|
282
|
+
expect(addTopicButtons()).toHaveLength(2);
|
|
283
|
+
});
|
|
284
|
+
it("disables the drag handles while filtering", async () => {
|
|
285
|
+
renderOutline(filterProps());
|
|
286
|
+
const moveTopicButtons = () => screen.getAllByRole("button", {
|
|
287
|
+
name: "Move Topic"
|
|
288
|
+
});
|
|
289
|
+
expect(moveTopicButtons().every(button => !button.disabled)).toBe(true);
|
|
290
|
+
selectDifficulty("card_B1");
|
|
291
|
+
expect(moveTopicButtons().every(button => button.disabled)).toBe(true);
|
|
292
|
+
expect(screen.getAllByRole("button", {
|
|
293
|
+
name: "Move Section"
|
|
294
|
+
}).every(button => button.disabled)).toBe(true);
|
|
295
|
+
});
|
|
89
296
|
});
|
|
@@ -10,6 +10,8 @@ import ArrowForward from "@mui/icons-material/ArrowForward";
|
|
|
10
10
|
import AutoStories from "@mui/icons-material/AutoStories";
|
|
11
11
|
import SpaceBar from "@mui/icons-material/SpaceBar";
|
|
12
12
|
import Headphones from "@mui/icons-material/Headphones";
|
|
13
|
+
import Extension from "@mui/icons-material/Extension";
|
|
14
|
+
import EditNote from "@mui/icons-material/EditNote";
|
|
13
15
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
14
16
|
const styles = () => ({
|
|
15
17
|
inline: {
|
|
@@ -103,6 +105,19 @@ function Icon({
|
|
|
103
105
|
color: "primary"
|
|
104
106
|
});
|
|
105
107
|
}
|
|
108
|
+
if (exercise === "meaning") {
|
|
109
|
+
return /*#__PURE__*/_jsx(Extension, {
|
|
110
|
+
color: "primary"
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (exercise === "meaning-listen-and-speak") {
|
|
114
|
+
return /*#__PURE__*/_jsx(ActItOutListeningSpeaking, {});
|
|
115
|
+
}
|
|
116
|
+
if (exercise === "meaning-read-and-write") {
|
|
117
|
+
return /*#__PURE__*/_jsx(EditNote, {
|
|
118
|
+
color: "primary"
|
|
119
|
+
});
|
|
120
|
+
}
|
|
106
121
|
return null;
|
|
107
122
|
}
|
|
108
123
|
function EventItem({
|
|
@@ -191,7 +191,7 @@ function AnswerAttempt({
|
|
|
191
191
|
children: attempt.type
|
|
192
192
|
})
|
|
193
193
|
})]
|
|
194
|
-
}), !["pronunciation", "meaning", "roleplay-story", "roleplay-fill-in-the-blanks", "roleplay-act-it-out", "roleplay-act-it-out-listening", "roleplay-act-it-out-speaking", "roleplay-act-it-out-listening-speaking"].includes(exercise) && /*#__PURE__*/_jsx(TableCell, {
|
|
194
|
+
}), !["pronunciation", "meaning", "meaning-listen-and-speak", "meaning-read-and-write", "roleplay-story", "roleplay-fill-in-the-blanks", "roleplay-act-it-out", "roleplay-act-it-out-listening", "roleplay-act-it-out-speaking", "roleplay-act-it-out-listening-speaking"].includes(exercise) && /*#__PURE__*/_jsx(TableCell, {
|
|
195
195
|
align: "left",
|
|
196
196
|
children: /*#__PURE__*/_jsx(Typography, {
|
|
197
197
|
children: t(attempt.method)
|
|
@@ -287,7 +287,7 @@ function Attempt({
|
|
|
287
287
|
children: t("type")
|
|
288
288
|
})
|
|
289
289
|
})]
|
|
290
|
-
}), !["pronunciation", "meaning", "roleplay-story", "roleplay-fill-in-the-blanks", "roleplay-act-it-out", "roleplay-act-it-out-listening", "roleplay-act-it-out-speaking", "roleplay-act-it-out-listening-speaking"].includes(exercise) && /*#__PURE__*/_jsx(TableCell, {
|
|
290
|
+
}), !["pronunciation", "meaning", "meaning-listen-and-speak", "meaning-read-and-write", "roleplay-story", "roleplay-fill-in-the-blanks", "roleplay-act-it-out", "roleplay-act-it-out-listening", "roleplay-act-it-out-speaking", "roleplay-act-it-out-listening-speaking"].includes(exercise) && /*#__PURE__*/_jsx(TableCell, {
|
|
291
291
|
align: "left",
|
|
292
292
|
children: /*#__PURE__*/_jsx("b", {
|
|
293
293
|
children: t("method")
|
|
@@ -905,7 +905,8 @@ export default function useExerciseState({
|
|
|
905
905
|
setInAnswerOptions(false);
|
|
906
906
|
handleMakeAttempt(option, "option");
|
|
907
907
|
};
|
|
908
|
-
const handleCorrectQuestionAnswer = (question, answer) => {
|
|
908
|
+
const handleCorrectQuestionAnswer = (question, answer, answers = []) => {
|
|
909
|
+
const correctAnswerText = answer?.correctAnswer ?? answers.find(a => a?.correct)?.text;
|
|
909
910
|
// question time over
|
|
910
911
|
let updatedAnswerAttempts = [];
|
|
911
912
|
let method = "";
|
|
@@ -916,7 +917,7 @@ export default function useExerciseState({
|
|
|
916
917
|
question,
|
|
917
918
|
attempt: answer.text,
|
|
918
919
|
result: "correct",
|
|
919
|
-
answer:
|
|
920
|
+
answer: correctAnswerText,
|
|
920
921
|
method
|
|
921
922
|
}];
|
|
922
923
|
} else {
|
|
@@ -925,7 +926,7 @@ export default function useExerciseState({
|
|
|
925
926
|
question,
|
|
926
927
|
attempt: answer.text,
|
|
927
928
|
result: "correct",
|
|
928
|
-
answer:
|
|
929
|
+
answer: correctAnswerText,
|
|
929
930
|
method: null
|
|
930
931
|
}];
|
|
931
932
|
}
|
|
@@ -942,7 +943,8 @@ export default function useExerciseState({
|
|
|
942
943
|
}
|
|
943
944
|
}
|
|
944
945
|
};
|
|
945
|
-
const handleIncorrectQuestionAnswer = (question, answer) => {
|
|
946
|
+
const handleIncorrectQuestionAnswer = (question, answer, answers = []) => {
|
|
947
|
+
const correctAnswerText = answer?.correctAnswer ?? answers.find(a => a?.correct)?.text;
|
|
946
948
|
let method = "";
|
|
947
949
|
let updatedAnswerAttempts = [];
|
|
948
950
|
if (gameId === "fill-in-the-blanks" || gameId === "story") {
|
|
@@ -952,7 +954,7 @@ export default function useExerciseState({
|
|
|
952
954
|
question,
|
|
953
955
|
attempt: answer.text,
|
|
954
956
|
result: "incorrect",
|
|
955
|
-
answer:
|
|
957
|
+
answer: correctAnswerText,
|
|
956
958
|
method
|
|
957
959
|
}];
|
|
958
960
|
} else {
|
|
@@ -961,7 +963,7 @@ export default function useExerciseState({
|
|
|
961
963
|
question,
|
|
962
964
|
attempt: answer.text,
|
|
963
965
|
result: "incorrect",
|
|
964
|
-
answer:
|
|
966
|
+
answer: correctAnswerText,
|
|
965
967
|
method: null
|
|
966
968
|
}];
|
|
967
969
|
}
|
|
@@ -109,6 +109,13 @@ export default function useRecognition(props = {}) {
|
|
|
109
109
|
// Guards against a second click landing during the async start-up window,
|
|
110
110
|
// before recognizing/isRecognizingDisabled state has re-rendered the button.
|
|
111
111
|
const isStartingRef = useRef(false);
|
|
112
|
+
// stopRecording()'s delayed closeAll(). Replaced, not stacked, so a second
|
|
113
|
+
// stop() can't shut down a recording that started in between.
|
|
114
|
+
const scheduledStopRecordingRef = useRef(null);
|
|
115
|
+
// This hook's own handlers on the shared socket, so cleanup removes only
|
|
116
|
+
// these and never another mounted hook's.
|
|
117
|
+
const speechDataListenerRef = useRef(null);
|
|
118
|
+
const serverErrorListenerRef = useRef(null);
|
|
112
119
|
|
|
113
120
|
// Lets us query, e.g., how often students end up on the polling fallback
|
|
114
121
|
// (a proxy for "couldn't connect via websocket") or hit connect errors.
|
|
@@ -243,11 +250,20 @@ export default function useRecognition(props = {}) {
|
|
|
243
250
|
* worklet module (a source of noticeable start-up lag). Pass fullClose
|
|
244
251
|
* when the hook itself is unmounting and the context should be released.
|
|
245
252
|
*/
|
|
253
|
+
function removeSocketListeners() {
|
|
254
|
+
if (speechDataListenerRef.current) {
|
|
255
|
+
socket.off("speechData", speechDataListenerRef.current);
|
|
256
|
+
speechDataListenerRef.current = null;
|
|
257
|
+
}
|
|
258
|
+
if (serverErrorListenerRef.current) {
|
|
259
|
+
socket.off("serverError", serverErrorListenerRef.current);
|
|
260
|
+
serverErrorListenerRef.current = null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
246
263
|
function closeAll(keepSocketAlive, fullClose = false) {
|
|
247
264
|
// Clear the listeners (prevents issue if opening and closing repeatedly)
|
|
248
265
|
if (keepSocketAlive) {
|
|
249
|
-
|
|
250
|
-
socket.off("serverError");
|
|
266
|
+
removeSocketListeners();
|
|
251
267
|
}
|
|
252
268
|
let tracks = streamRef.current ? streamRef.current.getTracks() : null;
|
|
253
269
|
if (tracks && Array.isArray(tracks)) {
|
|
@@ -302,6 +318,9 @@ export default function useRecognition(props = {}) {
|
|
|
302
318
|
* the mic has been quiet for `silenceMs`. `{ silenceMs: number }`.
|
|
303
319
|
*/
|
|
304
320
|
const initRecording = async (gcloudParams, onData, onError, autoStopOptions) => {
|
|
321
|
+
// When speechData last brought back words for this recording - arms the
|
|
322
|
+
// silence auto-stop below.
|
|
323
|
+
let lastTranscriptAt = null;
|
|
305
324
|
try {
|
|
306
325
|
await setupAudioContext();
|
|
307
326
|
const handleSuccess = async function (stream) {
|
|
@@ -380,15 +399,14 @@ export default function useRecognition(props = {}) {
|
|
|
380
399
|
const firstAudioFrameTimeoutId = setTimeout(resolveFirstAudioFrame, 800);
|
|
381
400
|
|
|
382
401
|
// Silence auto-stop: each buffer's RMS volume says whether the mic
|
|
383
|
-
// heard
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
// the recording exactly like clicking the mic
|
|
402
|
+
// heard sound just now. The timer only arms once speechData has
|
|
403
|
+
// returned words - a loud buffer alone can be the tail of the bot's
|
|
404
|
+
// audio or the mic opening, which would cut the student off before
|
|
405
|
+
// they begin. After that, `silenceMs` with no loud buffers and no new
|
|
406
|
+
// words auto-stops the recording exactly like clicking the mic would.
|
|
388
407
|
const silenceMs = autoStopOptions?.silenceMs;
|
|
389
408
|
const SILENCE_RMS_THRESHOLD = 0.02;
|
|
390
409
|
let lastLoudAt = Date.now();
|
|
391
|
-
let hasHeardSpeech = false;
|
|
392
410
|
let hasAutoStopped = false;
|
|
393
411
|
processorRef.current.port.onmessage = event => {
|
|
394
412
|
clearTimeout(firstAudioFrameTimeoutId);
|
|
@@ -405,8 +423,7 @@ export default function useRecognition(props = {}) {
|
|
|
405
423
|
if (silenceMs) {
|
|
406
424
|
if (rms > SILENCE_RMS_THRESHOLD) {
|
|
407
425
|
lastLoudAt = Date.now();
|
|
408
|
-
|
|
409
|
-
} else if (hasHeardSpeech && !hasAutoStopped && Date.now() - lastLoudAt > silenceMs) {
|
|
426
|
+
} else if (lastTranscriptAt && !hasAutoStopped && Date.now() - Math.max(lastLoudAt, lastTranscriptAt) > silenceMs) {
|
|
410
427
|
hasAutoStopped = true;
|
|
411
428
|
stop();
|
|
412
429
|
}
|
|
@@ -506,19 +523,26 @@ export default function useRecognition(props = {}) {
|
|
|
506
523
|
recognitionAlert("Speech recognition not supported, please try another browser.");
|
|
507
524
|
}
|
|
508
525
|
|
|
509
|
-
// Bind the data handler callback
|
|
526
|
+
// Bind the data handler callback. A failed getUserMedia leaves the last
|
|
527
|
+
// attempt's handlers attached, so drop them before replacing the refs.
|
|
528
|
+
removeSocketListeners();
|
|
510
529
|
if (onData) {
|
|
511
|
-
|
|
530
|
+
speechDataListenerRef.current = data => {
|
|
531
|
+
if (data?.results?.some(result => result?.alternatives?.[0]?.transcript?.trim())) {
|
|
532
|
+
lastTranscriptAt = Date.now();
|
|
533
|
+
}
|
|
512
534
|
onData(data);
|
|
513
|
-
}
|
|
535
|
+
};
|
|
536
|
+
socket.on("speechData", speechDataListenerRef.current);
|
|
514
537
|
}
|
|
515
|
-
|
|
538
|
+
serverErrorListenerRef.current = error => {
|
|
516
539
|
console.error(`There was an issue on the server`, error);
|
|
517
540
|
serverErrorRef.current = true;
|
|
518
541
|
if (onError) {
|
|
519
542
|
onError(error);
|
|
520
543
|
}
|
|
521
|
-
}
|
|
544
|
+
};
|
|
545
|
+
socket.on("serverError", serverErrorListenerRef.current);
|
|
522
546
|
} catch (error) {
|
|
523
547
|
isStartingRef.current = false;
|
|
524
548
|
setRecognitionState(prevState => ({
|
|
@@ -568,23 +592,48 @@ export default function useRecognition(props = {}) {
|
|
|
568
592
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
569
593
|
mediaRecorderRef.current.stop();
|
|
570
594
|
}
|
|
571
|
-
|
|
572
|
-
|
|
595
|
+
const scheduledStopRecording = scheduledStopRecordingRef.current;
|
|
596
|
+
if (scheduledStopRecording) {
|
|
597
|
+
clearTimeout(scheduledStopRecording.timeoutId);
|
|
598
|
+
}
|
|
599
|
+
// Carry over what the cancelled closeAll() would have done: its listener
|
|
600
|
+
// removal, and its no-transcript check against its own transcripts.
|
|
601
|
+
const shouldRemoveListeners = keepSocketAlive || scheduledStopRecording?.shouldRemoveListeners;
|
|
602
|
+
const {
|
|
603
|
+
finalTranscript,
|
|
604
|
+
interimTranscript
|
|
605
|
+
} = recognitionState;
|
|
606
|
+
const shouldReportNoTranscript = scheduledStopRecording?.shouldReportNoTranscript || triggerEvent && (!finalTranscript || finalTranscript === "") && (!interimTranscript || interimTranscript === "");
|
|
607
|
+
const timeoutId = setTimeout(() => {
|
|
608
|
+
scheduledStopRecordingRef.current = null;
|
|
609
|
+
closeAll(shouldRemoveListeners, fullClose);
|
|
573
610
|
setRecognitionState(prevState => ({
|
|
574
611
|
...prevState,
|
|
575
612
|
isRecognizingDisabled: false
|
|
576
613
|
}));
|
|
577
|
-
|
|
578
|
-
finalTranscript,
|
|
579
|
-
interimTranscript
|
|
580
|
-
} = recognitionState;
|
|
581
|
-
if (triggerEvent && (!finalTranscript || finalTranscript === "") && (!interimTranscript || interimTranscript === "")) {
|
|
614
|
+
if (shouldReportNoTranscript) {
|
|
582
615
|
captureSpeechRecognitionNoTranscript().catch(console.error);
|
|
583
616
|
}
|
|
584
617
|
}, 2000);
|
|
618
|
+
scheduledStopRecordingRef.current = {
|
|
619
|
+
timeoutId,
|
|
620
|
+
shouldRemoveListeners,
|
|
621
|
+
shouldReportNoTranscript
|
|
622
|
+
};
|
|
585
623
|
};
|
|
586
624
|
useEffect(() => {
|
|
587
625
|
return () => {
|
|
626
|
+
// Finish a pending stop now rather than on a timer that outlives this
|
|
627
|
+
// hook, and drop this hook's listeners while we still can.
|
|
628
|
+
const scheduledStopRecording = scheduledStopRecordingRef.current;
|
|
629
|
+
if (scheduledStopRecording) {
|
|
630
|
+
clearTimeout(scheduledStopRecording.timeoutId);
|
|
631
|
+
scheduledStopRecordingRef.current = null;
|
|
632
|
+
if (scheduledStopRecording.shouldReportNoTranscript) {
|
|
633
|
+
captureSpeechRecognitionNoTranscript().catch(console.error);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
removeSocketListeners();
|
|
588
637
|
stopRecording(false, false, true);
|
|
589
638
|
};
|
|
590
639
|
}, []);
|