Skip to content
Open
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
44 changes: 44 additions & 0 deletions packages/e2e/cypress/integration/common/storagebrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,47 @@ Then(
});
}
);

When('I click the {string} sort header', (columnLabel: string) => {
cy.get('.amplify-storage-browser__table-sort-header')
.contains(new RegExp(`^${columnLabel}$`, 'i'))
.click();
});

Then('the first table row name should contain {string}', (expected: string) => {
cy.get('table tbody tr')
.first()
.find('td:nth-child(2)')
.should('contain.text', expected);
});

Then(
'the table name column values should be in {string} order',
(direction: string) => {
cy.get('table tbody tr td:nth-child(2)').then(($cells) => {
const values = [...$cells].map(
(cell) => cell.textContent?.trim().toLowerCase() ?? ''
);

const folders = values.filter((v) => v.endsWith('/'));
const files = values.filter((v) => !v.endsWith('/'));

const compare = (a: string, b: string) =>
direction === 'ascending' ? a.localeCompare(b) : b.localeCompare(a);

const sortedFolders = [...folders].sort(compare);
const sortedFiles = [...files].sort(compare);

const sorted =
direction === 'ascending'
? [...sortedFolders, ...sortedFiles]
: [...sortedFiles, ...sortedFolders];

expect(values).to.deep.equal(sorted);
});
}
);

