diff --git a/pages/s3-resource-selector/data/i18n-strings.ts b/pages/s3-resource-selector/data/i18n-strings.ts index 061966ea69..193622d12f 100644 --- a/pages/s3-resource-selector/data/i18n-strings.ts +++ b/pages/s3-resource-selector/data/i18n-strings.ts @@ -17,6 +17,7 @@ export const i18nStrings: S3ResourceSelectorProps.I18nStrings = { modalCancelButton: 'Cancel', modalSubmitButton: 'Choose', modalBreadcrumbRootItem: 'S3 buckets', + modalLastUpdatedText: 'Last updated', selectionBuckets: 'Buckets', selectionObjects: 'Objects', diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 0c865f46ef..a319b2abbc 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -12859,6 +12859,11 @@ The function will be called when a user clicks on the trigger button.", "optional": true, "type": "string", }, + Object { + "name": "modalLastUpdatedText", + "optional": true, + "type": "string", + }, Object { "name": "modalSubmitButton", "optional": true, diff --git a/src/s3-resource-selector/__tests__/fixtures.ts b/src/s3-resource-selector/__tests__/fixtures.ts index 2c856bb455..0c5e09427d 100644 --- a/src/s3-resource-selector/__tests__/fixtures.ts +++ b/src/s3-resource-selector/__tests__/fixtures.ts @@ -23,6 +23,7 @@ export const i18nStrings: S3ResourceSelectorProps.I18nStrings = { modalCancelButton: 'Cancel', modalSubmitButton: 'Choose', modalBreadcrumbRootItem: 'S3 buckets', + modalLastUpdatedText: 'Last updated', selectionBuckets: 'Buckets', selectionObjects: 'Objects', diff --git a/src/s3-resource-selector/interfaces.ts b/src/s3-resource-selector/interfaces.ts index d007e792b3..982381d1af 100644 --- a/src/s3-resource-selector/interfaces.ts +++ b/src/s3-resource-selector/interfaces.ts @@ -218,6 +218,7 @@ export namespace S3ResourceSelectorProps { modalCancelButton?: string; modalSubmitButton?: string; modalBreadcrumbRootItem?: string; + modalLastUpdatedText?: string; selectionBuckets?: string; selectionObjects?: string; diff --git a/src/s3-resource-selector/s3-modal/__tests__/fetching.test.tsx b/src/s3-resource-selector/s3-modal/__tests__/fetching.test.tsx index 279237699e..b6643e4240 100644 --- a/src/s3-resource-selector/s3-modal/__tests__/fetching.test.tsx +++ b/src/s3-resource-selector/s3-modal/__tests__/fetching.test.tsx @@ -1,13 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { act, render } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { S3Modal } from '../../../../lib/components/s3-resource-selector/s3-modal'; import createWrapper, { ElementWrapper } from '../../../../lib/components/test-utils/dom'; -import { buckets, objects, versions, waitForFetch } from '../../__tests__/fixtures'; +import { buckets, i18nStrings, objects, versions, waitForFetch } from '../../__tests__/fixtures'; import { getElementsText, modalDefaultProps, navigateToTableItem } from './utils'; +import styles from '../../../../lib/components/s3-resource-selector/s3-modal/styles.css.js'; + jest.setTimeout(30_000); async function renderModal(jsx: React.ReactElement) { @@ -126,3 +128,74 @@ test('dives into folders containing slashes in names', async () => { await navigateToTableItem(wrapper, 1); expect(fetchObjectsSpy).toHaveBeenCalledWith('bucket-laborum', 'folder/folder///final'); }); + +describe('last updated status text', () => { + beforeEach(() => { + const lastUpdatedDate = new Date('2024-01-02T10:00:00.000Z'); + jest.useFakeTimers().setSystemTime(lastUpdatedDate); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('shows the last updated once the initial fetch is done', async () => { + const wrapper = await renderModal(); + const lastUpdated = wrapper.findByClassName(styles['last-updated-caption'])!.getElement(); + + expect(lastUpdated).toHaveTextContent('Last updatedJanuary 2, 2024, 10:00:00 (UTC)'); + }); + + test('renders the last updated time within the aria-live region', async () => { + const wrapper = await renderModal(); + const lastUpdated = wrapper.findByClassName(styles['last-updated-caption'])!.find('[aria-live]')!.getElement(); + + await waitFor(() => { + /** + * component is using innerText for setting the span element's text content. + * To populate the textContent from innerText, layout engine is required which JSDom doesn't have. + * So falling back to assert against innerText rather than the textContent. + */ + expect(lastUpdated.innerText).toBe('Last updated January 2, 2024, 10:00:00 (UTC)'); + }); + }); + + test('updates the "last updated" once the refresh button is clicked and the request is settled', async () => { + const wrapper = await renderModal(); + + const refreshFetchDate = new Date('2024-01-02T11:00:00.000Z'); + jest.useFakeTimers().setSystemTime(refreshFetchDate); + screen.getByRole('button', { name: i18nStrings.labelRefresh }).click(); + await waitForFetch(); + + const lastUpdated = wrapper.findByClassName(styles['last-updated-caption'])!.getElement(); + expect(lastUpdated).toHaveTextContent('Last updatedJanuary 2, 2024, 11:00:00 (UTC)'); + }); + + test('does not render "Last updated" when the i18n label is not specified', async () => { + const wrapper = await renderModal( + + ); + + const lastUpdated = wrapper.findByClassName(styles['last-updated-caption']); + expect(lastUpdated).toBeFalsy(); + }); + + test('does not render "Last updated" while the initial loading is in progress', async () => { + const fetchBuckets = jest.fn().mockReturnValue( + new Promise(() => { + // never resolving promise + }) + ); + const wrapper = await renderModal(); + + const refreshFetchDate = new Date('2024-01-02T11:00:00.000Z'); + jest.useFakeTimers().setSystemTime(refreshFetchDate); + + const lastUpdated = wrapper.findByClassName(styles['last-updated-caption']); + expect(lastUpdated).toBeFalsy(); + }); +}); diff --git a/src/s3-resource-selector/s3-modal/basic-table.tsx b/src/s3-resource-selector/s3-modal/basic-table.tsx index ac7e5c36c0..66dc249747 100644 --- a/src/s3-resource-selector/s3-modal/basic-table.tsx +++ b/src/s3-resource-selector/s3-modal/basic-table.tsx @@ -5,12 +5,16 @@ import React, { useEffect, useRef, useState } from 'react'; import { useCollection } from '@cloudscape-design/collection-hooks'; import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; +import InternalBox from '../../box/internal'; import { InternalButton } from '../../button/internal'; import InternalHeader from '../../header/internal'; import { ComponentFormatFunction } from '../../i18n/context'; +import LiveRegion from '../../internal/components/live-region'; import useForwardFocus, { ForwardFocusRef } from '../../internal/hooks/forward-focus'; +import formatDateLocalized from '../../internal/utils/date-time/format-date-localized'; import { PaginationProps } from '../../pagination/interfaces'; import InternalPagination from '../../pagination/internal'; +import InternalSpaceBetween from '../../space-between/internal'; import { TableProps } from '../../table/interfaces'; import InternalTable from '../../table/internal'; import { TextFilterProps } from '../../text-filter/interfaces'; @@ -18,6 +22,8 @@ import InternalTextFilter from '../../text-filter/internal'; import { S3ResourceSelectorProps } from '../interfaces'; import { EmptyState } from './empty-state'; +import styles from './styles.css.js'; + interface BasicS3TableStrings { labelRefresh?: string; labelsPagination?: PaginationProps.Labels; @@ -27,6 +33,7 @@ interface BasicS3TableStrings { filteringAriaLabel?: string; filteringClearAriaLabel?: string; filteringCounterText?: S3ResourceSelectorProps.I18nStrings['filteringCounterText']; + lastUpdatedText?: string; emptyText?: string; noMatchTitle?: string; noMatchSubtitle?: string; @@ -51,7 +58,7 @@ interface BasicS3TableProps { export function getSharedI18Strings( i18n: ComponentFormatFunction<'s3-resource-selector'>, i18nStrings: S3ResourceSelectorProps.I18nStrings | undefined -) { +): BasicS3TableStrings { return { filteringCounterText: i18n( 'i18nStrings.filteringCounterText', @@ -64,6 +71,7 @@ export function getSharedI18Strings( noMatchSubtitle: i18n('i18nStrings.filteringCantFindMatch', i18nStrings?.filteringCantFindMatch), clearFilterButtonText: i18n('i18nStrings.clearFilterButtonText', i18nStrings?.clearFilterButtonText), filteringClearAriaLabel: i18nStrings?.labelClearFilter, + lastUpdatedText: i18n('i18nStrings.modalLastUpdatedText', i18nStrings?.modalLastUpdatedText), }; } @@ -80,6 +88,7 @@ export function BasicS3Table({ }: BasicS3TableProps) { const [loading, setLoading] = useState(false); const [allItems, setAllItems] = useState>([]); + const [lastUpdated, setLastUpdated] = useState(); const textFilterRef = useRef(null); const onSelectLatest = useStableCallback(onSelect); @@ -87,6 +96,7 @@ export function BasicS3Table({ setLoading(true); fetchData() .then(items => { + setLastUpdated(new Date()); setAllItems(items); setLoading(false); }) @@ -142,7 +152,7 @@ export function BasicS3Table({ } + actions={ loadData={loadData} i18nStrings={i18nStrings} lastUpdated={lastUpdated} />} counter={selectedItem ? `(1/${allItems.length})` : `(${allItems.length})`} > {i18nStrings.header} @@ -172,3 +182,38 @@ export function BasicS3Table({ /> ); } + +interface InternalHeaderActionsProps { + loadData: () => void; + i18nStrings: BasicS3TableProps['i18nStrings']; + lastUpdated: Date | undefined; +} + +export function InternalHeaderActions({ i18nStrings, loadData, lastUpdated }: InternalHeaderActionsProps) { + function getLastUpdated() { + if (!lastUpdated || !i18nStrings.lastUpdatedText) { + return null; + } + + const formattedDate = formatDateLocalized({ + date: lastUpdated.toString(), + isDateOnly: false, + }); + + return ( + + {i18nStrings.lastUpdatedText} +
+ {formattedDate} + +
+ ); + } + + return ( + + {getLastUpdated()} + + + ); +} diff --git a/src/s3-resource-selector/s3-modal/styles.scss b/src/s3-resource-selector/s3-modal/styles.scss index abbce55297..755bde2754 100644 --- a/src/s3-resource-selector/s3-modal/styles.scss +++ b/src/s3-resource-selector/s3-modal/styles.scss @@ -2,6 +2,7 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ +@use '../../internal/styles/tokens.scss' as awsui; .modal-actions { justify-content: flex-end; @@ -10,3 +11,10 @@ .submit-button { /* used in test-utils */ } + +.last-updated-caption { + @include styles.font-heading-xs; + + text-align: end; + color: awsui.$color-text-status-inactive; +}