This comic's IDs:
@@ -327,24 +335,38 @@ export default {
sourceItems() {
return [
{
- title: "Metron Cloud",
+ title: sourceLabel("metron"),
value: "metron",
- props: this.hasMetron ? {} : { disabled: true },
+ disabled: !this.hasMetron,
},
{
- title: "Comic Vine",
+ title: sourceLabel("comicvine"),
value: "comicvine",
- props: this.hasComicvine ? {} : { disabled: true },
+ disabled: !this.hasComicvine,
},
];
},
+ unconfiguredSourcesHint() {
+ // Why a source's checkbox is disabled, and where to fix it.
+ const names = this.sourceItems
+ .filter((s) => s.disabled)
+ .map((s) => s.title)
+ .join(", ");
+ return names
+ ? `${names} has no API key. Add one in Admin → Tagging.`
+ : "";
+ },
+ sourcesError() {
+ // Why the action button is disabled.
+ return this.searchDisabled ? ["Pick at least one source"] : [];
+ },
allSourcesDisabled() {
return !this.hasMetron && !this.hasComicvine;
},
enabledSources() {
// The sources that could run at all: credentials configured.
return new Set(
- this.sourceItems.filter((s) => !s.props?.disabled).map((s) => s.value),
+ this.sourceItems.filter((s) => !s.disabled).map((s) => s.value),
);
},
idTaggable() {
diff --git a/frontend/src/plugins/vuetify.js b/frontend/src/plugins/vuetify.js
index 0792ecbe6..e12ef182f 100644
--- a/frontend/src/plugins/vuetify.js
+++ b/frontend/src/plugins/vuetify.js
@@ -67,6 +67,10 @@ const vuetify = createVuetify({
VCheckboxBtn: {
color: "primary",
},
+ // A group hands its own color to its checkboxes, unset or not.
+ VCheckboxGroup: {
+ color: "primary",
+ },
VCombobox: {
color: "primary",
},
diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss
index 9b61ff38a..746d604b9 100644
--- a/frontend/src/styles/global.scss
+++ b/frontend/src/styles/global.scss
@@ -54,6 +54,26 @@
color: rgb(var(--v-theme-text-disabled));
}
+ /*
+ * A checkbox group lines up with a lone checkbox. Vuetify's labs CSS
+ * indents the group's label 16px and its boxes 6px. Inline boxes get
+ * room between one label and the next box.
+ */
+ .v-checkbox-group > .v-input__control > .v-label {
+ margin-inline-start: 0;
+ }
+
+ .v-checkbox-group
+ > .v-input__control
+ > .v-label
+ + .v-selection-control-group {
+ padding-inline-start: 0;
+ }
+
+ .v-checkbox-group .v-selection-control-group--inline {
+ column-gap: 16px;
+ }
+
/*
* Sub-header inside the settings drawers. Shared by six components
* across the browser and reader drawers.
diff --git a/frontend/tests/unit/browser-settings-collection.test.js b/frontend/tests/unit/browser-settings-collection.test.js
new file mode 100644
index 000000000..24fab6cb4
--- /dev/null
+++ b/frontend/tests/unit/browser-settings-collection.test.js
@@ -0,0 +1,73 @@
+/*
+ * The browser drawer's Show Collection Levels checkboxes.
+ *
+ * Behavior locked in here:
+ * - The levels are one VCheckboxGroup labelled by the drawer sub-header.
+ * - A click sends exactly one setSettings call carrying only the flag it
+ * flipped, since every settings change reloads the browser page.
+ */
+import { createTestingPinia } from "@pinia/testing";
+import { mount } from "@vue/test-utils";
+import { describe, expect, test } from "vitest";
+
+import BrowserSettingsCollection from "@/components/browser/drawer/browser-settings-collection.vue";
+import vuetify from "@/plugins/vuetify";
+import { useBrowserStore } from "@/stores/browser";
+
+function mountSettings() {
+ const pinia = createTestingPinia({
+ initialState: {
+ // The store's own static choices supply the four levels.
+ browser: {
+ settings: {
+ show: {
+ publishers: true,
+ imprints: true,
+ series: true,
+ volumes: false,
+ },
+ },
+ },
+ },
+ });
+ const wrapper = mount(BrowserSettingsCollection, {
+ global: {
+ plugins: [pinia, vuetify],
+ mocks: { $route: { params: { collection: "publishers" } } },
+ },
+ });
+ return { wrapper, store: useBrowserStore() };
+}
+
+describe("BrowserSettingsCollection", () => {
+ test("renders the levels as a labelled checkbox group", () => {
+ const { wrapper } = mountSettings();
+ const group = wrapper.findComponent({ name: "VCheckboxGroup" });
+ expect(group.exists()).toBe(true);
+ expect(group.find(".v-label .settingsSubHeader").text()).toBe(
+ "Show Collection Levels",
+ );
+ expect(group.find('input[value="imprints"]').element.checked).toBe(true);
+ expect(group.find('input[value="volumes"]').element.checked).toBe(false);
+ });
+
+ test("unchecking Imprints sends only the imprints flag", async () => {
+ const { wrapper, store } = mountSettings();
+ await wrapper.find('input[value="imprints"]').setValue(false);
+ expect(store.setSettings).toHaveBeenCalledTimes(1);
+ expect(store.setSettings).toHaveBeenCalledWith({
+ show: { imprints: false },
+ });
+ });
+
+ test("checking Volumes sends only the volumes flag", async () => {
+ const { wrapper, store } = mountSettings();
+ await wrapper.find('input[value="volumes"]').setValue(true);
+ expect(store.setSettings).toHaveBeenCalledTimes(1);
+ expect(store.setSettings).toHaveBeenCalledWith({
+ show: { volumes: true },
+ });
+ });
+});
+
+export default {};
diff --git a/frontend/tests/unit/browser-table-column-picker-standalone.test.js b/frontend/tests/unit/browser-table-column-picker-standalone.test.js
index 68a4f4ab9..e9322645f 100644
--- a/frontend/tests/unit/browser-table-column-picker-standalone.test.js
+++ b/frontend/tests/unit/browser-table-column-picker-standalone.test.js
@@ -43,6 +43,23 @@ function mountPicker(props, { settings = {}, defaults } = {}) {
return { wrapper, vm: wrapper.vm, store: useBrowserStore() };
}
+// Render the dialog body inline so the column checkboxes exist.
+const VDialogStub = { name: "VDialog", template: "
" };
+
+function mountPickerBody(props) {
+ const pinia = createTestingPinia({
+ initialState: {
+ browser: {
+ settings: { topCollection: "comics", tableColumns: {}, show: SHOW },
+ },
+ },
+ });
+ return mount(BrowserTableColumnPicker, {
+ props: { modelValue: false, ...props },
+ global: { plugins: [pinia, vuetify], stubs: { VDialog: VDialogStub } },
+ });
+}
+
async function open(wrapper) {
await wrapper.setProps({ modelValue: true });
}
@@ -149,4 +166,47 @@ describe("column picker — live", () => {
});
});
+function columnInput(wrapper, key) {
+ return wrapper.find(`.pickerCategory input[value="${key}"]`);
+}
+
+async function openBody() {
+ const wrapper = mountPickerBody({
+ standalone: true,
+ standaloneTopCollection: "folders",
+ standaloneColumns: ["cover", "name", "child_count"],
+ standaloneShow: SHOW,
+ });
+ await open(wrapper);
+ return wrapper;
+}
+
+describe("column picker — category checkboxes", () => {
+ test("each category is a checkbox group titled by its label", async () => {
+ const wrapper = await openBody();
+ const groups = wrapper.findAllComponents({ name: "VCheckboxGroup" });
+ expect(groups.length).toBeGreaterThan(1);
+ expect(groups[0].find(".v-label").text()).not.toBe("");
+ expect(columnInput(wrapper, "name").element.checked).toBe(true);
+ expect(columnInput(wrapper, "favorite").element.checked).toBe(false);
+ });
+
+ test("checking a column lands at its canonical position", async () => {
+ const wrapper = await openBody();
+ await columnInput(wrapper, "favorite").setValue(true);
+ expect(wrapper.vm.draft).toEqual([
+ "cover",
+ "favorite",
+ "name",
+ "child_count",
+ ]);
+ });
+
+ test("unchecking a column removes only that column", async () => {
+ const wrapper = await openBody();
+ await columnInput(wrapper, "name").setValue(false);
+ expect(wrapper.vm.draft).toEqual(["cover", "child_count"]);
+ });
+});
+
export default {};
diff --git a/frontend/tests/unit/defaults-tab.test.js b/frontend/tests/unit/defaults-tab.test.js
index 5cd585501..9e8161811 100644
--- a/frontend/tests/unit/defaults-tab.test.js
+++ b/frontend/tests/unit/defaults-tab.test.js
@@ -109,11 +109,11 @@ describe("AdminDefaultsTab — drafts", () => {
expect(vm.lockedShowKey).toBeUndefined();
});
- test("a locked show box explains itself in the card footer", async () => {
+ test("a locked show box explains itself in the group's hint", async () => {
const { wrapper, vm } = mountTab();
const hint = () =>
wrapper
- .findAll(".adminHint")
+ .findAll(".v-messages__message")
.find((node) => node.text().includes("Top Collection"));
expect(hint().text()).toBe(
"Publishers is the Top Collection, so it must stay shown. Choose another Top Collection to hide Publishers.",
@@ -129,6 +129,27 @@ describe("AdminDefaultsTab — drafts", () => {
expect(hint()).toBeUndefined();
});
+ test("the show checkboxes flip one draft flag each", async () => {
+ const { wrapper, vm } = mountTab();
+ const group = wrapper.findComponent({ name: "VCheckboxGroup" });
+ expect(group.exists()).toBe(true);
+ const box = (key) => group.find(`input[value="${key}"]`);
+ expect(box("showPublishers").element.disabled).toBe(true);
+ expect(box("showImprints").element.checked).toBe(false);
+
+ await box("showImprints").setValue(true);
+ expect(vm.draft.browser).toMatchObject({
+ showPublishers: true,
+ showImprints: true,
+ showSeries: true,
+ showVolumes: false,
+ });
+
+ await box("showSeries").setValue(false);
+ expect(vm.draft.browser.showSeries).toBe(false);
+ expect(vm.draft.browser.showImprints).toBe(true);
+ });
+
test("a hidden collection is disabled with a reason", () => {
const { vm } = mountTab();
expect(vm.topCollectionItemProps({ value: "imprints" })).toEqual({
diff --git a/frontend/tests/unit/edit-panel-formats.test.js b/frontend/tests/unit/edit-panel-formats.test.js
new file mode 100644
index 000000000..6b5be78fe
--- /dev/null
+++ b/frontend/tests/unit/edit-panel-formats.test.js
@@ -0,0 +1,76 @@
+/*
+ * Tests for the tag edit panel's metadata format checkboxes.
+ *
+ * Behavior locked in here:
+ * - The formats are a real VCheckboxGroup (the labs component resolves),
+ * one checkbox per derived write format.
+ * - The last checked format is disabled, and the group says why, so the
+ * selection can never become empty.
+ * - Unchecking one of two formats leaves the other selected.
+ */
+import { createTestingPinia } from "@pinia/testing";
+import { flushPromises, mount } from "@vue/test-utils";
+import { describe, expect, test } from "vitest";
+
+import EditPanel from "@/components/metadata/edit-mode/edit-panel.vue";
+import vuetify from "@/plugins/vuetify";
+
+const LAST_FORMAT_HINT = "At least one format must be written";
+
+function formatGroup(wrapper) {
+ return wrapper.findComponent({ name: "VCheckboxGroup" });
+}
+
+function formatInput(wrapper, value) {
+ return formatGroup(wrapper).find(`input[value="${value}"]`);
+}
+
+async function mountPanel(formats) {
+ const pinia = createTestingPinia({
+ initialState: {
+ metadata: { md: {} },
+ admin: { taggingDefaults: { defaultFormats: formats } },
+ browser: { settings: { twentyFourHourTime: false } },
+ },
+ });
+ const wrapper = mount(EditPanel, {
+ props: { book: { pk: 1, collection: "comics", ids: [1] } },
+ global: { plugins: [pinia, vuetify] },
+ });
+ await flushPromises();
+ return wrapper;
+}
+
+describe("EditPanel — format checkboxes", () => {
+ test("renders a checkbox group with one box per format", async () => {
+ const wrapper = await mountPanel(["COMIC_INFO"]);
+ const group = formatGroup(wrapper);
+ expect(group.exists()).toBe(true);
+ expect(group.find('[role="group"]').attributes("aria-label")).toBe(
+ "Metadata formats",
+ );
+ expect(formatInput(wrapper, "METRON_INFO").exists()).toBe(true);
+ expect(formatInput(wrapper, "COMIC_INFO").exists()).toBe(true);
+ });
+
+ test("the last checked format is disabled and explained", async () => {
+ const wrapper = await mountPanel(["COMIC_INFO"]);
+ expect(formatInput(wrapper, "COMIC_INFO").element.disabled).toBe(true);
+ expect(formatInput(wrapper, "METRON_INFO").element.disabled).toBe(false);
+ expect(formatGroup(wrapper).text()).toContain(LAST_FORMAT_HINT);
+ });
+
+ test("unchecking one of two formats leaves the other", async () => {
+ const wrapper = await mountPanel(["METRON_INFO", "COMIC_INFO"]);
+ expect(formatInput(wrapper, "COMIC_INFO").element.disabled).toBe(false);
+ expect(formatGroup(wrapper).text()).not.toContain(LAST_FORMAT_HINT);
+
+ await formatInput(wrapper, "METRON_INFO").setValue(false);
+
+ expect(wrapper.vm.selectedFormats).toEqual(["COMIC_INFO"]);
+ expect(formatInput(wrapper, "COMIC_INFO").element.disabled).toBe(true);
+ expect(formatGroup(wrapper).text()).toContain(LAST_FORMAT_HINT);
+ });
+});
+
+export default {};
diff --git a/frontend/tests/unit/launcher-dialog.test.js b/frontend/tests/unit/launcher-dialog.test.js
index 5e3e3ad6e..fc834f49c 100644
--- a/frontend/tests/unit/launcher-dialog.test.js
+++ b/frontend/tests/unit/launcher-dialog.test.js
@@ -45,6 +45,12 @@ function mountDialog({
return { wrapper, onlineTagStore };
}
+function sourceInput(wrapper, source) {
+ return wrapper
+ .findComponent({ name: "VCheckboxGroup" })
+ .find(`input[value="${source}"]`);
+}
+
describe("OnlineTagLauncherDialog", () => {
describe("action button", () => {
test("reads Search when nothing is pinned", async () => {
@@ -125,6 +131,61 @@ describe("OnlineTagLauncherDialog", () => {
});
});
+ describe("source checkboxes", () => {
+ test("an unconfigured source is disabled and the group says why", async () => {
+ const { wrapper } = mountDialog({
+ taggingDefaults: {
+ hasMetronCredentials: true,
+ hasComicvineCredentials: false,
+ },
+ });
+ wrapper.vm.sources = ["metron"];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findComponent({ name: "VCheckboxGroup" }).exists()).toBe(
+ true,
+ );
+ expect(sourceInput(wrapper, "metron").element.disabled).toBe(false);
+ expect(sourceInput(wrapper, "comicvine").element.disabled).toBe(true);
+ expect(wrapper.text()).toContain(
+ "Comic Vine has no API key. Add one in Admin → Tagging.",
+ );
+ });
+
+ test("an empty selection explains the disabled action", async () => {
+ const { wrapper } = mountDialog();
+ wrapper.vm.sources = [];
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("Pick at least one source");
+ expect(wrapper.vm.actionDisabled).toBe(true);
+ });
+
+ test("checking a source adds it to the selection", async () => {
+ const { wrapper } = mountDialog();
+ wrapper.vm.sources = ["metron"];
+ await wrapper.vm.$nextTick();
+
+ await sourceInput(wrapper, "comicvine").setValue(true);
+
+ expect(wrapper.vm.sources).toEqual(["metron", "comicvine"]);
+ expect(wrapper.text()).not.toContain("Pick at least one source");
+ });
+
+ test("a pinned id checks its source's box", async () => {
+ const { wrapper } = mountDialog();
+ wrapper.vm.sources = ["comicvine"];
+ await wrapper.vm.$nextTick();
+ expect(sourceInput(wrapper, "metron").element.checked).toBe(false);
+
+ wrapper.vm.idInputs = ["metron:12345"];
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ expect(sourceInput(wrapper, "metron").element.checked).toBe(true);
+ });
+ });
+
describe("idTaggable (whether the id inputs exist)", () => {
test("true for a single comic", () => {
const { wrapper } = mountDialog();
diff --git a/frontend/tests/unit/tagging-tab.test.js b/frontend/tests/unit/tagging-tab.test.js
index a2557db11..c227b61fc 100644
--- a/frontend/tests/unit/tagging-tab.test.js
+++ b/frontend/tests/unit/tagging-tab.test.js
@@ -23,6 +23,9 @@
* - The Online Tagging Defaults section shows help for Match Mode and Prompts.
* - The Metadata Formats field's hint (not a section header) links out to the
* ComicInfo and MetronInfo docs.
+ * - Metadata Formats is a checkbox group whose last checked box is disabled
+ * and explained, so auto-save can never write an empty list. A row already
+ * saved empty shows an error and is not auto-saved on open.
*/
import { createTestingPinia } from "@pinia/testing";
import { mount } from "@vue/test-utils";
@@ -452,4 +455,48 @@ describe("AdminTaggingTab — Metadata Formats hint", () => {
});
});
+function formatInput(wrapper, value) {
+ return wrapper
+ .findComponent({ name: "VCheckboxGroup" })
+ .find(`input[value="${value}"]`);
+}
+
+describe("AdminTaggingTab — Metadata Formats checkboxes", () => {
+ test("the last checked format is disabled and explained", () => {
+ const wrapper = mountTab({ defaultFormats: ["COMIC_INFO"] });
+ expect(wrapper.findComponent({ name: "VCheckboxGroup" }).exists()).toBe(
+ true,
+ );
+ expect(formatInput(wrapper, "COMIC_INFO").element.disabled).toBe(true);
+ expect(formatInput(wrapper, "METRON_INFO").element.disabled).toBe(false);
+ expect(wrapper.text()).toContain("At least one format must be written");
+ });
+
+ test("checking a second format enables both and auto-saves", async () => {
+ const wrapper = mountTab({ defaultFormats: ["COMIC_INFO"] });
+ const store = useAdminStore();
+ await formatInput(wrapper, "METRON_INFO").setValue(true);
+ expect(wrapper.vm.draft.defaultFormats).toEqual([
+ "COMIC_INFO",
+ "METRON_INFO",
+ ]);
+ expect(formatInput(wrapper, "COMIC_INFO").element.disabled).toBe(false);
+ expect(store.updateTaggingDefaults).toHaveBeenCalledWith(
+ expect.objectContaining({
+ defaultFormats: ["COMIC_INFO", "METRON_INFO"],
+ }),
+ );
+ });
+
+ test("an empty saved row shows an error and does not auto-save", async () => {
+ const wrapper = mountTab({ defaultFormats: [] });
+ const store = useAdminStore();
+ await wrapper.vm.$nextTick();
+ const errors = wrapper.findAll(".v-messages__message").map((m) => m.text());
+ expect(errors).toContain("Choose at least one format");
+ expect(formatInput(wrapper, "COMIC_INFO").element.disabled).toBe(false);
+ expect(store.updateTaggingDefaults).not.toHaveBeenCalled();
+ });
+});
+
export default {};
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 9743dea60..f09386c00 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -206,7 +206,7 @@ const config = defineConfig(({ mode }) => {
define: defineObj,
plugins: [
vue(),
- vuetify({ autoImport: true }),
+ vuetify({ autoImport: { labs: true } }),
// Lint runs ESLint over the frontend; vitest needn't run it again.
!IS_TEST_ENV &&
checker({
diff --git a/tests/test_admin_singleton_settings.py b/tests/test_admin_singleton_settings.py
index 197f3d056..c5a405598 100644
--- a/tests/test_admin_singleton_settings.py
+++ b/tests/test_admin_singleton_settings.py
@@ -180,6 +180,29 @@ def test_tagging_defaults_rejects_unknown_source(self) -> None:
)
assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.content
+ def test_tagging_defaults_rejects_no_formats(self) -> None:
+ """An empty format list is a 400: comicbox can't write no formats."""
+ resp = _put_envelope(
+ self.client, "/api/v4/admin/tagging-defaults", {"defaultFormats": []}
+ )
+ assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.content
+
+ def test_tagging_defaults_rejects_unknown_format(self) -> None:
+ resp = _put_envelope(
+ self.client,
+ "/api/v4/admin/tagging-defaults",
+ {"defaultFormats": ["COMIC_INFO", "COMET"]},
+ )
+ assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.content
+
+ def test_tagging_defaults_rejects_non_list_formats(self) -> None:
+ resp = _put_envelope(
+ self.client,
+ "/api/v4/admin/tagging-defaults",
+ {"defaultFormats": "COMIC_INFO"},
+ )
+ assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.content
+
def test_throttle_settings_put(self) -> None:
"""PUT /admin/throttle-settings returns 200 with the saved row."""
resp = _put_envelope(