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
17 changes: 17 additions & 0 deletions packages/design-system/src/components/OcTable/OcTable.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,23 @@ describe('OcTable', () => {
})
})

it('re-renders a row when its itemKey changes', async () => {
const wrapper = mount(Table, {
props: {
fields,
data,
itemKey: (item: { id: string; doubled: number }) => `${item.id}-${item.doubled}`
},
global: { plugins: defaultPlugins() }
})
const row = wrapper.find(`[data-item-id="${data[0].id}"]`).element

await wrapper.setProps({ data: [{ ...data[0], doubled: 4 }, ...data.slice(1)] })

expect(wrapper.find(`[data-item-id="${data[0].id}"]`).element).not.toBe(row)
expect(wrapper.find(`[data-item-id="${data[1].id}"]`).exists()).toBe(true)
})

it('emits contextmenu-clicked event upon right click on table row', async () => {
const wrapper = shallowMount(Table, {
props: {
Expand Down
7 changes: 6 additions & 1 deletion packages/design-system/src/components/OcTable/OcTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
<oc-tbody class="has-item-context-menu">
<oc-table-row
v-for="(item, trIndex) in data"
:key="`oc-tbody-tr-${domSelector(item) || trIndex}`"
:key="`oc-tbody-tr-${itemKey?.(item) || domSelector(item) || trIndex}`"
:ref="`row-${trIndex}`"
:item="item"
:fields="fields"
Expand Down Expand Up @@ -188,6 +188,10 @@ export interface Props {
* @docs A function to get the dom selector for each item.
*/
itemDomSelector?: (item: Item) => string
/**
* @docs A function to get the key for each row. The row is re-rendered when its key changes. Falls back to the dom selector.
*/
itemKey?: (item: Item) => string
/**
* @docs Determines if the table should be lazy loaded.
* @default false
Expand Down Expand Up @@ -294,6 +298,7 @@ const {
hover = false,
idKey = 'id',
itemDomSelector,
itemKey,
lazy = false,
scrollContainer = undefined,
paddingX = 'small',
Expand Down
17 changes: 13 additions & 4 deletions packages/web-app-files/src/composables/useFileUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
useExtensionRegistry
} from '@opencloud-eu/web-pkg'
import { computed, onMounted, onBeforeUnmount, unref, watch, Ref } from 'vue'
import { SpaceResource, isPublicSpaceResource } from '@opencloud-eu/web-client'
import {
SpaceResource,
isPublicSpaceResource,
isShareSpaceResource
} from '@opencloud-eu/web-client'
import { useService, useUpload, UppyService, UploadResult } from '@opencloud-eu/web-pkg'
import { HandleUpload } from '../HandleUpload'
import { useGettext } from 'vue3-gettext'
Expand Down Expand Up @@ -81,9 +85,14 @@ export const useFileUpload = (space: Ref<SpaceResource>) => {
path: unref(currentFolder).path
})

const existingIds = new Set(resourcesStore.resources.map((r) => r.id))
const newResources = children.filter((child) => !existingIds.has(child.id))
resourcesStore.upsertResources(newResources)
if (isShareSpaceResource(unref(space))) {
// TODO: remove when server returns share id for federated shares in propfind response
children.forEach((r) => (r.remoteItemId = unref(space).id))
}

const existingEtags = new Map(resourcesStore.resources.map((r) => [r.id, r.etag]))
const changedResources = children.filter((child) => existingEtags.get(child.id) !== child.etag)
resourcesStore.upsertResources(changedResources)
Comment on lines +93 to +95

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upsertResources doesn't merge but overwrites resources, leading to thumbnail and remoteItemId getting lost. You can see this in a share space, where replacing a resource now resets the preview image to the default one (because SSE isn't there to cover for it).

@AlexAndBear AlexAndBear Oct 9, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, this turned out to be a general problem: a changed row is never re-rendered, so its preview isn't loaded again. Rows and tiles are now keyed by etag (fixes it for SSE, uploads and other updates), and the upload sets the share id like the folder loader does.

This also fixes:

  • Replaced files without SSE: on main the list didn't update replaced files at all, the old preview, size and etag stayed.
  • Previews in the tiles view after SSE updates: the SSE handler loads thumbnail-sized previews, the re-rendered tile now loads one in tile size.
  • Saving in apps: AppWrapper sets the new etag after saving, so the list now shows the updated preview instead of the old one.

}

onMounted(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { mock } from 'vitest-mock-extended'
import { ref } from 'vue'
import { Resource, SpaceResource } from '@opencloud-eu/web-client'
import { UploadResult, useResourcesStore } from '@opencloud-eu/web-pkg'
import { defaultComponentMocks, getComposableWrapper } from '@opencloud-eu/web-test-helpers'
import { useFileUpload } from '../../../src/composables'

vi.mock('@opencloud-eu/web-pkg', async (importOriginal) => ({
...(await importOriginal<any>()),
useUpload: vi.fn()
}))

describe('useFileUpload', () => {
it('updates existing resources that were replaced by the upload', async () => {
const existing = { id: 'file', path: '/folder/file.txt', etag: 'old', size: 20 } as Resource
const replaced = { id: 'file', path: '/folder/file.txt', etag: 'new', size: 50 } as Resource
const { resourcesStore, completeUpload } = setup({
space: mock<SpaceResource>({ id: 'space-1', driveType: 'personal' }),
existing: [existing],
children: [replaced]
})

await completeUpload()

expect(resourcesStore.upsertResources).toHaveBeenCalledWith([replaced])
})

it('keeps the share id of resources in a share space', async () => {
const replaced = { id: 'file', path: '/folder/file.txt', etag: 'new' } as Resource
const { resourcesStore, completeUpload } = setup({
space: mock<SpaceResource>({ id: 'space-1', driveType: 'share' }),
existing: [{ id: 'file', path: '/folder/file.txt', etag: 'old' } as Resource],
children: [replaced]
})

await completeUpload()

expect(resourcesStore.upsertResources).toHaveBeenCalledWith([
expect.objectContaining({ id: 'file', remoteItemId: 'space-1' })
])
})
})

function setup({
space,
existing,
children
}: {
space: SpaceResource
existing: Resource[]
children: Resource[]
}) {
const currentFolder = mock<Resource>({ id: 'folder', path: '/folder' })

const mocks = defaultComponentMocks()
mocks.$clientService.webdav.listFiles.mockResolvedValue({ resource: currentFolder, children })
let onUploadComplete: (result: UploadResult) => Promise<void>
mocks.$uppyService.subscribe.mockImplementation((_, callback) => {
onUploadComplete = callback as typeof onUploadComplete
return 'sub'
})

let resourcesStore: ReturnType<typeof useResourcesStore>
getComposableWrapper(
() => {
resourcesStore = useResourcesStore()
useFileUpload(ref(space))
},
{
mocks,
provide: mocks,
pluginOptions: {
piniaOptions: { resourcesStore: { currentFolder, resources: existing } }
}
}
)

return {
resourcesStore,
completeUpload: () =>
onUploadComplete(
mock<UploadResult>({
successful: [{ meta: { spaceId: space.id, driveType: space.driveType } }]
})
)
}
}
6 changes: 6 additions & 0 deletions packages/web-pkg/src/components/FilesList/ResourceTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
:drag-drop="dragDrop"
:hover="hover"
:item-dom-selector="resourceDomSelector"
:item-key="resourceKey"
:sort-by="sortBy"
:sort-dir="sortDir"
:lazy="lazy"
Expand Down Expand Up @@ -393,6 +394,11 @@ defineSlots<{
footer?: () => unknown
}>()

// re-render a row when its resource changed, so its preview gets loaded again
function resourceKey(resource: Resource) {
return `${resourceDomSelector(resource)}-${resource.etag}`
}

const router = useRouter()
const { getExtensionActions } = useFileActions()
const capabilityStore = useCapabilityStore()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
<oc-list class="oc-tiles grid justify-start gap-3">
<li
v-for="resource in resources"
:key="resource.id"
:key="`${resource.id}-${resource.etag}`"
class="oc-tiles-item has-item-context-menu"
>
<resource-tile
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,13 @@ export const useResourceContents = ({
const { current: currentLanguage, $gettext, $ngettext } = useGettext()
const router = useRouter()

const { resources, totalResourcesCount, areHiddenFilesShown, currentFolder } =
storeToRefs(resourcesStore)
const { resources, totalResourcesCount, areHiddenFilesShown } = storeToRefs(resourcesStore)

const itemSize = computed(() => {
if (!unref(currentFolder)?.size || unref(currentFolder)?.size === '0') {
const accumulatedSize = unref(resources)
.map((r) => (r.size ? parseInt(r.size.toString()) : 0))
.reduce((x, y) => x + y, 0)
return formatFileSize(accumulatedSize, currentLanguage)
}
return formatFileSize(unref(currentFolder).size, currentLanguage)
const accumulatedSize = unref(resources)
.map((r) => (r.size ? parseInt(r.size.toString()) : 0))
.reduce((x, y) => x + y, 0)
return formatFileSize(accumulatedSize, currentLanguage)
})

const resourceContentsText = computed(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ describe('ResourceTiles component', () => {
expect(wrapper.html()).toMatchSnapshot()
})

it('re-renders a tile when its resource changed, so its preview gets loaded again', async () => {
const resource = { ...spacesResources[0], etag: 'old' }
const { wrapper } = getWrapper({ props: { resources: [resource] } })
expect(wrapper.emitted('itemVisible')).toHaveLength(1)

await wrapper.setProps({ resources: [{ ...resource, etag: 'new' }] })

expect(wrapper.emitted('itemVisible')).toHaveLength(2)
})

it('renders a footer slot', () => {
const { wrapper } = getWrapper({ slots: { footer: 'Hello, ResourceTiles footer!' } })
expect(wrapper.html()).toMatchSnapshot()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@ describe('resourceContents', () => {
})
})
})
it('should sum up the resources instead of using the possibly stale size of the current folder', () => {
getWrapper({
currentFolder: mock<Resource>({ size: 3 }),
resources: [
mock<Resource>({ isFolder: false, size: 3, type: 'file', name: 'file1' }),
mock<Resource>({ isFolder: false, size: 2000000, type: 'file', name: 'file2' })
],
setup: ({ resourceContentsText }) => {
expect(unref(resourceContentsText)).toBe('2 items with 2 MB in total (2 files, 0 folders)')
}
})
})
it.each`
size | expectedSize
${1} | ${'1 B'}
Expand Down Expand Up @@ -162,10 +174,12 @@ describe('resourceContents', () => {
function getWrapper({
areHiddenFilesShown = true,
currentRouteName = 'files-spaces-generic',
currentFolder = null,
resources = [],
setup
}: {
areHiddenFilesShown?: boolean
currentFolder?: Resource
currentRouteName?: string
resources: Resource[]
setup: (instance: ReturnType<typeof useResourceContents>) => void
Expand All @@ -186,7 +200,7 @@ function getWrapper({
mocks,
pluginOptions: {
piniaOptions: {
resourcesStore: { resources, areHiddenFilesShown }
resourcesStore: { resources, areHiddenFilesShown, currentFolder }
}
},
provide: mocks
Expand Down
Loading