@nualang/nualang-ui-components 0.1.1421 → 0.1.1423
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/Exercises/Bot/Bot.js +5 -1
- package/dist/Exercises/Roleplay/RoleplayGameOptions/RoleplayGame/RoleplayGame.js +8 -3
- package/dist/Lists/CourseOutline/CourseOutline.js +232 -23
- package/dist/Lists/CourseOutline/CourseOutline.test.js +74 -8
- package/dist/hooks/useRecognition.js +67 -10
- package/package.json +2 -2
|
@@ -101,7 +101,8 @@ function Bot({
|
|
|
101
101
|
aiChatbotConversationHistory,
|
|
102
102
|
isUserInternal,
|
|
103
103
|
handleStudentMisuse,
|
|
104
|
-
username
|
|
104
|
+
username,
|
|
105
|
+
trackRecommendedEvent
|
|
105
106
|
}) {
|
|
106
107
|
const theme = useTheme();
|
|
107
108
|
const {
|
|
@@ -121,6 +122,9 @@ function Bot({
|
|
|
121
122
|
browserSupportsSpeechRecognition,
|
|
122
123
|
clearAttemptAudio
|
|
123
124
|
} = useRecognition({
|
|
125
|
+
trackRecommendedEvent,
|
|
126
|
+
exerciseName,
|
|
127
|
+
learnLang,
|
|
124
128
|
openSnackbar
|
|
125
129
|
});
|
|
126
130
|
const {
|
|
@@ -119,10 +119,11 @@ function RoleplayGame({
|
|
|
119
119
|
classes
|
|
120
120
|
} = useStyles();
|
|
121
121
|
const theme = useTheme();
|
|
122
|
+
const isDisabled = isGameHidden || isRoleplayEmpty;
|
|
122
123
|
return /*#__PURE__*/_jsxs(ListItem, {
|
|
123
124
|
alignItems: "flex-start",
|
|
124
|
-
disabled:
|
|
125
|
-
onClick: authenticated ? () => handleSelectRoleplayGame(gameId) : () => handleSignIn(),
|
|
125
|
+
disabled: isDisabled,
|
|
126
|
+
onClick: isDisabled ? undefined : authenticated ? () => handleSelectRoleplayGame(gameId) : () => handleSignIn(),
|
|
126
127
|
button: true,
|
|
127
128
|
divider: true,
|
|
128
129
|
classes: {
|
|
@@ -143,7 +144,11 @@ function RoleplayGame({
|
|
|
143
144
|
},
|
|
144
145
|
"&:focus": {
|
|
145
146
|
backgroundColor: "transparent"
|
|
146
|
-
}
|
|
147
|
+
},
|
|
148
|
+
...(isDisabled && {
|
|
149
|
+
opacity: 0.5,
|
|
150
|
+
pointerEvents: "none"
|
|
151
|
+
})
|
|
147
152
|
},
|
|
148
153
|
children: [/*#__PURE__*/_jsx(ListItemAvatar, {
|
|
149
154
|
children: gameIcon ? /*#__PURE__*/_jsx(Avatar, {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState, useCallback, useRef } 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 } from "@mui/material";
|
|
3
|
+
import { ListSubheader, List, ListItem, ListItemIcon, ListItemText, ListItemAvatar, ListItemSecondaryAction, IconButton, Button, Collapse, Typography, Box, Tooltip, Menu, MenuItem, Avatar, Chip } 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";
|
|
@@ -26,6 +26,7 @@ import CopyIcon from "@mui/icons-material/FileCopy";
|
|
|
26
26
|
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
|
+
import DifficultyIcon from "@mui/icons-material/School";
|
|
29
30
|
import ColorLinearProgress from "../../Misc/ColorLinearProgress/ColorLinearProgress";
|
|
30
31
|
import { calcCompletions, calcPercentageCompletion } from "../../utils/index";
|
|
31
32
|
import SelectClassroom from "../../Dialogs/SelectClassroom/SelectClassroom";
|
|
@@ -336,7 +337,10 @@ function Topic({
|
|
|
336
337
|
sectionIndex,
|
|
337
338
|
index,
|
|
338
339
|
moveTopic,
|
|
340
|
+
moveTopicAcrossSections,
|
|
339
341
|
onDropSection,
|
|
342
|
+
onMoveTopicToSection,
|
|
343
|
+
onCancelMove,
|
|
340
344
|
disableDrag,
|
|
341
345
|
classroomId,
|
|
342
346
|
courseSettings,
|
|
@@ -376,14 +380,34 @@ function Topic({
|
|
|
376
380
|
const [{
|
|
377
381
|
handlerId
|
|
378
382
|
}, drop] = useDrop({
|
|
379
|
-
type
|
|
380
|
-
|
|
383
|
+
// A single shared type lets a topic be dropped into any section, not just
|
|
384
|
+
// its own. Cross-section vs within-section is disambiguated in `hover`.
|
|
385
|
+
accept: "topic",
|
|
386
|
+
collect: monitor => ({
|
|
387
|
+
handlerId: monitor.getHandlerId()
|
|
388
|
+
}),
|
|
381
389
|
hover(item, monitor) {
|
|
382
390
|
if (!ref.current) {
|
|
383
391
|
return;
|
|
384
392
|
}
|
|
385
|
-
const dragIndex = item.index;
|
|
386
393
|
const hoverIndex = index;
|
|
394
|
+
|
|
395
|
+
// Cross-section move: the dragged topic originates in another section.
|
|
396
|
+
if (item.sectionIndex !== sectionIndex && moveTopicAcrossSections) {
|
|
397
|
+
// Insert before or after this topic depending on cursor position.
|
|
398
|
+
const hoverBoundingRect = ref.current.getBoundingClientRect();
|
|
399
|
+
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
|
400
|
+
const clientOffset = monitor.getClientOffset();
|
|
401
|
+
if (!clientOffset) return;
|
|
402
|
+
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
|
403
|
+
const targetIndex = hoverClientY < hoverMiddleY ? hoverIndex : hoverIndex + 1;
|
|
404
|
+
moveTopicAcrossSections(item.sectionIndex, item.topicId, sectionIndex, targetIndex);
|
|
405
|
+
item.sectionId = sectionId;
|
|
406
|
+
item.sectionIndex = sectionIndex;
|
|
407
|
+
item.index = targetIndex;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const dragIndex = item.index;
|
|
387
411
|
// Don't replace items with themselves
|
|
388
412
|
if (dragIndex === hoverIndex) {
|
|
389
413
|
return;
|
|
@@ -419,11 +443,18 @@ function Topic({
|
|
|
419
443
|
const [{
|
|
420
444
|
isDragging
|
|
421
445
|
}, drag] = useDrag({
|
|
422
|
-
type:
|
|
423
|
-
|
|
446
|
+
type: "topic",
|
|
447
|
+
// Touch is supported via react-dnd-multi-backend, so drag is not gated on
|
|
448
|
+
// screen size — only on being the course creator.
|
|
449
|
+
canDrag: isCreator && !disableDrag ? true : false,
|
|
424
450
|
item: {
|
|
425
451
|
topicId,
|
|
426
452
|
index,
|
|
453
|
+
sectionId,
|
|
454
|
+
sectionIndex,
|
|
455
|
+
// Immutable origin, used on drop to detect a re-parent and to persist it.
|
|
456
|
+
originSectionId: sectionId,
|
|
457
|
+
originSectionIndex: sectionIndex,
|
|
427
458
|
primary: topicName,
|
|
428
459
|
secondary: description,
|
|
429
460
|
image: picture
|
|
@@ -431,8 +462,23 @@ function Topic({
|
|
|
431
462
|
collect: monitor => ({
|
|
432
463
|
isDragging: monitor.isDragging()
|
|
433
464
|
}),
|
|
434
|
-
end: (
|
|
435
|
-
|
|
465
|
+
end: (item, monitor) => {
|
|
466
|
+
// Dropped outside any section (or cancelled with Esc) — revert.
|
|
467
|
+
if (!monitor.didDrop()) {
|
|
468
|
+
if (onCancelMove) onCancelMove();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (item && item.originSectionId && item.originSectionId !== item.sectionId && onMoveTopicToSection) {
|
|
472
|
+
// The topic changed section during the drag — persist the re-parent.
|
|
473
|
+
onMoveTopicToSection({
|
|
474
|
+
sourceSectionId: item.originSectionId,
|
|
475
|
+
targetSectionId: item.sectionId,
|
|
476
|
+
topicId: item.topicId
|
|
477
|
+
});
|
|
478
|
+
} else {
|
|
479
|
+
// Same-section reorder — persist the new order.
|
|
480
|
+
onDropSection();
|
|
481
|
+
}
|
|
436
482
|
}
|
|
437
483
|
});
|
|
438
484
|
const opacity = isDragging ? 0 : 1;
|
|
@@ -505,7 +551,35 @@ function Topic({
|
|
|
505
551
|
alignItems: "center",
|
|
506
552
|
mt: 1
|
|
507
553
|
},
|
|
508
|
-
children: [topic?.
|
|
554
|
+
children: [topic?.difficulty && /*#__PURE__*/_jsxs(Box, {
|
|
555
|
+
sx: {
|
|
556
|
+
display: "inline-flex",
|
|
557
|
+
alignItems: "center",
|
|
558
|
+
gap: 0.5,
|
|
559
|
+
mr: 1
|
|
560
|
+
},
|
|
561
|
+
role: "group",
|
|
562
|
+
"aria-label": `${t("difficulty")}: ${t(`card_${topic.difficulty}`)}`,
|
|
563
|
+
children: [/*#__PURE__*/_jsx(DifficultyIcon, {
|
|
564
|
+
fontSize: "small",
|
|
565
|
+
sx: {
|
|
566
|
+
marginRight: 1,
|
|
567
|
+
color: theme => theme.palette.text.secondary
|
|
568
|
+
}
|
|
569
|
+
}), /*#__PURE__*/_jsx(Chip, {
|
|
570
|
+
label: t(`card_${topic.difficulty}`),
|
|
571
|
+
size: "small",
|
|
572
|
+
sx: theme => ({
|
|
573
|
+
height: 24,
|
|
574
|
+
backgroundColor: theme.palette.action.selected,
|
|
575
|
+
color: theme.palette.text.secondary,
|
|
576
|
+
"& .MuiChip-label": {
|
|
577
|
+
fontSize: "0.75rem",
|
|
578
|
+
px: 0.75
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
})]
|
|
582
|
+
}), topic?.pdf && /*#__PURE__*/_jsx(Box, {
|
|
509
583
|
sx: {
|
|
510
584
|
display: "flex",
|
|
511
585
|
alignItems: "center"
|
|
@@ -789,6 +863,7 @@ function Section({
|
|
|
789
863
|
isCommunityAccessRestricted = false,
|
|
790
864
|
moveSection,
|
|
791
865
|
moveTopic,
|
|
866
|
+
moveTopicAcrossSections,
|
|
792
867
|
index,
|
|
793
868
|
onDropSection,
|
|
794
869
|
teachClassrooms,
|
|
@@ -922,8 +997,37 @@ function Section({
|
|
|
922
997
|
onDropSection();
|
|
923
998
|
}
|
|
924
999
|
});
|
|
1000
|
+
|
|
1001
|
+
// Section-level drop target for topics. Positioning within a non-empty
|
|
1002
|
+
// section is handled by the per-topic hover; this target handles dropping a
|
|
1003
|
+
// topic onto an EMPTY section and drives the "valid drop target" highlight.
|
|
1004
|
+
const [{
|
|
1005
|
+
isTopicOver,
|
|
1006
|
+
canDropTopic,
|
|
1007
|
+
draggedTopicItem
|
|
1008
|
+
}, dropTopicIntoSection] = useDrop({
|
|
1009
|
+
accept: "topic",
|
|
1010
|
+
hover(item, monitor) {
|
|
1011
|
+
if (!monitor.isOver({
|
|
1012
|
+
shallow: true
|
|
1013
|
+
})) return;
|
|
1014
|
+
if (item.sectionIndex === index) return;
|
|
1015
|
+
if ((topics || []).length > 0) return; // non-empty: topic hover handles it
|
|
1016
|
+
if (!moveTopicAcrossSections) return;
|
|
1017
|
+
moveTopicAcrossSections(item.sectionIndex, item.topicId, index, 0);
|
|
1018
|
+
item.sectionId = sectionId;
|
|
1019
|
+
item.sectionIndex = index;
|
|
1020
|
+
item.index = 0;
|
|
1021
|
+
},
|
|
1022
|
+
collect: monitor => ({
|
|
1023
|
+
isTopicOver: monitor.isOver(),
|
|
1024
|
+
canDropTopic: monitor.canDrop(),
|
|
1025
|
+
draggedTopicItem: monitor.getItem()
|
|
1026
|
+
})
|
|
1027
|
+
});
|
|
1028
|
+
const isTopicDropHighlight = isTopicOver && canDropTopic && draggedTopicItem && draggedTopicItem.originSectionId !== sectionId;
|
|
925
1029
|
const opacity = isDragging ? 0 : 1;
|
|
926
|
-
drag(drop(ref));
|
|
1030
|
+
drag(dropTopicIntoSection(drop(ref)));
|
|
927
1031
|
const [anchorEl, setAnchorEl] = useState(null);
|
|
928
1032
|
const handleClickMenu = event => {
|
|
929
1033
|
setAnchorEl(event.currentTarget);
|
|
@@ -947,6 +1051,14 @@ function Section({
|
|
|
947
1051
|
style: {
|
|
948
1052
|
opacity
|
|
949
1053
|
},
|
|
1054
|
+
"data-cy": "section-drop-zone",
|
|
1055
|
+
sx: isTopicDropHighlight ? {
|
|
1056
|
+
outline: theme => `2px dashed ${theme.palette.primary.main}`,
|
|
1057
|
+
outlineOffset: "-4px",
|
|
1058
|
+
borderRadius: 1,
|
|
1059
|
+
backgroundColor: theme => alpha(theme.palette.primary.main, 0.06),
|
|
1060
|
+
transition: "background-color 120ms ease"
|
|
1061
|
+
} : undefined,
|
|
950
1062
|
component: "nav",
|
|
951
1063
|
"aria-labelledby": "section-list-subheader",
|
|
952
1064
|
subheader: /*#__PURE__*/_jsx(ListSubheader, {
|
|
@@ -1139,6 +1251,7 @@ function Section({
|
|
|
1139
1251
|
handleUpdateSectionTopic: handleUpdateSectionTopic,
|
|
1140
1252
|
handleDuplicateTopic: handleDuplicateTopic,
|
|
1141
1253
|
moveTopic: moveTopic,
|
|
1254
|
+
moveTopicAcrossSections: moveTopicAcrossSections,
|
|
1142
1255
|
isMember: isMember,
|
|
1143
1256
|
disableDrag: disableDrag,
|
|
1144
1257
|
courseSettings: courseSettings,
|
|
@@ -1341,6 +1454,7 @@ function CourseOutline({
|
|
|
1341
1454
|
handleUpdateSections,
|
|
1342
1455
|
handleDeleteSectionVideo,
|
|
1343
1456
|
handleUpdateTopics,
|
|
1457
|
+
handleMoveTopic,
|
|
1344
1458
|
isPublic,
|
|
1345
1459
|
handleSwitchChange,
|
|
1346
1460
|
isLoading,
|
|
@@ -1360,6 +1474,13 @@ function CourseOutline({
|
|
|
1360
1474
|
percentage: 0
|
|
1361
1475
|
});
|
|
1362
1476
|
const [sects, setSects] = useState([]);
|
|
1477
|
+
|
|
1478
|
+
// Mirror `sects` into a ref so drop handlers can read the latest optimistic
|
|
1479
|
+
// order without being re-created on every change.
|
|
1480
|
+
const sectsRef = useRef(sects);
|
|
1481
|
+
useEffect(() => {
|
|
1482
|
+
sectsRef.current = sects;
|
|
1483
|
+
}, [sects]);
|
|
1363
1484
|
useEffect(() => {
|
|
1364
1485
|
if (isMember) {
|
|
1365
1486
|
const sectionsWithProgress = calcCompletions(sections, completions, null, isChallengeModeStudent, isReadWriteModeStudent);
|
|
@@ -1386,21 +1507,106 @@ function CourseOutline({
|
|
|
1386
1507
|
setSects(newSections);
|
|
1387
1508
|
}, [sects]);
|
|
1388
1509
|
const moveTopic = useCallback((sectionIndex, dragIndex, hoverIndex) => {
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1510
|
+
// Functional update so rapid successive hover events always build on the
|
|
1511
|
+
// latest state and cannot clobber one another (which would desync the
|
|
1512
|
+
// drag item's index from the actual source order).
|
|
1513
|
+
setSects(current => {
|
|
1514
|
+
const section = current[sectionIndex];
|
|
1515
|
+
if (!section) return current;
|
|
1516
|
+
const dragTopic = (section.topics || [])[dragIndex];
|
|
1517
|
+
if (!dragTopic) return current;
|
|
1518
|
+
const newTopics = update(section.topics, {
|
|
1519
|
+
$splice: [[dragIndex, 1], [hoverIndex, 0, {
|
|
1520
|
+
...dragTopic,
|
|
1521
|
+
idx: hoverIndex
|
|
1522
|
+
}]]
|
|
1523
|
+
});
|
|
1524
|
+
return update(current, {
|
|
1525
|
+
$splice: [[sectionIndex, 1], [sectionIndex, 0, {
|
|
1526
|
+
...section,
|
|
1527
|
+
topics: newTopics
|
|
1528
|
+
}]]
|
|
1529
|
+
});
|
|
1395
1530
|
});
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1531
|
+
}, []);
|
|
1532
|
+
|
|
1533
|
+
// Optimistically move a topic from one section to another at a given hover
|
|
1534
|
+
// position, restamping its section reference and idx. Uses a functional
|
|
1535
|
+
// update so repeated drag-hover events always build on the latest state.
|
|
1536
|
+
const moveTopicAcrossSections = useCallback((fromSectionIndex, topicId, toSectionIndex, hoverIndex) => {
|
|
1537
|
+
setSects(current => {
|
|
1538
|
+
if (fromSectionIndex === toSectionIndex) return current;
|
|
1539
|
+
const fromSection = current[fromSectionIndex];
|
|
1540
|
+
const toSection = current[toSectionIndex];
|
|
1541
|
+
if (!fromSection || !toSection) return current;
|
|
1542
|
+
const fromTopics = fromSection.topics || [];
|
|
1543
|
+
const toTopics = toSection.topics || [];
|
|
1544
|
+
// Identify the dragged topic by its stable id, not by a positional
|
|
1545
|
+
// index: the drag item's index can drift during a fast drag, which
|
|
1546
|
+
// would otherwise re-parent the wrong (e.g. last) topic. Also makes
|
|
1547
|
+
// repeated hover fires idempotent once the topic has already moved.
|
|
1548
|
+
const movedTopic = fromTopics.find(tpc => tpc.topicId === topicId);
|
|
1549
|
+
if (!movedTopic) return current;
|
|
1550
|
+
const targetSectionId = toSection.sectionId;
|
|
1551
|
+
const newFromTopics = fromTopics.filter(tpc => tpc.topicId !== topicId);
|
|
1552
|
+
const clampedIndex = Math.max(0, Math.min(hoverIndex, toTopics.length));
|
|
1553
|
+
const repositionedTopic = {
|
|
1554
|
+
...movedTopic,
|
|
1555
|
+
sectionId: targetSectionId,
|
|
1556
|
+
courseSectionId: `${courseId}|${targetSectionId}`,
|
|
1557
|
+
idx: clampedIndex
|
|
1558
|
+
};
|
|
1559
|
+
const newToTopics = [...toTopics.slice(0, clampedIndex), repositionedTopic, ...toTopics.slice(clampedIndex)];
|
|
1560
|
+
return current.map((s, i) => {
|
|
1561
|
+
if (i === fromSectionIndex) return {
|
|
1562
|
+
...s,
|
|
1563
|
+
topics: newFromTopics
|
|
1564
|
+
};
|
|
1565
|
+
if (i === toSectionIndex) return {
|
|
1566
|
+
...s,
|
|
1567
|
+
topics: newToTopics
|
|
1568
|
+
};
|
|
1569
|
+
return s;
|
|
1570
|
+
});
|
|
1401
1571
|
});
|
|
1402
|
-
|
|
1403
|
-
|
|
1572
|
+
}, [courseId]);
|
|
1573
|
+
|
|
1574
|
+
// Revert the optimistic order back to the authoritative `sections` prop —
|
|
1575
|
+
// used when a drag is cancelled / dropped outside any section.
|
|
1576
|
+
const resetSects = useCallback(() => {
|
|
1577
|
+
if (isMember) {
|
|
1578
|
+
setSects(calcCompletions(sections, completions, null, isChallengeModeStudent, isReadWriteModeStudent));
|
|
1579
|
+
} else {
|
|
1580
|
+
setSects(sections);
|
|
1581
|
+
}
|
|
1582
|
+
}, [isMember, sections, completions, isChallengeModeStudent, isReadWriteModeStudent]);
|
|
1583
|
+
|
|
1584
|
+
// On drop of a re-parented topic, hand the source/target ordering to the
|
|
1585
|
+
// page-level handler so it can persist the move + reindex both sections.
|
|
1586
|
+
const dispatchMoveTopic = useCallback(({
|
|
1587
|
+
sourceSectionId,
|
|
1588
|
+
targetSectionId,
|
|
1589
|
+
topicId
|
|
1590
|
+
}) => {
|
|
1591
|
+
if (typeof handleMoveTopic !== "function") {
|
|
1592
|
+
resetSects();
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const current = sectsRef.current || [];
|
|
1596
|
+
const targetSection = current.find(s => s.sectionId === targetSectionId);
|
|
1597
|
+
const sourceSection = current.find(s => s.sectionId === sourceSectionId);
|
|
1598
|
+
if (!targetSection) return;
|
|
1599
|
+
const movedTopic = (targetSection.topics || []).find(topicItem => topicItem.topicId === topicId);
|
|
1600
|
+
if (!movedTopic) return;
|
|
1601
|
+
handleMoveTopic({
|
|
1602
|
+
courseId,
|
|
1603
|
+
sourceSectionId,
|
|
1604
|
+
targetSectionId,
|
|
1605
|
+
topic: movedTopic,
|
|
1606
|
+
targetTopics: targetSection.topics || [],
|
|
1607
|
+
sourceTopics: sourceSection ? sourceSection.topics || [] : []
|
|
1608
|
+
});
|
|
1609
|
+
}, [courseId, handleMoveTopic, resetSects]);
|
|
1404
1610
|
const {
|
|
1405
1611
|
percentage,
|
|
1406
1612
|
completed,
|
|
@@ -1534,6 +1740,9 @@ function CourseOutline({
|
|
|
1534
1740
|
isCreator: isCreator,
|
|
1535
1741
|
moveSection: moveSection,
|
|
1536
1742
|
moveTopic: moveTopic,
|
|
1743
|
+
moveTopicAcrossSections: moveTopicAcrossSections,
|
|
1744
|
+
onMoveTopicToSection: dispatchMoveTopic,
|
|
1745
|
+
onCancelMove: resetSects,
|
|
1537
1746
|
handleUpdateSections: handleUpdateSections,
|
|
1538
1747
|
handleUpdateTopics: handleUpdateTopics,
|
|
1539
1748
|
isPublic: isPublic,
|
|
@@ -1,22 +1,88 @@
|
|
|
1
1
|
import { render } from "@testing-library/react";
|
|
2
|
-
import { describe, it } from "vitest";
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
3
|
import CourseOutline from "./CourseOutline";
|
|
4
4
|
import { props } from "./CourseOutline.stories";
|
|
5
5
|
import { DndProvider } from "react-dnd";
|
|
6
6
|
import { TestBackend } from "react-dnd-test-backend";
|
|
7
7
|
import { MemoryRouter } from "react-router";
|
|
8
|
+
|
|
9
|
+
// Two sections — one populated, one empty — to exercise cross-section drag
|
|
10
|
+
// wiring (drop zones, draggable topics, re-parent handler threading). Full
|
|
11
|
+
// pointer-driven drag interaction is covered by the app-level Playwright spec,
|
|
12
|
+
// since react-dnd hover geometry relies on getBoundingClientRect, which returns
|
|
13
|
+
// zeros under jsdom.
|
|
8
14
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
15
|
+
const topic = (id, name, idx) => ({
|
|
16
|
+
topicId: id,
|
|
17
|
+
topicName: name,
|
|
18
|
+
idx,
|
|
19
|
+
sectionId: "section-a",
|
|
20
|
+
roleplays: [],
|
|
21
|
+
bots: [],
|
|
22
|
+
completions: []
|
|
23
|
+
});
|
|
24
|
+
const crossSectionProps = (overrides = {}) => ({
|
|
25
|
+
...props,
|
|
26
|
+
isCreator: true,
|
|
27
|
+
isMember: false,
|
|
28
|
+
courseId: "course-1",
|
|
29
|
+
sections: [{
|
|
30
|
+
sectionId: "section-a",
|
|
31
|
+
sectionName: "Section A",
|
|
32
|
+
idx: 0,
|
|
33
|
+
topics: [topic("topic-1", "Topic 1", 0), topic("topic-2", "Topic 2", 1)]
|
|
34
|
+
}, {
|
|
35
|
+
sectionId: "section-b",
|
|
36
|
+
sectionName: "Section B",
|
|
37
|
+
idx: 1,
|
|
38
|
+
topics: []
|
|
39
|
+
}],
|
|
40
|
+
handleMoveTopic: vi.fn(),
|
|
41
|
+
...overrides
|
|
42
|
+
});
|
|
43
|
+
const renderOutline = p => render(/*#__PURE__*/_jsx(MemoryRouter, {
|
|
44
|
+
children: /*#__PURE__*/_jsx(DndProvider, {
|
|
45
|
+
backend: TestBackend,
|
|
46
|
+
children: /*#__PURE__*/_jsx(CourseOutline, {
|
|
47
|
+
...p
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
}));
|
|
9
51
|
describe("CourseOutline", () => {
|
|
10
52
|
it("renders without crashing", () => {
|
|
11
53
|
const {
|
|
12
54
|
unmount
|
|
13
|
-
} =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
55
|
+
} = renderOutline(props);
|
|
56
|
+
unmount();
|
|
57
|
+
});
|
|
58
|
+
it("renders without crashing when cross-section drag handlers are provided", () => {
|
|
59
|
+
const {
|
|
60
|
+
unmount
|
|
61
|
+
} = renderOutline(crossSectionProps());
|
|
62
|
+
unmount();
|
|
63
|
+
});
|
|
64
|
+
it("renders a drop zone for every section so topics can be dropped between them", () => {
|
|
65
|
+
const {
|
|
66
|
+
container,
|
|
67
|
+
unmount
|
|
68
|
+
} = renderOutline(crossSectionProps());
|
|
69
|
+
// One drop zone per rendered section (source + target).
|
|
70
|
+
expect(container.querySelectorAll('[data-cy="section-drop-zone"]').length).toBe(2);
|
|
71
|
+
unmount();
|
|
72
|
+
});
|
|
73
|
+
it("renders each topic as a draggable list item for the course creator", () => {
|
|
74
|
+
const {
|
|
75
|
+
container,
|
|
76
|
+
unmount
|
|
77
|
+
} = renderOutline(crossSectionProps());
|
|
78
|
+
expect(container.querySelectorAll('[data-cy="topic-list-item"]').length).toBe(2);
|
|
79
|
+
unmount();
|
|
80
|
+
});
|
|
81
|
+
it("does not throw when handleMoveTopic is omitted (drag stays inert)", () => {
|
|
82
|
+
const {
|
|
83
|
+
unmount
|
|
84
|
+
} = renderOutline(crossSectionProps({
|
|
85
|
+
handleMoveTopic: undefined
|
|
20
86
|
}));
|
|
21
87
|
unmount();
|
|
22
88
|
});
|
|
@@ -9,14 +9,46 @@ const ENDPOINT = import.meta.env.REACT_APP_GCLOUD_SPEECH || "wss://speech-rec-se
|
|
|
9
9
|
const socket = io(ENDPOINT, {
|
|
10
10
|
withCredentials: true,
|
|
11
11
|
autoConnect: false,
|
|
12
|
-
transports: ["websocket", "polling"]
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
12
|
+
transports: ["websocket", "polling"],
|
|
13
|
+
// use WebSocket, fallback to polling if necessary
|
|
14
|
+
// Without this, when websocket is listed first and fails outright (e.g. a
|
|
15
|
+
// school firewall blocking the WS upgrade), engine.io does NOT fall back to
|
|
16
|
+
// polling for that attempt - it just fails the whole connect_error and retries
|
|
17
|
+
// websocket again on the next reconnection attempt, looping forever on
|
|
18
|
+
// networks that block websocket entirely.
|
|
19
|
+
tryAllTransports: true
|
|
18
20
|
});
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Builds a single diagnostic message for a connect_error analytics event.
|
|
24
|
+
* `error.message` for a WebSocket transport failure is (by design of the
|
|
25
|
+
* browser WebSocket API) always the generic string "websocket error" - the
|
|
26
|
+
* spec deliberately withholds the underlying reason (DNS/TLS/refused/timed
|
|
27
|
+
* out) from JS to avoid leaking network topology info to a page. `error.type`
|
|
28
|
+
* and `error.description` are the only other fields engine.io-client
|
|
29
|
+
* attaches, and for polling transport failures `description` is often the
|
|
30
|
+
* XHR object (so `description.status` gives the HTTP status code) - folded
|
|
31
|
+
* into one field here rather than several, since they're only ever useful
|
|
32
|
+
* together and only for this one event.
|
|
33
|
+
*/
|
|
34
|
+
function describeConnectError(error) {
|
|
35
|
+
const description = error?.description;
|
|
36
|
+
let descriptionDetail = "none";
|
|
37
|
+
if (description && typeof description === "object") {
|
|
38
|
+
if (typeof description.status === "number") {
|
|
39
|
+
descriptionDetail = `http_status_${description.status}`;
|
|
40
|
+
} else if (description.message) {
|
|
41
|
+
descriptionDetail = `${description.message}`;
|
|
42
|
+
}
|
|
43
|
+
} else if (description) {
|
|
44
|
+
descriptionDetail = `${description}`;
|
|
45
|
+
}
|
|
46
|
+
const transport = socket.io?.engine?.transport?.name;
|
|
47
|
+
return {
|
|
48
|
+
A4_error_message: `${error?.message} (type=${error?.type}, description=${descriptionDetail}, transport=${transport}, online=${navigator.onLine})`
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
20
52
|
// audioStream constraints
|
|
21
53
|
const constraints = {
|
|
22
54
|
audio: true,
|
|
@@ -24,6 +56,21 @@ const constraints = {
|
|
|
24
56
|
};
|
|
25
57
|
const version = ["v1", "v2"].includes(import.meta.env.REACT_APP_SPEECH_RECOGNITION_VERSION) ? import.meta.env.REACT_APP_SPEECH_RECOGNITION_VERSION : "v1";
|
|
26
58
|
const audioRecordingLimit = 60000;
|
|
59
|
+
|
|
60
|
+
// Safari (16.4+) exposes a page-global AudioSession whose category WebKit
|
|
61
|
+
// otherwise flips to playback-only when TTS/<audio> plays, after which
|
|
62
|
+
// getUserMedia throws "AudioSession category is not compatible with audio
|
|
63
|
+
// capture". Pinning it to "play-and-record" keeps mic capture available even
|
|
64
|
+
// after audio playback. No-op where the API is unsupported (Chrome/Firefox).
|
|
65
|
+
function ensurePlayAndRecordAudioSession() {
|
|
66
|
+
try {
|
|
67
|
+
if (typeof navigator !== "undefined" && navigator.audioSession) {
|
|
68
|
+
navigator.audioSession.type = "play-and-record";
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
logger.warn(`Failed to set audioSession type: ${error}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
27
74
|
export default function useRecognition(props = {}) {
|
|
28
75
|
const {
|
|
29
76
|
currentPhraseText,
|
|
@@ -98,6 +145,12 @@ export default function useRecognition(props = {}) {
|
|
|
98
145
|
throw e;
|
|
99
146
|
}
|
|
100
147
|
}
|
|
148
|
+
|
|
149
|
+
// Set as early as the exercise mounts, before any TTS playback can claim a
|
|
150
|
+
// playback-only session on Safari.
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
ensurePlayAndRecordAudioSession();
|
|
153
|
+
}, []);
|
|
101
154
|
useEffect(() => {
|
|
102
155
|
function onConnect() {
|
|
103
156
|
hasAlertedConnectionErrorRef.current = false;
|
|
@@ -105,9 +158,13 @@ export default function useRecognition(props = {}) {
|
|
|
105
158
|
// socket.io.engine.transport.name is "polling" here only when the
|
|
106
159
|
// websocket attempt failed and it fell back - the clearest signal we
|
|
107
160
|
// have that a student couldn't connect via websocket.
|
|
161
|
+
const transport = socket.io.engine.transport.name;
|
|
108
162
|
trackConnectionDiagnostics("speech_recognition_socket_connected", {
|
|
109
|
-
A4_transport:
|
|
163
|
+
A4_transport: transport
|
|
110
164
|
});
|
|
165
|
+
if (transport === "polling") {
|
|
166
|
+
recognitionAlert("Using a slower backup connection for speech - ask your school's IT team to allow WebSocket connections.", "warning");
|
|
167
|
+
}
|
|
111
168
|
}
|
|
112
169
|
function onDisconnect() {
|
|
113
170
|
setIsSocketConnected(false);
|
|
@@ -151,9 +208,7 @@ export default function useRecognition(props = {}) {
|
|
|
151
208
|
}
|
|
152
209
|
function onConnectError(error) {
|
|
153
210
|
console.error(error);
|
|
154
|
-
trackConnectionDiagnostics("speech_recognition_connect_error",
|
|
155
|
-
A4_error_message: `${error?.message}`
|
|
156
|
-
});
|
|
211
|
+
trackConnectionDiagnostics("speech_recognition_connect_error", describeConnectError(error));
|
|
157
212
|
// Without this, a blocked/failed connection (e.g. a school network
|
|
158
213
|
// firewall blocking the websocket) leaves the mic button silently
|
|
159
214
|
// disabled with no feedback to the student.
|
|
@@ -342,6 +397,8 @@ export default function useRecognition(props = {}) {
|
|
|
342
397
|
}
|
|
343
398
|
};
|
|
344
399
|
if (navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
400
|
+
// Re-assert in case TTS playback downgraded the session since mount.
|
|
401
|
+
ensurePlayAndRecordAudioSession();
|
|
345
402
|
navigator.mediaDevices.getUserMedia(constraints).then(handleSuccess).then(() => {
|
|
346
403
|
isStartingRef.current = false;
|
|
347
404
|
// socket.connected is checked directly (not the isSocketConnected
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nualang/nualang-ui-components",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1423",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"react-swipeable-views-utils": "^0.14.0",
|
|
84
84
|
"regenerator-runtime": "^0.14.1",
|
|
85
85
|
"rehype-raw": "^7.0.0",
|
|
86
|
-
"socket.io-client": "^4.
|
|
86
|
+
"socket.io-client": "^4.8.3",
|
|
87
87
|
"styled-components": "^6.3.8",
|
|
88
88
|
"tss-react": "^4.9.16",
|
|
89
89
|
"uifx": "^2.0",
|