Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions frontend/src/components/admin/tabs/admin-section.scss
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,13 @@

// The icon-button cluster in a data table's Actions column: dimmed until
// hovered so the rows stay calm.
.adminActionCell :deep(> button) {
.adminActionCell :deep(> button),
.adminActionCell :deep(> .v-split-btn .v-btn) {
opacity: 0.7;
}

.adminActionCell :deep(> button:hover) {
.adminActionCell :deep(> button:hover),
.adminActionCell :deep(> .v-split-btn .v-btn:hover) {
opacity: 1;
}

Expand Down
42 changes: 36 additions & 6 deletions frontend/src/components/admin/tabs/library-table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,44 @@
</template>
<template #[`item.actions`]="{ item }">
<span class="adminActionCell">
<ConfirmDialog
<!-- Comfortable in a group is the size compact is alone. -->
<v-split-btn
:icon="mdiDatabaseClockOutline"
:size="iconSize"
density="comfortable"
class="pollSplitBtn"
title="Poll for updated comics"
menu-label="More poll options"
@click="pollPk = item.pk"
>
<template #menu>
<v-list density="compact">
<v-list-item
:prepend-icon="mdiDatabaseSyncOutline"
title="Force update comics"
@click="forcePollPk = item.pk"
/>
</v-list>
</template>
</v-split-btn>
<!--
Controlled and outside the menu: the menu closes on a click and
unmounts its content, which would take a dialog inside it along.
-->
<ConfirmDialog
:model-value="pollPk === item.pk"
title-text="Poll for updated comics"
:text="item.path"
confirm-text="Poll Library"
:size="iconSize"
density="compact"
@update:model-value="pollPk = $event ? item.pk : null"
@confirm="poll(item)"
/>
<ConfirmDialog
:icon="mdiDatabaseSyncOutline"
:model-value="forcePollPk === item.pk"
title-text="Force update comics"
:text="item.path"
confirm-text="Force Update"
:size="iconSize"
density="compact"
@update:model-value="forcePollPk = $event ? item.pk : null"
@confirm="forcePoll(item)"
/>
<AdminCreateUpdateDialog
Expand Down Expand Up @@ -126,6 +148,9 @@ export default {
mdiDatabaseSyncOutline,
mdiOpenInNew,
AdminLibraryCreateUpdateInputs: markRaw(AdminLibraryCreateUpdateInputs),
// The library whose Poll / Force Update confirmation is open.
pollPk: null,
forcePollPk: null,
};
},
computed: {
Expand Down Expand Up @@ -253,4 +278,9 @@ export default {
.missingComics {
color: rgb(var(--v-theme-warning));
}

// A narrow menu toggle, so the split button stays close to an icon's width.
.pollSplitBtn {
--v-split-btn-toggle-padding-inline: 0;
}
</style>
28 changes: 25 additions & 3 deletions frontend/src/components/confirm-dialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
width="auto"
overlay-opacity="0.5"
>
<template #activator="{ props }">
<template v-if="!controlled" #activator="{ props }">
<v-btn
v-if="button && icon"
v-bind="props"
Expand Down Expand Up @@ -78,6 +78,13 @@ export default {
default: "default",
},
icon: { type: String, default: "" },
/*
* Set it to open and close the dialog from outside, as for a dialog
* launched from a menu; it then renders no activator of its own. Left
* null, the dialog opens from its own button. Null, not false, is the
* default, because Vue casts an absent Boolean prop to false.
*/
modelValue: { type: Boolean, default: null },
prependIcon: { type: String, default: "" },
size: {
type: String,
Expand All @@ -93,12 +100,27 @@ export default {
},
variant: { type: String, default: "" },
},
emits: ["cancel", "confirm"],
emits: ["cancel", "confirm", "update:modelValue"],
data() {
return {
showDialog: false,
open: false,
};
},
computed: {
controlled() {
return this.modelValue !== null;
},
showDialog: {
get() {
return this.controlled ? this.modelValue : this.open;
},
set(value) {
// Every close reports here, a scrim click or Escape included.
this.open = value;
this.$emit("update:modelValue", value);
},
},
},
methods: {
close(event) {
this.$emit(event);
Expand Down
90 changes: 90 additions & 0 deletions frontend/tests/unit/confirm-dialog.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* ConfirmDialog's two modes.
*
* Behavior locked in here:
* - With no modelValue it manages itself: its own button opens it, and
* Confirm emits ``confirm`` and closes it.
* - With a modelValue it is controlled from outside, as for a dialog
* launched from a menu: it renders no activator, shows only while the
* model is true, and reports every close through ``update:modelValue``,
* a scrim click or Escape included.
*
* ``v-dialog`` is stubbed so the overlay/teleport machinery happy-dom lacks
* stays out of it; the stub keeps the activator slot and the open state.
*/
import { mount } from "@vue/test-utils";
import { describe, expect, test } from "vitest";

import ConfirmDialog from "@/components/confirm-dialog.vue";
import vuetify from "@/plugins/vuetify";

const VDialogStub = Object.freeze({
name: "VDialog",
props: { modelValue: { type: Boolean, default: false } },
emits: ["update:modelValue"],
template: `<div class="dialogStub">
<slot name="activator" :props="{ onClick: () => $emit('update:modelValue', true) }" />
<slot v-if="modelValue" />
</div>`,
});

const PROPS = Object.freeze({
titleText: "Poll for updated comics",
text: "/comics",
confirmText: "Poll Library",
buttonText: "Poll",
});

function confirmButton(wrapper) {
return wrapper.find(".confirmButton");
}

function mountDialog(props = {}) {
return mount(ConfirmDialog, {
props: { ...PROPS, ...props },
global: { plugins: [vuetify], stubs: { VDialog: VDialogStub } },
});
}

describe("ConfirmDialog — self-managed", () => {
test("its own button opens it and Confirm closes it", async () => {
const wrapper = mountDialog();
expect(wrapper.vm.controlled).toBe(false);
expect(confirmButton(wrapper).exists()).toBe(false);

await wrapper.find("button").trigger("click");
expect(wrapper.vm.showDialog).toBe(true);

await confirmButton(wrapper).trigger("click");
expect(wrapper.emitted("confirm")).toHaveLength(1);
expect(wrapper.vm.showDialog).toBe(false);
});
});

describe("ConfirmDialog — controlled", () => {
test("renders no activator and follows the model", async () => {
const wrapper = mountDialog({ modelValue: false });
expect(wrapper.vm.controlled).toBe(true);
expect(wrapper.find("button").exists()).toBe(false);

await wrapper.setProps({ modelValue: true });
expect(confirmButton(wrapper).exists()).toBe(true);
});

test("Confirm emits confirm, then asks to close", async () => {
const wrapper = mountDialog({ modelValue: true });
await confirmButton(wrapper).trigger("click");
expect(wrapper.emitted("confirm")).toHaveLength(1);
expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
});

test("a scrim click or Escape reports the close without confirming", async () => {
const wrapper = mountDialog({ modelValue: true });
wrapper.findComponent(VDialogStub).vm.$emit("update:modelValue", false);
await wrapper.vm.$nextTick();
expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
expect(wrapper.emitted("confirm")).toBeUndefined();
});
});

export default {};
127 changes: 127 additions & 0 deletions frontend/tests/unit/library-table-poll.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* The library table's Poll split button.
*
* Behavior locked in here:
* - Each row's Poll control is a real VSplitBtn.
* - Its main button opens the Poll confirmation; confirming polls.
* - Its menu's one item opens the Force Update confirmation; confirming
* force-polls. Both confirmations live outside the menu, controlled by
* the table, so the menu closing can't unmount them.
* - Closing a confirmation without confirming runs nothing.
*/
import { createTestingPinia } from "@pinia/testing";
import { mount } from "@vue/test-utils";
import { describe, expect, test } from "vitest";

import LibraryTable from "@/components/admin/tabs/library-table.vue";
import ConfirmDialog from "@/components/confirm-dialog.vue";
import vuetify from "@/plugins/vuetify";
import { useAdminStore } from "@/stores/admin";

const LIBRARY = Object.freeze({
pk: 3,
path: "/comics",
comicCount: 10,
events: true,
poll: true,
readOnly: false,
pollEvery: "01:00:00",
lastPoll: null,
groups: [],
});

// Render the menu's content inline instead of through an overlay.
const VMenuStub = {
name: "VMenu",
template: '<div><slot name="activator" :props="{}" /><slot /></div>',
};

// A dialog that shows its content only while open, with no overlay.
const VDialogStub = {
name: "VDialog",
props: { modelValue: { type: Boolean, default: false } },
emits: ["update:modelValue"],
template: '<div class="dialogStub"><slot v-if="modelValue" /></div>',
};

function confirmDialog(wrapper, titleText) {
return wrapper
.findAllComponents(ConfirmDialog)
.find((dialog) => dialog.props("titleText") === titleText);
}

function mountTable() {
const pinia = createTestingPinia({
initialState: {
auth: { user: { isStaff: true } },
admin: { groups: [], failedImports: [], pendingDeletes: [] },
browser: { settings: { twentyFourHourTime: false } },
},
});
const wrapper = mount(LibraryTable, {
attrs: { items: [LIBRARY] },
global: {
plugins: [vuetify, pinia],
stubs: {
VMenu: VMenuStub,
VDialog: VDialogStub,
AdminCreateUpdateDialog: true,
AdminDeleteRowDialog: true,
},
},
});
return { wrapper, store: useAdminStore() };
}

describe("LibraryTable — Poll split button", () => {
test("the row's poll control is a split button", () => {
const { wrapper } = mountTable();
const split = wrapper.findComponent({ name: "VSplitBtn" });
expect(split.exists()).toBe(true);
expect(split.attributes("title")).toBe("Poll for updated comics");
});

test("the main button confirms, then polls", async () => {
const { wrapper, store } = mountTable();
const poll = () => confirmDialog(wrapper, "Poll for updated comics");
expect(poll().props("modelValue")).toBe(false);

await wrapper.find(".v-split-btn__action").trigger("click");
expect(poll().props("modelValue")).toBe(true);

await poll().find(".confirmButton").trigger("click");
expect(store.librarianTask).toHaveBeenCalledWith(
"poll",
"Poll Library 3",
3,
);
expect(poll().props("modelValue")).toBe(false);
});

test("the menu item confirms, then force-polls", async () => {
const { wrapper, store } = mountTable();
const force = () => confirmDialog(wrapper, "Force update comics");

await wrapper.find(".v-split-btn .v-list-item").trigger("click");
expect(force().props("modelValue")).toBe(true);

await force().find(".confirmButton").trigger("click");
expect(store.librarianTask).toHaveBeenCalledWith(
"poll_force",
"Force Poll Library 3",
3,
);
});

test("cancelling runs nothing and closes the dialog", async () => {
const { wrapper, store } = mountTable();
const poll = () => confirmDialog(wrapper, "Poll for updated comics");
await wrapper.find(".v-split-btn__action").trigger("click");

await poll().find(".cancelButton").trigger("click");
expect(store.librarianTask).not.toHaveBeenCalled();
expect(poll().props("modelValue")).toBe(false);
});
});

export default {};
2 changes: 1 addition & 1 deletion frontend/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down