fix(studio): re-sort a column's items after appending the next page

Each list-v2 page is sorted on its own, so fetchMoreFolderContents'
col.items.concat(items) didn't keep the column sorted once a second
page came in — a naive concat leaves the new page's items after the
old ones regardless of the active sort column. Extracts the merge
comparator from formatFolderItemsV2 into a shared sortStorageItems
helper and re-sorts the combined list after every load-more.
This commit is contained in:
Ivan Vasilov committed 2026-09-25 18:13:13 +02:00
1 parent 0098c08b36
commit dedbea2ed3
4 files changed
+102 -14

No files matched your search

@@ -21,6 +21,7 @@ import {
parseStoragePath,
sanitizeNameForDuplicateInColumn,
serializeStoragePath,
sortStorageItems,
validateFolderName,
} from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils'
@@ -472,6 +473,48 @@ describe('getListV2EntryName', () => {
})
})
describe('sortStorageItems', () => {
function makeSortable(name: string, date: string | null = null) {
return { name, created_at: date, updated_at: date, last_accessed_at: date }
}
it('sorts by name ascending by default direction', () => {
const items = [makeSortable('zebra'), makeSortable('apple')]
expect(
sortStorageItems(items, {
column: STORAGE_SORT_BY.NAME,
order: STORAGE_SORT_BY_ORDER.ASC,
}).map((i) => i.name)
).toEqual(['apple', 'zebra'])
})
it('sorts descending when requested', () => {
const items = [makeSortable('apple'), makeSortable('zebra')]
expect(
sortStorageItems(items, {
column: STORAGE_SORT_BY.NAME,
order: STORAGE_SORT_BY_ORDER.DESC,
}).map((i) => i.name)
).toEqual(['zebra', 'apple'])
})
it('sorts by created_at, treating a null value as empty', () => {
const items = [makeSortable('has-date', '2024-01-01T00:00:00Z'), makeSortable('no-date', null)]
expect(
sortStorageItems(items, {
column: STORAGE_SORT_BY.CREATED_AT,
order: STORAGE_SORT_BY_ORDER.ASC,
}).map((i) => i.name)
).toEqual(['no-date', 'has-date'])
})
it('does not mutate the input array', () => {
const items = [makeSortable('zebra'), makeSortable('apple')]
sortStorageItems(items, { column: STORAGE_SORT_BY.NAME, order: STORAGE_SORT_BY_ORDER.ASC })
expect(items.map((i) => i.name)).toEqual(['zebra', 'apple'])
})
})
describe('formatFolderItemsV2', () => {
const NAME_ASC = { column: STORAGE_SORT_BY.NAME, order: STORAGE_SORT_BY_ORDER.ASC }
@@ -266,6 +266,30 @@ export function getListV2EntryName(name: string): string {
return trimmed.split('/').pop() ?? trimmed
}
type SortableByColumn = {
name: string
created_at: string | null
updated_at: string | null
last_accessed_at: string | null
}
/**
* Sorts by the given column/order. Used both to order a single merged folders+files page and
* to re-sort a column's items after appending another page onto it — concatenating two
* independently-sorted pages does not itself produce a sorted list.
*/
export function sortStorageItems<T extends SortableByColumn>(
items: T[],
sortBy: { column: STORAGE_SORT_BY; order: STORAGE_SORT_BY_ORDER }
): T[] {
const direction = sortBy.order === STORAGE_SORT_BY_ORDER.DESC ? -1 : 1
return [...items].sort((a, b) => {
const aValue = (sortBy.column === STORAGE_SORT_BY.NAME ? a.name : a[sortBy.column]) ?? ''
const bValue = (sortBy.column === STORAGE_SORT_BY.NAME ? b.name : b[sortBy.column]) ?? ''
return direction * String(aValue).localeCompare(String(bValue))
})
}
/**
* v2 returns folders and files as two separate arrays; merges them into the single sorted list
* `formatFolderItems` expects, tagging folders with `id: null` per its v1-derived convention.
@@ -290,12 +314,7 @@ export function formatFolderItemsV2(
...object,
name: getListV2EntryName(object.name),
}))
const direction = sortBy.order === STORAGE_SORT_BY_ORDER.DESC ? -1 : 1
const merged = [...folders, ...objects].sort((a, b) => {
const aValue = (sortBy.column === STORAGE_SORT_BY.NAME ? a.name : a[sortBy.column]) ?? ''
const bValue = (sortBy.column === STORAGE_SORT_BY.NAME ? b.name : b[sortBy.column]) ?? ''
return direction * String(aValue).localeCompare(String(bValue))
})
const merged = sortStorageItems([...folders, ...objects], sortBy)
return formatFolderItems(merged, prefix)
}
+28 -6
View File
@@ -2,8 +2,11 @@ import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
import { createStorageExplorerState } from './storage-explorer'
import { STORAGE_ROW_STATUS } from '@/components/interfaces/Storage/Storage.constants'
import type { StorageColumn } from '@/components/interfaces/Storage/Storage.types'
import {
STORAGE_ROW_STATUS,
STORAGE_ROW_TYPES,
} from '@/components/interfaces/Storage/Storage.constants'
import type { StorageColumn, StorageItem } from '@/components/interfaces/Storage/Storage.types'
import type { StorageObjectsPage } from '@/data/storage/bucket-objects-infinite-query'
import type { Bucket } from '@/data/storage/buckets-query'
import { addAPIMock } from '@/tests/lib/msw'
@@ -189,7 +192,21 @@ describe('fetchMoreFolderContents', () => {
}
}
it('sends the column cursor and appends the next page', async () => {
function makeItem(name: string): StorageItem {
return {
id: name,
name,
type: STORAGE_ROW_TYPES.FILE,
status: STORAGE_ROW_STATUS.READY,
metadata: null,
isCorrupted: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_accessed_at: '2024-01-01T00:00:00Z',
}
}
it('sends the column cursor, re-sorts, and appends the next page', async () => {
let requestBody: any
addAPIMock({
method: 'post',
@@ -201,7 +218,7 @@ describe('fetchMoreFolderContents', () => {
objects: [
{
id: 'f2',
name: 'page2.png',
name: 'aaa-page2.png',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_accessed_at: '2024-01-01T00:00:00Z',
@@ -214,13 +231,18 @@ describe('fetchMoreFolderContents', () => {
})
const state = createState(makeBucket('bucket-a'))
const column = makeReadyColumn()
// Existing item sorts after the new page's item by name — a naive concat would leave it
// first anyway, so this only passes if the combined list is actually re-sorted.
const column = makeReadyColumn({ items: [makeItem('zzz-page1.png')] })
state.columns = [column]
await state.fetchMoreFolderContents({ index: 0, column })
expect(requestBody.cursor).toBe('cursor-1')
expect(state.columns[0].items.map((item) => item.name)).toEqual(['page2.png'])
expect(state.columns[0].items.map((item) => item.name)).toEqual([
'aaa-page2.png',
'zzz-page1.png',
])
expect(state.columns[0].hasMoreItems).toBe(false)
expect(state.columns[0].cursor).toBeNull()
})
+6 -2
View File
@@ -29,6 +29,7 @@ import {
getListV2EntryName,
getPathAlongFoldersToIndex,
sanitizeNameForDuplicateInColumn,
sortStorageItems,
validateFolderName,
} from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils'
import { fetchFileUrl } from '@/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery'
@@ -127,6 +128,7 @@ export function createStorageExplorerState({
items: formatFolderItemsV2(page, sortOptions, path),
hasMoreItems: nextCursor !== null,
cursor: nextCursor,
sortBy: sortOptions,
}
}
@@ -389,7 +391,7 @@ export function createStorageExplorerState({
state.setColumnIsLoadingMore(index)
try {
const { items, hasMoreItems, cursor } = await listFolderPage(
const { items, hasMoreItems, cursor, sortBy } = await listFolderPage(
{
bucketId: state.selectedBucket.id,
path: column.path,
@@ -407,7 +409,9 @@ export function createStorageExplorerState({
if (isStale) return { ...col, isLoadingMoreItems: false }
return {
...col,
items: col.items.concat(items),
// Each page is sorted on its own; concatenating two sorted pages doesn't produce
// a sorted list, so the combined items need re-sorting.
items: sortStorageItems(col.items.concat(items), sortBy),
isLoadingMoreItems: false,
hasMoreItems,
cursor,