@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.
@@ -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
- ...props,
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(props) {
158
- return /*#__PURE__*/_jsx(_Fragment, {
159
- children: /*#__PURE__*/_jsx(CourseList, {
160
- ...props
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
- } = render(/*#__PURE__*/_jsx(QueryClientProvider, {
20
- client: queryClient,
21
- children: /*#__PURE__*/_jsx(ThemeProvider, {
22
- theme: theme,
23
- children: /*#__PURE__*/_jsx(Courses, {
24
- ...props
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nualang/nualang-ui-components",
3
- "version": "0.1.1411",
3
+ "version": "0.1.1412",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [