diff --git a/client/src/features/dashboard/components/DashboardSettingsSheet.spec.ts b/client/src/features/dashboard/components/DashboardSettingsSheet.spec.ts index 72a1d1d69..44667684c 100644 --- a/client/src/features/dashboard/components/DashboardSettingsSheet.spec.ts +++ b/client/src/features/dashboard/components/DashboardSettingsSheet.spec.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mount, type VueWrapper } from '@vue/test-utils' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { nextTick, ref, type Ref } from 'vue' -import { WIDGET_TYPES, type Library, type WidgetConfig } from '@bookorbit/types' +import { Permission, WIDGET_TYPES, type Library, type WidgetConfig } from '@bookorbit/types' import { setI18nLocale } from '@/i18n' import en from '@/locales/en.json' import pt from '@/locales/pt.json' +import type { api } from '@/lib/api' // Production falls back to English for any key a target catalog has not translated, // so these are the labels Portuguese actually renders. Reading pt directly would @@ -38,6 +39,11 @@ const libraryIdsRef = ref(null) const librariesRef = ref([]) const saveWidgetsMock = vi.fn<(widgets: WidgetConfig[], libraryIds?: readonly number[] | null) => Promise>() const saveLibraryScopeMock = vi.fn<(libraryIds: readonly number[] | null) => Promise>() +const shelfUser = ref({ id: 1, settings: {}, permissions: [] as Permission[], isSuperuser: false }) +const saveShelfApi = vi.fn() + +vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user: shelfUser }) })) +vi.mock('@/lib/api', () => ({ api: (...args: Parameters) => saveShelfApi(...args) })) vi.mock('@/components/ui/sheet', () => { const passthrough = { template: '
' } @@ -120,6 +126,8 @@ function shelfOptionLabels(wrapper: VueWrapper): string[] { beforeEach(async () => { vi.clearAllMocks() localStorage.clear() + shelfUser.value = { id: 1, settings: {}, permissions: [], isSuperuser: false } + saveShelfApi.mockResolvedValue(new Response(null, { status: 200 })) // useDashboardConfig keeps module-level state, so a shelf edit in one test // would otherwise seed the next test's draft. useDashboardConfig().reset() @@ -136,6 +144,105 @@ afterEach(async () => { }) describe('DashboardSettingsSheet', () => { + it.each([false, true])('hides shelf sync for demo-restricted accounts with isSuperuser=%s and keeps shelf edits local', async (isSuperuser) => { + shelfUser.value = { + id: 1, + permissions: [Permission.DemoRestricted], + isSuperuser, + settings: { dashboardShelfConfig: { syncAcrossSessions: true, shelfLayout: 'two-columns' } }, + } + const wrapper = await openSheet() + await wrapper.get('[aria-controls="dashboard-library-scope"]').trigger('click') + + expect(wrapper.find('input[aria-describedby="dashboard-shelf-sync-description"]').exists()).toBe(false) + expect(wrapper.text()).not.toContain(en.dashboard.settings.shelfSync.label) + expect(wrapper.text()).toContain(en.dashboard.settings.libraryScope.allLibraries) + await rowButtons(wrapper)[1]?.trigger('click') + await wrapper + .findAll('button') + .find((button) => button.text() === en.common.save) + ?.trigger('click') + await flushPromises() + + expect(saveShelfApi).not.toHaveBeenCalled() + expect(storedRows()[0]).toBe(2) + expect(wrapper.emitted('saved')).toHaveLength(1) + }) + + it('offers shelf sync off by default and persists opt-in only on save', async () => { + const wrapper = await openSheet() + const disclosure = wrapper.get('[aria-controls="dashboard-library-scope"]') + expect(disclosure.attributes('aria-expanded')).toBe('false') + expect(wrapper.get('#dashboard-library-scope').attributes('style')).toContain('display: none') + await disclosure.trigger('click') + expect(disclosure.attributes('aria-expanded')).toBe('true') + expect(wrapper.get('#dashboard-library-scope').attributes('style') ?? '').not.toContain('display: none') + const checkbox = wrapper.get('input[aria-describedby="dashboard-shelf-sync-description"]') + expect(wrapper.get('#dashboard-library-scope').element.contains(checkbox.element)).toBe(true) + expect((checkbox.element as HTMLInputElement).checked).toBe(false) + await checkbox.setValue(true) + expect(saveShelfApi).not.toHaveBeenCalled() + + await wrapper + .findAll('button') + .find((button) => button.text() === en.common.save) + ?.trigger('click') + await flushPromises() + + expect(JSON.parse(String(saveShelfApi.mock.calls[0]![1]?.body)).settings.dashboardShelfConfig.syncAcrossSessions).toBe(true) + expect(wrapper.emitted('saved')).toHaveLength(1) + }) + + it('saves the shared sync switch from the widgets tab without saving shelf drafts', async () => { + const wrapper = await openSheet() + await rowButtons(wrapper)[2]?.trigger('click') + await wrapper.get('input[aria-describedby="dashboard-shelf-sync-description"]').setValue(true) + await wrapper + .findAll('button') + .find((button) => button.text() === en.dashboard.settings.tabs.widgets) + ?.trigger('click') + + await wrapper + .findAll('button') + .find((button) => button.text() === en.common.save) + ?.trigger('click') + await flushPromises() + + expect(saveWidgetsMock).toHaveBeenCalled() + const config = JSON.parse(String(saveShelfApi.mock.calls[0]![1]?.body)).settings.dashboardShelfConfig + expect(config.syncAcrossSessions).toBe(true) + expect(config.scrollers[0].rows).toBe(1) + }) + + it('discards a sync change when cancelled and reopened', async () => { + const wrapper = await openSheet() + await wrapper.get('input[aria-describedby="dashboard-shelf-sync-description"]').setValue(true) + await wrapper + .findAll('button') + .find((button) => button.text() === en.common.cancel) + ?.trigger('click') + await wrapper.setProps({ open: false }) + await wrapper.setProps({ open: true }) + + expect((wrapper.get('input[aria-describedby="dashboard-shelf-sync-description"]').element as HTMLInputElement).checked).toBe(false) + expect(saveShelfApi).not.toHaveBeenCalled() + }) + + it('keeps the sheet open and reports a failed shelf sync save', async () => { + const wrapper = await openSheet() + await wrapper.get('input[aria-describedby="dashboard-shelf-sync-description"]').setValue(true) + saveShelfApi.mockResolvedValue(new Response(null, { status: 500 })) + + await wrapper + .findAll('button') + .find((button) => button.text() === en.common.save) + ?.trigger('click') + await flushPromises() + + expect(wrapper.get('[role="alert"]').text()).toBe(en.dashboard.settings.saveFailed) + expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.emitted('update:open')).toBeUndefined() + }) it('defaults to all accessible libraries', async () => { const wrapper = await openSheet() diff --git a/client/src/features/dashboard/components/DashboardSettingsSheet.vue b/client/src/features/dashboard/components/DashboardSettingsSheet.vue index 073d617d0..33f81bdbf 100644 --- a/client/src/features/dashboard/components/DashboardSettingsSheet.vue +++ b/client/src/features/dashboard/components/DashboardSettingsSheet.vue @@ -8,6 +8,7 @@ import { formatList } from '@/i18n/formatters' import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes' import { useLibraries } from '@/features/library/composables/useLibraries' +import { usePermissions } from '@/features/auth/composables/usePermissions' import { DEFAULT_SCROLLERS, SCROLLER_LABELS, SHELF_LAYOUT, useDashboardConfig, type DashboardShelfLayout } from '../composables/useDashboardConfig' import { SHELF_ROW_OPTIONS } from '../lib/shelf-rows' import { useDashboardLabels } from '../composables/useDashboardLabels' @@ -18,8 +19,9 @@ const props = defineProps<{ open: boolean }>() const emit = defineEmits<{ 'update:open': [value: boolean]; saved: [] }>() const { t } = useI18n() +const { isDemoRestrictedAccount } = usePermissions() -const { scrollers, shelfLayout, saveShelfSettings, MAX_SCROLLERS } = useDashboardConfig() +const { scrollers, shelfLayout, syncAcrossSessions, saveShelfSettings, MAX_SCROLLERS } = useDashboardConfig() const { widgets, libraryIds, saveWidgets, saveLibraryScope, DEFAULT_WIDGETS } = useDashboardWidgets() const { smartScopes, fetchSmartScopes } = useSmartScopes() const { libraries, fetchLibraries } = useLibraries() @@ -28,10 +30,12 @@ const { widgetName, shelfTypeName } = useDashboardLabels() const activeTab = ref<'widgets' | 'shelves'>('shelves') const draft = ref([]) const shelfLayoutDraft = ref(SHELF_LAYOUT.WIDE) +const syncShelvesDraft = ref(false) const widgetDraft = ref([]) const libraryIdsDraft = ref(null) const libraryScopeOpen = ref(false) const saving = ref(false) +const saveError = ref(false) const bookLibraries = computed(() => libraries.value) const accessibleLibraryIds = computed(() => new Set(bookLibraries.value.map((library) => library.id))) const selectedAccessibleLibraryIds = computed(() => @@ -55,6 +59,8 @@ watch( if (isOpen) { draft.value = (Array.isArray(scrollers.value) ? scrollers.value : DEFAULT_SCROLLERS).map((s) => ({ ...s })) shelfLayoutDraft.value = shelfLayout.value + syncShelvesDraft.value = syncAcrossSessions.value + saveError.value = false widgetDraft.value = widgets.value.map((w) => ({ ...w })) libraryIdsDraft.value = libraryIds.value ? [...libraryIds.value] : null libraryScopeOpen.value = false @@ -107,6 +113,14 @@ function setShelfRows(scroller: ScrollerConfig, rows: number) { scroller.rows = rows } +function toggleShelf(scroller: ScrollerConfig) { + scroller.enabled = !scroller.enabled +} + +function toggleWidget(widget: WidgetConfig) { + widget.enabled = !widget.enabled +} + function removeScroller(index: number) { if (draft.value.length <= 1) return draft.value.splice(index, 1) @@ -147,8 +161,8 @@ function handleLibraryChange(libraryId: number, event: Event) { // ── Save / Reset / Close ───────────────────────────────────── async function saveShelves() { - saveShelfSettings(draft.value, shelfLayoutDraft.value) await saveLibraryScope(selectedAccessibleLibraryIds.value) + await saveShelfSettings(draft.value, shelfLayoutDraft.value, syncShelvesDraft.value) } function selectWideShelfLayout() { @@ -161,6 +175,9 @@ function selectTwoColumnShelfLayout() { async function saveWidgetSettings() { await saveWidgets(widgetDraft.value, selectedAccessibleLibraryIds.value) + if (syncShelvesDraft.value !== syncAcrossSessions.value) { + await saveShelfSettings(scrollers.value, shelfLayout.value, syncShelvesDraft.value) + } } async function handleSave() { @@ -169,11 +186,14 @@ async function handleSave() { return } saving.value = true + saveError.value = false try { if (activeTab.value === 'widgets') await saveWidgetSettings() else await saveShelves() emit('saved') emit('update:open', false) + } catch { + saveError.value = true } finally { saving.value = false } @@ -187,10 +207,26 @@ function resetToDefault() { shelfLayoutDraft.value = SHELF_LAYOUT.WIDE } } + +function selectShelvesTab() { + activeTab.value = 'shelves' +} + +function selectWidgetsTab() { + activeTab.value = 'widgets' +} + +function handleOpenChange(open: boolean) { + emit('update:open', open) +} + +function handleCancel() { + emit('update:open', false) +}