From 10cd8f0fe93a79e6d4947d9b82c89d5cac731072 Mon Sep 17 00:00:00 2001 From: Joaquin Villagomez Date: Thu, 10 Sep 2026 15:53:15 -0400 Subject: [PATCH 1/3] making the playlist tab on user profile --- .../sc-39971-profile-playlist-tab.execplan.md | 94 ++++++++++++++ .../core/playlist-module/playlist.routes.ts | 38 ++++++ .../playlist-module/playlist.service.spec.ts | 111 ++++++++++++++++ .../core/playlist-module/playlist.service.ts | 76 +++++++++++ .../core/playlist-module/playlist.types.ts | 45 +++++++ .../profile-playlists.component.html | 47 +++++++ .../profile-playlists.component.scss | 119 ++++++++++++++++++ .../profile-playlists.component.ts | 21 ++++ .../user-profile/user-profile.component.html | 77 +++++++++++- .../user-profile/user-profile.component.scss | 53 ++++++++ .../user-profile.component.spec.ts | 92 ++++++++++++++ .../user-profile/user-profile.component.ts | 108 +++++++++++++--- 12 files changed, 862 insertions(+), 19 deletions(-) create mode 100644 plans/sc-39971-profile-playlist-tab.execplan.md create mode 100644 src/app/core/playlist-module/playlist.routes.ts create mode 100644 src/app/core/playlist-module/playlist.service.spec.ts create mode 100644 src/app/core/playlist-module/playlist.service.ts create mode 100644 src/app/core/playlist-module/playlist.types.ts create mode 100644 src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html create mode 100644 src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss create mode 100644 src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts create mode 100644 src/app/cube/user-profile/user-profile.component.spec.ts diff --git a/plans/sc-39971-profile-playlist-tab.execplan.md b/plans/sc-39971-profile-playlist-tab.execplan.md new file mode 100644 index 000000000..4ecd6048b --- /dev/null +++ b/plans/sc-39971-profile-playlist-tab.execplan.md @@ -0,0 +1,94 @@ +# SC-39971: Add playlists to user profiles + +This ExecPlan is a living document maintained in accordance with `PLANS.md`. + +## Purpose / Big Picture + +Add a second, responsive and keyboard-accessible tab to `/users/:username` so profile visitors can switch between Contributions and Playlists. Contributions remain the default for existing profile links, while `?tab=playlists` provides a directly navigable playlist view. Add a typed frontend Playlist API boundary with centralized builders for every backend playlist route, and use it to load the profile user's visible playlists. + +## Progress + +- [x] (2026-09-10) Inspected profile routing, resolver behavior, profile components, existing tab styling, API route helpers, service conventions, and available test/build scripts. +- [x] (2026-09-10) Added typed playlist routes, models, and service methods for CRUD and learning-object membership. +- [x] (2026-09-10) Added the profile playlist presentation component and integrated accessible URL-backed tabs into the existing profile. +- [x] (2026-09-10) Added focused service/profile tests and completed formatting, lint, focused TypeScript validation, and an Angular build; documented the repository-level Jest transformer blocker. + +## Surprises & Discoveries + +- Observation: The profile is a standalone component registered directly at `/users/:username`; Contributions are not currently a child route. + Evidence: `src/app/cube/cube.routing.ts` and `src/app/cube/user-profile/user-profile.component.*`. +- Observation: Profile resolver data contains the backend `_id`, while the reusable `User` entity exposes it as `userId`; the profile currently keeps the resolver result untyped. + Evidence: `src/app/cube/core/profile.resolver.ts`, `src/app/core/user-module/user.service.ts`, and `src/entity/user/user.ts`. +- Observation: The backend's user-filtered playlist GET already applies authentication-aware visibility, so the client should pass the profile user ID and credentials without duplicating access rules. +- Observation: Focused Jest execution fails during global setup before loading test files because the installed `jest-preset-angular` transformer calls a missing `processWithEsbuild` function. The same new specs compile successfully through a focused TypeScript configuration. + Evidence: `npx jest --runInBand ...` fails from `src/setup-jest.ts`; `npm ls` reports Jest 29.7.0, jest-preset-angular 14.6.2, and ts-jest 29.4.6. +- Observation: The Angular workspace has no `development` build configuration despite that name being common in newer Angular workspaces. + Evidence: `npx ng build clark --configuration=development` reports that the configuration is not set; the default `npx ng build clark` succeeds. + +## Decision Log + +- Decision: Represent the selected profile tab with the optional `tab=playlists` query parameter. + Rationale: `/users/:username` remains backward-compatible and defaults to Contributions, while playlist views can be directly linked, refreshed, and navigated with browser history without restructuring the existing resolved profile route. + Date/Author: 2026-09-10 / Codex +- Decision: Use native links with ARIA tab semantics and arrow-key handling rather than adding Angular Material tabs to this standalone component. + Rationale: Native links preserve direct navigation and keyboard activation while local SCSS can match the existing profile design without introducing another UI dependency. + Date/Author: 2026-09-10 / Codex +- Decision: Add a domain service and centralized route helper under `src/app/core/playlist-module`. + Rationale: Components should not construct endpoint strings or call `HttpClient` directly, and all backend playlist routes need one discoverable client contract. + Date/Author: 2026-09-10 / Codex + +## Outcomes & Retrospective + +The existing profile URL remains intact and now defaults to a Contributions tab, while `?tab=playlists` provides a refresh-safe and shareable Playlists selection. Tabs use native navigation, ARIA associations and selected state, roving tab stops, visible focus, and arrow/Home/End navigation. The new profile playlist view follows the existing profile card treatment and handles loading, failure, empty, public, and owner-private data responsively. + +The new core Playlist API boundary covers every backend playlist route and sends credentials for authentication-aware visibility. Production code compiles in the Angular build, lint introduces no new warnings or errors, and both added specs compile. Executing Jest remains blocked by the repository's preset/transformer mismatch before test discovery; package changes were intentionally left out of this story. + +## Context and Orientation + +`src/app/cube/user-profile/user-profile.component.*` owns the resolved profile and contribution loading. `src/app/cube/cube.routing.ts` maps `/users/:username` to that component. The new playlist service belongs under `src/app/core/playlist-module`, following other domain API modules. A feature-local `profile-playlists` standalone component will render loading, error, empty, and playlist-summary states beneath the profile tabs. + +The request flow will be URL → `UserProfileComponent` → `PlaylistService` → `GET /playlists?userId=`. Cookies are included so owners receive private playlists while other visitors receive public playlists only, as enforced by the backend. + +## Plan of Work + +Create playlist types describing visibility, summaries, hydrated details, create/update inputs, and learning-object card data. Create route builders for `GET/POST /playlists`, `PATCH/DELETE /playlists/:playlistId`, and `PUT/DELETE /playlists/:playlistId/objects/:cuid`. Create an injectable service exposing typed Observable methods with `withCredentials` enabled. + +Create a standalone profile-playlists component that presents playlist summaries without assuming a playlist-details page exists. Update the profile component to observe the query parameter, load playlists whenever resolved profile data changes, and clean up subscriptions. Add a tablist whose links update the URL, expose selected state, support Enter through native link behavior, and support left/right arrow navigation. Preserve the existing contribution component and base URL behavior. + +Add service tests for route/query/method construction and profile tests for URL selection, resolved-user loading, and keyboard navigation. Validate TypeScript templates and styles through Angular lint/build and attempt focused Jest execution without expanding scope into test-infrastructure package changes. + +## Concrete Steps + +From the `clark-client` root: + + npx prettier --write + npx ng lint clark + npx jest --runInBand + npx ng build clark + +## Validation and Acceptance + +- `/users/:username` selects Contributions and preserves current profile behavior. +- `/users/:username?tab=playlists` directly selects Playlists after refresh or navigation. +- Tab controls are links with `role=tab`, correct `aria-selected`, panel associations, visible keyboard focus, and left/right arrow navigation. +- The layout remains usable at narrow widths. +- Playlist profile requests send `userId` as a query parameter with credentials. +- Loading, request failure, empty results, public results, and owner-visible private results render safely. +- Every backend playlist route has a centralized frontend route builder and typed service method. + +## Idempotence and Recovery + +All changes are additive except bounded edits to the profile component and its route-backed UI. Existing `/users/:username` links remain valid. If validation fails, revert only the new profile playlist imports/template section and core playlist module files; no persisted data or migration is involved. + +## Artifacts and Notes + +- `npx prettier --write `: passed. +- `npx ng lint clark`: passed with 0 errors and the repository's existing 259 warnings; no changed-file warning was reported. +- `npx ng build clark`: passed; one pre-existing CommonJS optimization warning was reported for the standard-guidelines service. +- Focused `npx tsc` configuration containing only the two new specs: passed. +- `npx jest --runInBand src/app/core/playlist-module/playlist.service.spec.ts src/app/cube/user-profile/user-profile.component.spec.ts`: blocked in global setup by `TypeError: configSet.processWithEsbuild is not a function`; neither suite was loaded. +- `git diff --check`: passed. + +## Interfaces and Dependencies + +The implementation uses existing Angular `HttpClient`, Router, RxJS, standalone component support, environment API URL configuration, profile resolver output, and existing SCSS variables. It adds no package or environment dependency and does not change authentication guards or interceptors. diff --git a/src/app/core/playlist-module/playlist.routes.ts b/src/app/core/playlist-module/playlist.routes.ts new file mode 100644 index 000000000..448853977 --- /dev/null +++ b/src/app/core/playlist-module/playlist.routes.ts @@ -0,0 +1,38 @@ +import { environment } from "@env/environment"; +import { GetPlaylistsQuery } from "./playlist.types"; + +const playlistsPath = `${environment.apiURL}/playlists`; + +export const PLAYLIST_ROUTES = { + GET_PLAYLISTS(query: GetPlaylistsQuery = {}): string { + const params = new URLSearchParams(); + if (query.playlistId) { + params.set("playlistId", query.playlistId); + } + if (query.userId) { + params.set("userId", query.userId); + } + const queryString = params.toString(); + return queryString ? `${playlistsPath}?${queryString}` : playlistsPath; + }, + + CREATE_PLAYLIST(): string { + return playlistsPath; + }, + + UPDATE_PLAYLIST(playlistId: string): string { + return `${playlistsPath}/${encodeURIComponent(playlistId)}`; + }, + + DELETE_PLAYLIST(playlistId: string): string { + return `${playlistsPath}/${encodeURIComponent(playlistId)}`; + }, + + ADD_LEARNING_OBJECT(playlistId: string, cuid: string): string { + return `${playlistsPath}/${encodeURIComponent(playlistId)}/objects/${encodeURIComponent(cuid)}`; + }, + + REMOVE_LEARNING_OBJECT(playlistId: string, cuid: string): string { + return `${playlistsPath}/${encodeURIComponent(playlistId)}/objects/${encodeURIComponent(cuid)}`; + }, +}; diff --git a/src/app/core/playlist-module/playlist.service.spec.ts b/src/app/core/playlist-module/playlist.service.spec.ts new file mode 100644 index 000000000..76af15311 --- /dev/null +++ b/src/app/core/playlist-module/playlist.service.spec.ts @@ -0,0 +1,111 @@ +import { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; +import { environment } from "@env/environment"; +import { PlaylistService } from "./playlist.service"; +import { Playlist } from "./playlist.types"; + +describe("PlaylistService", () => { + let service: PlaylistService; + let httpMock: HttpTestingController; + + const playlist: Playlist = { + _id: "playlist/id", + userId: "user/id", + learningObjectCuids: [], + name: "Security playlist", + description: "A playlist description", + visibility: "public", + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [PlaylistService], + }); + service = TestBed.inject(PlaylistService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it("gets playlists for a profile user", () => { + service.getPlaylists(playlist.userId).subscribe((result) => { + expect(result).toEqual([playlist]); + }); + + const request = httpMock.expectOne( + `${environment.apiURL}/playlists?userId=user%2Fid`, + ); + expect(request.request.method).toBe("GET"); + expect(request.request.withCredentials).toBe(true); + request.flush([playlist]); + }); + + it("gets one hydrated playlist", () => { + service.getPlaylist(playlist._id).subscribe(); + + const request = httpMock.expectOne( + `${environment.apiURL}/playlists?playlistId=playlist%2Fid`, + ); + expect(request.request.method).toBe("GET"); + expect(request.request.withCredentials).toBe(true); + request.flush({ ...playlist, learningObjects: [] }); + }); + + it("creates and updates playlists", () => { + const createRequest = { + name: playlist.name, + description: playlist.description, + visibility: playlist.visibility, + }; + service.createPlaylist(createRequest).subscribe(); + const create = httpMock.expectOne(`${environment.apiURL}/playlists`); + expect(create.request.method).toBe("POST"); + expect(create.request.body).toEqual(createRequest); + create.flush(playlist); + + service + .updatePlaylist(playlist._id, { name: "Updated playlist" }) + .subscribe(); + const update = httpMock.expectOne( + `${environment.apiURL}/playlists/playlist%2Fid`, + ); + expect(update.request.method).toBe("PATCH"); + expect(update.request.body).toEqual({ name: "Updated playlist" }); + update.flush({ ...playlist, name: "Updated playlist" }); + }); + + it("deletes a playlist", () => { + service.deletePlaylist(playlist._id).subscribe((result) => { + expect(result).toBeUndefined(); + }); + + const request = httpMock.expectOne( + `${environment.apiURL}/playlists/playlist%2Fid`, + ); + expect(request.request.method).toBe("DELETE"); + request.flush(null, { status: 204, statusText: "No Content" }); + }); + + it("adds and removes learning objects", () => { + service.addLearningObject(playlist._id, "learning/object").subscribe(); + const add = httpMock.expectOne( + `${environment.apiURL}/playlists/playlist%2Fid/objects/learning%2Fobject`, + ); + expect(add.request.method).toBe("PUT"); + expect(add.request.body).toEqual({}); + add.flush(playlist); + + service + .removeLearningObject(playlist._id, "learning/object") + .subscribe(); + const remove = httpMock.expectOne( + `${environment.apiURL}/playlists/playlist%2Fid/objects/learning%2Fobject`, + ); + expect(remove.request.method).toBe("DELETE"); + remove.flush(playlist); + }); +}); diff --git a/src/app/core/playlist-module/playlist.service.ts b/src/app/core/playlist-module/playlist.service.ts new file mode 100644 index 000000000..f6e538711 --- /dev/null +++ b/src/app/core/playlist-module/playlist.service.ts @@ -0,0 +1,76 @@ +import { HttpClient } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; +import { PLAYLIST_ROUTES } from "./playlist.routes"; +import { + CreatePlaylistRequest, + Playlist, + PlaylistDetails, + UpdatePlaylistRequest, +} from "./playlist.types"; + +@Injectable({ providedIn: "root" }) +export class PlaylistService { + constructor(private readonly http: HttpClient) {} + + getPlaylists(userId?: string): Observable { + return this.http.get( + PLAYLIST_ROUTES.GET_PLAYLISTS({ userId }), + { withCredentials: true }, + ); + } + + getPlaylist(playlistId: string): Observable { + return this.http.get( + PLAYLIST_ROUTES.GET_PLAYLISTS({ playlistId }), + { withCredentials: true }, + ); + } + + createPlaylist(request: CreatePlaylistRequest): Observable { + return this.http.post( + PLAYLIST_ROUTES.CREATE_PLAYLIST(), + request, + { withCredentials: true }, + ); + } + + updatePlaylist( + playlistId: string, + request: UpdatePlaylistRequest, + ): Observable { + return this.http.patch( + PLAYLIST_ROUTES.UPDATE_PLAYLIST(playlistId), + request, + { withCredentials: true }, + ); + } + + deletePlaylist(playlistId: string): Observable { + return this.http + .delete(PLAYLIST_ROUTES.DELETE_PLAYLIST(playlistId), { + observe: "response", + withCredentials: true, + }) + .pipe(map(() => undefined)); + } + + addLearningObject(playlistId: string, cuid: string): Observable { + return this.http.put( + PLAYLIST_ROUTES.ADD_LEARNING_OBJECT(playlistId, cuid), + {}, + { withCredentials: true }, + ); + } + + removeLearningObject( + playlistId: string, + cuid: string, + ): Observable { + return this.http.delete( + PLAYLIST_ROUTES.REMOVE_LEARNING_OBJECT(playlistId, cuid), + { withCredentials: true }, + ); + } +} diff --git a/src/app/core/playlist-module/playlist.types.ts b/src/app/core/playlist-module/playlist.types.ts new file mode 100644 index 000000000..b3883a8fa --- /dev/null +++ b/src/app/core/playlist-module/playlist.types.ts @@ -0,0 +1,45 @@ +export type PlaylistVisibility = "public" | "private"; + +export interface Playlist { + _id: string; + userId: string; + learningObjectCuids: string[]; + name: string; + description: string; + visibility: PlaylistVisibility; + createdAt?: string; + updatedAt?: string; +} + +export interface PlaylistLearningObjectCard { + cuid: string; + name: string; + description: string; + objectCollection: string; + length: number; + levels: string[]; + version: number; + status: string; +} + +export interface PlaylistLearningObject { + cuid: string; + object: PlaylistLearningObjectCard | null; +} + +export interface PlaylistDetails extends Playlist { + learningObjects: PlaylistLearningObject[]; +} + +export interface GetPlaylistsQuery { + playlistId?: string; + userId?: string; +} + +export interface CreatePlaylistRequest { + name: string; + description: string; + visibility: PlaylistVisibility; +} + +export type UpdatePlaylistRequest = Partial; diff --git a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html new file mode 100644 index 000000000..f7c760e79 --- /dev/null +++ b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html @@ -0,0 +1,47 @@ +
+