Then('the table should have at least {string} rows', (count: string) => {
cy.get('table tbody tr').should('have.length.gte', parseInt(count));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
Feature: StorageBrowser cross-page search

Tests that search works across multiple pages of results,
including pagination of search results and subfolder search.

Background:
Given I'm running the example "ui/components/storage/storage-browser/default-auth"
And I type my "email" with status "CONFIRMED"
And I type my password
And I click the "Sign in" button

@react
Scenario: Search returns results from across pages
When I click the first button containing "public"
When I see input with placeholder "Search current folder" and type "DO_NOT"
Then I click the "Search" button
Then I see the button containing "DO_NOT_DELETE"

@react
Scenario: Clearing search returns to normal browsing
When I click the first button containing "public"
Then I see the button containing "DoNotDeleteThisFolder_CanDeleteAllChildren"
When I see input with placeholder "Search current folder" and type "DO_NOT"
Then I click the "Search" button
Then I see the button containing "DO_NOT_DELETE"
Then I do not see the button containing "DoNotDeleteThisFolder_CanDeleteAllChildren"
When I click the button containing "Clear search"
Then I see the button containing "DoNotDeleteThisFolder_CanDeleteAllChildren"

@react
Scenario: Search with no results shows empty state
When I click the first button containing "public"
When I see input with placeholder "Search current folder" and type "zzz_nonexistent_item_xyz"
Then I click the "Search" button
Then I see "No files"
When I click the button containing "Clear search"
Then I do not see "No files"

@react
Scenario: Search with subfolders enabled finds items in nested folders
When I click the first button containing "public"
When I click the "Include Subfolders" checkbox
When I see input with placeholder "Search current folder" and type "DELETE"
Then I click the "Search" button
Then I see "DO_NOT_DELETE/DONT_DELETE_SUB"

@react
Scenario: Search without subfolders does not show nested items
When I click the first button containing "public"
When I see input with placeholder "Search current folder" and type "DO_NOT"
Then I click the "Search" button
Then I see the button containing "DO_NOT_DELETE"
Then I do not see the button containing "DO_NOT_DELETE/DONT_DELETE_SUB"
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
Feature: StorageBrowser cross-page sorting

Tests that sorting works across multiple pages of results,
including sorting by different columns and combining sort with search.

Background:
Given I'm running the example "ui/components/storage/storage-browser/default-auth"
And I type my "email" with status "CONFIRMED"
And I type my password
And I click the "Sign in" button

@react
Scenario: Sort by name ascending
When I click the first button containing "public"
Then the table should have at least "2" rows
When I click the "Name" sort header
Then the table name column values should be in "ascending" order

@react
Scenario: Sort by name descending
When I click the first button containing "public"
Then the table should have at least "2" rows
When I click the "Name" sort header
Then the table name column values should be in "ascending" order
When I click the "Name" sort header
Then the table name column values should be in "descending" order

@react
Scenario: Sort by size column
When I click the first button containing "public"
Then the table should have at least "2" rows
When I click the "Size" sort header
Then the table should have at least "2" rows

@react
Scenario: Sort by last modified column
When I click the first button containing "public"
Then the table should have at least "2" rows
When I click the "Last modified" sort header
Then the table should have at least "2" rows

@react
Scenario: Changing sort column resets to ascending
When I click the first button containing "public"
Then the table should have at least "2" rows
When I click the "Name" sort header
Then the table name column values should be in "ascending" order
When I click the "Name" sort header
Then the table name column values should be in "descending" order
# Switching to a different column should reset to ascending
When I click the "Size" sort header
Then the table should have at least "2" rows

@react
Scenario: Sort combined with search results
When I click the first button containing "public"
When I see input with placeholder "Search current folder" and type "DO_NOT"
Then I click the "Search" button
Then I see the button containing "DO_NOT_DELETE"
# Sort the search results by name
When I click the "Name" sort header
Then the table should have at least "1" rows

@react
Scenario: Sort direction preserved after toggling
When I click the first button containing "public"
Then the table should have at least "2" rows
When I click the "Name" sort header
Then the table name column values should be in "ascending" order
When I click the "Name" sort header
Then the table name column values should be in "descending" order
# Toggle back to ascending
When I click the "Name" sort header
Then the table name column values should be in "ascending" order
4 changes: 2 additions & 2 deletions packages/react-storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"name": "createStorageBrowser",
"path": "dist/esm/browser.mjs",
"import": "{ createStorageBrowser }",
"limit": "130 kB",
"limit": "131 kB",
"ignore": [
"@aws-amplify/storage"
]
Expand All @@ -78,7 +78,7 @@
"name": "StorageBrowser",
"path": "dist/esm/index.mjs",
"import": "{ StorageBrowser }",
"limit": "154 kB"
"limit": "155 kB"
},
{
"name": "FileUploader",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export {
PaginationConfigProvider,
} from './paginationContext';
export type { PaginationConfig } from './paginationContext';
export { useSortConfig, SortConfigProvider } from './sortConfigContext';
export type { SortScope, SortConfig } from './sortConfigContext';
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from 'react';
import { createContextUtilities } from '@aws-amplify/ui-react-core';

/**
* Controls where sorting is applied in the Storage Browser.
*
* - `'page'` — Sort only the current display page. Headers are handled
* by the table-level local sort inside `useDataTable`.
* - `'all'` — Sort all loaded items before pagination (cross-page sort).
* Headers are handled by the view-level `useSort` hook.
* - `'global'` — Fetch ALL items from S3 (across all S3 pages, like
* search mode) before sorting. On first sort click, triggers a full
* fetch with progress reporting; subsequent sort changes reuse cached
* data. Cache invalidates on refresh or navigation.
*
* @default 'page'
*/
export type SortScope = 'page' | 'all' | 'global';

export interface SortConfig {
sortScope: SortScope;
}

const ERROR_MESSAGE =
'`useSortConfig` must be called from within a `SortConfigProvider`.';

export const { useSortConfig, SortConfigContext } =
createContextUtilities<SortConfig>({
contextName: 'SortConfig',
errorMessage: ERROR_MESSAGE,
});

export interface SortConfigProviderProps {
children?: React.ReactNode;
sortScope?: SortScope;
}

export function SortConfigProvider({
children,
sortScope = 'page',
}: SortConfigProviderProps): React.JSX.Element {
const value = React.useMemo(() => ({ sortScope }), [sortScope]);

return (
<SortConfigContext.Provider value={value}>
{children}
</SortConfigContext.Provider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
DataTableNumberDataCell,
} from '../../components';
import { useControlsContext } from '../context';
import type { HeaderKeys } from '../../views/LocationDetailView/getLocationDetailViewTableData/types';
import { compareButtonData } from './compareFunctions/compareButtonData';
import { compareDateData } from './compareFunctions/compareDateData';
import { compareNumberData } from './compareFunctions/compareNumberData';
Expand All @@ -34,31 +35,53 @@ const GROUP_ORDER: DataTableDataCell['type'][] = [
const UNSORTABLE_GROUPS: DataTableDataCell['type'][] = ['checkbox'];

export const useDataTable = (): DataTableProps => {
const { data } = useControlsContext();
const { isLoading, tableData } = data;
const { data, onSort: onCrossPageSort } = useControlsContext();
const { isLoading, tableData, sortState: crossPageSortState } = data;

const hasCrossPageSort = !!onCrossPageSort;

const defaultSortIndex = React.useMemo(
() => tableData?.headers?.findIndex(({ type }) => type === 'sort') ?? -1,
[tableData]
);

const [sortState, setSortState] = React.useState<SortState>({
const [localSortState, setLocalSortState] = React.useState<SortState>({
index: defaultSortIndex,
direction: 'ascending',
});

const sortState = localSortState;

const mappedHeaders = React.useMemo(
() =>
tableData?.headers.map((header, index) => {
const { type } = header;
switch (type) {
case 'sort': {
const headerKey = (header as { key?: HeaderKeys }).key;

if (hasCrossPageSort && headerKey) {
const isActive = crossPageSortState?.field === headerKey;
return {
...header,
content: {
...header.content,
onSort: () => {
onCrossPageSort(headerKey);
},
sortDirection: isActive
? crossPageSortState.direction
: undefined,
},
};
}

return {
...header,
content: {
...header.content,
onSort: () => {
setSortState({
setLocalSortState({
index,
direction:
sortState.index === index
Expand All @@ -80,15 +103,25 @@ export const useDataTable = (): DataTableProps => {
}
}
}),
[sortState, tableData]
[
crossPageSortState,
hasCrossPageSort,
onCrossPageSort,
sortState,
tableData,
]
);

const sortedRows = React.useMemo(() => {
// Early return if there is no table data
if (!tableData) {
return;
}
// Return rows as is if there are no sortable columns

// When cross-page sort is active, rows are already sorted upstream
if (hasCrossPageSort) {
return tableData.rows;
}

if (sortState.index < 0) {
return tableData.rows;
}
Expand Down Expand Up @@ -150,7 +183,7 @@ export const useDataTable = (): DataTableProps => {
});
})
.flat();
}, [sortState, tableData]);
}, [hasCrossPageSort, sortState, tableData]);

return {
headers: mappedHeaders ?? [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import type {
DataTableProps,
DataTableSortHeader,
MessageProps,
SortDirection,
} from '../components';
import type { ActionConfirmationModalProps } from '../components/composables/ActionConfirmationModal';
import type { LocationState } from '../store';
import type { StatusCounts } from '../tasks';
import type { FilePreviewState } from '../views/hooks/useFilePreview';
import type { HeaderKeys } from '../views/LocationDetailView/getLocationDetailViewTableData/types';

export interface Controls {
props: React.ComponentProps<Composables[keyof Composables]>;
Expand Down Expand Up @@ -39,6 +41,11 @@ interface PaginationData {
page: number;
}

export interface SortState {
field: HeaderKeys;
direction: SortDirection;
}

export interface ControlsContext {
data: {
actions?: ActionListItem[];
Expand Down Expand Up @@ -90,6 +97,7 @@ export interface ControlsContext {
statusDisplayFailedLabel?: string;
statusDisplayQueuedLabel?: string;
tableData?: TableData;
sortState?: SortState;
title?: string;
};
onActionCancel?: () => void;
Expand All @@ -108,6 +116,7 @@ export interface ControlsContext {
onSelectActiveFile?: (file?: FileData | 'prev' | 'next') => void;
onSearchClear?: () => void;
onSearchQueryChange?: (value: string) => void;
onSort?: (headerKey: HeaderKeys) => void;
onOpenFilePreview?: (f: FileData) => void;
onCloseFilePreview?: () => void;
onRetryFilePreview?: () => void;
Expand Down
Loading
Loading