mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(settings): add optional dashboard shelf sync
Keep dashboard layouts browser-local by default and let users opt in to account storage. Group library selection and shelf sync in one dropdown, preserve legacy client settings, and block sync changes for demo-restricted accounts. Fixes #1646
This commit is contained in:
@@ -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<number[] | null>(null)
|
||||
const librariesRef = ref<Library[]>([])
|
||||
const saveWidgetsMock = vi.fn<(widgets: WidgetConfig[], libraryIds?: readonly number[] | null) => Promise<void>>()
|
||||
const saveLibraryScopeMock = vi.fn<(libraryIds: readonly number[] | null) => Promise<void>>()
|
||||
const shelfUser = ref({ id: 1, settings: {}, permissions: [] as Permission[], isSuperuser: false })
|
||||
const saveShelfApi = vi.fn<typeof api>()
|
||||
|
||||
vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user: shelfUser }) }))
|
||||
vi.mock('@/lib/api', () => ({ api: (...args: Parameters<typeof api>) => saveShelfApi(...args) }))
|
||||
|
||||
vi.mock('@/components/ui/sheet', () => {
|
||||
const passthrough = { template: '<div><slot /></div>' }
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<ScrollerConfig[]>([])
|
||||
const shelfLayoutDraft = ref<DashboardShelfLayout>(SHELF_LAYOUT.WIDE)
|
||||
const syncShelvesDraft = ref(false)
|
||||
const widgetDraft = ref<WidgetConfig[]>([])
|
||||
const libraryIdsDraft = ref<number[] | null>(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<number[] | null>(() =>
|
||||
@@ -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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sheet :open="open" @update:open="emit('update:open', $event)">
|
||||
<Sheet :open="open" @update:open="handleOpenChange">
|
||||
<!-- SheetContent defaults to sm:max-w-sm; the override has to match that variant to win in tailwind-merge. -->
|
||||
<SheetContent side="right" class="flex w-[90vw] flex-col gap-0 p-0 sm:max-w-[480px]">
|
||||
<!-- Header -->
|
||||
@@ -206,7 +242,7 @@ function resetToDefault() {
|
||||
'flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
activeTab === 'shelves' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
@click="activeTab = 'shelves'"
|
||||
@click="selectShelvesTab"
|
||||
>
|
||||
{{ t('dashboard.settings.tabs.shelves') }}
|
||||
</button>
|
||||
@@ -215,7 +251,7 @@ function resetToDefault() {
|
||||
'flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
activeTab === 'widgets' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
@click="activeTab = 'widgets'"
|
||||
@click="selectWidgetsTab"
|
||||
>
|
||||
{{ t('dashboard.settings.tabs.widgets') }}
|
||||
</button>
|
||||
@@ -233,7 +269,7 @@ function resetToDefault() {
|
||||
@click="toggleLibraryScope"
|
||||
>
|
||||
<span class="min-w-0 grow">
|
||||
<span class="block text-sm font-medium text-foreground">{{ t('dashboard.settings.libraryScope.title') }}</span>
|
||||
<span class="block text-sm font-medium text-foreground">{{ t('dashboard.settings.preferencesTitle') }}</span>
|
||||
<span class="mt-0.5 block truncate text-xs text-muted-foreground">{{ librarySelectionSummary }}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
@@ -272,6 +308,20 @@ function resetToDefault() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label v-if="!isDemoRestrictedAccount" class="mt-3 flex cursor-pointer items-start gap-3 border-t border-border px-2 pt-3">
|
||||
<input
|
||||
v-model="syncShelvesDraft"
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 shrink-0 rounded border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-describedby="dashboard-shelf-sync-description"
|
||||
/>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-medium text-foreground">{{ t('dashboard.settings.shelfSync.label') }}</span>
|
||||
<span id="dashboard-shelf-sync-description" class="mt-1 block text-xs text-muted-foreground">{{
|
||||
t('dashboard.settings.shelfSync.description')
|
||||
}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<p v-if="!hasValidLibrarySelection" role="alert" class="border-t border-border px-3 py-2 text-xs text-destructive">
|
||||
{{ t('dashboard.settings.libraryScope.required') }}
|
||||
@@ -366,7 +416,7 @@ function resetToDefault() {
|
||||
<button
|
||||
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none"
|
||||
:class="scroller.enabled ? 'bg-primary' : 'bg-muted'"
|
||||
@click="scroller.enabled = !scroller.enabled"
|
||||
@click="toggleShelf(scroller)"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none block h-4 w-4 rounded-full bg-white shadow ring-0 transition-transform duration-200"
|
||||
@@ -491,7 +541,7 @@ function resetToDefault() {
|
||||
<button
|
||||
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none"
|
||||
:class="widget.enabled ? 'bg-primary' : 'bg-muted'"
|
||||
@click="widget.enabled = !widget.enabled"
|
||||
@click="toggleWidget(widget)"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none block h-4 w-4 rounded-full bg-white shadow ring-0 transition-transform duration-200"
|
||||
@@ -507,6 +557,7 @@ function resetToDefault() {
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p v-if="saveError" role="alert" class="border-t border-border px-5 py-3 text-sm text-destructive">{{ t('dashboard.settings.saveFailed') }}</p>
|
||||
<div class="flex items-center justify-between border-t border-border px-5 py-4">
|
||||
<button class="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground" @click="resetToDefault">
|
||||
<RotateCcw :size="13" />
|
||||
@@ -515,7 +566,7 @@ function resetToDefault() {
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="h-8 rounded-md border border-input px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
@click="emit('update:open', false)"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ScrollerConfig } from '@bookorbit/types'
|
||||
import { Permission, type AuthUser, type ScrollerConfig } from '@bookorbit/types'
|
||||
import { effectScope, ref } from 'vue'
|
||||
import type { api } from '@/lib/api'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ api: vi.fn<typeof api>() }))
|
||||
const user = ref<AuthUser | null>(null)
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: mocks.api }))
|
||||
vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user }) }))
|
||||
|
||||
function signedIn(settings: AuthUser['settings'] = {}, id = 1) {
|
||||
user.value = { id, settings } as AuthUser
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'bookorbit:dashboard:config'
|
||||
|
||||
@@ -11,6 +23,131 @@ describe('useDashboardConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
localStorage.clear()
|
||||
user.value = null
|
||||
mocks.api.mockReset()
|
||||
mocks.api.mockResolvedValue(new Response(null, { status: 200 }))
|
||||
})
|
||||
|
||||
it('keeps shelves browser-local by default without a settings request', async () => {
|
||||
signedIn()
|
||||
const { useDashboardConfig, SHELF_LAYOUT } = await import('../useDashboardConfig')
|
||||
const config = useDashboardConfig()
|
||||
|
||||
await config.saveShelfSettings([...config.scrollers.value].reverse(), SHELF_LAYOUT.TWO_COLUMNS)
|
||||
|
||||
expect(config.syncAcrossSessions.value).toBe(false)
|
||||
expect(storedConfig().shelfLayout).toBe('two-columns')
|
||||
expect(mocks.api).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores account shelf sync and forced opt-in for a demo-restricted account', async () => {
|
||||
signedIn({ dashboardShelfConfig: { syncAcrossSessions: true, shelfLayout: 'two-columns' } })
|
||||
user.value!.permissions = [Permission.DemoRestricted]
|
||||
const { useDashboardConfig, SHELF_LAYOUT } = await import('../useDashboardConfig')
|
||||
const config = useDashboardConfig()
|
||||
|
||||
expect(config.syncAcrossSessions.value).toBe(false)
|
||||
expect(config.shelfLayout.value).toBe('wide')
|
||||
await config.saveShelfSettings(config.scrollers.value, SHELF_LAYOUT.TWO_COLUMNS, true)
|
||||
|
||||
expect(mocks.api).not.toHaveBeenCalled()
|
||||
expect(storedConfig().shelfLayout).toBe('two-columns')
|
||||
})
|
||||
|
||||
it('uploads the current layout on opt-in and restores it in a fresh session', async () => {
|
||||
signedIn({ dashboardConfig: { readingGoal: 12 } })
|
||||
const { useDashboardConfig, SHELF_LAYOUT } = await import('../useDashboardConfig')
|
||||
const config = useDashboardConfig()
|
||||
const shelves = [...config.scrollers.value].reverse().map((scroller) => ({ ...scroller, rows: 2, enabled: false }))
|
||||
|
||||
await config.saveShelfSettings(shelves, SHELF_LAYOUT.TWO_COLUMNS, true)
|
||||
|
||||
const body = JSON.parse(String(mocks.api.mock.calls[0]![1]?.body))
|
||||
expect(mocks.api.mock.calls[0]![0]).toBe('/api/v1/users/me/settings')
|
||||
expect(body.settings.dashboardShelfConfig.syncAcrossSessions).toBe(true)
|
||||
expect(body.settings.dashboardConfig).toBeUndefined()
|
||||
expect(user.value?.settings.dashboardConfig).toEqual({ readingGoal: 12 })
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||
|
||||
localStorage.clear()
|
||||
vi.resetModules()
|
||||
const fresh = (await import('../useDashboardConfig')).useDashboardConfig()
|
||||
expect(fresh.syncAcrossSessions.value).toBe(true)
|
||||
expect(fresh.shelfLayout.value).toBe('two-columns')
|
||||
expect(fresh.scrollers.value).toEqual(body.settings.dashboardShelfConfig.scrollers)
|
||||
})
|
||||
|
||||
it('keeps the current layout locally after disabling sync', async () => {
|
||||
signedIn({ dashboardShelfConfig: { syncAcrossSessions: true, shelfLayout: 'two-columns' } })
|
||||
const config = (await import('../useDashboardConfig')).useDashboardConfig()
|
||||
|
||||
await config.saveShelfSettings(config.scrollers.value, config.shelfLayout.value, false)
|
||||
|
||||
expect(config.syncAcrossSessions.value).toBe(false)
|
||||
expect(storedConfig().shelfLayout).toBe('two-columns')
|
||||
expect(JSON.parse(String(mocks.api.mock.calls[0]![1]?.body)).settings.dashboardShelfConfig.syncAcrossSessions).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves local settings and leaves sync off when opt-in fails', async () => {
|
||||
signedIn()
|
||||
const { useDashboardConfig, SHELF_LAYOUT } = await import('../useDashboardConfig')
|
||||
const config = useDashboardConfig()
|
||||
config.saveScrollers(config.scrollers.value)
|
||||
const original = localStorage.getItem(STORAGE_KEY)
|
||||
mocks.api.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
await expect(config.saveShelfSettings([...config.scrollers.value].reverse(), SHELF_LAYOUT.TWO_COLUMNS, true)).rejects.toThrow(
|
||||
'Failed to save dashboard shelves: 500',
|
||||
)
|
||||
|
||||
expect(config.syncAcrossSessions.value).toBe(false)
|
||||
expect(config.shelfLayout.value).toBe('wide')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(original)
|
||||
})
|
||||
|
||||
it('does not carry synced shelves into another account or overwrite the browser layout', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ shelfLayout: 'wide' }))
|
||||
signedIn({ dashboardShelfConfig: { syncAcrossSessions: true, shelfLayout: 'two-columns' } })
|
||||
const config = (await import('../useDashboardConfig')).useDashboardConfig()
|
||||
expect(config.shelfLayout.value).toBe('two-columns')
|
||||
|
||||
signedIn({}, 2)
|
||||
|
||||
expect(config.syncAcrossSessions.value).toBe(false)
|
||||
expect(config.shelfLayout.value).toBe('wide')
|
||||
expect(storedConfig().shelfLayout).toBe('wide')
|
||||
})
|
||||
|
||||
it('ignores a completed save after the signed-in account changes', async () => {
|
||||
signedIn()
|
||||
const { useDashboardConfig, SHELF_LAYOUT } = await import('../useDashboardConfig')
|
||||
const config = useDashboardConfig()
|
||||
let resolve!: (response: Response) => void
|
||||
mocks.api.mockReturnValue(
|
||||
new Promise<Response>((done) => {
|
||||
resolve = done
|
||||
}),
|
||||
)
|
||||
const pending = config.saveShelfSettings(config.scrollers.value, SHELF_LAYOUT.TWO_COLUMNS, true)
|
||||
signedIn({}, 2)
|
||||
resolve(new Response(null, { status: 200 }))
|
||||
await pending
|
||||
|
||||
expect(user.value?.id).toBe(2)
|
||||
expect(config.syncAcrossSessions.value).toBe(false)
|
||||
expect(config.shelfLayout.value).toBe('wide')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('restores browser settings when an account changes while the dashboard is unmounted', async () => {
|
||||
signedIn({ dashboardShelfConfig: { syncAcrossSessions: true, shelfLayout: 'two-columns' } })
|
||||
const { useDashboardConfig } = await import('../useDashboardConfig')
|
||||
const scope = effectScope()
|
||||
scope.run(() => useDashboardConfig())
|
||||
scope.stop()
|
||||
signedIn({}, 2)
|
||||
|
||||
expect(useDashboardConfig().shelfLayout.value).toBe('wide')
|
||||
})
|
||||
|
||||
it('normalizes legacy object storage into a scroller array', async () => {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { APP_FEATURES, SCROLLER_TYPES, type ScrollerConfig, type ScrollerType } from '@bookorbit/types'
|
||||
import {
|
||||
APP_FEATURES,
|
||||
SCROLLER_TYPES,
|
||||
type DashboardShelfConfig,
|
||||
type DashboardShelfLayout,
|
||||
type ScrollerConfig,
|
||||
type ScrollerType,
|
||||
} from '@bookorbit/types'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { api } from '@/lib/api'
|
||||
import { normalizeShelfRows } from '../lib/shelf-rows'
|
||||
|
||||
const STORAGE_KEY = 'bookorbit:dashboard:config'
|
||||
@@ -11,7 +21,7 @@ export const SHELF_LAYOUT = {
|
||||
TWO_COLUMNS: 'two-columns',
|
||||
} as const
|
||||
|
||||
export type DashboardShelfLayout = (typeof SHELF_LAYOUT)[keyof typeof SHELF_LAYOUT]
|
||||
export type { DashboardShelfLayout } from '@bookorbit/types'
|
||||
|
||||
interface StoredDashboardConfig {
|
||||
scrollers: ScrollerConfig[]
|
||||
@@ -166,11 +176,35 @@ function areScrollersEqual(left: ScrollerConfig[], right: ScrollerConfig[]): boo
|
||||
const initialConfig = loadConfig()
|
||||
const scrollers = ref<ScrollerConfig[]>(initialConfig.scrollers)
|
||||
const shelfLayout = ref<DashboardShelfLayout>(initialConfig.shelfLayout)
|
||||
let hydratedUserId: number | undefined
|
||||
let hydratedFromServer = false
|
||||
|
||||
export function useDashboardConfig() {
|
||||
const { user } = useAuth()
|
||||
const { isDemoRestrictedAccount } = usePermissions()
|
||||
const syncAcrossSessions = computed(() => !isDemoRestrictedAccount.value && user.value?.settings?.dashboardShelfConfig?.syncAcrossSessions === true)
|
||||
|
||||
watch(
|
||||
() => [user.value?.id, user.value?.settings?.dashboardShelfConfig, isDemoRestrictedAccount.value] as const,
|
||||
([userId, config, demoRestricted]) => {
|
||||
if (!demoRestricted && config?.syncAcrossSessions === true) {
|
||||
scrollers.value = normalizeScrollers(config.scrollers)
|
||||
shelfLayout.value = normalizeShelfLayout(config.shelfLayout)
|
||||
} else if (hydratedUserId !== userId || hydratedFromServer) {
|
||||
const local = loadConfig()
|
||||
scrollers.value = local.scrollers
|
||||
shelfLayout.value = local.shelfLayout
|
||||
}
|
||||
hydratedUserId = userId
|
||||
hydratedFromServer = !demoRestricted && config?.syncAcrossSessions === true
|
||||
},
|
||||
{ immediate: true, flush: 'sync' },
|
||||
)
|
||||
|
||||
function save() {
|
||||
scrollers.value = normalizeScrollers(scrollers.value)
|
||||
shelfLayout.value = normalizeShelfLayout(shelfLayout.value)
|
||||
if (syncAcrossSessions.value) return
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
@@ -185,9 +219,25 @@ export function useDashboardConfig() {
|
||||
save()
|
||||
}
|
||||
|
||||
function saveShelfSettings(newScrollers: ScrollerConfig[], newShelfLayout: DashboardShelfLayout) {
|
||||
scrollers.value = normalizeScrollers(newScrollers)
|
||||
shelfLayout.value = normalizeShelfLayout(newShelfLayout)
|
||||
async function saveShelfSettings(newScrollers: ScrollerConfig[], newShelfLayout: DashboardShelfLayout, sync = syncAcrossSessions.value) {
|
||||
sync = sync && !isDemoRestrictedAccount.value
|
||||
const nextScrollers = normalizeScrollers(newScrollers)
|
||||
const nextLayout = normalizeShelfLayout(newShelfLayout)
|
||||
if (sync || syncAcrossSessions.value) {
|
||||
const currentUser = user.value
|
||||
if (!currentUser) throw new Error('Sign in to sync dashboard shelves')
|
||||
const config: DashboardShelfConfig = { syncAcrossSessions: sync, scrollers: nextScrollers, shelfLayout: nextLayout }
|
||||
const response = await api('/api/v1/users/me/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: { dashboardShelfConfig: config } }),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to save dashboard shelves: ${response.status}`)
|
||||
if (user.value?.id !== currentUser.id) return
|
||||
user.value = { ...user.value, settings: { ...user.value.settings, dashboardShelfConfig: config } }
|
||||
}
|
||||
scrollers.value = nextScrollers
|
||||
shelfLayout.value = nextLayout
|
||||
save()
|
||||
}
|
||||
|
||||
@@ -228,5 +278,15 @@ export function useDashboardConfig() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
return { scrollers, shelfLayout, saveScrollers, saveShelfSettings, addScroller, pruneDeletedSmartScopeScrollers, reset, MAX_SCROLLERS }
|
||||
return {
|
||||
scrollers,
|
||||
shelfLayout,
|
||||
syncAcrossSessions,
|
||||
saveScrollers,
|
||||
saveShelfSettings,
|
||||
addScroller,
|
||||
pruneDeletedSmartScopeScrollers,
|
||||
reset,
|
||||
MAX_SCROLLERS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8141,6 +8141,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Customize Dashboard",
|
||||
"preferencesTitle": "Dashboard preferences",
|
||||
"tabs": {
|
||||
"widgets": "Widgets",
|
||||
"shelves": "Shelves"
|
||||
@@ -8177,6 +8178,11 @@
|
||||
"twoColumns": "Two columns",
|
||||
"twoColumnsDescription": "Show two shelves per row."
|
||||
},
|
||||
"shelfSync": {
|
||||
"label": "Sync shelves across sessions",
|
||||
"description": "Save shelf order, visibility, row counts, and layout to your account. When off, shelf settings stay in this browser."
|
||||
},
|
||||
"saveFailed": "Could not save dashboard settings. Check your connection and try again.",
|
||||
"libraryScope": {
|
||||
"title": "Dashboard libraries",
|
||||
"description": "Choose which libraries contribute books and reading data to this dashboard.",
|
||||
|
||||
@@ -69,7 +69,7 @@ const greetingName = computed(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
[smartScopesLoaded, smartScopes],
|
||||
[smartScopesLoaded, smartScopes, scrollers],
|
||||
([isLoaded, allSmartScopes]) => {
|
||||
if (!isLoaded) return
|
||||
pruneDeletedSmartScopeScrollers(allSmartScopes.map((smartScope) => smartScope.id))
|
||||
|
||||
Reference in New Issue
Block a user