diff --git a/NEWS.md b/NEWS.md index d6db613cd..3cd1de184 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,6 +10,7 @@ border-radius: 128px; - Features - Series years, status and summary in the metadata pane. + - Browser title shows series years and a Tags button. - Tag editor edits the MetronInfo series start year. ## v2.5.5 diff --git a/codex/serializers/browser/page.py b/codex/serializers/browser/page.py index eb68a6ab9..291ff4c40 100644 --- a/codex/serializers/browser/page.py +++ b/codex/serializers/browser/page.py @@ -154,6 +154,8 @@ class BrowserTitleSerializer(Serializer): collection_name = CharField(read_only=True) collection_number_to = CharField(read_only=True) collection_count = IntegerField(read_only=True, allow_null=True) + collection_start_year = IntegerField(read_only=True, allow_null=True) + collection_end_year = IntegerField(read_only=True, allow_null=True) def _emit_cover(row, instance) -> None: diff --git a/codex/views/browser/title.py b/codex/views/browser/title.py index dade93f82..647fe564a 100644 --- a/codex/views/browser/title.py +++ b/codex/views/browser/title.py @@ -1,10 +1,22 @@ """Browser title.""" from collections.abc import Mapping +from types import MappingProxyType +from typing import Final -from codex.models import Comic, Volume +from codex.models import Comic, Series, Volume from codex.views.browser.breadcrumbs import BrowserBreadcrumbsView +_EMPTY_TITLE: Final = MappingProxyType( + { + "collection_name": "", + "collection_number_to": None, + "collection_count": 0, + "collection_start_year": None, + "collection_end_year": None, + } +) + class BrowserTitleView(BrowserBreadcrumbsView): """Browser title methods.""" @@ -19,7 +31,7 @@ def _get_root_collection_name(self) -> tuple: raise ValueError(reason) return plural.capitalize(), 0 - def _get_collection_name(self) -> tuple: + def _get_collection_name(self) -> Mapping: """ Name the collection the route points at. @@ -29,32 +41,27 @@ def _get_collection_name(self) -> tuple: collection this user cannot see, so the empty defaults below are now only the root-listing case (no pks). """ - collection_number_to = None - collection_count = 0 - collection_name = "" + title = dict(_EMPTY_TITLE) if gi := self.collection_instance: - collection_name = gi.name - if isinstance(gi, Volume): - collection_number_to = gi.number_to - collection_count = gi.series.volume_count + title["collection_name"] = gi.name + if isinstance(gi, Series): + title["collection_start_year"] = gi.start_year + title["collection_end_year"] = gi.end_year + elif isinstance(gi, Volume): + title["collection_number_to"] = gi.number_to + title["collection_count"] = gi.series.volume_count elif isinstance(gi, Comic): - collection_number_to = gi.volume.number_to - collection_count = gi.volume.issue_count - return collection_name, collection_number_to, collection_count + title["collection_number_to"] = gi.volume.number_to + title["collection_count"] = gi.volume.issue_count + return title def get_browser_page_title(self) -> Mapping: """Get the proper title for this browse level.""" - pks = self.kwargs.get("pks") - if not pks: - collection_name, collection_count = self._get_root_collection_name() - collection_number_to = None - else: - collection_name, collection_number_to, collection_count = ( - self._get_collection_name() - ) - + if self.kwargs.get("pks"): + return self._get_collection_name() + collection_name, collection_count = self._get_root_collection_name() return { + **_EMPTY_TITLE, "collection_name": collection_name, - "collection_number_to": collection_number_to, "collection_count": collection_count, } diff --git a/frontend/src/comic-name.js b/frontend/src/comic-name.js index d82c206a9..7fd5776ac 100644 --- a/frontend/src/comic-name.js +++ b/frontend/src/comic-name.js @@ -77,8 +77,17 @@ export const getFullComicName = function ( return [seriesName, fvn, issueName].filter(Boolean).join(" "); }; +export const formattedYearRange = function (start, end) { + // A series run: "1963–1996", open-ended "1963–", "–1996", or one year. + if (!start && !end) { + return ""; + } + return start === end ? String(start) : `${start || ""}–${end || ""}`; +}; + export default { getFullComicName, formattedVolumeName, + formattedYearRange, getIssueName, }; diff --git a/frontend/src/components/browser/toolbars/browser-toolbar-title.vue b/frontend/src/components/browser/toolbars/browser-toolbar-title.vue index aec57b91e..aaee8b895 100644 --- a/frontend/src/components/browser/toolbars/browser-toolbar-title.vue +++ b/frontend/src/components/browser/toolbars/browser-toolbar-title.vue @@ -3,6 +3,14 @@ {{ title }} +
+ +
@@ -79,6 +81,14 @@ export default { type: Boolean, default: false, }, + size: { + type: String, + default: undefined, + }, + density: { + type: String, + default: undefined, + }, }, emits: ["close"], data() { diff --git a/frontend/src/components/metadata/metadata-header.vue b/frontend/src/components/metadata/metadata-header.vue index a5e4801e3..00d3b4efb 100644 --- a/frontend/src/components/metadata/metadata-header.vue +++ b/frontend/src/components/metadata/metadata-header.vue @@ -24,8 +24,6 @@ collection="series" :highlight="'series' === md.collection" /> - - +
+ {{ seriesFacts }} +
{{ collectionTitle }} {{ md.name }} @@ -125,7 +126,7 @@ import { mapState } from "pinia"; import prettyBytes from "pretty-bytes"; -import { formattedIssue } from "@/comic-name"; +import { formattedIssue, formattedYearRange } from "@/comic-name"; import MetadataControls from "@/components/metadata/metadata-controls.vue"; import MetadataBookCover from "@/components/metadata/metadata-cover.vue"; import MetadataTags from "@/components/metadata/metadata-tags.vue"; @@ -242,11 +243,12 @@ export default { return this?.md?.fileType || "Unknown"; }, seriesYears() { - const start = this.md.seriesStartYear; - const end = this.md.seriesEndYear; - if (!start && !end) return ""; - if (start === end) return `(${start})`; - return `(${start || ""}–${end || " "})`; + return formattedYearRange(this.md.seriesStartYear, this.md.seriesEndYear); + }, + seriesFacts() { + return [this.seriesYears, this.md.seriesStatus] + .filter(Boolean) + .join(" · "); }, seriesVolumeCount() { const count = this.md.seriesVolumeCount; @@ -304,7 +306,8 @@ export default { padding-top: 0px; } - #titleRow { + #titleRow, + #seriesFactsRow { font-size: large; } @@ -312,10 +315,6 @@ export default { color: rgb(var(--v-theme-text-disabled)); } - .noWrap { - white-space: nowrap; - } - #pageDateRow { display: block; font-size: smaller; @@ -335,7 +334,8 @@ export default { font-size: large; } - #titleRow { + #titleRow, + #seriesFactsRow { font-size: x-small; } } diff --git a/frontend/tests/unit/browser-toolbar-title.test.js b/frontend/tests/unit/browser-toolbar-title.test.js new file mode 100644 index 000000000..2051fc8c4 --- /dev/null +++ b/frontend/tests/unit/browser-toolbar-title.test.js @@ -0,0 +1,111 @@ +/* + * Tests for ``browser-toolbar-title.vue`` — the centred collection title. + * + * A series title carries the series' year range, and every collection + * page but the root listing gets a Tags button that opens the browsed + * collection's own metadata, built like a browser card's book. + */ +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; +import { describe, expect, test } from "vitest"; + +import BrowserToolbarTitle from "@/components/browser/toolbars/browser-toolbar-title.vue"; +import MetadataDialog from "@/components/metadata/metadata-dialog.vue"; +import vuetify from "@/plugins/vuetify"; + +function mountTitle({ params, title = {}, modelCollection = "comics" }) { + const pinia = createTestingPinia({ + initialState: { + browser: { page: { title, modelCollection } }, + }, + }); + return mount(BrowserToolbarTitle, { + global: { + plugins: [pinia, vuetify], + mocks: { $route: { params } }, + stubs: { MetadataDialog: true }, + }, + }); +} + +const titleText = (wrapper) => wrapper.find("#browserTitle").text(); + +describe("BrowserToolbarTitle — title", () => { + test("names the root listing All", () => { + const wrapper = mountTitle({ + params: { collection: "publishers" }, + title: { collectionName: "Publishers", collectionCount: 0 }, + modelCollection: "publishers", + }); + expect(titleText(wrapper)).toBe("All"); + }); + + test("follows a series name with its years", () => { + const wrapper = mountTitle({ + params: { collection: "series", parentIds: "5" }, + title: { + collectionName: "Amazing Spider-Man", + collectionCount: 0, + collectionStartYear: 1963, + collectionEndYear: 1996, + }, + }); + expect(titleText(wrapper)).toBe("Amazing Spider-Man 1963–1996"); + }); + + test("shows a series without years by name alone", () => { + const wrapper = mountTitle({ + params: { collection: "series", parentIds: "5" }, + title: { + collectionName: "Amazing Spider-Man", + collectionCount: 0, + collectionStartYear: null, + collectionEndYear: null, + }, + }); + expect(titleText(wrapper)).toBe("Amazing Spider-Man"); + }); + + test("keeps a volume's own name and count", () => { + const wrapper = mountTitle({ + params: { collection: "volumes", parentIds: "7" }, + title: { + collectionName: "1963", + collectionNumberTo: null, + collectionCount: 3, + collectionStartYear: null, + collectionEndYear: null, + }, + }); + expect(titleText(wrapper)).toBe("(1963) of 3"); + }); +}); + +describe("BrowserToolbarTitle — Tags button", () => { + test("is absent on the root listing", () => { + const wrapper = mountTitle({ + params: { collection: "publishers" }, + title: { collectionName: "Publishers", collectionCount: 0 }, + modelCollection: "publishers", + }); + expect(wrapper.findComponent(MetadataDialog).exists()).toBe(false); + }); + + test("opens the browsed series' own metadata", () => { + const wrapper = mountTitle({ + params: { collection: "series", parentIds: "5" }, + title: { collectionName: "Amazing Spider-Man", collectionCount: 0 }, + }); + const dialog = wrapper.findComponent(MetadataDialog); + expect(dialog.exists()).toBe(true); + expect(dialog.props("book")).toStrictEqual({ + collection: "series", + ids: [5], + pk: 5, + childCount: 0, + }); + expect(dialog.props("toolbar")).toBe(true); + expect(dialog.props("size")).toBe("small"); + expect(dialog.props("density")).toBe("compact"); + }); +}); diff --git a/frontend/tests/unit/metadata-header.test.js b/frontend/tests/unit/metadata-header.test.js index a7cf7eb6d..0e6e61f99 100644 --- a/frontend/tests/unit/metadata-header.test.js +++ b/frontend/tests/unit/metadata-header.test.js @@ -71,22 +71,22 @@ const seriesYears = (md) => mountHeader({ md }).vm.seriesYears; describe("MetadataHeader — series years", () => { test("shows a closed range", () => { expect(seriesYears({ seriesStartYear: 1963, seriesEndYear: 1996 })).toBe( - "(1963–1996)", + "1963–1996", ); }); test("leaves an ongoing range open", () => { - expect(seriesYears({ seriesStartYear: 1963 })).toBe("(1963– )"); + expect(seriesYears({ seriesStartYear: 1963 })).toBe("1963–"); }); test("collapses a single-year run", () => { expect(seriesYears({ seriesStartYear: 1963, seriesEndYear: 1963 })).toBe( - "(1963)", + "1963", ); }); test("shows an end year with no known start", () => { - expect(seriesYears({ seriesEndYear: 1996 })).toBe("(–1996)"); + expect(seriesYears({ seriesEndYear: 1996 })).toBe("–1996"); }); test("shows nothing when neither year is known", () => { @@ -95,3 +95,43 @@ describe("MetadataHeader — series years", () => { }); export default {}; + +const seriesFacts = (md) => mountHeader({ md }).vm.seriesFacts; +const ONE_SERIES = Object.freeze([{ ids: [1], name: "Amazing Spider-Man" }]); + +describe("MetadataHeader — series facts", () => { + test("joins the years and the status", () => { + expect( + seriesFacts({ + seriesStartYear: 1963, + seriesEndYear: 1996, + seriesStatus: "Completed", + }), + ).toBe("1963–1996 · Completed"); + }); + + test("shows the status alone when no year is known", () => { + expect(seriesFacts({ seriesStatus: "Ongoing" })).toBe("Ongoing"); + }); + + test("is empty when nothing is known", () => { + expect(seriesFacts({})).toBe(""); + }); + + test("renders its own line under the series row", () => { + const wrapper = mountHeader({ + md: { + seriesList: ONE_SERIES, + seriesStartYear: 1963, + seriesStatus: "Ongoing", + }, + }); + expect(wrapper.find("#seriesFactsRow").text()).toBe("1963– · Ongoing"); + }); + + test("renders no line when there are no facts", () => { + const wrapper = mountHeader({ md: { seriesList: ONE_SERIES } }); + expect(wrapper.find("#seriesRow").exists()).toBe(true); + expect(wrapper.find("#seriesFactsRow").exists()).toBe(false); + }); +}); diff --git a/tests/test_metadata_series_fields.py b/tests/test_metadata_series_fields.py index 784f11a2d..054615103 100644 --- a/tests/test_metadata_series_fields.py +++ b/tests/test_metadata_series_fields.py @@ -1,4 +1,4 @@ -"""The metadata pane carries the series' year range, status and summary.""" +"""The metadata pane and browser title carry the series' attributes.""" import shutil from typing import Final, override @@ -18,6 +18,10 @@ _STATUS: Final = "Completed" _SUMMARY: Final = "A friendly neighborhood series." _COMIC_SUMMARY: Final = "One issue of it." +_SHOW_ALL_SETTINGS: Final = ( + '{"topCollection": "publishers", "show": {"publishers": true,' + ' "imprints": true, "series": true, "volumes": true}}' +) def _v4(response): @@ -48,7 +52,7 @@ def setUp(self) -> None: status=_STATUS, summary=_SUMMARY, ) - volume = Volume.objects.create( + self.volume = Volume.objects.create( # pyright: ignore[reportUninitializedInstanceVariable] name="1963", series=self.series, imprint=imprint, publisher=publisher ) path = TMP_DIR / "c1.cbz" @@ -61,7 +65,7 @@ def setUp(self) -> None: publisher=publisher, imprint=imprint, series=self.series, - volume=volume, + volume=self.volume, size=42, summary=_COMIC_SUMMARY, ) @@ -107,3 +111,27 @@ def test_unknown_years_are_omitted(self) -> None: data = self._get_metadata("comics", self.comic.pk) assert "seriesStartYear" not in data assert "seriesEndYear" not in data + + def _get_title(self, collection: str, pk: int) -> dict: + """Browse one collection with every level shown and return its title.""" + response = self.client.patch( + "/api/v4/browse/publishers/settings", + data=_SHOW_ALL_SETTINGS, + content_type="application/json", + ) + assert response.status_code == _HTTP_OK, response.content + response = self.client.get(f"/api/v4/browse/{collection}/{pk}?page=1") + assert response.status_code == _HTTP_OK, response.content + return _v4(response)["title"] + + def test_series_title_has_years(self) -> None: + title = self._get_title("series", self.series.pk) + assert title["collectionName"] == "Ser" + assert title["collectionStartYear"] == _START_YEAR + assert title["collectionEndYear"] == _END_YEAR + + def test_volume_title_has_no_years(self) -> None: + title = self._get_title("volumes", self.volume.pk) + assert title["collectionName"] == "1963" + assert title["collectionStartYear"] is None + assert title["collectionEndYear"] is None