{{ isUser ? "My Playlists" : "Playlists" }}

+ +
+ + Loading playlists... +
+ + + +

+ {{ + isUser + ? "You don't have any playlists yet." + : "This user doesn't have any public playlists yet." + }} +

+ +
    +
  • +
    +

    {{ playlist.name }}

    + + {{ playlist.visibility }} + +
    +

    {{ playlist.description }}

    + + {{ playlist.learningObjectCuids.length }} + {{ + playlist.learningObjectCuids.length === 1 + ? "object" + : "objects" + }} + +
  • +
+
diff --git a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss new file mode 100644 index 000000000..9719087d1 --- /dev/null +++ b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss @@ -0,0 +1,119 @@ +@import "_vars.scss"; + +.playlists { + max-width: 950px; + box-sizing: border-box; + margin: 30px auto; + padding: 40px; + border: 0.5px rgba(28, 112, 221, 0.1) solid; + border-radius: 8px; + background: $secondary-white-background; + box-shadow: 0 10px 20px -10px rgba(28, 112, 221, 0.1); +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + color: #32363f; + margin-bottom: 24px; +} + +.playlist-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin: 0; + padding: 0; + list-style: none; +} + +.playlist-card { + min-width: 0; + padding: 22px; + border: 1px solid $profile-border; + border-radius: 8px; + background: white; + + p { + color: $dark-grey; + overflow-wrap: anywhere; + } +} + +.playlist-card__heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + h2 { + overflow-wrap: anywhere; + color: #32363f; + font-size: $larger; + } +} + +.visibility { + flex: 0 0 auto; + padding: 4px 8px; + border-radius: 12px; + color: $light-blue; + background: rgba(28, 112, 221, 0.1); + font-size: $small; + text-transform: capitalize; +} + +.visibility--private { + color: $dark-grey; + background: rgba(50, 54, 63, 0.1); +} + +.object-count { + color: $light-blue; + font-size: $small; +} + +.status { + margin: 0; + padding: 32px; + border-radius: 8px; + background: white; + color: $dark-grey; + text-align: center; + + .svg-inline--fa { + margin-right: 8px; + color: $light-blue; + } +} + +.status--error { + color: $error-red; +} + +@media (max-width: 1060px) { + .playlists { + margin: 30px; + } +} + +@media (max-width: 750px) { + .playlist-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 450px) { + .playlists { + margin: 30px 5px; + padding: 24px 16px; + } + + .playlist-card__heading { + flex-direction: column; + } +} diff --git a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts new file mode 100644 index 000000000..1e0fec01e --- /dev/null +++ b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts @@ -0,0 +1,21 @@ +import { NgFor, NgIf } from "@angular/common"; +import { Component, Input } from "@angular/core"; +import { Playlist } from "app/core/playlist-module/playlist.types"; + +@Component({ + selector: "clark-profile-playlists", + templateUrl: "./profile-playlists.component.html", + styleUrls: ["./profile-playlists.component.scss"], + standalone: true, + imports: [NgFor, NgIf], +}) +export class ProfilePlaylistsComponent { + @Input() playlists: Playlist[] = []; + @Input() loading = false; + @Input() hasError = false; + @Input() isUser = false; + + trackPlaylist(_: number, playlist: Playlist): string { + return playlist._id; + } +} diff --git a/src/app/cube/user-profile/user-profile.component.html b/src/app/cube/user-profile/user-profile.component.html index ce8b7be46..233bfbc5f 100644 --- a/src/app/cube/user-profile/user-profile.component.html +++ b/src/app/cube/user-profile/user-profile.component.html @@ -1,11 +1,78 @@ - - + + + +
+ + +
+ +
+ + +
diff --git a/src/app/cube/user-profile/user-profile.component.scss b/src/app/cube/user-profile/user-profile.component.scss index 183a5116b..01bc29265 100644 --- a/src/app/cube/user-profile/user-profile.component.scss +++ b/src/app/cube/user-profile/user-profile.component.scss @@ -19,3 +19,56 @@ margin-bottom: 10px; } } + +.profile-tabs { + display: flex; + width: min(950px, calc(100% - 60px)); + box-sizing: border-box; + margin: 30px auto -30px; + padding-left: 20px; + border-bottom: 1px solid $profile-border; +} + +.profile-tabs__item { + padding: 12px 28px; + border: 1px solid transparent; + border-bottom: 0; + border-radius: 4px 4px 0 0; + color: $dark-grey; + background: transparent; + text-decoration: none; + transition: + color 0.2s ease, + background-color 0.2s ease; + + &:hover { + color: $light-blue; + } + + &:focus-visible { + outline: 3px solid rgba(28, 112, 221, 0.35); + outline-offset: 2px; + } +} + +.profile-tabs__item--active { + position: relative; + top: 1px; + border-color: $profile-border; + color: $light-blue; + background: $secondary-white-background; +} + +@media (max-width: 450px) { + .profile-tabs { + width: calc(100% - 10px); + padding-left: 0; + } + + .profile-tabs__item { + flex: 1; + padding-right: 12px; + padding-left: 12px; + text-align: center; + } +} diff --git a/src/app/cube/user-profile/user-profile.component.spec.ts b/src/app/cube/user-profile/user-profile.component.spec.ts new file mode 100644 index 000000000..d22aebed9 --- /dev/null +++ b/src/app/cube/user-profile/user-profile.component.spec.ts @@ -0,0 +1,92 @@ +import { convertToParamMap, Router } from "@angular/router"; +import { BehaviorSubject, of } from "rxjs"; +import { AuthService } from "app/core/auth-module/auth.service"; +import { CollectionService } from "app/core/collection-module/collections.service"; +import { LearningObjectService } from "app/core/learning-object-module/learning-object/learning-object.service"; +import { PlaylistService } from "app/core/playlist-module/playlist.service"; +import { Playlist } from "app/core/playlist-module/playlist.types"; +import { UserProfileComponent } from "./user-profile.component"; + +describe("UserProfileComponent", () => { + const routeData = new BehaviorSubject>({}); + const queryParams = new BehaviorSubject(convertToParamMap({})); + const playlist: Playlist = { + _id: "playlist-id", + userId: "profile-user-id", + learningObjectCuids: [], + name: "Profile playlist", + description: "A public playlist", + visibility: "public", + }; + const playlistService = { + getPlaylists: jest.fn().mockReturnValue(of([playlist])), + }; + const router = { + navigate: jest.fn().mockResolvedValue(true), + }; + let component: UserProfileComponent; + + beforeEach(() => { + routeData.next({ + user: { + _id: "profile-user-id", + username: "profile-user", + }, + }); + queryParams.next(convertToParamMap({})); + playlistService.getPlaylists.mockClear(); + router.navigate.mockClear(); + + component = new UserProfileComponent( + { + data: routeData.asObservable(), + queryParamMap: queryParams.asObservable(), + } as any, + { username: "profile-user" } as AuthService, + { + fetchLearningObject: jest.fn().mockResolvedValue({}), + } as unknown as LearningObjectService, + { + getUserSubmittedCollections: jest.fn().mockResolvedValue([]), + } as unknown as CollectionService, + playlistService as unknown as PlaylistService, + router as unknown as Router, + ); + void component.ngOnInit(); + }); + + afterEach(() => component.ngOnDestroy()); + + it("defaults direct profile navigation to Contributions", () => { + expect(component.activeTab).toBe("contributions"); + }); + + it("selects Playlists from a directly navigated query parameter", () => { + queryParams.next(convertToParamMap({ tab: "playlists" })); + expect(component.activeTab).toBe("playlists"); + }); + + it("loads playlists using the resolved profile user id", () => { + expect(playlistService.getPlaylists).toHaveBeenCalledWith( + "profile-user-id", + ); + expect(component.playlists).toEqual([playlist]); + expect(component.isUser).toBe(true); + }); + + it("supports arrow-key tab navigation and moves focus", async () => { + const target = document.createElement("a"); + const focus = jest.spyOn(target, "focus"); + const event = { preventDefault: jest.fn() } as unknown as Event; + + component.selectAdjacentTab("playlists", event, target); + await Promise.resolve(); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: expect.anything(), + queryParams: { tab: "playlists" }, + }); + expect(focus).toHaveBeenCalled(); + }); +}); diff --git a/src/app/cube/user-profile/user-profile.component.ts b/src/app/cube/user-profile/user-profile.component.ts index d886d7e37..7dfb8c26f 100644 --- a/src/app/cube/user-profile/user-profile.component.ts +++ b/src/app/cube/user-profile/user-profile.component.ts @@ -1,44 +1,97 @@ -import { Component, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; -import { SubscriptionLike as ISubscription } from "rxjs"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; +import { Subject, Subscription } from "rxjs"; +import { takeUntil } from "rxjs/operators"; import { AuthService } from "app/core/auth-module/auth.service"; import { CollectionService } from "app/core/collection-module/collections.service"; import { LearningObjectService } from "app/core/learning-object-module/learning-object/learning-object.service"; +import { PlaylistService } from "app/core/playlist-module/playlist.service"; +import { Playlist } from "app/core/playlist-module/playlist.types"; import { NgIf } from "@angular/common"; import { ProfileHeaderComponent } from "./components/profile-header/profile-header.component"; import { ProfileLearningObjectsComponent } from "./components/profile-learning-objects/profile-learning-objects.component"; +import { ProfilePlaylistsComponent } from "./components/profile-playlists/profile-playlists.component"; + +type ProfileTab = "contributions" | "playlists"; + @Component({ selector: "clark-user-profile", templateUrl: "./user-profile.component.html", styleUrls: ["./user-profile.component.scss"], standalone: true, - imports: [NgIf, ProfileHeaderComponent, ProfileLearningObjectsComponent], + imports: [ + NgIf, + RouterLink, + ProfileHeaderComponent, + ProfileLearningObjectsComponent, + ProfilePlaylistsComponent, + ], }) -export class UserProfileComponent implements OnInit { +export class UserProfileComponent implements OnInit, OnDestroy { loading: boolean; - subscription: ISubscription; user: any; isUser = false; + activeTab: ProfileTab = "contributions"; + playlists: Playlist[] = []; + playlistsLoading = false; + playlistsError = false; // Array of users learning objects allUserContributions = []; + private readonly destroy$ = new Subject(); + private playlistSubscription?: Subscription; + constructor( private route: ActivatedRoute, private auth: AuthService, private learningObjectService: LearningObjectService, private collectionService: CollectionService, + private playlistService: PlaylistService, + private router: Router, ) {} async ngOnInit() { + this.route.queryParamMap + .pipe(takeUntil(this.destroy$)) + .subscribe((params) => { + this.activeTab = + params.get("tab") === "playlists" + ? "playlists" + : "contributions"; + }); + // Subscribe to data returned from profile.resolver - this.subscription = this.route.data.subscribe(async (val) => { - // Toggle page loading - this.loading = true; - this.user = val.user; - // Check if current user is on their profile - this.isUser = this.user.username === this.auth.username; - await this.initProfileData(); - }); + this.route.data + .pipe(takeUntil(this.destroy$)) + .subscribe(async (val) => { + // Toggle page loading + this.loading = true; + this.user = val.user; + // Check if current user is on their profile + this.isUser = this.user.username === this.auth.username; + this.loadPlaylists(this.user.userId ?? this.user._id); + await this.initProfileData(); + }); + } + + ngOnDestroy(): void { + this.playlistSubscription?.unsubscribe(); + this.destroy$.next(); + this.destroy$.complete(); + } + + selectAdjacentTab( + tab: ProfileTab, + event: Event, + target: HTMLElement, + ): void { + event.preventDefault(); + void this.router + .navigate([], { + relativeTo: this.route, + queryParams: tab === "playlists" ? { tab } : {}, + }) + .then(() => target.focus()); } /** @@ -82,4 +135,31 @@ export class UserProfileComponent implements OnInit { this.loading = false; }); } + + private loadPlaylists(userId: string): void { + this.playlistSubscription?.unsubscribe(); + this.playlists = []; + this.playlistsError = false; + this.playlistsLoading = true; + + if (!userId) { + this.playlistsError = true; + this.playlistsLoading = false; + return; + } + + this.playlistSubscription = this.playlistService + .getPlaylists(userId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (playlists) => { + this.playlists = playlists; + this.playlistsLoading = false; + }, + error: () => { + this.playlistsError = true; + this.playlistsLoading = false; + }, + }); + } } From b7ba4ba8806ce92cb756cb10057e50d71699c39f Mon Sep 17 00:00:00 2001 From: Joaquin Villagomez Date: Thu, 10 Sep 2026 16:26:19 -0400 Subject: [PATCH 2/3] playlist browsing page --- .../sc-39971-profile-playlist-tab.execplan.md | 35 ++- .../secondary-navbar.component.html | 1 + .../playlist-module/playlist.service.spec.ts | 11 + .../core/playlist-module/playlist.types.ts | 2 +- src/app/cube/cube.routing.ts | 13 ++ .../playlist-card.component.html | 31 +++ .../playlist-card.component.scss | 129 +++++++++++ .../playlist-card.component.spec.ts | 34 +++ .../playlist-card/playlist-card.component.ts | 20 ++ .../playlist-details.component.html | 73 ++++++ .../playlist-details.component.scss | 209 ++++++++++++++++++ .../playlist-details.component.spec.ts | 87 ++++++++ .../playlist-details.component.ts | 116 ++++++++++ .../cube/playlists/playlists.component.html | 47 ++++ .../cube/playlists/playlists.component.scss | 111 ++++++++++ .../playlists/playlists.component.spec.ts | 49 ++++ src/app/cube/playlists/playlists.component.ts | 50 +++++ src/app/cube/playlists/playlists.module.ts | 13 ++ src/app/cube/playlists/playlists.routing.ts | 23 ++ .../profile-playlists.component.html | 23 +- .../profile-playlists.component.scss | 46 +--- .../profile-playlists.component.ts | 3 +- 22 files changed, 1054 insertions(+), 72 deletions(-) create mode 100644 src/app/cube/playlists/components/playlist-card/playlist-card.component.html create mode 100644 src/app/cube/playlists/components/playlist-card/playlist-card.component.scss create mode 100644 src/app/cube/playlists/components/playlist-card/playlist-card.component.spec.ts create mode 100644 src/app/cube/playlists/components/playlist-card/playlist-card.component.ts create mode 100644 src/app/cube/playlists/playlist-details/playlist-details.component.html create mode 100644 src/app/cube/playlists/playlist-details/playlist-details.component.scss create mode 100644 src/app/cube/playlists/playlist-details/playlist-details.component.spec.ts create mode 100644 src/app/cube/playlists/playlist-details/playlist-details.component.ts create mode 100644 src/app/cube/playlists/playlists.component.html create mode 100644 src/app/cube/playlists/playlists.component.scss create mode 100644 src/app/cube/playlists/playlists.component.spec.ts create mode 100644 src/app/cube/playlists/playlists.component.ts create mode 100644 src/app/cube/playlists/playlists.module.ts create mode 100644 src/app/cube/playlists/playlists.routing.ts diff --git a/plans/sc-39971-profile-playlist-tab.execplan.md b/plans/sc-39971-profile-playlist-tab.execplan.md index 4ecd6048b..0e2491ebb 100644 --- a/plans/sc-39971-profile-playlist-tab.execplan.md +++ b/plans/sc-39971-profile-playlist-tab.execplan.md @@ -1,10 +1,10 @@ -# SC-39971: Add playlists to user profiles +# SC-39971: Add playlist discovery and profile navigation This ExecPlan is a living document maintained in accordance with `PLANS.md`. ## Purpose / Big Picture -Add a second, responsive and keyboard-accessible tab to `/users/:username` so profile visitors can switch between Contributions and Playlists. Contributions remain the default for existing profile links, while `?tab=playlists` provides a directly navigable playlist view. Add a typed frontend Playlist API boundary with centralized builders for every backend playlist route, and use it to load the profile user's visible playlists. +Add a second, responsive and keyboard-accessible tab to `/users/:username` so profile visitors can switch between Contributions and Playlists. Contributions remain the default for existing profile links, while `?tab=playlists` provides a directly navigable playlist view. Add public playlist discovery at `/playlists`, playlist details at `/playlists/:playlistId`, and navigation from both profile cards and the secondary Browse menu. Add a typed frontend Playlist API boundary with centralized builders for every backend playlist route and use it across these views. ## Progress @@ -12,6 +12,11 @@ Add a second, responsive and keyboard-accessible tab to `/users/:username` so pr - [x] (2026-09-10) Added typed playlist routes, models, and service methods for CRUD and learning-object membership. - [x] (2026-09-10) Added the profile playlist presentation component and integrated accessible URL-backed tabs into the existing profile. - [x] (2026-09-10) Added focused service/profile tests and completed formatting, lint, focused TypeScript validation, and an Angular build; documented the repository-level Jest transformer blocker. +- [x] (2026-09-10) Inspected the existing Cube routing, Browse card grid, secondary Browse menu, and profile playlist presentation before expanding the feature. +- [x] (2026-09-10) Added public playlist browse and playlist-details routes and responsive views. +- [x] (2026-09-10) Linked profile playlist cards and the secondary Browse menu to the new routes. +- [x] (2026-09-10) Added focused service/view/card coverage and completed formatting, lint, focused TypeScript validation, and an Angular build; confirmed the existing Jest transformer blocker remains. +- [x] (2026-09-10) Replaced static playlist-content summaries with the existing linked learning-object cards, resolving each hydrated CUID/version through `LearningObjectService` while preserving unavailable placeholders. ## Surprises & Discoveries @@ -36,10 +41,21 @@ Add a second, responsive and keyboard-accessible tab to `/users/:username` so pr - Decision: Add a domain service and centralized route helper under `src/app/core/playlist-module`. Rationale: Components should not construct endpoint strings or call `HttpClient` directly, and all backend playlist routes need one discoverable client contract. Date/Author: 2026-09-10 / Codex +- Decision: Use `/playlists` as the canonical client route and redirect `/playlits` to it. + Rationale: The requested URL included a misspelling, while the existing feature and backend consistently use `playlists`; the compatibility redirect satisfies direct navigation without preserving the typo in links. + Date/Author: 2026-09-10 / Codex +- Decision: Use `/playlists/:playlistId` for shareable playlist details. + Rationale: It gives every profile and browse card a stable native link and cleanly separates the all-public index from hydrated single-playlist rendering. + Date/Author: 2026-09-10 / Codex +- Decision: Resolve hydrated playlist entries through `LearningObjectService` before rendering the existing learning-object card component. + Rationale: The playlist API's compact card contract does not contain the author username required by the canonical learning-object detail URL. The established card component receives the full object, supplies the correct link, and keeps playlist rendering consistent with Browse. + Date/Author: 2026-09-10 / Codex ## Outcomes & Retrospective -The existing profile URL remains intact and now defaults to a Contributions tab, while `?tab=playlists` provides a refresh-safe and shareable Playlists selection. Tabs use native navigation, ARIA associations and selected state, roving tab stops, visible focus, and arrow/Home/End navigation. The new profile playlist view follows the existing profile card treatment and handles loading, failure, empty, public, and owner-private data responsively. +The existing profile URL remains intact and now defaults to a Contributions tab, while `?tab=playlists` provides a refresh-safe and shareable Playlists selection. Tabs use native navigation, ARIA associations and selected state, roving tab stops, visible focus, and arrow/Home/End navigation. Profile playlist cards now reuse the public browse card and link to a shareable detail page. + +`/playlists` loads the backend's unfiltered public collection and presents it in a responsive card grid matching the Browse page's visual language. `/playlists/:playlistId` renders playlist metadata and hydrated learning-object summaries, including a safe unavailable state for stale CUID references. `/playlits` redirects to the canonical spelling. The secondary Browse menu exposes Browse Playlists in the requested position. The new core Playlist API boundary covers every backend playlist route and sends credentials for authentication-aware visibility. Production code compiles in the Angular build, lint introduces no new warnings or errors, and both added specs compile. Executing Jest remains blocked by the repository's preset/transformer mismatch before test discovery; package changes were intentionally left out of this story. @@ -53,7 +69,9 @@ The request flow will be URL → `UserProfileComponent` → `PlaylistService` Create playlist types describing visibility, summaries, hydrated details, create/update inputs, and learning-object card data. Create route builders for `GET/POST /playlists`, `PATCH/DELETE /playlists/:playlistId`, and `PUT/DELETE /playlists/:playlistId/objects/:cuid`. Create an injectable service exposing typed Observable methods with `withCredentials` enabled. -Create a standalone profile-playlists component that presents playlist summaries without assuming a playlist-details page exists. Update the profile component to observe the query parameter, load playlists whenever resolved profile data changes, and clean up subscriptions. Add a tablist whose links update the URL, expose selected state, support Enter through native link behavior, and support left/right arrow navigation. Preserve the existing contribution component and base URL behavior. +Create a reusable playlist card and standalone public browse/details components under a lazy-loaded Cube playlist feature module. Register `/playlists` and `/playlists/:playlistId` in its feature routing module, plus a compatibility redirect from `/playlits` in the parent router. The index calls the existing unfiltered playlist service method so the backend returns all public playlists. The detail view calls the hydrated single-playlist endpoint and safely renders unavailable learning-object references. Link profile playlist cards to details and insert Browse Playlists between the existing Browse Curriculum and Browse Resources menu items. + +Keep the existing profile behavior: observe the query parameter, load playlists whenever resolved profile data changes, and clean up subscriptions. Keep the tablist URL-backed and keyboard accessible. Add service tests for route/query/method construction and profile tests for URL selection, resolved-user loading, and keyboard navigation. Validate TypeScript templates and styles through Angular lint/build and attempt focused Jest execution without expanding scope into test-infrastructure package changes. @@ -75,6 +93,11 @@ From the `clark-client` root: - Playlist profile requests send `userId` as a query parameter with credentials. - Loading, request failure, empty results, public results, and owner-visible private results render safely. - Every backend playlist route has a centralized frontend route builder and typed service method. +- `/playlists` displays all public playlists and each card navigates to `/playlists/:playlistId`. +- `/playlits` redirects to the canonical public playlist page. +- Playlist details render hydrated learning-object cards while stale/unavailable CUIDs do not break the page. +- Profile playlist cards link to the same detail route. +- The secondary Browse menu orders Browse Playlists between Browse Curriculum and Browse Resources. ## Idempotence and Recovery @@ -85,8 +108,8 @@ All changes are additive except bounded edits to the profile component and its r - `npx prettier --write `: passed. - `npx ng lint clark`: passed with 0 errors and the repository's existing 259 warnings; no changed-file warning was reported. - `npx ng build clark`: passed; one pre-existing CommonJS optimization warning was reported for the standard-guidelines service. -- Focused `npx tsc` configuration containing only the two new specs: passed. -- `npx jest --runInBand src/app/core/playlist-module/playlist.service.spec.ts src/app/cube/user-profile/user-profile.component.spec.ts`: blocked in global setup by `TypeError: configSet.processWithEsbuild is not a function`; neither suite was loaded. +- Focused `npx tsc` configuration containing the playlist service, profile, browse, details, and card specs: passed. +- Focused `npx jest --runInBand ...` across those five specs: blocked in global setup by `TypeError: configSet.processWithEsbuild is not a function`; none of the suites were loaded. - `git diff --check`: passed. ## Interfaces and Dependencies diff --git a/src/app/components/secondary-navbar/secondary-navbar.component.html b/src/app/components/secondary-navbar/secondary-navbar.component.html index 391498278..c22704425 100644 --- a/src/app/components/secondary-navbar/secondary-navbar.component.html +++ b/src/app/components/secondary-navbar/secondary-navbar.component.html @@ -20,6 +20,7 @@
  • Browse Curriculum
  • +
  • Browse Playlists
  • Browse Resources
diff --git a/src/app/core/playlist-module/playlist.service.spec.ts b/src/app/core/playlist-module/playlist.service.spec.ts index 76af15311..2acd8ea81 100644 --- a/src/app/core/playlist-module/playlist.service.spec.ts +++ b/src/app/core/playlist-module/playlist.service.spec.ts @@ -31,6 +31,17 @@ describe("PlaylistService", () => { afterEach(() => httpMock.verify()); + it("gets all public playlists without a user filter", () => { + service.getPlaylists().subscribe((result) => { + expect(result).toEqual([playlist]); + }); + + const request = httpMock.expectOne(`${environment.apiURL}/playlists`); + expect(request.request.method).toBe("GET"); + expect(request.request.withCredentials).toBe(true); + request.flush([playlist]); + }); + it("gets playlists for a profile user", () => { service.getPlaylists(playlist.userId).subscribe((result) => { expect(result).toEqual([playlist]); diff --git a/src/app/core/playlist-module/playlist.types.ts b/src/app/core/playlist-module/playlist.types.ts index b3883a8fa..8f59613af 100644 --- a/src/app/core/playlist-module/playlist.types.ts +++ b/src/app/core/playlist-module/playlist.types.ts @@ -16,7 +16,7 @@ export interface PlaylistLearningObjectCard { name: string; description: string; objectCollection: string; - length: number; + length: string; levels: string[]; version: number; status: string; diff --git a/src/app/cube/cube.routing.ts b/src/app/cube/cube.routing.ts index 8f020a44d..70005de37 100644 --- a/src/app/cube/cube.routing.ts +++ b/src/app/cube/cube.routing.ts @@ -56,6 +56,19 @@ const cube_routes: Routes = [ component: BrowseComponent, data: { title: "Browse Learning Objects" }, }, + { + path: "playlists", + loadChildren: () => + import("./playlists/playlists.module").then( + (m) => m.PlaylistsModule, + ), + data: { title: "Browse Playlists" }, + }, + { + path: "playlits", + redirectTo: "playlists", + pathMatch: "full", + }, { path: "press", component: PressComponent, diff --git a/src/app/cube/playlists/components/playlist-card/playlist-card.component.html b/src/app/cube/playlists/components/playlist-card/playlist-card.component.html new file mode 100644 index 000000000..ef28af755 --- /dev/null +++ b/src/app/cube/playlists/components/playlist-card/playlist-card.component.html @@ -0,0 +1,31 @@ + + + +
+
+ Playlist + + {{ playlist.visibility }} + +
+

{{ playlist.name }}

+

{{ playlist.description }}

+ + {{ objectCount }} + {{ objectCount === 1 ? "learning object" : "learning objects" }} + +
+
diff --git a/src/app/cube/playlists/components/playlist-card/playlist-card.component.scss b/src/app/cube/playlists/components/playlist-card/playlist-card.component.scss new file mode 100644 index 000000000..5fd1ac24b --- /dev/null +++ b/src/app/cube/playlists/components/playlist-card/playlist-card.component.scss @@ -0,0 +1,129 @@ +@import "_vars.scss"; + +:host { + display: block; +} + +.playlist-card { + display: flex; + height: 100%; + min-height: 350px; + flex-direction: column; + overflow: hidden; + border: 1px solid $profile-border; + border-radius: 6px; + background: white; + color: inherit; + text-decoration: none; + transition: + box-shadow 0.15s ease-out, + transform 0.15s ease-out; + + &:hover { + transform: scale(1.01); + box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.25); + } + + &:focus-visible { + outline: 3px solid $light-blue; + outline-offset: 3px; + } +} + +.playlist-card__image { + position: relative; + display: flex; + min-height: 180px; + align-items: center; + justify-content: center; + overflow: hidden; + background: $light-blue-gradient; + color: white; + + &::after { + position: absolute; + inset: 0; + background: white; + content: ""; + mask: $cube-pattern repeat; + opacity: 0.08; + } + + i { + z-index: 1; + font-size: 52px; + } +} + +.playlist-card__content { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + padding: 16px; + + h2 { + margin: 8px 0; + overflow-wrap: anywhere; + color: $darker-grey; + font-size: $normal; + line-height: 1.4; + } + + p { + display: -webkit-box; + margin: 0 0 16px; + overflow: hidden; + color: $dark-grey; + font-size: $small; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } +} + +.playlist-card__labels { + display: flex; + min-height: 24px; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.playlist-label, +.visibility { + padding: 4px 8px; + border-radius: 12px; + font-size: $smaller; + text-transform: uppercase; +} + +.playlist-label { + background: $box-background; + color: $homepage-light-blue; +} + +.visibility { + background: rgba(28, 112, 221, 0.1); + color: $light-blue; +} + +.visibility--private { + background: rgba(50, 54, 63, 0.1); + color: $dark-grey; +} + +.object-count { + margin-top: auto; + color: $light-blue; + font-size: $small; +} + +@media (max-width: 750px) { + .playlist-card { + min-height: 310px; + } + + .playlist-card__image { + min-height: 150px; + } +} diff --git a/src/app/cube/playlists/components/playlist-card/playlist-card.component.spec.ts b/src/app/cube/playlists/components/playlist-card/playlist-card.component.spec.ts new file mode 100644 index 000000000..2b27cb273 --- /dev/null +++ b/src/app/cube/playlists/components/playlist-card/playlist-card.component.spec.ts @@ -0,0 +1,34 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RouterTestingModule } from "@angular/router/testing"; +import { Playlist } from "app/core/playlist-module/playlist.types"; +import { PlaylistCardComponent } from "./playlist-card.component"; + +describe("PlaylistCardComponent", () => { + let fixture: ComponentFixture; + + const playlist: Playlist = { + _id: "playlist-id", + userId: "user-id", + learningObjectCuids: ["object-cuid"], + name: "Public playlist", + description: "A public playlist", + visibility: "public", + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PlaylistCardComponent, RouterTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(PlaylistCardComponent); + fixture.componentInstance.playlist = playlist; + fixture.detectChanges(); + }); + + it("links to the playlist detail page", () => { + const link = fixture.nativeElement.querySelector("a"); + + expect(link.getAttribute("href")).toBe("/playlists/playlist-id"); + expect(link.getAttribute("aria-label")).toContain("1 learning object"); + }); +}); diff --git a/src/app/cube/playlists/components/playlist-card/playlist-card.component.ts b/src/app/cube/playlists/components/playlist-card/playlist-card.component.ts new file mode 100644 index 000000000..1ff20f09b --- /dev/null +++ b/src/app/cube/playlists/components/playlist-card/playlist-card.component.ts @@ -0,0 +1,20 @@ +import { NgIf } from "@angular/common"; +import { Component, Input } from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { Playlist } from "app/core/playlist-module/playlist.types"; + +@Component({ + selector: "clark-playlist-card", + templateUrl: "./playlist-card.component.html", + styleUrls: ["./playlist-card.component.scss"], + standalone: true, + imports: [NgIf, RouterLink], +}) +export class PlaylistCardComponent { + @Input({ required: true }) playlist!: Playlist; + @Input() showVisibility = false; + + get objectCount(): number { + return this.playlist.learningObjectCuids?.length ?? 0; + } +} diff --git a/src/app/cube/playlists/playlist-details/playlist-details.component.html b/src/app/cube/playlists/playlist-details/playlist-details.component.html new file mode 100644 index 000000000..29f7c7557 --- /dev/null +++ b/src/app/cube/playlists/playlist-details/playlist-details.component.html @@ -0,0 +1,73 @@ +
+ + + Browse Playlists + + +
+ + Loading playlist... +
+ + + + +
+ +
+

Playlist

+

{{ playlist.name }}

+

{{ playlist.description }}

+ + {{ learningObjects.length }} + {{ + learningObjects.length === 1 + ? "learning object" + : "learning objects" + }} + +
+
+ +
+

Learning Objects

+ +

+ This playlist does not contain any learning objects yet. +

+ +
    +
  • + + + +
    + +
    + Unavailable +

    Learning object unavailable

    +

    + The learning object referenced by + {{ entry.cuid }} + is missing or not currently available. +

    +
    +
    +
    +
  • +
+
+
+
diff --git a/src/app/cube/playlists/playlist-details/playlist-details.component.scss b/src/app/cube/playlists/playlist-details/playlist-details.component.scss new file mode 100644 index 000000000..56dd5c6f4 --- /dev/null +++ b/src/app/cube/playlists/playlist-details/playlist-details.component.scss @@ -0,0 +1,209 @@ +@import "_vars.scss"; + +.playlist-details { + max-width: 1200px; + margin: 0 auto; + padding: 36px 20px 70px; +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 28px; + color: $light-blue; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $light-blue; + outline-offset: 4px; + } +} + +.playlist-header { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + align-items: center; + gap: 32px; + margin-bottom: 48px; + padding: 34px; + border: 1px solid $profile-border; + border-radius: 8px; + background: $secondary-white-background; + + h1 { + margin: 0 0 12px; + overflow-wrap: anywhere; + color: $darker-grey; + font-size: $largest; + } +} + +.playlist-header__icon { + position: relative; + display: flex; + width: 180px; + height: 180px; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 6px; + background: $light-blue-gradient; + color: white; + + &::after { + position: absolute; + inset: 0; + background: white; + content: ""; + mask: $cube-pattern repeat; + opacity: 0.08; + } + + i { + z-index: 1; + font-size: 56px; + } +} + +.eyebrow, +.object-type { + color: $light-blue; + font-size: $smaller; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; +} + +.eyebrow, +.description { + margin: 0 0 10px; +} + +.description { + max-width: 720px; + color: $dark-grey; +} + +.object-count, +.object-version { + color: $dark-grey; + font-size: $small; +} + +section h2 { + margin-bottom: 22px; + color: $darker-grey; +} + +.object-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 20px; + margin: 0; + padding: 0; + list-style: none; + + li, + clark-learning-object-component { + height: 100%; + min-width: 0; + } +} + +.unavailable-card { + display: flex; + height: 100%; + min-height: 350px; + flex-direction: column; + overflow: hidden; + border: 1px solid $profile-border; + border-radius: 6px; + background: white; +} + +.unavailable-card__icon { + display: flex; + min-height: 180px; + align-items: center; + justify-content: center; + background: $primary-white; + color: $medium-grey; + font-size: 38px; +} + +.unavailable-card__content { + display: flex; + flex: 1; + flex-direction: column; + padding: 18px; + + h3 { + margin: 8px 0; + overflow-wrap: anywhere; + color: $darker-grey; + font-size: $normal; + } + + p { + margin: 0 0 14px; + overflow-wrap: anywhere; + color: $dark-grey; + font-size: $small; + } +} + +.cuid { + font-family: monospace; +} + +.status { + padding: 50px 20px; + border-radius: 6px; + background: $secondary-white-background; + color: $dark-grey; + text-align: center; + + i { + margin-right: 8px; + color: $light-blue; + } +} + +.status--error { + color: $error-red; + + h1 { + margin-top: 0; + } +} + +@media (max-width: 900px) { + .object-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 650px) { + .playlist-details { + padding: 28px 15px 50px; + } + + .playlist-header { + grid-template-columns: 1fr; + padding: 22px; + } + + .playlist-header__icon { + width: 100%; + height: 150px; + } + + .object-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/cube/playlists/playlist-details/playlist-details.component.spec.ts b/src/app/cube/playlists/playlist-details/playlist-details.component.spec.ts new file mode 100644 index 000000000..5ce834696 --- /dev/null +++ b/src/app/cube/playlists/playlist-details/playlist-details.component.spec.ts @@ -0,0 +1,87 @@ +import { convertToParamMap } from "@angular/router"; +import { BehaviorSubject, of, throwError } from "rxjs"; +import { PlaylistService } from "app/core/playlist-module/playlist.service"; +import { PlaylistDetails } from "app/core/playlist-module/playlist.types"; +import { LearningObjectService } from "app/core/learning-object-module/learning-object/learning-object.service"; +import { PlaylistDetailsComponent } from "./playlist-details.component"; + +describe("PlaylistDetailsComponent", () => { + const params = new BehaviorSubject( + convertToParamMap({ playlistId: "playlist-id" }), + ); + const playlist: PlaylistDetails = { + _id: "playlist-id", + userId: "user-id", + learningObjectCuids: ["available-cuid", "stale-cuid"], + learningObjects: [ + { + cuid: "available-cuid", + object: { + cuid: "available-cuid", + name: "Available object", + description: "Object description", + objectCollection: "collection", + length: "nanomodule", + levels: ["undergraduate"], + version: 2, + status: "released", + }, + }, + { cuid: "stale-cuid", object: null }, + ], + name: "Public playlist", + description: "A public playlist", + visibility: "public", + }; + + it("loads a hydrated playlist from the route id", () => { + const service = { + getPlaylist: jest.fn().mockReturnValue(of(playlist)), + }; + const learningObjectService = { + getLearningObjectObservable: jest.fn().mockReturnValue( + of({ + cuid: "available-cuid", + version: 2, + author: { username: "author" }, + }), + ), + }; + const component = new PlaylistDetailsComponent( + { paramMap: params.asObservable() } as any, + service as unknown as PlaylistService, + learningObjectService as unknown as LearningObjectService, + ); + + component.ngOnInit(); + + expect(service.getPlaylist).toHaveBeenCalledWith("playlist-id"); + expect( + learningObjectService.getLearningObjectObservable, + ).toHaveBeenCalledWith({ + cuidInfo: { cuid: "available-cuid", version: 2 }, + }); + expect(component.learningObjects[1].object).toBeNull(); + expect(component.loading).toBe(false); + component.ngOnDestroy(); + }); + + it("shows an unavailable state when the playlist request fails", () => { + const service = { + getPlaylist: jest + .fn() + .mockReturnValue(throwError(() => new Error("not found"))), + }; + const component = new PlaylistDetailsComponent( + { paramMap: params.asObservable() } as any, + service as unknown as PlaylistService, + {} as LearningObjectService, + ); + + component.ngOnInit(); + + expect(component.hasError).toBe(true); + expect(component.loading).toBe(false); + component.ngOnDestroy(); + }); +}); diff --git a/src/app/cube/playlists/playlist-details/playlist-details.component.ts b/src/app/cube/playlists/playlist-details/playlist-details.component.ts new file mode 100644 index 000000000..b6827b99b --- /dev/null +++ b/src/app/cube/playlists/playlist-details/playlist-details.component.ts @@ -0,0 +1,116 @@ +import { NgFor, NgIf } from "@angular/common"; +import { HttpErrorResponse } from "@angular/common/http"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute, RouterLink } from "@angular/router"; +import { LearningObject } from "@entity"; +import { LearningObjectService } from "app/core/learning-object-module/learning-object/learning-object.service"; +import { PlaylistService } from "app/core/playlist-module/playlist.service"; +import { PlaylistDetails } from "app/core/playlist-module/playlist.types"; +import { LearningObjectListingComponent } from "app/cube/shared/learning-object/learning-object.component"; +import { LearningObjectCardDirective } from "app/shared/directives/learning-object-card.directive"; +import { forkJoin, Observable, of, Subject } from "rxjs"; +import { catchError, map, switchMap, takeUntil } from "rxjs/operators"; + +interface PlaylistLearningObjectView { + cuid: string; + object: LearningObject | null; +} + +@Component({ + selector: "clark-playlist-details", + templateUrl: "./playlist-details.component.html", + styleUrls: ["./playlist-details.component.scss"], + standalone: true, + imports: [ + NgFor, + NgIf, + RouterLink, + LearningObjectListingComponent, + LearningObjectCardDirective, + ], +}) +export class PlaylistDetailsComponent implements OnInit, OnDestroy { + playlist?: PlaylistDetails; + learningObjects: PlaylistLearningObjectView[] = []; + loading = true; + hasError = false; + + private readonly destroy$ = new Subject(); + + constructor( + private readonly route: ActivatedRoute, + private readonly playlistService: PlaylistService, + private readonly learningObjectService: LearningObjectService, + ) {} + + ngOnInit(): void { + this.route.paramMap + .pipe( + takeUntil(this.destroy$), + switchMap((params) => { + const playlistId = params.get("playlistId"); + if (!playlistId) { + throw new Error("Playlist ID is required"); + } + this.loading = true; + this.hasError = false; + return this.playlistService.getPlaylist(playlistId); + }), + switchMap((playlist) => { + this.playlist = playlist; + return this.loadLearningObjects(playlist); + }), + ) + .subscribe({ + next: (learningObjects) => { + this.learningObjects = learningObjects; + this.loading = false; + }, + error: () => { + this.hasError = true; + this.loading = false; + }, + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private loadLearningObjects( + playlist: PlaylistDetails, + ): Observable { + if (!playlist.learningObjects.length) { + return of([]); + } + + return forkJoin( + playlist.learningObjects.map((entry) => { + if (!entry.object) { + return of({ cuid: entry.cuid, object: null }); + } + + return this.learningObjectService + .getLearningObjectObservable({ + cuidInfo: { + cuid: entry.cuid, + version: entry.object.version, + }, + }) + .pipe( + map((object) => ({ + cuid: entry.cuid, + object: + object instanceof HttpErrorResponse + ? null + : object, + })), + catchError(() => + of({ cuid: entry.cuid, object: null }), + ), + ); + }), + ); + } +} diff --git a/src/app/cube/playlists/playlists.component.html b/src/app/cube/playlists/playlists.component.html new file mode 100644 index 000000000..d358882c4 --- /dev/null +++ b/src/app/cube/playlists/playlists.component.html @@ -0,0 +1,47 @@ +
+ + + + +
+
+ + Loading playlists... +
+ + + +

+ There are no public playlists yet. +

+ +
    +
  • + +
  • +
+
+
diff --git a/src/app/cube/playlists/playlists.component.scss b/src/app/cube/playlists/playlists.component.scss new file mode 100644 index 000000000..3547c5285 --- /dev/null +++ b/src/app/cube/playlists/playlists.component.scss @@ -0,0 +1,111 @@ +@import "_vars.scss"; + +.playlists-page { + max-width: $max-width; + margin: 0 auto; + padding: 40px 20px 70px; +} + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 30px; + margin-bottom: 32px; + + h1 { + margin: 0 0 8px; + color: $darker-grey; + font-size: $largest; + } +} + +.eyebrow { + margin: 0 0 4px; + color: $light-blue; + font-size: $small; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; +} + +.intro { + max-width: 650px; + margin: 0; + color: $dark-grey; +} + +.result-count { + flex: 0 0 auto; + color: $dark-grey; + font-size: $small; + text-transform: uppercase; +} + +.playlist-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 20px; + margin: 0; + padding: 0; + list-style: none; + + li, + clark-playlist-card { + height: 100%; + min-width: 0; + } +} + +.status { + margin: 0; + padding: 60px 20px; + border-radius: 6px; + background: $secondary-white-background; + color: $dark-grey; + text-align: center; + + i { + margin-right: 8px; + color: $light-blue; + } +} + +.status--error { + color: $error-red; +} + +@media (max-width: 1600px) { + .playlist-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 1200px) { + .playlist-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .playlist-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 600px) { + .playlists-page { + padding: 28px 15px 50px; + } + + .page-header { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + + .playlist-grid { + grid-template-columns: 1fr; + gap: 16px; + } +} diff --git a/src/app/cube/playlists/playlists.component.spec.ts b/src/app/cube/playlists/playlists.component.spec.ts new file mode 100644 index 000000000..7763ce81f --- /dev/null +++ b/src/app/cube/playlists/playlists.component.spec.ts @@ -0,0 +1,49 @@ +import { of, throwError } from "rxjs"; +import { PlaylistService } from "app/core/playlist-module/playlist.service"; +import { Playlist } from "app/core/playlist-module/playlist.types"; +import { PlaylistsComponent } from "./playlists.component"; + +describe("PlaylistsComponent", () => { + const playlist: Playlist = { + _id: "playlist-id", + userId: "user-id", + learningObjectCuids: [], + name: "Public playlist", + description: "A public playlist", + visibility: "public", + }; + + it("loads the unfiltered public playlist collection", () => { + const service = { + getPlaylists: jest.fn().mockReturnValue(of([playlist])), + }; + const component = new PlaylistsComponent( + service as unknown as PlaylistService, + ); + + component.ngOnInit(); + + expect(service.getPlaylists).toHaveBeenCalledWith(); + expect(component.playlists).toEqual([playlist]); + expect(component.loading).toBe(false); + expect(component.hasError).toBe(false); + component.ngOnDestroy(); + }); + + it("shows an error state when public playlists cannot be loaded", () => { + const service = { + getPlaylists: jest + .fn() + .mockReturnValue(throwError(() => new Error("failed"))), + }; + const component = new PlaylistsComponent( + service as unknown as PlaylistService, + ); + + component.ngOnInit(); + + expect(component.hasError).toBe(true); + expect(component.loading).toBe(false); + component.ngOnDestroy(); + }); +}); diff --git a/src/app/cube/playlists/playlists.component.ts b/src/app/cube/playlists/playlists.component.ts new file mode 100644 index 000000000..f10875c2d --- /dev/null +++ b/src/app/cube/playlists/playlists.component.ts @@ -0,0 +1,50 @@ +import { NgFor, NgIf } from "@angular/common"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { PlaylistService } from "app/core/playlist-module/playlist.service"; +import { Playlist } from "app/core/playlist-module/playlist.types"; +import { SkipLinkComponent } from "app/shared/components/skip-link/skip-link.component"; +import { Subject } from "rxjs"; +import { takeUntil } from "rxjs/operators"; +import { PlaylistCardComponent } from "./components/playlist-card/playlist-card.component"; + +@Component({ + selector: "clark-playlists", + templateUrl: "./playlists.component.html", + styleUrls: ["./playlists.component.scss"], + standalone: true, + imports: [NgFor, NgIf, PlaylistCardComponent, SkipLinkComponent], +}) +export class PlaylistsComponent implements OnInit, OnDestroy { + playlists: Playlist[] = []; + loading = true; + hasError = false; + + private readonly destroy$ = new Subject(); + + constructor(private readonly playlistService: PlaylistService) {} + + ngOnInit(): void { + this.playlistService + .getPlaylists() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (playlists) => { + this.playlists = playlists; + this.loading = false; + }, + error: () => { + this.hasError = true; + this.loading = false; + }, + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + trackPlaylist(_: number, playlist: Playlist): string { + return playlist._id; + } +} diff --git a/src/app/cube/playlists/playlists.module.ts b/src/app/cube/playlists/playlists.module.ts new file mode 100644 index 000000000..fc91404c4 --- /dev/null +++ b/src/app/cube/playlists/playlists.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from "@angular/core"; +import { PlaylistDetailsComponent } from "./playlist-details/playlist-details.component"; +import { PlaylistsComponent } from "./playlists.component"; +import { PlaylistsRoutingModule } from "./playlists.routing"; + +@NgModule({ + imports: [ + PlaylistsRoutingModule, + PlaylistsComponent, + PlaylistDetailsComponent, + ], +}) +export class PlaylistsModule {} diff --git a/src/app/cube/playlists/playlists.routing.ts b/src/app/cube/playlists/playlists.routing.ts new file mode 100644 index 000000000..64899de8b --- /dev/null +++ b/src/app/cube/playlists/playlists.routing.ts @@ -0,0 +1,23 @@ +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; +import { PlaylistDetailsComponent } from "./playlist-details/playlist-details.component"; +import { PlaylistsComponent } from "./playlists.component"; + +const playlistRoutes: Routes = [ + { + path: "", + component: PlaylistsComponent, + pathMatch: "full", + }, + { + path: ":playlistId", + component: PlaylistDetailsComponent, + data: { title: "Playlist" }, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(playlistRoutes)], + exports: [RouterModule], +}) +export class PlaylistsRoutingModule {} diff --git a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html index f7c760e79..4817ed06c 100644 --- a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html +++ b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html @@ -22,26 +22,9 @@

{{ isUser ? "My Playlists" : "Playlists" }}

  • -
    -

    {{ playlist.name }}

    - - {{ playlist.visibility }} - -
    -

    {{ playlist.description }}

    - - {{ playlist.learningObjectCuids.length }} - {{ - playlist.learningObjectCuids.length === 1 - ? "object" - : "objects" - }} - +
  • diff --git a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss index 9719087d1..510c9d813 100644 --- a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss +++ b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss @@ -33,50 +33,12 @@ h1 { .playlist-card { min-width: 0; - padding: 22px; - border: 1px solid $profile-border; - border-radius: 8px; - background: white; - - p { - color: $dark-grey; - overflow-wrap: anywhere; - } -} - -.playlist-card__heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - h2 { - overflow-wrap: anywhere; - color: #32363f; - font-size: $larger; + clark-playlist-card { + height: 100%; } } -.visibility { - flex: 0 0 auto; - padding: 4px 8px; - border-radius: 12px; - color: $light-blue; - background: rgba(28, 112, 221, 0.1); - font-size: $small; - text-transform: capitalize; -} - -.visibility--private { - color: $dark-grey; - background: rgba(50, 54, 63, 0.1); -} - -.object-count { - color: $light-blue; - font-size: $small; -} - .status { margin: 0; padding: 32px; @@ -112,8 +74,4 @@ h1 { margin: 30px 5px; padding: 24px 16px; } - - .playlist-card__heading { - flex-direction: column; - } } diff --git a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts index 1e0fec01e..d16ff2585 100644 --- a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts +++ b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts @@ -1,13 +1,14 @@ import { NgFor, NgIf } from "@angular/common"; import { Component, Input } from "@angular/core"; import { Playlist } from "app/core/playlist-module/playlist.types"; +import { PlaylistCardComponent } from "../../../playlists/components/playlist-card/playlist-card.component"; @Component({ selector: "clark-profile-playlists", templateUrl: "./profile-playlists.component.html", styleUrls: ["./profile-playlists.component.scss"], standalone: true, - imports: [NgFor, NgIf], + imports: [NgFor, NgIf, PlaylistCardComponent], }) export class ProfilePlaylistsComponent { @Input() playlists: Playlist[] = []; From 55347cebe06346b5187182cca4012fe13a6c01f8 Mon Sep 17 00:00:00 2001 From: Joaquin Villagomez Date: Fri, 11 Sep 2026 13:49:05 -0400 Subject: [PATCH 3/3] basic browse page --- ...971-browse-playlists-dashboard.execplan.md | 79 ++++++++++++ .../sc-39971-profile-playlist-tab.execplan.md | 117 ------------------ .../core/playlist-module/playlist.routes.ts | 23 ---- .../playlist-module/playlist.service.spec.ts | 67 ---------- .../core/playlist-module/playlist.service.ts | 63 +--------- .../core/playlist-module/playlist.types.ts | 9 -- .../profile-playlists.component.html | 30 ----- .../profile-playlists.component.scss | 77 ------------ .../profile-playlists.component.ts | 22 ---- .../user-profile/user-profile.component.html | 77 +----------- .../user-profile/user-profile.component.scss | 53 -------- .../user-profile.component.spec.ts | 92 -------------- .../user-profile/user-profile.component.ts | 108 +++------------- 13 files changed, 103 insertions(+), 714 deletions(-) create mode 100644 plans/sc-39971-browse-playlists-dashboard.execplan.md delete mode 100644 plans/sc-39971-profile-playlist-tab.execplan.md delete mode 100644 src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html delete mode 100644 src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss delete mode 100644 src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts delete mode 100644 src/app/cube/user-profile/user-profile.component.spec.ts diff --git a/plans/sc-39971-browse-playlists-dashboard.execplan.md b/plans/sc-39971-browse-playlists-dashboard.execplan.md new file mode 100644 index 000000000..b09c3f7d7 --- /dev/null +++ b/plans/sc-39971-browse-playlists-dashboard.execplan.md @@ -0,0 +1,79 @@ +# SC-39971: Add the public playlist browse dashboard + +This ExecPlan is a living document maintained in accordance with `PLANS.md`. + +## Purpose / Big Picture + +Add a public playlist dashboard at `/playlists`. Visitors can browse every public playlist, open a playlist detail page, and follow its available learning objects to their existing CLARK detail pages. The user profile remains unchanged and contributions-only. + +## Progress + +- [x] (2026-09-10) Added the typed playlist read service and route builder. +- [x] (2026-09-10) Added lazy-loaded browse and detail routes with responsive cards and loading, empty, and failure states. +- [x] (2026-09-10) Added Browse Playlists to the secondary navigation and linked learning objects through the existing card component. +- [x] (2026-09-11) Removed the profile playlist tabs, profile-specific component/tests, and unused playlist mutation client methods. +- [x] (2026-09-11) Re-ran formatting, lint, build, focused TypeScript spec compilation, and Jest; documented the unchanged Jest infrastructure blocker. + +## Surprises & Discoveries + +- Observation: The hydrated playlist response has CUID/version card data but not the author username required by the existing learning-object URL. The detail page resolves each available entry through `LearningObjectService` before rendering the established linked card. +- Observation: Jest is currently blocked during global setup by `TypeError: configSet.processWithEsbuild is not a function`; focused TypeScript compilation is the available spec-validation fallback. + +## Decision Log + +- Decision: Keep `/playlists` as the canonical URL and redirect `/playlits` to it. + Rationale: This supports the originally requested misspelling without making it the permanent route contract. + Date/Author: 2026-09-10 / Codex +- Decision: Keep playlist browsing independent from user profiles. + Rationale: The revised branch scope is the public browse dashboard only. + Date/Author: 2026-09-11 / Codex +- Decision: Retain only `getPlaylists()` and `getPlaylist()` in the client service. + Rationale: Creation, mutation, membership, and profile filtering are not used by this read-only dashboard. + Date/Author: 2026-09-11 / Codex + +## Outcomes & Retrospective + +The branch now contains only the public playlist browse/detail experience. The user profile matches `origin/main` and has no playlist tab, playlist request, or profile-only test/component. The playlist client boundary contains only the two GET operations consumed by the dashboard. The Angular build, lint, and focused TypeScript spec compilation pass; Jest remains blocked before test discovery by the repository's transformer mismatch. + +## Context and Orientation + +`src/app/cube/cube.routing.ts` lazy-loads `src/app/cube/playlists/playlists.module.ts`. The feature router owns the index and `:playlistId` detail routes. `src/app/core/playlist-module` owns the backend read contract. The existing learning-object service and card component supply canonical learning-object navigation. + +## Plan of Work + +Keep the public playlist grid, playlist detail page, shared playlist card, secondary Browse link, read-only service, and related tests. Restore `src/app/cube/user-profile/user-profile.component.*` to its original contributions-only behavior and remove all profile playlist files and tests. + +## Concrete Steps + +From the `clark-client` root: + + npx prettier --write + npx ng lint clark + npx ng build clark + npx jest --runInBand + +## Validation and Acceptance + +- `/users/:username` has no playlist tab and behaves as it did before this branch. +- `/playlists` requests the unfiltered public playlist collection. +- `/playlists/:playlistId` displays the selected playlist. +- Available learning-object cards navigate to their real detail pages; stale references render safely. +- Browse Playlists remains between Browse Curriculum and Browse Resources. +- No profile playlist component, state, service call, or test remains. + +## Idempotence and Recovery + +The cleanup restores profile files to their base-branch content and deletes only branch-added profile files. No persisted data or backend behavior changes. + +## Artifacts and Notes + +- `npx prettier --write `: passed. +- `npx ng lint clark`: passed with 0 errors and 259 existing warnings. +- `npx ng build clark`: passed. +- Focused `npx tsc` spec configuration: passed. +- Focused `npx jest --runInBand ...`: blocked before test discovery by `TypeError: configSet.processWithEsbuild is not a function`. +- `git diff --check`: passed. + +## Interfaces and Dependencies + +The dashboard uses Angular Router, `HttpClient`, RxJS, the existing learning-object service/card, and the backend `GET /playlists` query contract. It adds no package or environment dependency. diff --git a/plans/sc-39971-profile-playlist-tab.execplan.md b/plans/sc-39971-profile-playlist-tab.execplan.md deleted file mode 100644 index 0e2491ebb..000000000 --- a/plans/sc-39971-profile-playlist-tab.execplan.md +++ /dev/null @@ -1,117 +0,0 @@ -# SC-39971: Add playlist discovery and profile navigation - -This ExecPlan is a living document maintained in accordance with `PLANS.md`. - -## Purpose / Big Picture - -Add a second, responsive and keyboard-accessible tab to `/users/:username` so profile visitors can switch between Contributions and Playlists. Contributions remain the default for existing profile links, while `?tab=playlists` provides a directly navigable playlist view. Add public playlist discovery at `/playlists`, playlist details at `/playlists/:playlistId`, and navigation from both profile cards and the secondary Browse menu. Add a typed frontend Playlist API boundary with centralized builders for every backend playlist route and use it across these views. - -## Progress - -- [x] (2026-09-10) Inspected profile routing, resolver behavior, profile components, existing tab styling, API route helpers, service conventions, and available test/build scripts. -- [x] (2026-09-10) Added typed playlist routes, models, and service methods for CRUD and learning-object membership. -- [x] (2026-09-10) Added the profile playlist presentation component and integrated accessible URL-backed tabs into the existing profile. -- [x] (2026-09-10) Added focused service/profile tests and completed formatting, lint, focused TypeScript validation, and an Angular build; documented the repository-level Jest transformer blocker. -- [x] (2026-09-10) Inspected the existing Cube routing, Browse card grid, secondary Browse menu, and profile playlist presentation before expanding the feature. -- [x] (2026-09-10) Added public playlist browse and playlist-details routes and responsive views. -- [x] (2026-09-10) Linked profile playlist cards and the secondary Browse menu to the new routes. -- [x] (2026-09-10) Added focused service/view/card coverage and completed formatting, lint, focused TypeScript validation, and an Angular build; confirmed the existing Jest transformer blocker remains. -- [x] (2026-09-10) Replaced static playlist-content summaries with the existing linked learning-object cards, resolving each hydrated CUID/version through `LearningObjectService` while preserving unavailable placeholders. - -## Surprises & Discoveries - -- Observation: The profile is a standalone component registered directly at `/users/:username`; Contributions are not currently a child route. - Evidence: `src/app/cube/cube.routing.ts` and `src/app/cube/user-profile/user-profile.component.*`. -- Observation: Profile resolver data contains the backend `_id`, while the reusable `User` entity exposes it as `userId`; the profile currently keeps the resolver result untyped. - Evidence: `src/app/cube/core/profile.resolver.ts`, `src/app/core/user-module/user.service.ts`, and `src/entity/user/user.ts`. -- Observation: The backend's user-filtered playlist GET already applies authentication-aware visibility, so the client should pass the profile user ID and credentials without duplicating access rules. -- Observation: Focused Jest execution fails during global setup before loading test files because the installed `jest-preset-angular` transformer calls a missing `processWithEsbuild` function. The same new specs compile successfully through a focused TypeScript configuration. - Evidence: `npx jest --runInBand ...` fails from `src/setup-jest.ts`; `npm ls` reports Jest 29.7.0, jest-preset-angular 14.6.2, and ts-jest 29.4.6. -- Observation: The Angular workspace has no `development` build configuration despite that name being common in newer Angular workspaces. - Evidence: `npx ng build clark --configuration=development` reports that the configuration is not set; the default `npx ng build clark` succeeds. - -## Decision Log - -- Decision: Represent the selected profile tab with the optional `tab=playlists` query parameter. - Rationale: `/users/:username` remains backward-compatible and defaults to Contributions, while playlist views can be directly linked, refreshed, and navigated with browser history without restructuring the existing resolved profile route. - Date/Author: 2026-09-10 / Codex -- Decision: Use native links with ARIA tab semantics and arrow-key handling rather than adding Angular Material tabs to this standalone component. - Rationale: Native links preserve direct navigation and keyboard activation while local SCSS can match the existing profile design without introducing another UI dependency. - Date/Author: 2026-09-10 / Codex -- Decision: Add a domain service and centralized route helper under `src/app/core/playlist-module`. - Rationale: Components should not construct endpoint strings or call `HttpClient` directly, and all backend playlist routes need one discoverable client contract. - Date/Author: 2026-09-10 / Codex -- Decision: Use `/playlists` as the canonical client route and redirect `/playlits` to it. - Rationale: The requested URL included a misspelling, while the existing feature and backend consistently use `playlists`; the compatibility redirect satisfies direct navigation without preserving the typo in links. - Date/Author: 2026-09-10 / Codex -- Decision: Use `/playlists/:playlistId` for shareable playlist details. - Rationale: It gives every profile and browse card a stable native link and cleanly separates the all-public index from hydrated single-playlist rendering. - Date/Author: 2026-09-10 / Codex -- Decision: Resolve hydrated playlist entries through `LearningObjectService` before rendering the existing learning-object card component. - Rationale: The playlist API's compact card contract does not contain the author username required by the canonical learning-object detail URL. The established card component receives the full object, supplies the correct link, and keeps playlist rendering consistent with Browse. - Date/Author: 2026-09-10 / Codex - -## Outcomes & Retrospective - -The existing profile URL remains intact and now defaults to a Contributions tab, while `?tab=playlists` provides a refresh-safe and shareable Playlists selection. Tabs use native navigation, ARIA associations and selected state, roving tab stops, visible focus, and arrow/Home/End navigation. Profile playlist cards now reuse the public browse card and link to a shareable detail page. - -`/playlists` loads the backend's unfiltered public collection and presents it in a responsive card grid matching the Browse page's visual language. `/playlists/:playlistId` renders playlist metadata and hydrated learning-object summaries, including a safe unavailable state for stale CUID references. `/playlits` redirects to the canonical spelling. The secondary Browse menu exposes Browse Playlists in the requested position. - -The new core Playlist API boundary covers every backend playlist route and sends credentials for authentication-aware visibility. Production code compiles in the Angular build, lint introduces no new warnings or errors, and both added specs compile. Executing Jest remains blocked by the repository's preset/transformer mismatch before test discovery; package changes were intentionally left out of this story. - -## Context and Orientation - -`src/app/cube/user-profile/user-profile.component.*` owns the resolved profile and contribution loading. `src/app/cube/cube.routing.ts` maps `/users/:username` to that component. The new playlist service belongs under `src/app/core/playlist-module`, following other domain API modules. A feature-local `profile-playlists` standalone component will render loading, error, empty, and playlist-summary states beneath the profile tabs. - -The request flow will be URL → `UserProfileComponent` → `PlaylistService` → `GET /playlists?userId=`. Cookies are included so owners receive private playlists while other visitors receive public playlists only, as enforced by the backend. - -## Plan of Work - -Create playlist types describing visibility, summaries, hydrated details, create/update inputs, and learning-object card data. Create route builders for `GET/POST /playlists`, `PATCH/DELETE /playlists/:playlistId`, and `PUT/DELETE /playlists/:playlistId/objects/:cuid`. Create an injectable service exposing typed Observable methods with `withCredentials` enabled. - -Create a reusable playlist card and standalone public browse/details components under a lazy-loaded Cube playlist feature module. Register `/playlists` and `/playlists/:playlistId` in its feature routing module, plus a compatibility redirect from `/playlits` in the parent router. The index calls the existing unfiltered playlist service method so the backend returns all public playlists. The detail view calls the hydrated single-playlist endpoint and safely renders unavailable learning-object references. Link profile playlist cards to details and insert Browse Playlists between the existing Browse Curriculum and Browse Resources menu items. - -Keep the existing profile behavior: observe the query parameter, load playlists whenever resolved profile data changes, and clean up subscriptions. Keep the tablist URL-backed and keyboard accessible. - -Add service tests for route/query/method construction and profile tests for URL selection, resolved-user loading, and keyboard navigation. Validate TypeScript templates and styles through Angular lint/build and attempt focused Jest execution without expanding scope into test-infrastructure package changes. - -## Concrete Steps - -From the `clark-client` root: - - npx prettier --write - npx ng lint clark - npx jest --runInBand - npx ng build clark - -## Validation and Acceptance - -- `/users/:username` selects Contributions and preserves current profile behavior. -- `/users/:username?tab=playlists` directly selects Playlists after refresh or navigation. -- Tab controls are links with `role=tab`, correct `aria-selected`, panel associations, visible keyboard focus, and left/right arrow navigation. -- The layout remains usable at narrow widths. -- Playlist profile requests send `userId` as a query parameter with credentials. -- Loading, request failure, empty results, public results, and owner-visible private results render safely. -- Every backend playlist route has a centralized frontend route builder and typed service method. -- `/playlists` displays all public playlists and each card navigates to `/playlists/:playlistId`. -- `/playlits` redirects to the canonical public playlist page. -- Playlist details render hydrated learning-object cards while stale/unavailable CUIDs do not break the page. -- Profile playlist cards link to the same detail route. -- The secondary Browse menu orders Browse Playlists between Browse Curriculum and Browse Resources. - -## Idempotence and Recovery - -All changes are additive except bounded edits to the profile component and its route-backed UI. Existing `/users/:username` links remain valid. If validation fails, revert only the new profile playlist imports/template section and core playlist module files; no persisted data or migration is involved. - -## Artifacts and Notes - -- `npx prettier --write `: passed. -- `npx ng lint clark`: passed with 0 errors and the repository's existing 259 warnings; no changed-file warning was reported. -- `npx ng build clark`: passed; one pre-existing CommonJS optimization warning was reported for the standard-guidelines service. -- Focused `npx tsc` configuration containing the playlist service, profile, browse, details, and card specs: passed. -- Focused `npx jest --runInBand ...` across those five specs: blocked in global setup by `TypeError: configSet.processWithEsbuild is not a function`; none of the suites were loaded. -- `git diff --check`: passed. - -## Interfaces and Dependencies - -The implementation uses existing Angular `HttpClient`, Router, RxJS, standalone component support, environment API URL configuration, profile resolver output, and existing SCSS variables. It adds no package or environment dependency and does not change authentication guards or interceptors. diff --git a/src/app/core/playlist-module/playlist.routes.ts b/src/app/core/playlist-module/playlist.routes.ts index 448853977..1156d92a7 100644 --- a/src/app/core/playlist-module/playlist.routes.ts +++ b/src/app/core/playlist-module/playlist.routes.ts @@ -9,30 +9,7 @@ export const PLAYLIST_ROUTES = { if (query.playlistId) { params.set("playlistId", query.playlistId); } - if (query.userId) { - params.set("userId", query.userId); - } const queryString = params.toString(); return queryString ? `${playlistsPath}?${queryString}` : playlistsPath; }, - - CREATE_PLAYLIST(): string { - return playlistsPath; - }, - - UPDATE_PLAYLIST(playlistId: string): string { - return `${playlistsPath}/${encodeURIComponent(playlistId)}`; - }, - - DELETE_PLAYLIST(playlistId: string): string { - return `${playlistsPath}/${encodeURIComponent(playlistId)}`; - }, - - ADD_LEARNING_OBJECT(playlistId: string, cuid: string): string { - return `${playlistsPath}/${encodeURIComponent(playlistId)}/objects/${encodeURIComponent(cuid)}`; - }, - - REMOVE_LEARNING_OBJECT(playlistId: string, cuid: string): string { - return `${playlistsPath}/${encodeURIComponent(playlistId)}/objects/${encodeURIComponent(cuid)}`; - }, }; diff --git a/src/app/core/playlist-module/playlist.service.spec.ts b/src/app/core/playlist-module/playlist.service.spec.ts index 2acd8ea81..8427ea6f2 100644 --- a/src/app/core/playlist-module/playlist.service.spec.ts +++ b/src/app/core/playlist-module/playlist.service.spec.ts @@ -42,19 +42,6 @@ describe("PlaylistService", () => { request.flush([playlist]); }); - it("gets playlists for a profile user", () => { - service.getPlaylists(playlist.userId).subscribe((result) => { - expect(result).toEqual([playlist]); - }); - - const request = httpMock.expectOne( - `${environment.apiURL}/playlists?userId=user%2Fid`, - ); - expect(request.request.method).toBe("GET"); - expect(request.request.withCredentials).toBe(true); - request.flush([playlist]); - }); - it("gets one hydrated playlist", () => { service.getPlaylist(playlist._id).subscribe(); @@ -65,58 +52,4 @@ describe("PlaylistService", () => { expect(request.request.withCredentials).toBe(true); request.flush({ ...playlist, learningObjects: [] }); }); - - it("creates and updates playlists", () => { - const createRequest = { - name: playlist.name, - description: playlist.description, - visibility: playlist.visibility, - }; - service.createPlaylist(createRequest).subscribe(); - const create = httpMock.expectOne(`${environment.apiURL}/playlists`); - expect(create.request.method).toBe("POST"); - expect(create.request.body).toEqual(createRequest); - create.flush(playlist); - - service - .updatePlaylist(playlist._id, { name: "Updated playlist" }) - .subscribe(); - const update = httpMock.expectOne( - `${environment.apiURL}/playlists/playlist%2Fid`, - ); - expect(update.request.method).toBe("PATCH"); - expect(update.request.body).toEqual({ name: "Updated playlist" }); - update.flush({ ...playlist, name: "Updated playlist" }); - }); - - it("deletes a playlist", () => { - service.deletePlaylist(playlist._id).subscribe((result) => { - expect(result).toBeUndefined(); - }); - - const request = httpMock.expectOne( - `${environment.apiURL}/playlists/playlist%2Fid`, - ); - expect(request.request.method).toBe("DELETE"); - request.flush(null, { status: 204, statusText: "No Content" }); - }); - - it("adds and removes learning objects", () => { - service.addLearningObject(playlist._id, "learning/object").subscribe(); - const add = httpMock.expectOne( - `${environment.apiURL}/playlists/playlist%2Fid/objects/learning%2Fobject`, - ); - expect(add.request.method).toBe("PUT"); - expect(add.request.body).toEqual({}); - add.flush(playlist); - - service - .removeLearningObject(playlist._id, "learning/object") - .subscribe(); - const remove = httpMock.expectOne( - `${environment.apiURL}/playlists/playlist%2Fid/objects/learning%2Fobject`, - ); - expect(remove.request.method).toBe("DELETE"); - remove.flush(playlist); - }); }); diff --git a/src/app/core/playlist-module/playlist.service.ts b/src/app/core/playlist-module/playlist.service.ts index f6e538711..35380561a 100644 --- a/src/app/core/playlist-module/playlist.service.ts +++ b/src/app/core/playlist-module/playlist.service.ts @@ -1,24 +1,17 @@ import { HttpClient } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { Observable } from "rxjs"; -import { map } from "rxjs/operators"; import { PLAYLIST_ROUTES } from "./playlist.routes"; -import { - CreatePlaylistRequest, - Playlist, - PlaylistDetails, - UpdatePlaylistRequest, -} from "./playlist.types"; +import { Playlist, PlaylistDetails } from "./playlist.types"; @Injectable({ providedIn: "root" }) export class PlaylistService { constructor(private readonly http: HttpClient) {} - getPlaylists(userId?: string): Observable { - return this.http.get( - PLAYLIST_ROUTES.GET_PLAYLISTS({ userId }), - { withCredentials: true }, - ); + getPlaylists(): Observable { + return this.http.get(PLAYLIST_ROUTES.GET_PLAYLISTS(), { + withCredentials: true, + }); } getPlaylist(playlistId: string): Observable { @@ -27,50 +20,4 @@ export class PlaylistService { { withCredentials: true }, ); } - - createPlaylist(request: CreatePlaylistRequest): Observable { - return this.http.post( - PLAYLIST_ROUTES.CREATE_PLAYLIST(), - request, - { withCredentials: true }, - ); - } - - updatePlaylist( - playlistId: string, - request: UpdatePlaylistRequest, - ): Observable { - return this.http.patch( - PLAYLIST_ROUTES.UPDATE_PLAYLIST(playlistId), - request, - { withCredentials: true }, - ); - } - - deletePlaylist(playlistId: string): Observable { - return this.http - .delete(PLAYLIST_ROUTES.DELETE_PLAYLIST(playlistId), { - observe: "response", - withCredentials: true, - }) - .pipe(map(() => undefined)); - } - - addLearningObject(playlistId: string, cuid: string): Observable { - return this.http.put( - PLAYLIST_ROUTES.ADD_LEARNING_OBJECT(playlistId, cuid), - {}, - { withCredentials: true }, - ); - } - - removeLearningObject( - playlistId: string, - cuid: string, - ): Observable { - return this.http.delete( - PLAYLIST_ROUTES.REMOVE_LEARNING_OBJECT(playlistId, cuid), - { withCredentials: true }, - ); - } } diff --git a/src/app/core/playlist-module/playlist.types.ts b/src/app/core/playlist-module/playlist.types.ts index 8f59613af..92e18aa5d 100644 --- a/src/app/core/playlist-module/playlist.types.ts +++ b/src/app/core/playlist-module/playlist.types.ts @@ -33,13 +33,4 @@ export interface PlaylistDetails extends Playlist { export interface GetPlaylistsQuery { playlistId?: string; - userId?: string; } - -export interface CreatePlaylistRequest { - name: string; - description: string; - visibility: PlaylistVisibility; -} - -export type UpdatePlaylistRequest = Partial; diff --git a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html deleted file mode 100644 index 4817ed06c..000000000 --- a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.html +++ /dev/null @@ -1,30 +0,0 @@ -
    -

    {{ isUser ? "My Playlists" : "Playlists" }}

    - -
    - - Loading playlists... -
    - - - -

    - {{ - isUser - ? "You don't have any playlists yet." - : "This user doesn't have any public playlists yet." - }} -

    - -
      -
    • - -
    • -
    -
    diff --git a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss deleted file mode 100644 index 510c9d813..000000000 --- a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.scss +++ /dev/null @@ -1,77 +0,0 @@ -@import "_vars.scss"; - -.playlists { - max-width: 950px; - box-sizing: border-box; - margin: 30px auto; - padding: 40px; - border: 0.5px rgba(28, 112, 221, 0.1) solid; - border-radius: 8px; - background: $secondary-white-background; - box-shadow: 0 10px 20px -10px rgba(28, 112, 221, 0.1); -} - -h1, -h2, -p { - margin-top: 0; -} - -h1 { - color: #32363f; - margin-bottom: 24px; -} - -.playlist-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; - margin: 0; - padding: 0; - list-style: none; -} - -.playlist-card { - min-width: 0; - - clark-playlist-card { - height: 100%; - } -} - -.status { - margin: 0; - padding: 32px; - border-radius: 8px; - background: white; - color: $dark-grey; - text-align: center; - - .svg-inline--fa { - margin-right: 8px; - color: $light-blue; - } -} - -.status--error { - color: $error-red; -} - -@media (max-width: 1060px) { - .playlists { - margin: 30px; - } -} - -@media (max-width: 750px) { - .playlist-grid { - grid-template-columns: 1fr; - } -} - -@media (max-width: 450px) { - .playlists { - margin: 30px 5px; - padding: 24px 16px; - } -} diff --git a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts b/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts deleted file mode 100644 index d16ff2585..000000000 --- a/src/app/cube/user-profile/components/profile-playlists/profile-playlists.component.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NgFor, NgIf } from "@angular/common"; -import { Component, Input } from "@angular/core"; -import { Playlist } from "app/core/playlist-module/playlist.types"; -import { PlaylistCardComponent } from "../../../playlists/components/playlist-card/playlist-card.component"; - -@Component({ - selector: "clark-profile-playlists", - templateUrl: "./profile-playlists.component.html", - styleUrls: ["./profile-playlists.component.scss"], - standalone: true, - imports: [NgFor, NgIf, PlaylistCardComponent], -}) -export class ProfilePlaylistsComponent { - @Input() playlists: Playlist[] = []; - @Input() loading = false; - @Input() hasError = false; - @Input() isUser = false; - - trackPlaylist(_: number, playlist: Playlist): string { - return playlist._id; - } -} diff --git a/src/app/cube/user-profile/user-profile.component.html b/src/app/cube/user-profile/user-profile.component.html index 233bfbc5f..ce8b7be46 100644 --- a/src/app/cube/user-profile/user-profile.component.html +++ b/src/app/cube/user-profile/user-profile.component.html @@ -1,78 +1,11 @@ - - - -
    - - -
    - -
    - - -
    + +
    diff --git a/src/app/cube/user-profile/user-profile.component.scss b/src/app/cube/user-profile/user-profile.component.scss index 01bc29265..183a5116b 100644 --- a/src/app/cube/user-profile/user-profile.component.scss +++ b/src/app/cube/user-profile/user-profile.component.scss @@ -19,56 +19,3 @@ margin-bottom: 10px; } } - -.profile-tabs { - display: flex; - width: min(950px, calc(100% - 60px)); - box-sizing: border-box; - margin: 30px auto -30px; - padding-left: 20px; - border-bottom: 1px solid $profile-border; -} - -.profile-tabs__item { - padding: 12px 28px; - border: 1px solid transparent; - border-bottom: 0; - border-radius: 4px 4px 0 0; - color: $dark-grey; - background: transparent; - text-decoration: none; - transition: - color 0.2s ease, - background-color 0.2s ease; - - &:hover { - color: $light-blue; - } - - &:focus-visible { - outline: 3px solid rgba(28, 112, 221, 0.35); - outline-offset: 2px; - } -} - -.profile-tabs__item--active { - position: relative; - top: 1px; - border-color: $profile-border; - color: $light-blue; - background: $secondary-white-background; -} - -@media (max-width: 450px) { - .profile-tabs { - width: calc(100% - 10px); - padding-left: 0; - } - - .profile-tabs__item { - flex: 1; - padding-right: 12px; - padding-left: 12px; - text-align: center; - } -} diff --git a/src/app/cube/user-profile/user-profile.component.spec.ts b/src/app/cube/user-profile/user-profile.component.spec.ts deleted file mode 100644 index d22aebed9..000000000 --- a/src/app/cube/user-profile/user-profile.component.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { convertToParamMap, Router } from "@angular/router"; -import { BehaviorSubject, of } from "rxjs"; -import { AuthService } from "app/core/auth-module/auth.service"; -import { CollectionService } from "app/core/collection-module/collections.service"; -import { LearningObjectService } from "app/core/learning-object-module/learning-object/learning-object.service"; -import { PlaylistService } from "app/core/playlist-module/playlist.service"; -import { Playlist } from "app/core/playlist-module/playlist.types"; -import { UserProfileComponent } from "./user-profile.component"; - -describe("UserProfileComponent", () => { - const routeData = new BehaviorSubject>({}); - const queryParams = new BehaviorSubject(convertToParamMap({})); - const playlist: Playlist = { - _id: "playlist-id", - userId: "profile-user-id", - learningObjectCuids: [], - name: "Profile playlist", - description: "A public playlist", - visibility: "public", - }; - const playlistService = { - getPlaylists: jest.fn().mockReturnValue(of([playlist])), - }; - const router = { - navigate: jest.fn().mockResolvedValue(true), - }; - let component: UserProfileComponent; - - beforeEach(() => { - routeData.next({ - user: { - _id: "profile-user-id", - username: "profile-user", - }, - }); - queryParams.next(convertToParamMap({})); - playlistService.getPlaylists.mockClear(); - router.navigate.mockClear(); - - component = new UserProfileComponent( - { - data: routeData.asObservable(), - queryParamMap: queryParams.asObservable(), - } as any, - { username: "profile-user" } as AuthService, - { - fetchLearningObject: jest.fn().mockResolvedValue({}), - } as unknown as LearningObjectService, - { - getUserSubmittedCollections: jest.fn().mockResolvedValue([]), - } as unknown as CollectionService, - playlistService as unknown as PlaylistService, - router as unknown as Router, - ); - void component.ngOnInit(); - }); - - afterEach(() => component.ngOnDestroy()); - - it("defaults direct profile navigation to Contributions", () => { - expect(component.activeTab).toBe("contributions"); - }); - - it("selects Playlists from a directly navigated query parameter", () => { - queryParams.next(convertToParamMap({ tab: "playlists" })); - expect(component.activeTab).toBe("playlists"); - }); - - it("loads playlists using the resolved profile user id", () => { - expect(playlistService.getPlaylists).toHaveBeenCalledWith( - "profile-user-id", - ); - expect(component.playlists).toEqual([playlist]); - expect(component.isUser).toBe(true); - }); - - it("supports arrow-key tab navigation and moves focus", async () => { - const target = document.createElement("a"); - const focus = jest.spyOn(target, "focus"); - const event = { preventDefault: jest.fn() } as unknown as Event; - - component.selectAdjacentTab("playlists", event, target); - await Promise.resolve(); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith([], { - relativeTo: expect.anything(), - queryParams: { tab: "playlists" }, - }); - expect(focus).toHaveBeenCalled(); - }); -}); diff --git a/src/app/cube/user-profile/user-profile.component.ts b/src/app/cube/user-profile/user-profile.component.ts index 7dfb8c26f..d886d7e37 100644 --- a/src/app/cube/user-profile/user-profile.component.ts +++ b/src/app/cube/user-profile/user-profile.component.ts @@ -1,97 +1,44 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; -import { Subject, Subscription } from "rxjs"; -import { takeUntil } from "rxjs/operators"; +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { SubscriptionLike as ISubscription } from "rxjs"; import { AuthService } from "app/core/auth-module/auth.service"; import { CollectionService } from "app/core/collection-module/collections.service"; import { LearningObjectService } from "app/core/learning-object-module/learning-object/learning-object.service"; -import { PlaylistService } from "app/core/playlist-module/playlist.service"; -import { Playlist } from "app/core/playlist-module/playlist.types"; import { NgIf } from "@angular/common"; import { ProfileHeaderComponent } from "./components/profile-header/profile-header.component"; import { ProfileLearningObjectsComponent } from "./components/profile-learning-objects/profile-learning-objects.component"; -import { ProfilePlaylistsComponent } from "./components/profile-playlists/profile-playlists.component"; - -type ProfileTab = "contributions" | "playlists"; - @Component({ selector: "clark-user-profile", templateUrl: "./user-profile.component.html", styleUrls: ["./user-profile.component.scss"], standalone: true, - imports: [ - NgIf, - RouterLink, - ProfileHeaderComponent, - ProfileLearningObjectsComponent, - ProfilePlaylistsComponent, - ], + imports: [NgIf, ProfileHeaderComponent, ProfileLearningObjectsComponent], }) -export class UserProfileComponent implements OnInit, OnDestroy { +export class UserProfileComponent implements OnInit { loading: boolean; + subscription: ISubscription; user: any; isUser = false; - activeTab: ProfileTab = "contributions"; - playlists: Playlist[] = []; - playlistsLoading = false; - playlistsError = false; // Array of users learning objects allUserContributions = []; - private readonly destroy$ = new Subject(); - private playlistSubscription?: Subscription; - constructor( private route: ActivatedRoute, private auth: AuthService, private learningObjectService: LearningObjectService, private collectionService: CollectionService, - private playlistService: PlaylistService, - private router: Router, ) {} async ngOnInit() { - this.route.queryParamMap - .pipe(takeUntil(this.destroy$)) - .subscribe((params) => { - this.activeTab = - params.get("tab") === "playlists" - ? "playlists" - : "contributions"; - }); - // Subscribe to data returned from profile.resolver - this.route.data - .pipe(takeUntil(this.destroy$)) - .subscribe(async (val) => { - // Toggle page loading - this.loading = true; - this.user = val.user; - // Check if current user is on their profile - this.isUser = this.user.username === this.auth.username; - this.loadPlaylists(this.user.userId ?? this.user._id); - await this.initProfileData(); - }); - } - - ngOnDestroy(): void { - this.playlistSubscription?.unsubscribe(); - this.destroy$.next(); - this.destroy$.complete(); - } - - selectAdjacentTab( - tab: ProfileTab, - event: Event, - target: HTMLElement, - ): void { - event.preventDefault(); - void this.router - .navigate([], { - relativeTo: this.route, - queryParams: tab === "playlists" ? { tab } : {}, - }) - .then(() => target.focus()); + this.subscription = this.route.data.subscribe(async (val) => { + // Toggle page loading + this.loading = true; + this.user = val.user; + // Check if current user is on their profile + this.isUser = this.user.username === this.auth.username; + await this.initProfileData(); + }); } /** @@ -135,31 +82,4 @@ export class UserProfileComponent implements OnInit, OnDestroy { this.loading = false; }); } - - private loadPlaylists(userId: string): void { - this.playlistSubscription?.unsubscribe(); - this.playlists = []; - this.playlistsError = false; - this.playlistsLoading = true; - - if (!userId) { - this.playlistsError = true; - this.playlistsLoading = false; - return; - } - - this.playlistSubscription = this.playlistService - .getPlaylists(userId) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (playlists) => { - this.playlists = playlists; - this.playlistsLoading = false; - }, - error: () => { - this.playlistsError = true; - this.playlistsLoading = false; - }, - }); - } }