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
4 changes: 4 additions & 0 deletions apps/docs/src/docs/components/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,10 @@ for proper reactive detection of changes to its value. Read more about
[how reactivity works in Vue](https://vuejs.org/guide/extras/reactivity-in-depth.html#Change-Detection-Caveats).
:::

::: info NOTE
When using the `primary-key` prop, row details will persist even when items are replaced with new object references, as long as the primary key value remains the same. This allows row details to stay open in scenarios like "Load more" or pagination. Without a `primary-key`, the component uses a `WeakMap` for memory efficiency, and row details will close when items are garbage collected or replaced with new object references.
:::

**Available `row-details` scoped variable properties:**

| Property | Type | Description |
Expand Down
87 changes: 77 additions & 10 deletions packages/bootstrap-vue-next/src/components/BTable/BTableLite.vue
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
:field="field"
:items="items"
:toggle-details="() => toggleRowDetails(item)"
:details-showing="isTableItem(item) ? (detailsMap.get(item) ?? false) : false"
:details-showing="isTableItem(item) ? (getDetailsValue(item) ?? false) : false"
>
<template v-if="!slots[`cell(${String(field.key)})`] && !slots['cell()']">
{{ formatItem(item, String(field.key), field.formatter) }}
Expand All @@ -130,7 +130,7 @@
</BTr>

<template
v-if="isTableItem(item) && detailsMap.get(item) === true && slots['row-details']"
v-if="isTableItem(item) && getDetailsValue(item) === true && slots['row-details']"
>
<BTr aria-hidden="true" role="presentation" class="d-none" />
<BTr
Expand Down Expand Up @@ -299,17 +299,62 @@ const keyboardNavigation = inject(tableKeyboardNavigationKey, null)

const computedId = useId(() => props.id)

const generateDetailsItem = (item: TableItem): [object, boolean | undefined] => [
item,
item._showDetails,
]
const detailsMap = ref(new WeakMap<object, boolean | undefined>())
const generateDetailsItem = (item: TableItem): [object | string, boolean | undefined] => {
// Use primary key as the map key if available and the item has that key
if (props.primaryKey && get(item, props.primaryKey) !== undefined) {
return [String(get(item, props.primaryKey)), item._showDetails]
}
// Fall back to object reference
return [item, item._showDetails]
}

// Use WeakMap when no primaryKey (for memory efficiency), Map when primaryKey is defined (to support strings)
const detailsMap = ref<
Map<object | string, boolean | undefined> | WeakMap<object, boolean | undefined>
>(props.primaryKey ? new Map() : new WeakMap())

// Helper functions for type-safe map operations
const hasDetailsValue = (key: object | string): boolean => {
if (typeof key === 'string') {
// When using string keys, we must be using a Map
return (detailsMap.value as Map<object | string, boolean | undefined>).has(key)
}
// When using object keys, could be either Map or WeakMap
return detailsMap.value.has(key)
}

const setDetailsValueByKey = (key: object | string, value: boolean | undefined): void => {
if (typeof key === 'string') {
// When using string keys, we must be using a Map
;(detailsMap.value as Map<object | string, boolean | undefined>).set(key, value)
} else {
// When using object keys, could be either Map or WeakMap
detailsMap.value.set(key, value)
}
}

// Watch for primaryKey changes and recreate the map
watch(
() => props.primaryKey,
(newKey, oldKey) => {
// If primaryKey changes, clear and recreate the map with the appropriate type
if (newKey !== oldKey) {
detailsMap.value = newKey ? new Map() : new WeakMap()
}
}
)

watch(
() => props.items,
(items) => {
items.forEach((item) => {
if (!isTableItem(item)) return
detailsMap.value.set(...generateDetailsItem(item))
const [key, showDetails] = generateDetailsItem(item)
// Only set if not already in map, or if _showDetails is explicitly set
// This preserves toggled state when items are replaced with same primary key
if (!hasDetailsValue(key) || showDetails !== undefined) {
setDetailsValueByKey(key, showDetails)
}
})
},
{deep: true, immediate: true}
Expand Down Expand Up @@ -397,10 +442,32 @@ const headerClicked = (field: TableField<Items>, event: Readonly<MouseEvent>, is
emit('head-clicked', field.key as string, field, event, isFooter)
}

const getDetailsMapKey = (item: Items): object | string => {
if (isTableItem(item) && props.primaryKey && get(item, props.primaryKey) !== undefined) {
return String(get(item, props.primaryKey))
}
return item as object
}

const getDetailsValue = (item: Items): boolean | undefined => {
const key = getDetailsMapKey(item)
if (typeof key === 'string') {
// When using string keys, we must be using a Map
return (detailsMap.value as Map<object | string, boolean | undefined>).get(key)
}
// When using object keys, could be either Map or WeakMap
return detailsMap.value.get(key)
}

const setDetailsValue = (item: Items, value: boolean | undefined): void => {
const key = getDetailsMapKey(item)
setDetailsValueByKey(key, value)
}

const toggleRowDetails = (tr: Items) => {
if (isTableItem(tr)) {
const prevValue = detailsMap.value.get(tr)
detailsMap.value.set(tr, !prevValue)
const prevValue = getDetailsValue(tr)
setDetailsValue(tr, !prevValue)
tr._showDetails = !prevValue
}
}
Expand Down
184 changes: 184 additions & 0 deletions packages/bootstrap-vue-next/src/components/BTable/table-lite.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,190 @@ describe('btablelite', () => {
})
expect(wrapper.text()).not.toContain('foobar!')
})

