@nualang/nualang-ui-components 0.1.1431 → 0.1.1432

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.
@@ -54,6 +54,39 @@ const DEFAULT_FILTERS = {
54
54
  difficulty: "all",
55
55
  sort: "popular"
56
56
  };
57
+ const MY_LIBRARY_TAB = 0;
58
+ // My library lists the user's own courses, so newest first is more useful
59
+ // there than popularity. The Community tab keeps the "popular" default.
60
+ const MY_LIBRARY_DEFAULT_SORT = "newest";
61
+
62
+ // My library rows are fetched in full and filtered here, the same way the
63
+ // Courses screen does it, so search never goes through the global course search.
64
+ function filterLibraryCourses(courses, filters, search) {
65
+ const searchText = search.trim().toLowerCase();
66
+ const filtered = courses.filter(course => {
67
+ if (filters.learnLang !== "all" && course.learnLang !== filters.learnLang) {
68
+ return false;
69
+ }
70
+ if (filters.forLang !== "all" && course.forLang !== filters.forLang) {
71
+ return false;
72
+ }
73
+ if (filters.difficulty !== "all") {
74
+ const difficulties = Array.isArray(course.difficulty) ? course.difficulty : [course.difficulty];
75
+ if (!difficulties.includes(filters.difficulty)) {
76
+ return false;
77
+ }
78
+ }
79
+ if (searchText) {
80
+ const nameMatch = (course.courseName || "").toLowerCase().includes(searchText);
81
+ const descriptionMatch = (course.description || "").toLowerCase().includes(searchText);
82
+ if (!nameMatch && !descriptionMatch) {
83
+ return false;
84
+ }
85
+ }
86
+ return true;
87
+ });
88
+ return sortItems(filtered, filters.sort);
89
+ }
57
90
  function CourseFilters({
58
91
  t,
59
92
  search,
@@ -368,6 +401,7 @@ export default function FindCoursesDialog({
368
401
  formatCourses,
369
402
  groupCourseIds = [],
370
403
  groupCoursesLimit = 100,
404
+ createdCoursesLimit = 1000,
371
405
  alreadyAddedCourseIds = [],
372
406
  isCommunityAccessRestricted = false,
373
407
  learnLangOptions = [],
@@ -382,16 +416,19 @@ export default function FindCoursesDialog({
382
416
  } = useInView({
383
417
  threshold: 0
384
418
  });
385
- const [tab, setTab] = useState(0);
386
- const effectiveTab = isCommunityAccessRestricted ? 0 : tab;
419
+ const [tab, setTab] = useState(MY_LIBRARY_TAB);
420
+ const effectiveTab = isCommunityAccessRestricted ? MY_LIBRARY_TAB : tab;
387
421
  const [selectedCourseIds, setSelectedCourseIds] = useState([]);
388
422
  const [search, setSearch] = useState("");
389
- const defaultFilters = {
423
+ const getDefaultFilters = tabIndex => ({
390
424
  ...DEFAULT_FILTERS,
391
425
  learnLang: defaultLearnLang,
392
- forLang: defaultForLang
393
- };
394
- const [filters, setFilters] = useState(defaultFilters);
426
+ forLang: defaultForLang,
427
+ ...(tabIndex === MY_LIBRARY_TAB && {
428
+ sort: MY_LIBRARY_DEFAULT_SORT
429
+ })
430
+ });
431
+ const [filters, setFilters] = useState(() => getDefaultFilters(MY_LIBRARY_TAB));
395
432
  // Every course object ever handed to this dialog, keyed by id. search/
396
433
  // difficulty/sort are now applied upstream (the caller only fetches
397
434
  // matching pages), so a course that's selected and then filtered out of
@@ -407,8 +444,8 @@ export default function FindCoursesDialog({
407
444
  const reset = () => {
408
445
  setSelectedCourseIds([]);
409
446
  setSearch("");
410
- setFilters(defaultFilters);
411
- setTab(0);
447
+ setFilters(getDefaultFilters(MY_LIBRARY_TAB));
448
+ setTab(MY_LIBRARY_TAB);
412
449
  setSearchResetKey(key => key + 1);
413
450
  setCourseCache(new Map());
414
451
  };
@@ -432,7 +469,7 @@ export default function FindCoursesDialog({
432
469
  if (isCommunityAccessRestricted) return;
433
470
  setTab(v);
434
471
  setSearch("");
435
- setFilters(defaultFilters);
472
+ setFilters(getDefaultFilters(v));
436
473
  setSearchResetKey(key => key + 1);
437
474
  };
438
475
  const handleFilterChange = event => {
@@ -450,28 +487,19 @@ export default function FindCoursesDialog({
450
487
  // every page load - so the carousels below know when a reorder is a real
451
488
  // reset versus just more of the same sort settling in as data grows.
452
489
  const librarySortResetKey = `${search}|${filters.learnLang}|${filters.forLang}|${filters.difficulty}|${filters.sort}`;
490
+
491
+ // Search/filters/sort are applied client-side in libraryCreatedCourses, so
492
+ // changing them doesn't refetch
453
493
  const teachCoursesQuery = useInfiniteQuery({
454
- queryKey: ["findCoursesDialog", "created", username, filters.learnLang, filters.forLang, filters.difficulty, search, filters.sort],
494
+ queryKey: ["findCoursesDialog", "created", username],
455
495
  queryFn: async ({
456
496
  pageParam = {}
457
497
  }) => {
458
498
  const response = await getCourses({
459
499
  createdBy: username,
460
500
  verbose: false,
461
- limit: LIBRARY_PAGE_SIZE,
462
- sort: filters.sort,
463
- ...(filters.learnLang !== "all" && {
464
- learnLang: filters.learnLang
465
- }),
466
- ...(filters.forLang !== "all" && {
467
- forLang: filters.forLang
468
- }),
469
- ...(filters.difficulty !== "all" && {
470
- difficulty: filters.difficulty
471
- }),
472
- ...(search && {
473
- search
474
- }),
501
+ // Get all using high limit, same as the Courses screen
502
+ limit: createdCoursesLimit,
475
503
  page: pageParam.page || 1,
476
504
  lastEvaluatedCourseId: pageParam.courseId || "",
477
505
  lastEvaluatedKey: pageParam.courseId || "",
@@ -488,6 +516,20 @@ export default function FindCoursesDialog({
488
516
  getNextPageParam: nextLibraryPageParam,
489
517
  enabled: !!username && open
490
518
  });
519
+ const {
520
+ hasNextPage: hasMoreCreated,
521
+ isFetchingNextPage: isFetchingMoreCreated,
522
+ isFetchNextPageError: isCreatedNextPageError
523
+ } = teachCoursesQuery;
524
+ useEffect(() => {
525
+ if (!open || !username || !hasMoreCreated || isFetchingMoreCreated || isCreatedNextPageError) {
526
+ return;
527
+ }
528
+ teachCoursesQuery.fetchNextPage({
529
+ cancelRefetch: false
530
+ });
531
+ // eslint-disable-next-line react-hooks/exhaustive-deps
532
+ }, [open, username, hasMoreCreated, isFetchingMoreCreated, isCreatedNextPageError]);
491
533
  const learnCoursesQuery = useInfiniteQuery({
492
534
  queryKey: ["findCoursesDialog", "joined", username],
493
535
  queryFn: async ({
@@ -530,24 +572,9 @@ export default function FindCoursesDialog({
530
572
  const groupCourses = groupCoursesQuery.isSuccess && Array.isArray(groupCoursesQuery.data) ? groupCoursesQuery.data : [];
531
573
  const combined = [...learnCourses, ...groupCourses];
532
574
  const deduped = combined.filter((course, index) => combined.findIndex(c => c.courseId === course.courseId) === index);
533
- const searchText = search.trim().toLowerCase();
534
- const filtered = deduped.filter(course => {
535
- if (filters.learnLang !== "all" && course.learnLang !== filters.learnLang) return false;
536
- if (filters.forLang !== "all" && course.forLang !== filters.forLang) return false;
537
- if (filters.difficulty !== "all") {
538
- const difficulties = Array.isArray(course.difficulty) ? course.difficulty : [course.difficulty];
539
- if (!difficulties.includes(filters.difficulty)) return false;
540
- }
541
- if (searchText) {
542
- const nameMatch = (course.courseName || "").toLowerCase().includes(searchText);
543
- const descriptionMatch = (course.description || "").toLowerCase().includes(searchText);
544
- if (!nameMatch && !descriptionMatch) return false;
545
- }
546
- return true;
547
- });
548
- return sortItems(filtered, filters.sort);
549
- }, [learnCoursesQuery.data, groupCoursesQuery.data, groupCoursesQuery.isSuccess, filters.learnLang, filters.forLang, filters.difficulty, search, filters.sort]);
550
- const libraryCreatedCourses = useMemo(() => (teachCoursesQuery.data?.pages || []).flatMap(page => page.Items || []), [teachCoursesQuery.data]);
575
+ return filterLibraryCourses(deduped, filters, search);
576
+ }, [learnCoursesQuery.data, groupCoursesQuery.data, groupCoursesQuery.isSuccess, filters, search]);
577
+ const libraryCreatedCourses = useMemo(() => filterLibraryCourses((teachCoursesQuery.data?.pages || []).flatMap(page => page.Items || []), filters, search), [teachCoursesQuery.data, filters, search]);
551
578
  const communityCoursesQuery = courseQuerys.useCommunityCourses(async queryFilters => {
552
579
  const response = await getCourses(queryFilters);
553
580
  const formattedCourses = await formatCourses(response.Items, authData);
@@ -1,10 +1,17 @@
1
1
  import { ReactQuery } from "@nualang/nualang-api-and-queries/Queries";
2
2
  import "intersection-observer";
3
3
  import { createTheme, ThemeProvider } from "@mui/material/styles";
4
- import { render, fireEvent, act } from "@testing-library/react";
4
+ import { render, fireEvent, act, screen, waitFor, cleanup } from "@testing-library/react";
5
5
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import { getCourses } from "@nualang/nualang-api-and-queries/APIs/Courses";
6
7
  import FindCoursesDialog from "./FindCoursesDialog";
7
8
  import { jsx as _jsx } from "react/jsx-runtime";
9
+ vi.mock("@nualang/nualang-api-and-queries/APIs/Courses", () => ({
10
+ getCourses: vi.fn(async () => ({
11
+ Items: [],
12
+ Count: 0
13
+ }))
14
+ }));
8
15
  const {
9
16
  QueryClient,
10
17
  QueryClientProvider
@@ -77,4 +84,252 @@ describe("FindCoursesDialog", () => {
77
84
  expect(searchInput.value).toBe("");
78
85
  });
79
86
  });
87
+ describe("My library search", () => {
88
+ const createdCourses = [{
89
+ courseId: "mine-1",
90
+ courseName: "Spanish Basics",
91
+ createdBy: "me",
92
+ learnLang: "spanish",
93
+ forLang: "english",
94
+ difficulty: "A1",
95
+ visibility: "private"
96
+ }, {
97
+ courseId: "mine-2",
98
+ courseName: "French Verbs",
99
+ createdBy: "me",
100
+ learnLang: "french",
101
+ forLang: "english",
102
+ difficulty: "A1",
103
+ visibility: "public"
104
+ }];
105
+ const joinedCourses = [{
106
+ courseId: "joined-1",
107
+ courseName: "Spanish Joined",
108
+ createdBy: "teacher",
109
+ learnLang: "spanish",
110
+ forLang: "english",
111
+ difficulty: "A1",
112
+ visibility: "public"
113
+ }];
114
+ const communityCourses = [{
115
+ courseId: "other-1",
116
+ courseName: "Spanish Community",
117
+ createdBy: "someone-else",
118
+ learnLang: "spanish",
119
+ forLang: "english",
120
+ difficulty: "A1",
121
+ visibility: "public"
122
+ }];
123
+ afterEach(() => {
124
+ cleanup();
125
+ });
126
+ beforeEach(() => {
127
+ // Earlier tests leave their dialog mounted - remove it so typeSearch
128
+ // targets this test's search input
129
+ cleanup();
130
+ getCourses.mockReset();
131
+ getCourses.mockImplementation(async filters => {
132
+ if (filters.createdBy) {
133
+ return {
134
+ Items: createdCourses,
135
+ Count: createdCourses.length
136
+ };
137
+ }
138
+ if (filters.memberId) {
139
+ return {
140
+ Items: joinedCourses,
141
+ Count: joinedCourses.length
142
+ };
143
+ }
144
+ return {
145
+ Items: communityCourses,
146
+ Count: communityCourses.length
147
+ };
148
+ });
149
+ });
150
+ const typeSearch = value => {
151
+ const searchInput = document.querySelector('[data-cy="find-courses-filter-search"] input');
152
+ act(() => {
153
+ fireEvent.change(searchInput, {
154
+ target: {
155
+ value
156
+ }
157
+ });
158
+ });
159
+ };
160
+ it("filters created courses client-side and never sends the search to the API", async () => {
161
+ renderDialog({
162
+ username: "me",
163
+ formatCourses: async items => items
164
+ });
165
+ await screen.findByText("French Verbs");
166
+ typeSearch("spanish");
167
+
168
+ // Allow for the 500ms search debounce
169
+ await waitFor(() => {
170
+ expect(screen.queryByText("French Verbs")).toBeNull();
171
+ }, {
172
+ timeout: 3000
173
+ });
174
+ expect(screen.getByText("Spanish Basics")).toBeTruthy();
175
+ // Other people's courses must not appear in My library
176
+ expect(screen.queryByText("Spanish Community")).toBeNull();
177
+ const createdCalls = getCourses.mock.calls.filter(([filters]) => filters.createdBy);
178
+ // Fetched once with only the creator - search/filters don't refetch
179
+ expect(createdCalls).toHaveLength(1);
180
+ expect(createdCalls[0][0]).not.toHaveProperty("search");
181
+ expect(createdCalls[0][0].createdBy).toBe("me");
182
+ });
183
+ it("defaults My library to newest and Community to popular", async () => {
184
+ getCourses.mockImplementation(async filters => {
185
+ if (filters.createdBy) {
186
+ return {
187
+ Items: [{
188
+ courseId: "old",
189
+ courseName: "Older Course",
190
+ createdBy: "me",
191
+ createdAt: 1000,
192
+ exerciseStartTotal: 50
193
+ }, {
194
+ courseId: "new",
195
+ courseName: "Newer Course",
196
+ createdBy: "me",
197
+ createdAt: 2000,
198
+ exerciseStartTotal: 0
199
+ }]
200
+ };
201
+ }
202
+ return {
203
+ Items: []
204
+ };
205
+ });
206
+ renderDialog({
207
+ username: "me",
208
+ formatCourses: async items => items
209
+ });
210
+ const sortInput = () => document.querySelector('input[name="sort"]');
211
+ expect(sortInput().value).toBe("newest");
212
+
213
+ // Newest first, even though the older course has more plays
214
+ await screen.findByText("Newer Course");
215
+ const names = screen.getAllByText(/^(Older|Newer) Course$/).map(el => el.textContent);
216
+ expect(names).toEqual(["Newer Course", "Older Course"]);
217
+ act(() => {
218
+ fireEvent.click(document.querySelector('[data-cy="find-courses-tab-community"]'));
219
+ });
220
+ expect(sortInput().value).toBe("popular");
221
+ act(() => {
222
+ fireEvent.click(document.querySelector('[data-cy="find-courses-tab-library"]'));
223
+ });
224
+ expect(sortInput().value).toBe("newest");
225
+ });
226
+ it("loads later created-course pages without scrolling when the first page has no matches", async () => {
227
+ const firstPage = [{
228
+ courseId: "mine-1",
229
+ courseName: "French Verbs",
230
+ createdBy: "me",
231
+ learnLang: "french",
232
+ forLang: "english",
233
+ difficulty: "A1"
234
+ }];
235
+ const secondPage = [{
236
+ courseId: "mine-2",
237
+ courseName: "Spanish Later Page",
238
+ createdBy: "me",
239
+ learnLang: "spanish",
240
+ forLang: "english",
241
+ difficulty: "A1"
242
+ }];
243
+ getCourses.mockImplementation(async filters => {
244
+ if (filters.createdBy) {
245
+ if (filters.page === 2) {
246
+ return {
247
+ Items: secondPage,
248
+ Count: secondPage.length
249
+ };
250
+ }
251
+ return {
252
+ Items: firstPage,
253
+ Count: firstPage.length,
254
+ LastEvaluatedKey: {
255
+ page: 2
256
+ }
257
+ };
258
+ }
259
+ if (filters.memberId) {
260
+ return {
261
+ Items: joinedCourses,
262
+ Count: joinedCourses.length
263
+ };
264
+ }
265
+ return {
266
+ Items: communityCourses,
267
+ Count: communityCourses.length
268
+ };
269
+ });
270
+ renderDialog({
271
+ username: "me",
272
+ formatCourses: async items => items,
273
+ createdCoursesLimit: 1
274
+ });
275
+ await screen.findByText("Spanish Joined");
276
+ typeSearch("spanish");
277
+
278
+ // Allow for the 500ms search debounce - no carousel scroll happens here
279
+ await waitFor(() => {
280
+ expect(screen.queryByText("French Verbs")).toBeNull();
281
+ }, {
282
+ timeout: 3000
283
+ });
284
+ expect(await screen.findByText("Spanish Later Page")).toBeTruthy();
285
+ expect(screen.queryByText("No created courses match these filters")).toBeNull();
286
+ const createdCalls = getCourses.mock.calls.filter(([filters]) => filters.createdBy);
287
+ expect(createdCalls).toHaveLength(2);
288
+ expect(createdCalls.map(([filters]) => filters.page)).toEqual([1, 2]);
289
+ createdCalls.forEach(([filters]) => {
290
+ expect(filters).not.toHaveProperty("search");
291
+ expect(filters.limit).toBe(1);
292
+ });
293
+
294
+ // Courses joined still filters client-side without search in its request
295
+ expect(screen.getByText("Spanish Joined")).toBeTruthy();
296
+ getCourses.mock.calls.filter(([filters]) => filters.memberId).forEach(([filters]) => {
297
+ expect(filters).not.toHaveProperty("search");
298
+ });
299
+
300
+ // Community still sends search to the API
301
+ act(() => {
302
+ fireEvent.click(document.querySelector('[data-cy="find-courses-tab-community"]'));
303
+ });
304
+ await screen.findByText("Spanish Community");
305
+ typeSearch("spanish");
306
+ await waitFor(() => {
307
+ const communityCalls = getCourses.mock.calls.filter(([filters]) => !filters.createdBy && !filters.memberId && !filters.courseIds);
308
+ expect(communityCalls.some(([filters]) => filters.search === "spanish")).toBe(true);
309
+ }, {
310
+ timeout: 3000
311
+ });
312
+ });
313
+ it("leaves Courses joined filtering unchanged", async () => {
314
+ renderDialog({
315
+ username: "me",
316
+ formatCourses: async items => items
317
+ });
318
+ await screen.findByText("Spanish Joined");
319
+ typeSearch("french");
320
+
321
+ // Allow for the 500ms search debounce
322
+ await waitFor(() => {
323
+ expect(screen.queryByText("Spanish Joined")).toBeNull();
324
+ }, {
325
+ timeout: 3000
326
+ });
327
+ expect(screen.getByText("French Verbs")).toBeTruthy();
328
+ const joinedCalls = getCourses.mock.calls.filter(([filters]) => filters.memberId);
329
+ expect(joinedCalls.length).toBeGreaterThan(0);
330
+ joinedCalls.forEach(([filters]) => {
331
+ expect(filters).not.toHaveProperty("search");
332
+ });
333
+ });
334
+ });
80
335
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nualang/nualang-ui-components",
3
- "version": "0.1.1431",
3
+ "version": "0.1.1432",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [