diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts index 50842171995..ed6bd629f31 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.test.ts @@ -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 } diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx index 74fa58f0d92..8d3a7949706 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx @@ -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( + 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) } diff --git a/apps/studio/state/storage-explorer.test.ts b/apps/studio/state/storage-explorer.test.ts index 84a6bd975ab..e91ca7209b8 100644 --- a/apps/studio/state/storage-explorer.test.ts +++ b/apps/studio/state/storage-explorer.test.ts @@ -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() }) diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index 340c0a0691c..c6db85d46b9 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -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,