diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b3d98e..8fee916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [4.10.0] - 2026-09-11 + +### Changed + +- **genre-tree**: Bumped `@behindthemusictree/genre-tree-view` catalog pin to `1.6.0`. +- **genre-tree**: `GenreTreeView` now surfaces the selected genre's essential tracks and + archived track count via the new `renderExtraDetails` render prop (added in + `@behindthemusictree/genre-tree-view` `1.6.0`), which renders inside the library's own + info panel. This replaces the standalone `GenreDetailPanel` component, which has been + removed along with its export from `@behindthemusictree/app-kit`. +- **genre-tree**: `CriteriaDetailed` now includes `summary` (nullable string), matching the + backend's detail genre response, and `GenreTreeView`'s `renderExtraDetails` now displays it + as a labeled "Summary" field above the essential tracks list, showing "—" when there is no + value, consistent with the always-visible core fields. + +### Fixed + +- **genre-tree**: `GenreTreeView` now fetches the selected genre's detail via the `useFetchGenreDetail` + React Query hook instead of a manual `useEffect`/fetch, fixing inconsistent caching and error handling. + ## [4.9.1] - 2026-09-09 ### Changed diff --git a/packages/app-kit/package.json b/packages/app-kit/package.json index 1316f1c..0cf93eb 100644 --- a/packages/app-kit/package.json +++ b/packages/app-kit/package.json @@ -1,6 +1,6 @@ { "name": "@behindthemusictree/app-kit", - "version": "4.9.1", + "version": "4.10.0", "description": "Shared transport, auth, popup, UI, player, and genre-tree plumbing for BehindTheMusicTree React apps", "repository": { "type": "git", diff --git a/packages/app-kit/src/genre-tree/GenreDetailPanel.test.tsx b/packages/app-kit/src/genre-tree/GenreDetailPanel.test.tsx deleted file mode 100644 index eda7e34..0000000 --- a/packages/app-kit/src/genre-tree/GenreDetailPanel.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; - -import GenreDetailPanel from "./GenreDetailPanel"; -import { CriteriaDetailed } from "./schemas/criteria/detailed"; - -function buildCriteria(overrides: Partial = {}): CriteriaDetailed { - return { - uuid: "11111111-1111-1111-1111-111111111111", - name: "Deep House", - parent: null, - ascendants: [], - descendants: [], - root: { - uuid: "22222222-2222-2222-2222-222222222222", - name: "Electronic", - side: null, - } as CriteriaDetailed["root"], - children: [], - criteriaPlaylist: { - uuid: "33333333-3333-3333-3333-333333333333", - name: "Deep House", - tracksCount: 0, - } as CriteriaDetailed["criteriaPlaylist"], - tracks: [], - essentialTracks: [], - tracksCount: 0, - tracksArchivedCount: 0, - updatedOn: null, - ...overrides, - }; -} - -describe("GenreDetailPanel", () => { - it("shows a loading state and no data", () => { - render(); - - expect(screen.getByText("Loading…")).toBeInTheDocument(); - expect(screen.getByText("Loading genre details…")).toBeInTheDocument(); - }); - - it("shows a no-details state when not loading and there is no criteria", () => { - render(); - - expect(screen.getByText("No details available.")).toBeInTheDocument(); - }); - - it("renders track count, children count, and essential tracks", () => { - const criteria = buildCriteria({ - tracksCount: 12, - tracksArchivedCount: 3, - children: [ - { uuid: "44444444-4444-4444-4444-444444444444", name: "Sub Genre", side: null } as CriteriaDetailed["children"][number], - ], - essentialTracks: [ - { uuid: "55555555-5555-5555-5555-555555555555", title: "Track One", artists: null }, - { uuid: "66666666-6666-6666-6666-666666666666", title: "Track Two", artists: null }, - ], - }); - - render(); - - expect(screen.getByText("Deep House")).toBeInTheDocument(); - expect(screen.getByText(/12/)).toBeInTheDocument(); - expect(screen.getByText(/3 archived/)).toBeInTheDocument(); - expect(screen.getByText("1")).toBeInTheDocument(); - expect(screen.getByText("Track One")).toBeInTheDocument(); - expect(screen.getByText("Track Two")).toBeInTheDocument(); - }); - - it("omits the essential tracks section when there are none", () => { - const criteria = buildCriteria({ essentialTracks: [] }); - - render(); - - expect(screen.queryByText("Essential tracks")).not.toBeInTheDocument(); - }); - - it("calls onClose when the close button is clicked", () => { - const onClose = vi.fn(); - const criteria = buildCriteria(); - - render(); - - fireEvent.click(screen.getByLabelText("Close")); - - expect(onClose).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/app-kit/src/genre-tree/GenreDetailPanel.tsx b/packages/app-kit/src/genre-tree/GenreDetailPanel.tsx deleted file mode 100644 index 78db4f7..0000000 --- a/packages/app-kit/src/genre-tree/GenreDetailPanel.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { CriteriaDetailed } from "./schemas/criteria/detailed"; - -export interface GenreDetailPanelProps { - criteria: CriteriaDetailed | null; - isLoading: boolean; - onClose: () => void; - className?: string; -} - -export default function GenreDetailPanel({ - criteria, - isLoading, - onClose, - className = "", -}: GenreDetailPanelProps) { - return ( -
-
-

