@nualang/nualang-ui-components 0.1.1421 → 0.1.1422
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.
|
@@ -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 {
|
|
@@ -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
|
});
|