From fe6319ced8db52a8666f0c84e5272ae8e7ff37c4 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 10 Sep 2026 19:18:40 +0200 Subject: [PATCH 1/8] fix: replace manual fetch effect with useFetchGenreDetail query hook GenreTreeView managed its own fetch/loading/cancellation state for the selected genre's detail via useEffect; replaced it with the existing React Query useFetchGenreDetail hook for consistent caching and error handling. Added test coverage for useFetchGenreDetail's reference/me endpoint selection and disabled-when-no-id behavior. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018rxCzstzrhcXbrqN7NpJE4 --- .../src/genre-tree/GenreTreeView.test.tsx | 51 +++++++++++-------- .../app-kit/src/genre-tree/GenreTreeView.tsx | 34 ++----------- .../app-kit/src/genre-tree/useGenre.test.ts | 31 +++++++++++ packages/app-kit/src/genre-tree/useGenre.ts | 14 +++++ 4 files changed, 78 insertions(+), 52 deletions(-) diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx index 6b0239b..b84f461 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", () => { @@ -694,7 +694,9 @@ describe("GenreTreeView", () => { children: [], essentialTracks: [], }; - fetchGenreMock.mockResolvedValue(detail); + useFetchGenreDetailMock.mockImplementation((id: string | null) => + id === "c1" ? { data: detail, isPending: false } : { data: undefined, isPending: false }, + ); renderView(); fireEvent.click(screen.getByRole("button", { name: "Wheel" })); @@ -703,7 +705,7 @@ describe("GenreTreeView", () => { treeWheelPropsMock.mock.calls.at(-1)?.[0].onNodeClick({ id: "gp1" }); }); - expect(fetchGenreMock).toHaveBeenCalledWith("c1"); + expect(useFetchGenreDetailMock).toHaveBeenCalledWith("c1"); expect(screen.getByText("Jazz")).toBeInTheDocument(); expect(screen.getByText(/5/)).toBeInTheDocument(); }); @@ -721,7 +723,7 @@ describe("GenreTreeView", () => { treeWheelPropsMock.mock.calls.at(-1)?.[0].onNodeClick({ id: "gp1" }); }); - expect(fetchGenreMock).not.toHaveBeenCalled(); + expect(useFetchGenreDetailMock).toHaveBeenCalledWith(null); expect(screen.queryByLabelText("Close")).not.toBeInTheDocument(); }); @@ -730,14 +732,21 @@ describe("GenreTreeView", () => { 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", + tracksCount: 0, + tracksArchivedCount: 0, + children: [], + essentialTracks: [], + }, + isPending: false, + } + : { data: undefined, isPending: false }, + ); renderView(); fireEvent.click(screen.getByRole("button", { name: "Wheel" })); @@ -756,8 +765,11 @@ describe("GenreTreeView", () => { 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: false, isError: true, error: new Error("boom") } + : { data: undefined, isPending: false }, + ); renderView(); fireEvent.click(screen.getByRole("button", { name: "Wheel" })); @@ -766,12 +778,7 @@ describe("GenreTreeView", () => { treeWheelPropsMock.mock.calls.at(-1)?.[0].onNodeClick({ id: "gp1" }); }); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "Failed to fetch genre details:", - expect.any(Error), - ); expect(screen.getByText("No details available.")).toBeInTheDocument(); - consoleErrorSpy.mockRestore(); }); }); }); diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.tsx index 3cc1960..d18b99f 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.tsx @@ -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, @@ -73,10 +72,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,29 +90,6 @@ 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]); - const groupedGenrePlaylistsByRoot = useMemo( () => genrePlaylists?.results @@ -342,7 +316,7 @@ export function GenreTreeView({ {selectedGenreUuid && ( setSelectedGenreUuid(null)} /> 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(); From 52fc08f342b7fb18d46d5616f8a6c356f4f90d2b Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 10 Sep 2026 19:21:36 +0200 Subject: [PATCH 2/8] docs: add changelog entry for useFetchGenreDetail fix Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018rxCzstzrhcXbrqN7NpJE4 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b3d98e..a67590c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### 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 From 276c30f38579dceeb49ae5afe8fcd5984c96d2a2 Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 10 Sep 2026 23:36:33 +0200 Subject: [PATCH 3/8] feat(genre-tree): surface essential tracks via renderExtraDetails, retire GenreDetailPanel Bump genre-tree-view to 1.6.0 for the new renderExtraDetails render prop and thread it through TreeWheel, TreeWheelRadialPopCore, and TreePerRoot. GenreTreeView now renders essential tracks and archived track count inside the library's own info panel via this prop, with a staleness guard against info-panel chip navigation bypassing onNodeClick. The standalone GenreDetailPanel component and its export are removed as redundant. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018rxCzstzrhcXbrqN7NpJE4 --- CHANGELOG.md | 9 ++ .../src/genre-tree/GenreDetailPanel.test.tsx | 89 ------------ .../src/genre-tree/GenreDetailPanel.tsx | 66 --------- .../src/genre-tree/GenreTreeView.test.tsx | 136 ++++++++++++------ .../app-kit/src/genre-tree/GenreTreeView.tsx | 66 +++++++-- packages/app-kit/src/genre-tree/index.ts | 2 - .../genre-tree/playlist-tree/TreePerRoot.tsx | 5 +- .../genre-tree/playlist-tree/TreeWheel.tsx | 5 +- .../playlist-tree/TreeWheelRadialPopCore.tsx | 5 +- pnpm-lock.yaml | 14 +- pnpm-workspace.yaml | 2 +- 11 files changed, 180 insertions(+), 219 deletions(-) delete mode 100644 packages/app-kit/src/genre-tree/GenreDetailPanel.test.tsx delete mode 100644 packages/app-kit/src/genre-tree/GenreDetailPanel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index a67590c..e2ffab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### 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`. + ### Fixed - **genre-tree**: `GenreTreeView` now fetches the selected genre's detail via the `useFetchGenreDetail` 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 b84f461..f086c22 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx @@ -680,54 +680,39 @@ 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: [], - }; - useFetchGenreDetailMock.mockImplementation((id: string | null) => - id === "c1" ? { data: detail, isPending: false } : { data: undefined, isPending: false }, - ); renderView(); - fireEvent.click(screen.getByRole("button", { name: "Wheel" })); - - await act(async () => { - treeWheelPropsMock.mock.calls.at(-1)?.[0].onNodeClick({ id: "gp1" }); - }); + selectGenre(); expect(useFetchGenreDetailMock).toHaveBeenCalledWith("c1"); - expect(screen.getByText("Jazz")).toBeInTheDocument(); - expect(screen.getByText(/5/)).toBeInTheDocument(); }); - it("does not show the panel when the clicked node has no associated criteria", async () => { + it("fetches null when the clicked node has no associated criteria", () => { useListFullGenrePlaylistsMock.mockReturnValue({ data: { results: [makePlaylist({ uuid: "gp1", criteria: null })] }, isPending: false, }); renderView(); - fireEvent.click(screen.getByRole("button", { name: "Wheel" })); - - await act(async () => { - treeWheelPropsMock.mock.calls.at(-1)?.[0].onNodeClick({ id: "gp1" }); - }); + selectGenre(); expect(useFetchGenreDetailMock).toHaveBeenCalledWith(null); - expect(screen.queryByLabelText("Close")).not.toBeInTheDocument(); }); - it("closes the panel when the close button is clicked", async () => { + it("renders essential tracks and archived count for the selected node", () => { useListFullGenrePlaylistsMock.mockReturnValue({ data: { results: [makePlaylist({ uuid: "gp1", criteria: { uuid: "c1", name: "Jazz" } })] }, isPending: false, @@ -738,10 +723,52 @@ describe("GenreTreeView", () => { data: { uuid: "c1", name: "Jazz", - tracksCount: 0, + 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(); + + selectGenre(); + + const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({ + id: "gp1", + }); + render(<>{output}); + + expect(screen.getByText("Track One")).toBeInTheDocument(); + expect(screen.getByText("Track Two")).toBeInTheDocument(); + expect(screen.getByText(/2/)).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", + tracksCount: 5, tracksArchivedCount: 0, children: [], - essentialTracks: [], + essentialTracks: [{ uuid: "t1", title: "Track One", artists: null }], }, isPending: false, } @@ -749,36 +776,63 @@ describe("GenreTreeView", () => { ); 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: "gp2", + }); + + expect(output).toBeNull(); + }); + + it("returns null while the genre detail is still loading", () => { + useListFullGenrePlaylistsMock.mockReturnValue({ + data: { results: [makePlaylist({ uuid: "gp1", criteria: { uuid: "c1", name: "Jazz" } })] }, + isPending: false, }); + useFetchGenreDetailMock.mockImplementation((id: string | null) => + id === "c1" ? { data: undefined, isPending: true } : { data: undefined, isPending: false }, + ); + renderView(); - fireEvent.click(screen.getByLabelText("Close")); + selectGenre(); + + const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({ + id: "gp1", + }); - expect(screen.queryByLabelText("Close")).not.toBeInTheDocument(); + expect(output).toBeNull(); }); - it("clears the selected genre detail when fetching fails", async () => { + it("returns null when there are 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: undefined, isPending: false, isError: true, error: new Error("boom") } + ? { + data: { + uuid: "c1", + name: "Jazz", + 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", }); - expect(screen.getByText("No details available.")).toBeInTheDocument(); + expect(output).toBeNull(); }); }); }); diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.tsx index d18b99f..7ce87fb 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"; @@ -32,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"; @@ -90,6 +89,58 @@ export function GenreTreeView({ [genrePlaylists?.results], ); + // 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 { essentialTracks, tracksArchivedCount } = selectedGenreDetail; + if (essentialTracks.length === 0 && tracksArchivedCount === 0) { + return null; + } + + return ( +
+ {tracksArchivedCount > 0 && ( +
+ Archived tracks: + {tracksArchivedCount} +
+ )} + {essentialTracks.length > 0 && ( +
+
Essential tracks
+
    + {essentialTracks.map((track) => ( +
  • {track.title}
  • + ))} +
+
+ )} +
+ ); + }, + [ + genrePlaylists?.results, + selectedGenreUuid, + selectedGenreDetail, + isLoadingSelectedGenre, + ], + ); + const groupedGenrePlaylistsByRoot = useMemo( () => genrePlaylists?.results @@ -245,6 +296,7 @@ export function GenreTreeView({ } additionalActions={additionalActions} onNodeClick={handleNodeClick} + renderExtraDetails={renderExtraDetails} readOnly={readOnly} allowWheelRotation={allowWheelRotation} showToolbar={showToolbar} @@ -272,6 +324,7 @@ export function GenreTreeView({ } additionalActions={additionalActions} onNodeClick={handleNodeClick} + renderExtraDetails={renderExtraDetails} readOnly={readOnly} allowWheelRotation={allowWheelRotation} showToolbar={showToolbar} @@ -302,6 +355,7 @@ export function GenreTreeView({ } additionalActions={additionalActions} onNodeClick={handleNodeClick} + renderExtraDetails={renderExtraDetails} readOnly={readOnly} showToolbar={showToolbar} /> @@ -313,14 +367,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/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: From 1f43c40f33a8a137d616a33d56e8b133c1d48eed Mon Sep 17 00:00:00 2001 From: mignot Date: Fri, 11 Sep 2026 00:01:03 +0200 Subject: [PATCH 4/8] feat(genre-tree): surface genre summary in the info panel CriteriaDetailed now includes summary (nullable string), matching the backend's genre-detail/tag-detail response (grow-the-music-tree-api#67). renderExtraDetails displays it above the essential tracks list when present. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 ++ .../src/genre-tree/GenreTreeView.test.tsx | 38 ++++++++++++++++++- .../app-kit/src/genre-tree/GenreTreeView.tsx | 5 ++- .../genre-tree/schemas/criteria/detailed.ts | 1 + 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ffab6..e8e36eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@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 + above the essential tracks list when present. ### Fixed diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx index f086c22..86bce48 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx @@ -723,6 +723,7 @@ describe("GenreTreeView", () => { data: { uuid: "c1", name: "Jazz", + summary: null, tracksCount: 5, tracksArchivedCount: 2, children: [], @@ -749,6 +750,39 @@ describe("GenreTreeView", () => { expect(screen.getByText(/2/)).toBeInTheDocument(); }); + it("renders the genre summary for the selected node", () => { + 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: "Improvised music with swung rhythms.", + tracksCount: 5, + tracksArchivedCount: 0, + children: [], + essentialTracks: [], + }, + isPending: false, + } + : { data: undefined, isPending: false }, + ); + renderView(); + + selectGenre(); + + 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: { @@ -765,6 +799,7 @@ describe("GenreTreeView", () => { data: { uuid: "c1", name: "Jazz", + summary: null, tracksCount: 5, tracksArchivedCount: 0, children: [], @@ -804,7 +839,7 @@ describe("GenreTreeView", () => { expect(output).toBeNull(); }); - it("returns null when there are no essential tracks and nothing archived", () => { + it("returns null 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, @@ -815,6 +850,7 @@ describe("GenreTreeView", () => { data: { uuid: "c1", name: "Jazz", + summary: null, tracksCount: 5, tracksArchivedCount: 0, children: [], diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.tsx index 7ce87fb..2017aaf 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.tsx @@ -107,13 +107,14 @@ export function GenreTreeView({ return null; } - const { essentialTracks, tracksArchivedCount } = selectedGenreDetail; - if (essentialTracks.length === 0 && tracksArchivedCount === 0) { + const { summary, essentialTracks, tracksArchivedCount } = selectedGenreDetail; + if (!summary && essentialTracks.length === 0 && tracksArchivedCount === 0) { return null; } return (
+ {summary &&

{summary}

} {tracksArchivedCount > 0 && (
Archived tracks: 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), From 710bd923de288e5bc6a9d22ef9e5db0ed870e63b Mon Sep 17 00:00:00 2001 From: mignot Date: Fri, 11 Sep 2026 00:11:45 +0200 Subject: [PATCH 5/8] test(criteria): add summary to CriteriaDetailedSchema fixture The detailed schema test fixture predated the summary field, so CriteriaDetailedSchema.parse(valid) started failing once summary became a required (nullable) key. Add it plus null/missing-key coverage mirroring the CriteriaSimpleSchema block. Co-Authored-By: Claude Sonnet 5 --- .../src/genre-tree/schemas/criteria/criteria.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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", () => { From 0b396f3223db6476e78759cd2a30cc29d288e5b0 Mon Sep 17 00:00:00 2001 From: mignot Date: Fri, 11 Sep 2026 00:36:22 +0200 Subject: [PATCH 6/8] fix(genre-tree): always render the Summary field in the info panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Summary field was hidden entirely when a genre had no summary set, inconsistent with the other core fields (song count, side) which always render. Show it as a labeled field with a "—" placeholder when there is no value, and only conditionally render the essential tracks and archived count sections below it. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 ++- packages/app-kit/src/genre-tree/GenreTreeView.test.tsx | 6 ++++-- packages/app-kit/src/genre-tree/GenreTreeView.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e36eb..b9c22f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 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 - above the essential tracks list when present. + as a labeled "Summary" field above the essential tracks list, showing "—" when there is no + value, consistent with the always-visible core fields. ### Fixed diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx index 86bce48..47502d5 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx @@ -839,7 +839,7 @@ describe("GenreTreeView", () => { expect(output).toBeNull(); }); - it("returns null when there is no summary, no essential tracks, and nothing archived", () => { + 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, @@ -867,8 +867,10 @@ describe("GenreTreeView", () => { const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({ id: "gp1", }); + render(<>{output}); - expect(output).toBeNull(); + 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 2017aaf..046857e 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.tsx @@ -108,13 +108,13 @@ export function GenreTreeView({ } const { summary, essentialTracks, tracksArchivedCount } = selectedGenreDetail; - if (!summary && essentialTracks.length === 0 && tracksArchivedCount === 0) { - return null; - } return (
- {summary &&

{summary}

} +
+
Summary
+

{summary ?? "—"}

+
{tracksArchivedCount > 0 && (
Archived tracks: From 5a31020e4b0fd1fde76630cdbd0abb38a2031853 Mon Sep 17 00:00:00 2001 From: mignot Date: Fri, 11 Sep 2026 00:54:59 +0200 Subject: [PATCH 7/8] fix: match extra-details field style to base info-panel fields Reuse gtv-info-panel-children / gtv-info-panel-children-title classes from @behindthemusictree/genre-tree-view so Summary, Archived tracks, and Essential tracks look consistent with Song count, Side, Children, etc. instead of using ad-hoc Tailwind classes. Co-Authored-By: Claude Sonnet 5 --- .../app-kit/src/genre-tree/GenreTreeView.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.tsx index 046857e..a029921 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.tsx @@ -110,20 +110,24 @@ export function GenreTreeView({ const { summary, essentialTracks, tracksArchivedCount } = selectedGenreDetail; return ( -
-
-
Summary
+ <> +
+ Summary

{summary ?? "—"}

{tracksArchivedCount > 0 && ( -
- Archived tracks: - {tracksArchivedCount} +
+ + Archived tracks + +

{tracksArchivedCount}

)} {essentialTracks.length > 0 && ( -
-
Essential tracks
+
+ + Essential tracks +
    {essentialTracks.map((track) => (
  • {track.title}
  • @@ -131,7 +135,7 @@ export function GenreTreeView({
)} -
+ ); }, [ From 25a4b1c28f7b2f36811343535b6f040c6001a93b Mon Sep 17 00:00:00 2001 From: mignot Date: Fri, 11 Sep 2026 01:00:43 +0200 Subject: [PATCH 8/8] chore: release 4.10.0 --- CHANGELOG.md | 2 ++ packages/app-kit/package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c22f7..8fee916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ 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`. 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",