- {criteria?.name ?? (isLoading ? "Loading…" : "")} -

- -
- {isLoading ? ( -
Loading genre details…
- ) : criteria ? ( -
-
- Tracks: - {criteria.tracksCount} - {criteria.tracksArchivedCount > 0 - ? ` (${criteria.tracksArchivedCount} archived)` - : ""} -
-
- Children: - {criteria.children.length} -
- {criteria.essentialTracks.length > 0 && ( -
-
Essential tracks
-
    - {criteria.essentialTracks.map((track) => ( -
  • {track.title}
  • - ))} -
-
- )} -
- ) : ( -
No details available.
- )} -
- ); -} diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx index 6b0239b..47502d5 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx @@ -5,7 +5,7 @@ import { z } from "zod"; const { useListFullGenrePlaylistsMock, useLoadExampleTreeGenreMock, - fetchGenreMock, + useFetchGenreDetailMock, loadTreeMutateMock, treePerRootPropsMock, treeWheelPropsMock, @@ -13,7 +13,7 @@ const { } = vi.hoisted(() => ({ useListFullGenrePlaylistsMock: vi.fn(), useLoadExampleTreeGenreMock: vi.fn(), - fetchGenreMock: vi.fn(), + useFetchGenreDetailMock: vi.fn(), loadTreeMutateMock: vi.fn(), treePerRootPropsMock: vi.fn(), treeWheelPropsMock: vi.fn(), @@ -26,7 +26,7 @@ vi.mock("./useGenrePlaylist", () => ({ vi.mock("./useGenre", () => ({ useLoadExampleTreeGenre: () => useLoadExampleTreeGenreMock(), - useFetchGenre: () => fetchGenreMock, + useFetchGenreDetail: (id: string | null) => useFetchGenreDetailMock(id), })); vi.mock("./playlist-tree/TreePerRoot", () => ({ @@ -128,7 +128,7 @@ describe("GenreTreeView", () => { mutate: loadTreeMutateMock, isPending: false, }); - fetchGenreMock.mockReset(); + useFetchGenreDetailMock.mockReturnValue({ data: undefined, isPending: false }); }); it("shows the wheel skeleton while the genre playlists are loading", () => { @@ -680,98 +680,197 @@ describe("GenreTreeView", () => { }); }); - describe("genre detail panel", () => { - it("fetches and shows genre details when a node is clicked", async () => { + describe("renderExtraDetails", () => { + function selectGenre() { + fireEvent.click(screen.getByRole("button", { name: "Wheel" })); + act(() => { + treeWheelPropsMock.mock.calls.at(-1)?.[0].onNodeClick({ id: "gp1" }); + }); + } + + it("fetches genre details for the clicked node's criteria uuid", () => { useListFullGenrePlaylistsMock.mockReturnValue({ data: { results: [makePlaylist({ uuid: "gp1", criteria: { uuid: "c1", name: "Jazz" } })] }, isPending: false, }); - const detail = { - uuid: "c1", - name: "Jazz", - tracksCount: 5, - tracksArchivedCount: 0, - children: [], - essentialTracks: [], - }; - fetchGenreMock.mockResolvedValue(detail); renderView(); - fireEvent.click(screen.getByRole("button", { name: "Wheel" })); + selectGenre(); - await act(async () => { - treeWheelPropsMock.mock.calls.at(-1)?.[0].onNodeClick({ id: "gp1" }); + expect(useFetchGenreDetailMock).toHaveBeenCalledWith("c1"); + }); + + it("fetches null when the clicked node has no associated criteria", () => { + useListFullGenrePlaylistsMock.mockReturnValue({ + data: { results: [makePlaylist({ uuid: "gp1", criteria: null })] }, + isPending: false, }); + renderView(); + + selectGenre(); - expect(fetchGenreMock).toHaveBeenCalledWith("c1"); - expect(screen.getByText("Jazz")).toBeInTheDocument(); - expect(screen.getByText(/5/)).toBeInTheDocument(); + expect(useFetchGenreDetailMock).toHaveBeenCalledWith(null); }); - it("does not show the panel when the clicked node has no associated criteria", async () => { + it("renders essential tracks and archived count for the selected node", () => { useListFullGenrePlaylistsMock.mockReturnValue({ - data: { results: [makePlaylist({ uuid: "gp1", criteria: null })] }, + data: { results: [makePlaylist({ uuid: "gp1", criteria: { uuid: "c1", name: "Jazz" } })] }, isPending: false, }); + useFetchGenreDetailMock.mockImplementation((id: string | null) => + id === "c1" + ? { + data: { + uuid: "c1", + name: "Jazz", + summary: null, + tracksCount: 5, + tracksArchivedCount: 2, + children: [], + essentialTracks: [ + { uuid: "t1", title: "Track One", artists: null }, + { uuid: "t2", title: "Track Two", artists: null }, + ], + }, + isPending: false, + } + : { data: undefined, isPending: false }, + ); renderView(); - fireEvent.click(screen.getByRole("button", { name: "Wheel" })); + selectGenre(); - await act(async () => { - treeWheelPropsMock.mock.calls.at(-1)?.[0].onNodeClick({ id: "gp1" }); + const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({ + id: "gp1", }); + render(<>{output}); - expect(fetchGenreMock).not.toHaveBeenCalled(); - expect(screen.queryByLabelText("Close")).not.toBeInTheDocument(); + expect(screen.getByText("Track One")).toBeInTheDocument(); + expect(screen.getByText("Track Two")).toBeInTheDocument(); + expect(screen.getByText(/2/)).toBeInTheDocument(); }); - it("closes the panel when the close button is clicked", async () => { + it("renders the genre summary for the selected node", () => { useListFullGenrePlaylistsMock.mockReturnValue({ data: { results: [makePlaylist({ uuid: "gp1", criteria: { uuid: "c1", name: "Jazz" } })] }, isPending: false, }); - fetchGenreMock.mockResolvedValue({ - uuid: "c1", - name: "Jazz", - tracksCount: 0, - tracksArchivedCount: 0, - children: [], - essentialTracks: [], - }); + useFetchGenreDetailMock.mockImplementation((id: string | null) => + id === "c1" + ? { + data: { + uuid: "c1", + name: "Jazz", + summary: "Improvised music with swung rhythms.", + tracksCount: 5, + tracksArchivedCount: 0, + children: [], + essentialTracks: [], + }, + isPending: false, + } + : { data: undefined, isPending: false }, + ); renderView(); - fireEvent.click(screen.getByRole("button", { name: "Wheel" })); + selectGenre(); - await act(async () => { - treeWheelPropsMock.mock.calls.at(-1)?.[0].onNodeClick({ id: "gp1" }); + const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({ + id: "gp1", + }); + render(<>{output}); + + expect(screen.getByText("Improvised music with swung rhythms.")).toBeInTheDocument(); + }); + + it("returns null when the node doesn't match the selected genre (e.g. info-panel chip navigation)", () => { + useListFullGenrePlaylistsMock.mockReturnValue({ + data: { + results: [ + makePlaylist({ uuid: "gp1", criteria: { uuid: "c1", name: "Jazz" } }), + makePlaylist({ uuid: "gp2", criteria: { uuid: "c2", name: "Blues" } }), + ], + }, + isPending: false, }); + useFetchGenreDetailMock.mockImplementation((id: string | null) => + id === "c1" + ? { + data: { + uuid: "c1", + name: "Jazz", + summary: null, + tracksCount: 5, + tracksArchivedCount: 0, + children: [], + essentialTracks: [{ uuid: "t1", title: "Track One", artists: null }], + }, + isPending: false, + } + : { data: undefined, isPending: false }, + ); + renderView(); - fireEvent.click(screen.getByLabelText("Close")); + selectGenre(); - expect(screen.queryByLabelText("Close")).not.toBeInTheDocument(); + const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({ + id: "gp2", + }); + + expect(output).toBeNull(); }); - it("clears the selected genre detail when fetching fails", async () => { + it("returns null while the genre detail is still loading", () => { useListFullGenrePlaylistsMock.mockReturnValue({ data: { results: [makePlaylist({ uuid: "gp1", criteria: { uuid: "c1", name: "Jazz" } })] }, isPending: false, }); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - fetchGenreMock.mockRejectedValue(new Error("boom")); + useFetchGenreDetailMock.mockImplementation((id: string | null) => + id === "c1" ? { data: undefined, isPending: true } : { data: undefined, isPending: false }, + ); renderView(); - fireEvent.click(screen.getByRole("button", { name: "Wheel" })); + selectGenre(); - await act(async () => { - treeWheelPropsMock.mock.calls.at(-1)?.[0].onNodeClick({ id: "gp1" }); + const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({ + id: "gp1", }); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "Failed to fetch genre details:", - expect.any(Error), + expect(output).toBeNull(); + }); + + it("renders a blank summary placeholder when there is no summary, no essential tracks, and nothing archived", () => { + useListFullGenrePlaylistsMock.mockReturnValue({ + data: { results: [makePlaylist({ uuid: "gp1", criteria: { uuid: "c1", name: "Jazz" } })] }, + isPending: false, + }); + useFetchGenreDetailMock.mockImplementation((id: string | null) => + id === "c1" + ? { + data: { + uuid: "c1", + name: "Jazz", + summary: null, + tracksCount: 5, + tracksArchivedCount: 0, + children: [], + essentialTracks: [], + }, + isPending: false, + } + : { data: undefined, isPending: false }, ); - expect(screen.getByText("No details available.")).toBeInTheDocument(); - consoleErrorSpy.mockRestore(); + renderView(); + + selectGenre(); + + const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({ + id: "gp1", + }); + render(<>{output}); + + expect(screen.getByText("Summary")).toBeInTheDocument(); + expect(screen.getByText("—")).toBeInTheDocument(); }); }); }); diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.tsx index 3cc1960..a029921 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo, useEffect, useCallback } from "react"; +import { useState, useMemo, useEffect, useCallback, type ReactNode } from "react"; import { z } from "zod"; import { FaTree } from "react-icons/fa"; import { Plus } from "lucide-react"; @@ -18,12 +18,11 @@ import type { import { CriteriaPlaylistSimple } from "./schemas/criteria-playlist/simple"; import { CriteriaMinimum } from "./schemas/criteria/minimum"; -import { CriteriaDetailed } from "./schemas/criteria/detailed"; import { TrackBase } from "./schemas/track/base"; import { CriteriaPlaylistDetailedLike } from "./models/TrackListOrigin"; import { Scope } from "../transport/lib/scope"; import { useListFullGenrePlaylists } from "./useGenrePlaylist"; -import { useLoadExampleTreeGenre, useFetchGenre } from "./useGenre"; +import { useLoadExampleTreeGenre, useFetchGenreDetail } from "./useGenre"; import { getGenrePlaylistsGroupedByRoot, hasMainstreamPopRoot, @@ -33,7 +32,6 @@ import GenrePlaylistTreePerRoot from "./playlist-tree/TreePerRoot"; import GenrePlaylistTreeWheel from "./playlist-tree/TreeWheel"; import GenrePlaylistTreeWheelRadialPopCore from "./playlist-tree/TreeWheelRadialPopCore"; import { GenreTreeWheelHandoff } from "./GenreTreeWheelHandoff"; -import GenreDetailPanel from "./GenreDetailPanel"; export type { GenreTreeViewMode } from "@behindthemusictree/genre-tree-view"; @@ -73,10 +71,8 @@ export function GenreTreeView({ const [selectedGenreUuid, setSelectedGenreUuid] = useState( null, ); - const [selectedGenreDetail, setSelectedGenreDetail] = - useState(null); - const [isLoadingSelectedGenre, setIsLoadingSelectedGenre] = useState(false); - const fetchGenre = useFetchGenre(scope, getBackendBaseUrl); + const { data: selectedGenreDetail, isPending: isLoadingSelectedGenre } = + useFetchGenreDetail(selectedGenreUuid, scope, getBackendBaseUrl); const { data: genrePlaylists, isPending: isListingGenrePlaylists } = useListFullGenrePlaylists(scope, getBackendBaseUrl); @@ -93,28 +89,62 @@ export function GenreTreeView({ [genrePlaylists?.results], ); - useEffect(() => { - if (!selectedGenreUuid) { - setSelectedGenreDetail(null); - return; - } - let cancelled = false; - setIsLoadingSelectedGenre(true); - fetchGenre(selectedGenreUuid) - .then((detail) => { - if (!cancelled) setSelectedGenreDetail(detail); - }) - .catch((error) => { - console.error("Failed to fetch genre details:", error); - if (!cancelled) setSelectedGenreDetail(null); - }) - .finally(() => { - if (!cancelled) setIsLoadingSelectedGenre(false); - }); - return () => { - cancelled = true; - }; - }, [selectedGenreUuid, fetchGenre]); + // The info panel can also navigate via its own ancestor/child chips, which don't go through + // onNodeClick — so the node passed here isn't guaranteed to be the one selectedGenreDetail was + // fetched for. Render nothing rather than stale essential tracks when they've diverged. + const renderExtraDetails = useCallback( + (node: GenreTreeNode): ReactNode => { + const genrePlaylist = ( + genrePlaylists?.results as CriteriaPlaylistSimple[] | undefined + )?.find((gp) => gp.uuid === node.id); + const nodeGenreUuid = genrePlaylist?.criteria?.uuid ?? null; + if ( + nodeGenreUuid === null || + nodeGenreUuid !== selectedGenreUuid || + isLoadingSelectedGenre || + !selectedGenreDetail + ) { + return null; + } + + const { summary, essentialTracks, tracksArchivedCount } = selectedGenreDetail; + + return ( + <> +
+ Summary +

{summary ?? "—"}

+
+ {tracksArchivedCount > 0 && ( +
+ + Archived tracks + +

{tracksArchivedCount}

+
+ )} + {essentialTracks.length > 0 && ( +
+ + Essential tracks + +
    + {essentialTracks.map((track) => ( +
  • {track.title}
  • + ))} +
+
+ )} + + ); + }, + [ + genrePlaylists?.results, + selectedGenreUuid, + selectedGenreDetail, + isLoadingSelectedGenre, + ], + ); const groupedGenrePlaylistsByRoot = useMemo( () => @@ -271,6 +301,7 @@ export function GenreTreeView({ } additionalActions={additionalActions} onNodeClick={handleNodeClick} + renderExtraDetails={renderExtraDetails} readOnly={readOnly} allowWheelRotation={allowWheelRotation} showToolbar={showToolbar} @@ -298,6 +329,7 @@ export function GenreTreeView({ } additionalActions={additionalActions} onNodeClick={handleNodeClick} + renderExtraDetails={renderExtraDetails} readOnly={readOnly} allowWheelRotation={allowWheelRotation} showToolbar={showToolbar} @@ -328,6 +360,7 @@ export function GenreTreeView({ } additionalActions={additionalActions} onNodeClick={handleNodeClick} + renderExtraDetails={renderExtraDetails} readOnly={readOnly} showToolbar={showToolbar} /> @@ -339,14 +372,6 @@ export function GenreTreeView({ )} - {selectedGenreUuid && ( - setSelectedGenreUuid(null)} - /> - )} ); diff --git a/packages/app-kit/src/genre-tree/index.ts b/packages/app-kit/src/genre-tree/index.ts index b9cf1cd..a31bf5c 100644 --- a/packages/app-kit/src/genre-tree/index.ts +++ b/packages/app-kit/src/genre-tree/index.ts @@ -1,7 +1,5 @@ // Tree view + data hooks export * from "./GenreTreeView"; -export { default as GenreDetailPanel } from "./GenreDetailPanel"; -export type { GenreDetailPanelProps } from "./GenreDetailPanel"; export { default as GenrePlaylistTreePerRoot } from "./playlist-tree/TreePerRoot"; export type { GenrePlaylistTreePerRootProps } from "./playlist-tree/TreePerRoot"; export { diff --git a/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx b/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx index e039a4b..ecd7403 100644 --- a/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx +++ b/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, type ReactNode } from "react"; import { z } from "zod"; import { GenreTree, @@ -37,6 +37,7 @@ export type GenrePlaylistTreePerRootProps = { criteriaPlaylistDetailedSchema: z.ZodType>; additionalActions?: (node: GenreTreeNode) => GenreTreeAction[]; onNodeClick?: (node: GenreTreeNode) => void; + renderExtraDetails?: (node: GenreTreeNode) => ReactNode; /** When true, suppresses per-node create/rename/reparent affordances. Defaults to false. */ readOnly?: boolean; /** When false, suppresses the hover toolbar on every node. Defaults to true. */ @@ -56,6 +57,7 @@ export default function GenrePlaylistTreePerRoot({ criteriaPlaylistDetailedSchema, additionalActions, onNodeClick, + renderExtraDetails, readOnly = false, showToolbar, }: GenrePlaylistTreePerRootProps) { @@ -186,6 +188,7 @@ export default function GenrePlaylistTreePerRoot({ onReparent={readOnly ? undefined : handleReparent} additionalActions={additionalActions} onNodeClick={onNodeClick} + renderExtraDetails={renderExtraDetails} showToolbar={showToolbar} /> ); diff --git a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx index d6e0974..0f3f659 100644 --- a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx +++ b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, type ReactNode } from "react"; import { z } from "zod"; import { GenreTreeWheel, @@ -35,6 +35,7 @@ export type GenrePlaylistTreeWheelProps = { criteriaPlaylistDetailedSchema: z.ZodType>; additionalActions?: (node: GenreTreeNode) => GenreTreeAction[]; onNodeClick?: (node: GenreTreeNode) => void; + renderExtraDetails?: (node: GenreTreeNode) => ReactNode; /** When true, suppresses per-node create/rename/reparent affordances. Defaults to false. */ readOnly?: boolean; /** When false, clicking a chip still selects its root, but the wheel doesn't spin to the @@ -56,6 +57,7 @@ export default function GenrePlaylistTreeWheel({ criteriaPlaylistDetailedSchema, additionalActions, onNodeClick, + renderExtraDetails, readOnly = false, allowWheelRotation, showToolbar, @@ -186,6 +188,7 @@ export default function GenrePlaylistTreeWheel({ onReparent={readOnly ? undefined : handleReparent} additionalActions={additionalActions} onNodeClick={onNodeClick} + renderExtraDetails={renderExtraDetails} allowWheelRotation={allowWheelRotation} showToolbar={showToolbar} /> diff --git a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx index eeeda82..7561fef 100644 --- a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx +++ b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, type ReactNode } from "react"; import { z } from "zod"; import { GenreTreeWheelRadialPopCore, @@ -33,6 +33,7 @@ export type GenrePlaylistTreeWheelRadialPopCoreProps = { criteriaPlaylistDetailedSchema: z.ZodType>; additionalActions?: (node: GenreTreeNode) => GenreTreeAction[]; onNodeClick?: (node: GenreTreeNode) => void; + renderExtraDetails?: (node: GenreTreeNode) => ReactNode; /** When true, suppresses per-node create/rename/reparent affordances. Defaults to false. */ readOnly?: boolean; /** When false, clicking a chip still selects its root, but the ring doesn't spin to the @@ -54,6 +55,7 @@ export default function GenrePlaylistTreeWheelRadialPopCore criteriaPlaylistDetailedSchema, additionalActions, onNodeClick, + renderExtraDetails, readOnly = false, allowWheelRotation, showToolbar, @@ -173,6 +175,7 @@ export default function GenrePlaylistTreeWheelRadialPopCore onReparent={readOnly ? undefined : handleReparent} additionalActions={additionalActions} onNodeClick={onNodeClick} + renderExtraDetails={renderExtraDetails} allowWheelRotation={allowWheelRotation} showToolbar={showToolbar} /> diff --git a/packages/app-kit/src/genre-tree/schemas/criteria/criteria.test.ts b/packages/app-kit/src/genre-tree/schemas/criteria/criteria.test.ts index 2ac5837..3d274a8 100644 --- a/packages/app-kit/src/genre-tree/schemas/criteria/criteria.test.ts +++ b/packages/app-kit/src/genre-tree/schemas/criteria/criteria.test.ts @@ -36,6 +36,7 @@ describe("CriteriaDetailedSchema", () => { const valid = { uuid, name: "Rock", + summary: "A genre summary", parent: null, ascendants: [], descendants: [], @@ -61,6 +62,15 @@ describe("CriteriaDetailedSchema", () => { it("rejects an invalid essentialTracks entry", () => { expect(() => CriteriaDetailedSchema.parse({ ...valid, essentialTracks: [{ uuid: "not-a-uuid" }] })).toThrow(); }); + + it("accepts a null summary", () => { + expect(() => CriteriaDetailedSchema.parse({ ...valid, summary: null })).not.toThrow(); + }); + + it("rejects a shape missing summary", () => { + const { summary: _summary, ...invalid } = valid; + expect(() => CriteriaDetailedSchema.parse(invalid)).toThrow(); + }); }); describe("CriteriaCreationSchema", () => { diff --git a/packages/app-kit/src/genre-tree/schemas/criteria/detailed.ts b/packages/app-kit/src/genre-tree/schemas/criteria/detailed.ts index 3be0ecd..394b6f5 100644 --- a/packages/app-kit/src/genre-tree/schemas/criteria/detailed.ts +++ b/packages/app-kit/src/genre-tree/schemas/criteria/detailed.ts @@ -9,6 +9,7 @@ import { CriteriaLineageRelWithoutDescendantSchema } from "./lineage-rel/without export const CriteriaDetailedSchema = UuidResourceSchema.extend({ name: z.string(), + summary: z.string().nullable(), parent: CriteriaMinimumSchema.nullable(), ascendants: z.array(CriteriaLineageRelWithoutDescendantSchema), descendants: z.array(CriteriaLineageRelWithoutAscendantSchema), diff --git a/packages/app-kit/src/genre-tree/useGenre.test.ts b/packages/app-kit/src/genre-tree/useGenre.test.ts index e02691a..65f7f00 100644 --- a/packages/app-kit/src/genre-tree/useGenre.test.ts +++ b/packages/app-kit/src/genre-tree/useGenre.test.ts @@ -54,6 +54,7 @@ vi.mock("../transport/lib/parse-with-log", () => ({ import { useListGenres, useFetchGenre, + useFetchGenreDetail, useLoadExampleTreeGenre, useCreateGenre, useUpdateGenre, @@ -123,6 +124,36 @@ describe("useGenre", () => { }); }); + describe("useFetchGenreDetail", () => { + it("queries the reference detail endpoint", () => { + renderHook(() => useFetchGenreDetail("g1", "reference", getBackendBaseUrl)); + const { queryKey, enabled, queryFn } = useQueryWithParseMock.mock.calls[0][0]; + + expect(queryKey).toEqual(["referenceGenres", "detail", "g1"]); + expect(enabled).toBe(true); + + queryFn(); + expect(fetchMock).toHaveBeenCalledWith("genres/g1/", true, false); + }); + + it("queries the me detail endpoint", () => { + renderHook(() => useFetchGenreDetail("g1", "me", getBackendBaseUrl)); + const { queryKey, enabled, queryFn } = useQueryWithParseMock.mock.calls[0][0]; + + expect(queryKey).toEqual(["genres", "detail", "g1"]); + expect(enabled).toBe(true); + + queryFn(); + expect(fetchMock).toHaveBeenCalledWith("me/genres/g1/", true, true); + }); + + it("disables the query when there is no selected id", () => { + renderHook(() => useFetchGenreDetail(null, "me", getBackendBaseUrl)); + + expect(useQueryWithParseMock.mock.calls[0][0].enabled).toBe(false); + }); + }); + describe("useLoadExampleTreeGenre", () => { it("posts to the reference load-example endpoint", async () => { renderHook(() => useLoadExampleTreeGenre("reference", getBackendBaseUrl)); diff --git a/packages/app-kit/src/genre-tree/useGenre.ts b/packages/app-kit/src/genre-tree/useGenre.ts index ac71df1..40c8d35 100644 --- a/packages/app-kit/src/genre-tree/useGenre.ts +++ b/packages/app-kit/src/genre-tree/useGenre.ts @@ -50,6 +50,20 @@ export function useFetchGenre(scope: Scope, getBackendBaseUrl: () => string) { ); } +export function useFetchGenreDetail(id: string | null, scope: Scope, getBackendBaseUrl: () => string) { + const { fetch } = useFetchWrapper(getBackendBaseUrl); + const queryKeys = scope === "reference" ? genreQueryKeys.reference : genreQueryKeys.me; + const endpoints = scope === "reference" ? genreEndpoints.reference : genreEndpoints.me; + + return useQueryWithParse({ + queryKey: queryKeys.detail(id ?? ""), + queryFn: () => fetch(endpoints.detail(id as string), true, scope === "me"), + schema: CriteriaDetailedSchema, + context: "useFetchGenreDetail", + enabled: id !== null, + }); +} + export function useLoadExampleTreeGenre(scope: Scope, getBackendBaseUrl: () => string) { const { fetch } = useFetchWrapper(getBackendBaseUrl); const queryClient = useQueryClient(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85650a0..d3e2165 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,8 +10,8 @@ catalogs: specifier: 12.0.0 version: 12.0.0 '@behindthemusictree/genre-tree-view': - specifier: 1.5.0 - version: 1.5.0 + specifier: 1.6.0 + version: 1.6.0 '@behindthemusictree/ui': specifier: ^0.1.0 version: 0.1.0 @@ -31,7 +31,7 @@ importers: version: link:../../packages/app-kit '@behindthemusictree/genre-tree-view': specifier: 'catalog:' - version: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@behindthemusictree/ui': specifier: 'catalog:' version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -77,7 +77,7 @@ importers: version: 12.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@behindthemusictree/genre-tree-view': specifier: 'catalog:' - version: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@behindthemusictree/ui': specifier: 'catalog:' version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -267,8 +267,8 @@ packages: react: '>=18.0.0' react-dom: '>=18.0.0' - '@behindthemusictree/genre-tree-view@1.5.0': - resolution: {integrity: sha512-GASsRmCpmwWOg3j1/TMZKoif1MpqCx2S5N9h+eQkrSbD3M9ec+jyQRz4slpG86dMOJkvr5GcbSODY2kD69qv2w==, tarball: https://npm.pkg.github.com/download/@behindthemusictree/genre-tree-view/1.5.0/c17a312a9a4818d7baf8e2686dde5b51d27c44ed} + '@behindthemusictree/genre-tree-view@1.6.0': + resolution: {integrity: sha512-8PbEv/heGPUwYQxFrA/hsX2682TkKRAbYasGUTpcsO/KzN1+1eIoaPMvd2jrKF83Px/6k9sv2gIMomTXzUU0ZA==, tarball: https://npm.pkg.github.com/download/@behindthemusictree/genre-tree-view/1.6.0/e99cc6bf8e558153251f151aa69d3226d5ea6aff} peerDependencies: react: '>=18.0.0' react-dom: '>=18.0.0' @@ -2522,7 +2522,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@behindthemusictree/genre-tree-view@1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@behindthemusictree/genre-tree-view@1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: d3: 7.9.0 react: 18.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09a67c9..622c613 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,7 @@ packages: catalog: "@behindthemusictree/brand": 12.0.0 - "@behindthemusictree/genre-tree-view": 1.5.0 + "@behindthemusictree/genre-tree-view": 1.6.0 "@behindthemusictree/ui": ^0.1.0 allowBuilds: