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
69 changes: 69 additions & 0 deletions ui/src/__tests__/subscriptions-panel.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { LjSegmented, VibrantDataTable } from '@ligoj/host'
import SubscriptionsPanel from '../components/SubscriptionsPanel.vue'

// Minimal grouped model: one tool with one subscription row. Enough for the
// component to render either the cards grid (`.sp-grid`) or the list table.
const GROUPS = [{
key: 'tool-1', name: 'Tool 1', kind: 'kind', color: '#123456', icon: 'div', health: 'ok',
rows: [{ name: 'sub-a', status: 'ok', pills: [], sub: { id: 1 } }],
}]

function mountPanel(props = {}) {
return mount(SubscriptionsPanel, {
props: { groups: GROUPS, ...props },
global: {
// Stub the heavy host children; we only drive the LjSegmented v-model.
stubs: { LjSearch: true, VibrantDataTable: true, SubscriptionGroupCard: true, PluginFeatures: true },
},
})
}

// view === 'cards' renders the `.sp-grid` div; view === 'list' renders the table.
const isCards = (w) => w.find('.sp-grid').exists()

describe('SubscriptionsPanel — view persistence', () => {
beforeEach(() => { setActivePinia(createPinia()) }) // setup.js clears the localStorage mock

it('persists the selected view under ligoj-subview:<storageKey>', async () => {
const w = mountPanel({ storageKey: 'test', defaultView: 'list' })
expect(isCards(w)).toBe(false) // starts on the default (list)
await w.findComponent(LjSegmented).vm.$emit('update:modelValue', 'cards')
expect(localStorage.getItem('ligoj-subview:test')).toBe('cards')
expect(isCards(w)).toBe(true)
})

it('restores the stored view on remount, overriding default-view', () => {
localStorage.setItem('ligoj-subview:test', 'cards')
const w = mountPanel({ storageKey: 'test', defaultView: 'list' })
expect(isCards(w)).toBe(true) // restored to cards even though default is list
})

it('keeps contexts independent (home vs project)', async () => {
const home = mountPanel({ storageKey: 'home', defaultView: 'cards' })
await home.findComponent(LjSegmented).vm.$emit('update:modelValue', 'list')
expect(localStorage.getItem('ligoj-subview:home')).toBe('list')
expect(localStorage.getItem('ligoj-subview:project')).toBe(null)

const project = mountPanel({ storageKey: 'project', defaultView: 'list' })
await project.findComponent(LjSegmented).vm.$emit('update:modelValue', 'cards')
expect(localStorage.getItem('ligoj-subview:project')).toBe('cards')
expect(localStorage.getItem('ligoj-subview:home')).toBe('list') // untouched
})

it('without storageKey, leaves localStorage untouched (backward compatible)', async () => {
const w = mountPanel({ defaultView: 'list' })
await w.findComponent(LjSegmented).vm.$emit('update:modelValue', 'cards')
expect(localStorage.length).toBe(0)
expect(isCards(w)).toBe(true) // still reactive in-memory
})

it('ignores an invalid stored value and falls back to default-view', () => {
localStorage.setItem('ligoj-subview:test', 'garbage')
const w = mountPanel({ storageKey: 'test', defaultView: 'list' })
expect(isCards(w)).toBe(false)
expect(w.findComponent(VibrantDataTable).exists()).toBe(true)
})
})
24 changes: 21 additions & 3 deletions ui/src/components/SubscriptionsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
</template>

<script setup>
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { PluginFeatures, useI18nStore, LjSegmented, LjSearch, VibrantDataTable } from '@ligoj/host'
import SubscriptionGroupCard from './SubscriptionGroupCard.vue'
import { vAppear } from '../directives/appear.js'
Expand All @@ -81,6 +81,7 @@ const props = defineProps({
collapsible: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
cog: { type: Boolean, default: true }, // show the host overflow (unsubscribe) cog
storageKey: { type: String, default: '' }, // localStorage scope for the cards/list choice; '' disables persistence
})
defineEmits(['rowmenu', 'row-appear'])

Expand All @@ -92,7 +93,22 @@ const viewOptions = [
{ value: 'list', icon: 'mdi-format-list-bulleted', label: 'Liste' },
]

const view = ref(props.defaultView)
// View mode (cards/list) is remembered per context in localStorage when a
// `storageKey` is given (e.g. 'home', 'project'); without one the panel keeps
// its previous in-memory-only behaviour (backward compatible).
const STORAGE_PREFIX = 'ligoj-subview:'
function readStoredView() {
if (!props.storageKey) return null
try {
const v = localStorage.getItem(STORAGE_PREFIX + props.storageKey)
return (v === 'cards' || v === 'list') ? v : null
} catch { return null }
}
const view = ref(readStoredView() ?? props.defaultView)
watch(view, (v) => {
if (!props.storageKey) return
try { localStorage.setItem(STORAGE_PREFIX + props.storageKey, v) } catch { /* storage unavailable (private mode / quota) */ }
})
const query = ref('')
const collapsedKeys = ref(new Set())

Expand Down Expand Up @@ -147,7 +163,9 @@ function toggleAll() {
.collapse-all { display: inline-flex; align-items: center; gap: 6px; height: 38px; padding: 0 12px; border-radius: var(--radius-sm); border: var(--border-w) var(--lj-border-style, solid) var(--border-c); background: var(--card); color: var(--ink-2); font-family: var(--font); font-weight: 700; font-size: 13px; cursor: pointer; transition: background .12s, color .12s; }
.collapse-all:hover { background: var(--pill); color: var(--ink); }

.sp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 18px; }
.sp-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
@media (max-width: 1100px) { .sp-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 700px) { .sp-grid { grid-template-columns: 1fr; } }
.sp-skel { height: 220px; border-radius: var(--radius); background: linear-gradient(100deg, var(--card), color-mix(in srgb, var(--ink) 4%, var(--card)), var(--card)); background-size: 200% 100%; animation: sp-shimmer 1.3s linear infinite; }
@keyframes sp-shimmer { to { background-position: -200% 0; } }
.sp-empty { padding: 48px 0; text-align: center; color: var(--ink-3); font-weight: 600; }
Expand Down
2 changes: 1 addition & 1 deletion ui/src/views/HomeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
</template>
</LjPageHeader>

<SubscriptionsPanel :groups="groups" :loading="loading && !groups.length" default-view="cards"
<SubscriptionsPanel :groups="groups" :loading="loading && !groups.length" default-view="cards" storage-key="home"
searchable collapsible :cog="false" @row-appear="onRowAppear">
<template #toolbar>
<label class="demo-toggle" :class="{ on: demo }">
Expand Down
2 changes: 1 addition & 1 deletion ui/src/views/ProjectDetailView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
</div>

<SubscriptionsPanel :groups="groups" :loading="loading && !groups.length"
default-view="list" @rowmenu="onRowMenu">
default-view="list" storage-key="project" @rowmenu="onRowMenu">
<template #empty>
<div class="empty">
<v-icon size="44" color="rgba(var(--v-theme-on-surface),.25)">mdi-cloud-off-outline</v-icon>
Expand Down