@nualang/nualang-ui-components 0.1.1411 → 0.1.1412
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/Dialogs/QuickCreateClassroomDialog/QuickCreateClassroomDialog.js +2 -2
- package/dist/Dialogs/QuickCreateCourseDialog/QuickCreateCourseDialog.js +2 -2
- package/dist/Dialogs/QuickCreateTopicDialog/QuickCreateTopicDialog.js +2 -2
- package/dist/Forms/ChipInput/ChipInput.js +17 -3
- package/dist/Forms/CreateClassroom/Steps/ClassroomSettings/ClassroomSettings.js +4 -1
- package/dist/Forms/UpdateClassroom/UpdateClassroom.js +3 -1
- package/dist/Lists/Courses/Courses.js +97 -7
- package/dist/Lists/Courses/Courses.test.js +61 -11
- package/dist/utils/courseSort.js +64 -0
- package/package.json +1 -1
|
@@ -133,7 +133,7 @@ export default function QuickCreateClassroomDialog({
|
|
|
133
133
|
return String(Math.floor(Math.random() * 90000) + 10000);
|
|
134
134
|
};
|
|
135
135
|
const handleSubmit = async () => {
|
|
136
|
-
if (!classroomName.trim() || !classroomDescription.trim()) return;
|
|
136
|
+
if (!classroomName.trim() || !classroomDescription.trim() || !classroomLearnLang || !classroomForLang) return;
|
|
137
137
|
setIsLoading(true);
|
|
138
138
|
try {
|
|
139
139
|
const createdClassroom = await handleCreate({
|
|
@@ -187,7 +187,7 @@ export default function QuickCreateClassroomDialog({
|
|
|
187
187
|
handleClose: isLoading ? undefined : resetAndClose,
|
|
188
188
|
handleSubmit: handleSubmit,
|
|
189
189
|
dialogTitle: t("create_classroom") || "Create Classroom",
|
|
190
|
-
isSubmitDisabled: !classroomName.trim() || !classroomDescription.trim() || isLoading,
|
|
190
|
+
isSubmitDisabled: !classroomName.trim() || !classroomDescription.trim() || !classroomLearnLang || !classroomForLang || isLoading,
|
|
191
191
|
closeText: t("cancel") || "Cancel",
|
|
192
192
|
submitText: t("create") || "Create",
|
|
193
193
|
PaperComponent: ClassroomDialogPaper,
|
|
@@ -133,7 +133,7 @@ export default function QuickCreateCourseDialog({
|
|
|
133
133
|
return String(Math.floor(Math.random() * 90000) + 10000);
|
|
134
134
|
};
|
|
135
135
|
const handleSubmit = async () => {
|
|
136
|
-
if (!courseName.trim() || !courseDescription.trim() || !difficulty) return;
|
|
136
|
+
if (!courseName.trim() || !courseDescription.trim() || !difficulty || !courseLearnLang || !courseForLang) return;
|
|
137
137
|
setIsLoading(true);
|
|
138
138
|
try {
|
|
139
139
|
const createdCourse = await handleCreate({
|
|
@@ -185,7 +185,7 @@ export default function QuickCreateCourseDialog({
|
|
|
185
185
|
handleClose: isLoading ? undefined : resetAndClose,
|
|
186
186
|
handleSubmit: handleSubmit,
|
|
187
187
|
dialogTitle: t("create_course") || "Create Course",
|
|
188
|
-
isSubmitDisabled: !courseName.trim() || !difficulty || isLoading,
|
|
188
|
+
isSubmitDisabled: !courseName.trim() || !courseDescription.trim() || !difficulty || !courseLearnLang || !courseForLang || isLoading,
|
|
189
189
|
closeText: t("cancel") || "Cancel",
|
|
190
190
|
submitText: t("create") || "Create",
|
|
191
191
|
PaperComponent: CourseDialogPaper,
|
|
@@ -142,7 +142,7 @@ export default function QuickCreateTopicDialog({
|
|
|
142
142
|
setPicture(inheritCourseImage ? coursePicture : "");
|
|
143
143
|
};
|
|
144
144
|
const handleSubmit = async () => {
|
|
145
|
-
if (!topicName.trim() || !topicDifficulty) return;
|
|
145
|
+
if (!topicName.trim() || !topicDifficulty || !learnLang || !forLang) return;
|
|
146
146
|
setIsLoading(true);
|
|
147
147
|
try {
|
|
148
148
|
const createdTopic = await handleCreate({
|
|
@@ -190,7 +190,7 @@ export default function QuickCreateTopicDialog({
|
|
|
190
190
|
handleClose: isLoading ? undefined : resetAndClose,
|
|
191
191
|
handleSubmit: handleSubmit,
|
|
192
192
|
dialogTitle: t("create_topic") || "Create Topic",
|
|
193
|
-
isSubmitDisabled: !topicName.trim() || !topicDifficulty || isLoading,
|
|
193
|
+
isSubmitDisabled: !topicName.trim() || !topicDifficulty || !learnLang || !forLang || isLoading,
|
|
194
194
|
closeText: t("cancel") || "Cancel",
|
|
195
195
|
submitText: t("create") || "Create",
|
|
196
196
|
PaperComponent: TopicDialogPaper,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from "react"; // already present, just ensure it's used
|
|
2
2
|
|
|
3
|
-
import CustomChipInput from "../../Misc/CustomChipInput/CustomChipInput";
|
|
3
|
+
import CustomChipInput, { DefaultChipRenderer } from "../../Misc/CustomChipInput/CustomChipInput";
|
|
4
4
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
5
5
|
function ChipInput(props) {
|
|
6
6
|
const {
|
|
@@ -11,6 +11,13 @@ function ChipInput(props) {
|
|
|
11
11
|
label,
|
|
12
12
|
delimiter = /[\s,]+/
|
|
13
13
|
} = props;
|
|
14
|
+
// `chipLabels` is a display-only map of { chipValue: label }. When provided,
|
|
15
|
+
// chips render the mapped label while their stored value stays unchanged.
|
|
16
|
+
// Strip it from the props spread so it doesn't leak onto the DOM node.
|
|
17
|
+
const {
|
|
18
|
+
chipLabels,
|
|
19
|
+
...rest
|
|
20
|
+
} = props;
|
|
14
21
|
const [errorMessage, setErrorMessage] = useState("");
|
|
15
22
|
const genericDomains = ["gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "icloud.com"];
|
|
16
23
|
const handleAddChip = chip => {
|
|
@@ -61,14 +68,21 @@ function ChipInput(props) {
|
|
|
61
68
|
}
|
|
62
69
|
});
|
|
63
70
|
};
|
|
71
|
+
const labelledChipRenderer = chipLabels ? (chipProps, key) => DefaultChipRenderer({
|
|
72
|
+
...chipProps,
|
|
73
|
+
text: chipLabels[chipProps.value] ?? chipProps.text
|
|
74
|
+
}, key) : undefined;
|
|
64
75
|
return /*#__PURE__*/_jsx(CustomChipInput, {
|
|
65
76
|
label: label,
|
|
66
|
-
...
|
|
77
|
+
...rest,
|
|
67
78
|
onAdd: handleAddChip,
|
|
68
79
|
onDelete: deleteChip,
|
|
69
80
|
delimiter: delimiter,
|
|
70
81
|
helperText: errorMessage || props.helperText,
|
|
71
|
-
error: !!errorMessage || props.error
|
|
82
|
+
error: !!errorMessage || props.error,
|
|
83
|
+
...(labelledChipRenderer ? {
|
|
84
|
+
chipRenderer: labelledChipRenderer
|
|
85
|
+
} : {})
|
|
72
86
|
});
|
|
73
87
|
}
|
|
74
88
|
export default ChipInput;
|
|
@@ -21,7 +21,8 @@ function ClassroomSettings({
|
|
|
21
21
|
setScrollToDiscuss,
|
|
22
22
|
scrollToDynamicResponses = false,
|
|
23
23
|
setScrollToDynamicResponses,
|
|
24
|
-
isVideoChatEnabled
|
|
24
|
+
isVideoChatEnabled,
|
|
25
|
+
collaboratorLabels
|
|
25
26
|
}) {
|
|
26
27
|
const {
|
|
27
28
|
visibility,
|
|
@@ -351,6 +352,7 @@ function ClassroomSettings({
|
|
|
351
352
|
"data-cy": "collaborators",
|
|
352
353
|
label: t("collaborators"),
|
|
353
354
|
value: collaborators || [],
|
|
355
|
+
chipLabels: collaboratorLabels,
|
|
354
356
|
name: "collaborators",
|
|
355
357
|
helperText: touched.collaborators ? t(errors.collaborators) : t("add_collaborator_tag"),
|
|
356
358
|
error: touched.collaborators && Boolean(errors.collaborators),
|
|
@@ -370,6 +372,7 @@ function ClassroomSettings({
|
|
|
370
372
|
"data-cy": "collaborators",
|
|
371
373
|
label: t("collaborators"),
|
|
372
374
|
value: collaborators || [],
|
|
375
|
+
chipLabels: collaboratorLabels,
|
|
373
376
|
name: "collaborators",
|
|
374
377
|
helperText: touched.collaborators ? errors.collaborators : t("add_collaborator_tag"),
|
|
375
378
|
error: touched.collaborators && Boolean(errors.collaborators),
|
|
@@ -45,7 +45,8 @@ export default function UpdateClassroom({
|
|
|
45
45
|
setScrollToDynamicResponses,
|
|
46
46
|
isClassroomArchived = false,
|
|
47
47
|
verificationStatus,
|
|
48
|
-
isVideoChatEnabled
|
|
48
|
+
isVideoChatEnabled,
|
|
49
|
+
collaboratorLabels
|
|
49
50
|
}) {
|
|
50
51
|
const {
|
|
51
52
|
classes
|
|
@@ -230,6 +231,7 @@ export default function UpdateClassroom({
|
|
|
230
231
|
scrollToDynamicResponses: scrollToDynamicResponses,
|
|
231
232
|
setScrollToDynamicResponses: setScrollToDynamicResponses,
|
|
232
233
|
isVideoChatEnabled: isVideoChatEnabled,
|
|
234
|
+
collaboratorLabels: collaboratorLabels,
|
|
233
235
|
...otherProps
|
|
234
236
|
})
|
|
235
237
|
}), /*#__PURE__*/_jsx(Divider, {}), /*#__PURE__*/_jsx(CardActions, {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
1
|
+
import { memo, useMemo, useState } from "react";
|
|
2
2
|
import PropTypes from "prop-types";
|
|
3
|
-
import { Grid } from "@mui/material";
|
|
3
|
+
import { Box, FormControl, Grid, InputLabel, MenuItem, Select } from "@mui/material";
|
|
4
4
|
import CourseCard from "../../Cards/Course/Course";
|
|
5
5
|
import useListScroll from "../../hooks/useListScroll";
|
|
6
6
|
import { SingleLineGridList, SingleLineGridListItem, GridListItem } from "../ListElements";
|
|
7
|
+
import { getStudentSortOrder, setStudentSortOrder } from "../../utils/studentSort";
|
|
8
|
+
import { CLASSROOM_COURSES_SORT_ORDER_KEY, compareCoursesBySortOrder, compareCoursesByLastVisit, getCourseVisits, recordCourseVisit } from "../../utils/courseSort";
|
|
7
9
|
|
|
8
10
|
// import { propsAreEqual } from '../../utils';
|
|
9
11
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
@@ -154,11 +156,99 @@ function CourseList({
|
|
|
154
156
|
})
|
|
155
157
|
});
|
|
156
158
|
}
|
|
157
|
-
function Courses(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
function Courses({
|
|
160
|
+
t = text => text,
|
|
161
|
+
courses,
|
|
162
|
+
showSortControl = false,
|
|
163
|
+
handleViewCourse,
|
|
164
|
+
handleStartCourse,
|
|
165
|
+
handleViewCourseTopic,
|
|
166
|
+
...otherProps
|
|
167
|
+
}) {
|
|
168
|
+
const [sortOrder, setSortOrder] = useState(() => getStudentSortOrder(CLASSROOM_COURSES_SORT_ORDER_KEY, "newest"));
|
|
169
|
+
// Snapshot taken on mount so opening a course doesn't reshuffle the list
|
|
170
|
+
// under the user mid-click; the fresh order shows on the way back in.
|
|
171
|
+
const [visits, setVisits] = useState(getCourseVisits);
|
|
172
|
+
const handleSortOrderChange = value => {
|
|
173
|
+
setSortOrder(value);
|
|
174
|
+
setStudentSortOrder(CLASSROOM_COURSES_SORT_ORDER_KEY, value);
|
|
175
|
+
// Switching to this order is an explicit ask for the current history,
|
|
176
|
+
// including any course opened since this list mounted.
|
|
177
|
+
if (value === "recentlyViewed") setVisits(getCourseVisits());
|
|
178
|
+
};
|
|
179
|
+
console.log("Courses.jsx: sortOrder:", sortOrder, "visits:", visits);
|
|
180
|
+
const trackedHandlers = useMemo(() => {
|
|
181
|
+
// Wraps a card action so opening a course records it as the most recent
|
|
182
|
+
// one. Handlers are passed through untouched when absent, since the card
|
|
183
|
+
// treats a missing handler as "this action isn't available".
|
|
184
|
+
const trackVisit = (handler, getCourseId) => typeof handler === "function" ? (...args) => {
|
|
185
|
+
recordCourseVisit(getCourseId(...args));
|
|
186
|
+
return handler(...args);
|
|
187
|
+
} : handler;
|
|
188
|
+
return {
|
|
189
|
+
handleViewCourse: trackVisit(handleViewCourse, courseId => courseId),
|
|
190
|
+
handleStartCourse: trackVisit(handleStartCourse, (course = {}) => course.courseId),
|
|
191
|
+
handleViewCourseTopic: trackVisit(handleViewCourseTopic, courseId => courseId)
|
|
192
|
+
};
|
|
193
|
+
}, [handleViewCourse, handleStartCourse, handleViewCourseTopic]);
|
|
194
|
+
const sortedCourses = useMemo(() => {
|
|
195
|
+
if (!courses) return courses;
|
|
196
|
+
// Newest-first is the baseline order; for recentlyViewed it also decides
|
|
197
|
+
// where never-opened courses land, since the visit sort is stable.
|
|
198
|
+
const newestFirst = [...courses].reverse();
|
|
199
|
+
if (sortOrder === "newest") return newestFirst;
|
|
200
|
+
if (sortOrder === "recentlyViewed") {
|
|
201
|
+
return newestFirst.sort((a, b) => compareCoursesByLastVisit(a, b, visits));
|
|
202
|
+
}
|
|
203
|
+
return [...courses].sort((a, b) => compareCoursesBySortOrder(a, b, sortOrder));
|
|
204
|
+
}, [courses, sortOrder, visits]);
|
|
205
|
+
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
206
|
+
children: [showSortControl && courses && courses.length > 1 && /*#__PURE__*/_jsx(Box, {
|
|
207
|
+
sx: {
|
|
208
|
+
display: "flex",
|
|
209
|
+
justifyContent: "flex-start",
|
|
210
|
+
alignItems: "center",
|
|
211
|
+
mb: 3,
|
|
212
|
+
mt: "8px"
|
|
213
|
+
},
|
|
214
|
+
children: /*#__PURE__*/_jsxs(FormControl, {
|
|
215
|
+
size: "large",
|
|
216
|
+
sx: {
|
|
217
|
+
minWidth: 170,
|
|
218
|
+
marginTop: 1
|
|
219
|
+
},
|
|
220
|
+
children: [/*#__PURE__*/_jsx(InputLabel, {
|
|
221
|
+
id: "courses-sort-by-label",
|
|
222
|
+
children: t("sort_by")
|
|
223
|
+
}), /*#__PURE__*/_jsxs(Select, {
|
|
224
|
+
labelId: "courses-sort-by-label",
|
|
225
|
+
id: "courses_sort_by",
|
|
226
|
+
"data-cy": "courses_sort_by",
|
|
227
|
+
value: sortOrder,
|
|
228
|
+
onChange: e => handleSortOrderChange(e.target.value),
|
|
229
|
+
label: t("sort_by"),
|
|
230
|
+
autoWidth: true,
|
|
231
|
+
children: [/*#__PURE__*/_jsx(MenuItem, {
|
|
232
|
+
value: "newest",
|
|
233
|
+
children: t("newest")
|
|
234
|
+
}), /*#__PURE__*/_jsx(MenuItem, {
|
|
235
|
+
value: "alphabetical",
|
|
236
|
+
children: t("alphabetical")
|
|
237
|
+
}), /*#__PURE__*/_jsx(MenuItem, {
|
|
238
|
+
value: "recentlyChanged",
|
|
239
|
+
children: t("recently_changed")
|
|
240
|
+
}), /*#__PURE__*/_jsx(MenuItem, {
|
|
241
|
+
value: "recentlyViewed",
|
|
242
|
+
children: t("recently_viewed")
|
|
243
|
+
})]
|
|
244
|
+
})]
|
|
245
|
+
})
|
|
246
|
+
}), /*#__PURE__*/_jsx(CourseList, {
|
|
247
|
+
t: t,
|
|
248
|
+
courses: sortedCourses,
|
|
249
|
+
...trackedHandlers,
|
|
250
|
+
...otherProps
|
|
251
|
+
})]
|
|
162
252
|
});
|
|
163
253
|
}
|
|
164
254
|
//
|
|
@@ -1,30 +1,80 @@
|
|
|
1
|
-
import { render } from "@testing-library/react";
|
|
2
|
-
import { describe, it } from "vitest";
|
|
1
|
+
import { render, screen, cleanup } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
3
3
|
import { ReactQuery } from "@nualang/nualang-api-and-queries/Queries";
|
|
4
4
|
import "intersection-observer";
|
|
5
5
|
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
|
6
6
|
import Courses from "./Courses";
|
|
7
7
|
import { props } from "./Courses.stories";
|
|
8
|
+
import { CLASSROOM_COURSES_SORT_ORDER_KEY, COURSE_VISITS_KEY } from "../../utils/courseSort";
|
|
8
9
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
9
10
|
const {
|
|
10
11
|
QueryClient,
|
|
11
12
|
QueryClientProvider
|
|
12
13
|
} = ReactQuery;
|
|
13
14
|
const queryClient = new QueryClient();
|
|
15
|
+
const renderCourses = (extraProps = {}) => {
|
|
16
|
+
const theme = createTheme();
|
|
17
|
+
return render(/*#__PURE__*/_jsx(QueryClientProvider, {
|
|
18
|
+
client: queryClient,
|
|
19
|
+
children: /*#__PURE__*/_jsx(ThemeProvider, {
|
|
20
|
+
theme: theme,
|
|
21
|
+
children: /*#__PURE__*/_jsx(Courses, {
|
|
22
|
+
...props,
|
|
23
|
+
...extraProps
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
}));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Course cards render their name in the card title, so the titles in DOM order
|
|
30
|
+
// are the rendered sort order.
|
|
31
|
+
const renderedCourseNames = async () => {
|
|
32
|
+
await screen.findAllByText("English Basics");
|
|
33
|
+
return Array.from(document.querySelectorAll(".MuiCardHeader-title")).map(el => el.textContent);
|
|
34
|
+
};
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
cleanup();
|
|
37
|
+
localStorage.removeItem(CLASSROOM_COURSES_SORT_ORDER_KEY);
|
|
38
|
+
localStorage.removeItem(COURSE_VISITS_KEY);
|
|
39
|
+
});
|
|
14
40
|
describe("Courses", () => {
|
|
15
41
|
it("renders without crashing", () => {
|
|
16
|
-
const theme = createTheme();
|
|
17
42
|
const {
|
|
18
43
|
unmount
|
|
19
|
-
} =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
44
|
+
} = renderCourses();
|
|
45
|
+
unmount();
|
|
46
|
+
});
|
|
47
|
+
it("puts recently viewed courses first when that preference is stored", async () => {
|
|
48
|
+
localStorage.setItem(CLASSROOM_COURSES_SORT_ORDER_KEY, "recentlyViewed");
|
|
49
|
+
// Courses 2, 3 and 5 are "English Basics", "Learn Greetings 2" and
|
|
50
|
+
// "English Basics 2" — none of which lead the default newest-first order.
|
|
51
|
+
localStorage.setItem(COURSE_VISITS_KEY, JSON.stringify({
|
|
52
|
+
2: 100,
|
|
53
|
+
5: 300,
|
|
54
|
+
3: 200
|
|
27
55
|
}));
|
|
56
|
+
const {
|
|
57
|
+
unmount
|
|
58
|
+
} = renderCourses({
|
|
59
|
+
showSortControl: true
|
|
60
|
+
});
|
|
61
|
+
const courseNames = await renderedCourseNames();
|
|
62
|
+
expect(courseNames.slice(0, 3)).toEqual(["English Basics 2", "Learn Greetings 2", "English Basics"]);
|
|
63
|
+
unmount();
|
|
64
|
+
});
|
|
65
|
+
it("does not mutate the courses prop", async () => {
|
|
66
|
+
localStorage.setItem(CLASSROOM_COURSES_SORT_ORDER_KEY, "recentlyViewed");
|
|
67
|
+
localStorage.setItem(COURSE_VISITS_KEY, JSON.stringify({
|
|
68
|
+
5: 300
|
|
69
|
+
}));
|
|
70
|
+
const originalOrder = props.courses.map(c => c.courseId);
|
|
71
|
+
const {
|
|
72
|
+
unmount
|
|
73
|
+
} = renderCourses({
|
|
74
|
+
showSortControl: true
|
|
75
|
+
});
|
|
76
|
+
await renderedCourseNames();
|
|
77
|
+
expect(props.courses.map(c => c.courseId)).toEqual(originalOrder);
|
|
28
78
|
unmount();
|
|
29
79
|
});
|
|
30
80
|
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Classroom courses tab keeps its own independent sort preference, separate
|
|
2
|
+
// from the members/progress sort preferences in studentSort.js.
|
|
3
|
+
export const CLASSROOM_COURSES_SORT_ORDER_KEY = "classroomCoursesSortOrder";
|
|
4
|
+
|
|
5
|
+
// Map of { [courseId]: timestamp } recording the last time the user opened each
|
|
6
|
+
// course. Written whenever a course is opened from a course list, and read back
|
|
7
|
+
// to power the "recentlyViewed" sort order. Deliberately not scoped per
|
|
8
|
+
// classroom — the same course can live in several classrooms and "the course I
|
|
9
|
+
// was last in" is a single global answer.
|
|
10
|
+
export const COURSE_VISITS_KEY = "courseVisits";
|
|
11
|
+
|
|
12
|
+
// Enough to cover any realistic course list without the entry growing forever.
|
|
13
|
+
const MAX_TRACKED_VISITS = 100;
|
|
14
|
+
export const getCourseVisits = () => {
|
|
15
|
+
try {
|
|
16
|
+
const stored = JSON.parse(localStorage.getItem(COURSE_VISITS_KEY));
|
|
17
|
+
// Guard against a hand-edited or half-written value — anything that isn't a
|
|
18
|
+
// plain object is treated as "no history" rather than crashing the list.
|
|
19
|
+
if (!stored || typeof stored !== "object" || Array.isArray(stored)) return {};
|
|
20
|
+
return stored;
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Records a visit and returns the updated map so callers can re-sort without
|
|
27
|
+
// another read. Keeps only the most recent MAX_TRACKED_VISITS courses.
|
|
28
|
+
export const recordCourseVisit = (courseId, timestamp = Date.now()) => {
|
|
29
|
+
if (!courseId) return getCourseVisits();
|
|
30
|
+
const visits = {
|
|
31
|
+
...getCourseVisits(),
|
|
32
|
+
[courseId]: timestamp
|
|
33
|
+
};
|
|
34
|
+
const pruned = Object.fromEntries(Object.entries(visits).sort(([, a], [, b]) => (b || 0) - (a || 0)).slice(0, MAX_TRACKED_VISITS));
|
|
35
|
+
try {
|
|
36
|
+
localStorage.setItem(COURSE_VISITS_KEY, JSON.stringify(pruned));
|
|
37
|
+
} catch {
|
|
38
|
+
// localStorage unavailable (e.g. blocked storage) — history just won't persist
|
|
39
|
+
}
|
|
40
|
+
return pruned;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Most recently opened first. Never-opened courses share a timestamp of 0, so a
|
|
44
|
+
// stable sort leaves them in whatever order the caller passed them in.
|
|
45
|
+
export const compareCoursesByLastVisit = (a = {}, b = {}, visits = {}) => (visits[b.courseId] || 0) - (visits[a.courseId] || 0);
|
|
46
|
+
|
|
47
|
+
// "newest" (most recently added to the classroom) has no comparator here —
|
|
48
|
+
// there is no timestamp for classroom-course membership, only array position,
|
|
49
|
+
// so callers handle it by reversing the already-ordered courses array instead.
|
|
50
|
+
// "recentlyViewed" needs the visits map, so it has its own comparator above.
|
|
51
|
+
export const compareCoursesBySortOrder = (a = {}, b = {}, order) => {
|
|
52
|
+
if (order === "alphabetical") {
|
|
53
|
+
return (a.courseName || "").toLowerCase().localeCompare((b.courseName || "").toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
if (order === "recentlyChanged") {
|
|
56
|
+
const toTimestamp = value => {
|
|
57
|
+
if (typeof value === "number") return value;
|
|
58
|
+
if (typeof value === "string") return Date.parse(value) || Number(value) || 0;
|
|
59
|
+
return 0;
|
|
60
|
+
};
|
|
61
|
+
return toTimestamp(b.updatedAt ?? b.createdAt) - toTimestamp(a.updatedAt ?? a.createdAt);
|
|
62
|
+
}
|
|
63
|
+
return 0;
|
|
64
|
+
};
|