it('persists details when items are replaced with new object references but same primary key', async () => {
const fields = [{key: 'actions', label: 'Actions', sortable: false}]
const wrapper = mount(BTableLite, {
props: {
primaryKey: 'id',
items: [
{id: 1, isActive: true, age: 40, name: 'John'},
{id: 2, isActive: false, age: 30, name: 'Jane'},
],
fields,
},
slots: {
'cell(actions)':
'<template #cell(actions)="row"><button class="toggle-btn" @click="row.toggleDetails"></button></template>',
'row-details': '<template #row-details="row">Details for {{ row.item.name }}</template>',
},
})

// Open details for the first row
const buttons = wrapper.findAll('.toggle-btn')
await buttons[0].trigger('click')
expect(wrapper.text()).toContain('Details for John')
expect(wrapper.text()).not.toContain('Details for Jane')

// Replace items with new object references but same IDs
// This simulates the "Load more" scenario from the issue
await wrapper.setProps({
items: [
{id: 1, isActive: true, age: 40, name: 'John'},
{id: 2, isActive: false, age: 30, name: 'Jane'},
{id: 3, isActive: true, age: 25, name: 'Bob'},
],
})

// Details for first row should still be visible
expect(wrapper.text()).toContain('Details for John')
expect(wrapper.text()).not.toContain('Details for Jane')
expect(wrapper.text()).not.toContain('Details for Bob')
})

it('falls back to object reference when primaryKey is not provided', async () => {
const fields = [{key: 'actions', label: 'Actions', sortable: false}]
const wrapper = mount(BTableLite, {
props: {
items: [
{isActive: true, age: 40, name: 'John'},
{isActive: false, age: 30, name: 'Jane'},
],
fields,
},
slots: {
'cell(actions)':
'<template #cell(actions)="row"><button class="toggle-btn" @click="row.toggleDetails"></button></template>',
'row-details': '<template #row-details="row">Details for {{ row.item.name }}</template>',
},
})

// Open details for the first row
const buttons = wrapper.findAll('.toggle-btn')
await buttons[0].trigger('click')
expect(wrapper.text()).toContain('Details for John')

// Replace items with new object references
await wrapper.setProps({
items: [
{isActive: true, age: 40, name: 'John'},
{isActive: false, age: 30, name: 'Jane'},
{isActive: true, age: 25, name: 'Bob'},
],
})

// Details should be closed since object references changed
expect(wrapper.text()).not.toContain('Details for John')
expect(wrapper.text()).not.toContain('Details for Jane')
expect(wrapper.text()).not.toContain('Details for Bob')
})

it('clears details when primaryKey prop changes', async () => {
const fields = [{key: 'actions', label: 'Actions', sortable: false}]
const wrapper = mount(BTableLite, {
props: {
primaryKey: 'id',
items: [
{id: 1, altId: 'a', name: 'John'},
{id: 2, altId: 'b', name: 'Jane'},
],
fields,
},
slots: {
'cell(actions)':
'<template #cell(actions)="row"><button class="toggle-btn" @click="row.toggleDetails"></button></template>',
'row-details': '<template #row-details="row">Details for {{ row.item.name }}</template>',
},
})

// Open details for the first row
const buttons = wrapper.findAll('.toggle-btn')
await buttons[0].trigger('click')
expect(wrapper.text()).toContain('Details for John')

// Change primaryKey to a different field
await wrapper.setProps({
primaryKey: 'altId',
})

// Details should be closed after primaryKey changes
expect(wrapper.text()).not.toContain('Details for John')
expect(wrapper.text()).not.toContain('Details for Jane')
})

it('clears details when primaryKey is removed', async () => {
const fields = [{key: 'actions', label: 'Actions', sortable: false}]
const wrapper = mount(BTableLite, {
props: {
primaryKey: 'id',
items: [
{id: 1, name: 'John'},
{id: 2, name: 'Jane'},
],
fields,
},
slots: {
'cell(actions)':
'<template #cell(actions)="row"><button class="toggle-btn" @click="row.toggleDetails"></button></template>',
'row-details': '<template #row-details="row">Details for {{ row.item.name }}</template>',
},
})

// Open details for the first row
const buttons = wrapper.findAll('.toggle-btn')
await buttons[0].trigger('click')
expect(wrapper.text()).toContain('Details for John')

// Remove primaryKey
await wrapper.setProps({
primaryKey: undefined,
})

// Details should be closed after primaryKey is removed
expect(wrapper.text()).not.toContain('Details for John')
expect(wrapper.text()).not.toContain('Details for Jane')
})

it('clears details when primaryKey is changed and then changed back', async () => {
const fields = [{key: 'actions', label: 'Actions', sortable: false}]
const wrapper = mount(BTableLite, {
props: {
primaryKey: 'id',
items: [
{id: 1, altId: 'a', name: 'John'},
{id: 2, altId: 'b', name: 'Jane'},
],
fields,
},
slots: {
'cell(actions)':
'<template #cell(actions)="row"><button class="toggle-btn" @click="row.toggleDetails"></button></template>',
'row-details': '<template #row-details="row">Details for {{ row.item.name }}</template>',
},
})

// Open details for the first row
const buttons = wrapper.findAll('.toggle-btn')
await buttons[0].trigger('click')
expect(wrapper.text()).toContain('Details for John')

// Change primaryKey to a different field
await wrapper.setProps({
primaryKey: 'altId',
})

// Details should be closed after primaryKey changes
expect(wrapper.text()).not.toContain('Details for John')

// Change primaryKey back to original
await wrapper.setProps({
primaryKey: 'id',
})

// Details should still be closed (map was cleared)
expect(wrapper.text()).not.toContain('Details for John')
expect(wrapper.text()).not.toContain('Details for Jane')
})
})
describe('isRowHeader field property', () => {
it('sets td/th appropriately based on isRowHeader is true, false, or undefined', async () => {
Expand Down
Loading