diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json index 312ceffa0..138c21e0f 100644 --- a/web/ui/package-lock.json +++ b/web/ui/package-lock.json @@ -4241,9 +4241,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.6", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz", - "integrity": "sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==", + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -4299,9 +4299,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { diff --git a/web/ui/react-app/src/components/approvals/service-info.tsx b/web/ui/react-app/src/components/approvals/service-info.tsx index 7794ec688..4915ae7d6 100644 --- a/web/ui/react-app/src/components/approvals/service-info.tsx +++ b/web/ui/react-app/src/components/approvals/service-info.tsx @@ -2,6 +2,8 @@ import type { FC } from 'react'; import { ServiceActionRelease } from '@/components/approvals'; import ServiceInfoDeployedVersion from '@/components/approvals/service-info--deployed-version'; import ServiceInfoLatestVersion from '@/components/approvals/service-info--latest-version'; +import { useToolbar } from '@/components/approvals/toolbar/toolbar-context'; +import { CardTimestamp } from '@/constants/toolbar'; import { relativeDate } from '@/utils'; import type { ServiceSummary } from '@/utils/api/types/config/summary'; @@ -24,6 +26,9 @@ const ServiceInfo: FC = ({ updateAvailable, updateSkipped, }) => { + const { cardTimestamps } = useToolbar(); + const status = service?.status; + return (
diff --git a/web/ui/react-app/src/components/approvals/toolbar/filter-dropdown.tsx b/web/ui/react-app/src/components/approvals/toolbar/filter-dropdown.tsx index 362707dcb..20b1c88f2 100644 --- a/web/ui/react-app/src/components/approvals/toolbar/filter-dropdown.tsx +++ b/web/ui/react-app/src/components/approvals/toolbar/filter-dropdown.tsx @@ -34,6 +34,7 @@ import { type HideValueType, TABLE_COLUMNS_ORDER_STORAGE_KEY, toolbarHideOptions, + toolbarTimestampOptions, } from '@/constants/toolbar'; import { getServiceSummaries } from '@/hooks/use-services'; import { @@ -55,6 +56,8 @@ const FilterDropdown: FC = () => { const queryClient = useQueryClient(); const { values, + cardTimestamps, + toggleCardTimestamp, setHide, setView, tableInstance, @@ -212,7 +215,10 @@ const FilterDropdown: FC = () => { handleHideOptionClick(key)} + onClick={(event) => { + event.preventDefault(); + handleHideOptionClick(key); + }} > {label} @@ -220,11 +226,34 @@ const FilterDropdown: FC = () => { })} { + event.preventDefault(); + handleResetHideFilters(); + }} > Reset + {values.view === APPROVALS_TOOLBAR_VIEW.GRID.value && ( + <> + + + Timestamps: + {toolbarTimestampOptions.map(({ key, label }) => ( + { + event.preventDefault(); + toggleCardTimestamp(key); + }} + > + {label} + + ))} + + + )} Layout: @@ -261,7 +290,10 @@ const FilterDropdown: FC = () => { {tableColumnOptions} { + event.preventDefault(); + handleResetColumns(); + }} > Reset diff --git a/web/ui/react-app/src/components/approvals/toolbar/toolbar-context.tsx b/web/ui/react-app/src/components/approvals/toolbar/toolbar-context.tsx index be2487490..af5eac5cc 100644 --- a/web/ui/react-app/src/components/approvals/toolbar/toolbar-context.tsx +++ b/web/ui/react-app/src/components/approvals/toolbar/toolbar-context.tsx @@ -7,6 +7,7 @@ import { } from 'react'; import type { ApprovalsToolbarOptions, + CardTimestampType, ToolbarViewOption, } from '@/constants/toolbar'; import type { TagsTriType } from '@/types/util'; @@ -27,6 +28,11 @@ export type ToolbarContextValue = { /* Toggles the 'editMode' value in the URL */ toggleEditMode: () => void; + /* Timestamps shown at the bottom of the service card */ + cardTimestamps: CardTimestampType[]; + /* Toggles a timestamp shown at the bottom of the service card */ + toggleCardTimestamp: (value: CardTimestampType) => void; + /* Tanstack table instance */ tableInstance?: Table; /* Function to set the table instance */ diff --git a/web/ui/react-app/src/constants/toolbar.ts b/web/ui/react-app/src/constants/toolbar.ts index 521b27cf7..b9f55f25d 100644 --- a/web/ui/react-app/src/constants/toolbar.ts +++ b/web/ui/react-app/src/constants/toolbar.ts @@ -57,6 +57,28 @@ export const DEFAULT_HIDE_VALUE: HideValueType[] = [ HideValue.Inactive, ] as const; +/* Timestamps that can be shown at the bottom of the service card. */ +export const CardTimestamp = { + Deployed: 'deployed', + Found: 'found', + Queried: 'queried', +} as const; + +export type CardTimestampType = + (typeof CardTimestamp)[keyof typeof CardTimestamp]; + +/* Card timestamp options for the toolbar. */ +export const toolbarTimestampOptions = [ + { key: CardTimestamp.Deployed, label: 'Show deployed' }, + { key: CardTimestamp.Found, label: 'Show found' }, + { key: CardTimestamp.Queried, label: 'Show queried' }, +] as const; + +/* Timestamps shown until the user says otherwise. */ +export const DEFAULT_CARD_TIMESTAMPS: CardTimestampType[] = [ + CardTimestamp.Queried, +] as const; + /* Query params for the toolbar. */ export const URL_PARAMS = { EDIT_MODE: 'editMode', @@ -77,3 +99,5 @@ export const TABLE_COLUMNS_HIDDEN_STORAGE_KEY = 'tableColumnsHidden'; export const TABLE_COLUMNS_VISIBLE_STORAGE_KEY_LEGACY = 'tableColumnsVisible'; /* Storage key for the order of table columns. */ export const TABLE_COLUMNS_ORDER_STORAGE_KEY = 'tableColumnsOrder'; +/* Storage key for the timestamps shown under each service on a card. */ +export const CARD_TIMESTAMPS_STORAGE_KEY = 'cardTimestamps'; diff --git a/web/ui/react-app/src/pages/approvals/index.tsx b/web/ui/react-app/src/pages/approvals/index.tsx index 75910c427..522e5701a 100644 --- a/web/ui/react-app/src/pages/approvals/index.tsx +++ b/web/ui/react-app/src/pages/approvals/index.tsx @@ -7,6 +7,7 @@ import { ToolbarProvider } from '@/components/approvals/toolbar/toolbar-context' import { APPROVALS_TOOLBAR_VIEW, type ApprovalsToolbarOptions, + type CardTimestampType, DEFAULT_HIDE_VALUE, DEFAULT_VIEW_VALUE, HideValue, @@ -21,6 +22,10 @@ import { GridLayout } from '@/pages/approvals/layouts/grid'; import { TableLayout } from '@/pages/approvals/layouts/table/table'; import type { TagsTriType } from '@/types/util'; import type { ServiceSummary } from '@/utils/api/types/config/summary'; +import { + loadCardTimestamps, + persistCardTimestamps, +} from '@/utils/card-timestamps'; import { visibleServices as getVisibleServices } from '@/utils/visible-services'; const toolbarDefaults: ApprovalsToolbarOptions = { @@ -225,9 +230,23 @@ export const Approvals = (): ReactElement => { const [tableColumnVisibility, setTableColumnVisibility] = useState({}); + // Timestamps shown at the bottom of the service card.. + const [cardTimestamps, setCardTimestamps] = + useState(loadCardTimestamps); + const toggleCardTimestamp = useCallback((value: CardTimestampType) => { + setCardTimestamps((current) => { + const next = current.includes(value) + ? current.filter((timestamp) => timestamp !== value) + : [...current, value]; + persistCardTimestamps(next); + return next; + }); + }, []); + return ( { tableColumnVisibility, tableInstance, + toggleCardTimestamp, toggleEditMode, values: toolbarOptions, diff --git a/web/ui/react-app/src/utils/card-timestamps.ts b/web/ui/react-app/src/utils/card-timestamps.ts new file mode 100644 index 000000000..bf5a52002 --- /dev/null +++ b/web/ui/react-app/src/utils/card-timestamps.ts @@ -0,0 +1,32 @@ +import { + CARD_TIMESTAMPS_STORAGE_KEY, + type CardTimestampType, + DEFAULT_CARD_TIMESTAMPS, + toolbarTimestampOptions, +} from '@/constants/toolbar'; + +/** + * Reads which timestamps to show at the bottom of the service card. + * An empty string means every timestamp was switched off, which is distinct + * from the key being absent (never configured). + * + * @returns The enabled timestamps. + */ +export const loadCardTimestamps = (): CardTimestampType[] => { + const stored = localStorage.getItem(CARD_TIMESTAMPS_STORAGE_KEY); + if (stored === null) return [...DEFAULT_CARD_TIMESTAMPS]; + + const enabled = new Set(stored.split(',').filter(Boolean)); + return toolbarTimestampOptions + .map(({ key }) => key) + .filter((key) => enabled.has(key)); +}; + +/** + * Persists the timestamps to show at the bottom of the service card. + * + * @param timestamps - The enabled timestamps. + */ +export const persistCardTimestamps = (timestamps: CardTimestampType[]) => { + localStorage.setItem(CARD_TIMESTAMPS_STORAGE_KEY, timestamps.join(',')); +}; diff --git a/web/ui/react-app/src/utils/index.tsx b/web/ui/react-app/src/utils/index.tsx index 765bc5bde..ae2e38fca 100644 --- a/web/ui/react-app/src/utils/index.tsx +++ b/web/ui/react-app/src/utils/index.tsx @@ -1,4 +1,8 @@ export { default as beautifyGoErrors } from './beautify-go-errors'; +export { + loadCardTimestamps, + persistCardTimestamps, +} from './card-timestamps'; export { containsEndsWith, containsStartsWith, diff --git a/web/ui/react-app/src/utils/relative-date.ts b/web/ui/react-app/src/utils/relative-date.ts index 7dafa368f..d910fc21c 100644 --- a/web/ui/react-app/src/utils/relative-date.ts +++ b/web/ui/react-app/src/utils/relative-date.ts @@ -1,13 +1,11 @@ -import { formatRelative } from 'date-fns'; +import { formatDistanceToNow } from 'date-fns'; import { enGB } from 'date-fns/locale'; /** - * Returns a relative date string. + * Returns how long ago date was, e.g. '20 days ago'. * * @param date - The date to format. */ -const relativeDate = (date: Date) => { - const now = new Date(); - return formatRelative(date, now, { locale: enGB }); -}; +const relativeDate = (date: Date) => + formatDistanceToNow(date, { addSuffix: true, locale: enGB }); export default relativeDate; diff --git a/web/ui/react-app/tests/dashboard-table.spec.ts b/web/ui/react-app/tests/dashboard-table.spec.ts index 2ebf809af..9da6f971a 100644 --- a/web/ui/react-app/tests/dashboard-table.spec.ts +++ b/web/ui/react-app/tests/dashboard-table.spec.ts @@ -68,4 +68,19 @@ test.describe('Dashboard table view', () => { await expect(page.getByRole('table')).toBeVisible(); await expect(iconHeader).toBeHidden(); }); + + test('the card timestamp options are not offered', async ({ page }) => { + // GIVEN: timestamps are a grid-only option. + const timestampLabels = ['Show deployed', 'Show found', 'Show queried']; + + // WHEN: the filter dropdown is opened in the table view. + await page.getByRole('button', { name: 'Filter shown services' }).click(); + await expect(page.getByRole('menu')).toBeVisible(); + + // THEN: none of them are listed. + for (const label of timestampLabels) + await expect( + page.getByRole('menuitemcheckbox', { exact: true, name: label }), + ).toHaveCount(0); + }); }); diff --git a/web/ui/react-app/tests/dashboard.spec.ts b/web/ui/react-app/tests/dashboard.spec.ts index 6ce9c194e..45b12c7ce 100644 --- a/web/ui/react-app/tests/dashboard.spec.ts +++ b/web/ui/react-app/tests/dashboard.spec.ts @@ -1,4 +1,95 @@ -import { expect, test } from '@playwright/test'; +import { expect, type Page, test } from '@playwright/test'; +import { expectServiceLoaded, serviceCard } from './fixtures/dashboard'; + +/** + * The service seeded from config.yml.example. + * It's a GitHub lookup with no `deployed_version`, so the first + * successful query gives it a `deployed_version_timestamp` as + * well as a `latest_version_timestamp` and a `last_queried`. + */ +const EXAMPLE_SERVICE_ID = 'release-argus/Argus'; + +/** `aria-label` of the toolbar button holding the filter/timestamp options. */ +const FILTER_BUTTON_NAME = 'Filter shown services'; + +/** + * Each timestamp's menu label, and the prefix its card line renders with. + */ +const TIMESTAMP = { + deployed: { label: 'Show deployed', prefix: 'deployed' }, + found: { label: 'Show found', prefix: 'found' }, + queried: { label: 'Show queried', prefix: 'queried' }, +} as const; + +type TimestampName = keyof typeof TIMESTAMP; + +/** The fixed order the card renders the timestamps in. */ +const CARD_ORDER: TimestampName[] = ['deployed', 'found', 'queried']; + +/** + * Locates the timestamp lines under the example service's card. + * + * @param page - The dashboard page. + * @returns The `[data-timestamp]` per shown timestamp, in render order. + */ +const timestampLines = (page: Page) => + serviceCard(page, EXAMPLE_SERVICE_ID).locator('[data-timestamp]'); + +/** + * Asserts exactly which timestamps the example service's card shows, and in + * which order. + * + * @param page - The dashboard page. + * @param expected - The expected timestamps, in render order. + */ +const expectTimestamps = async ( + page: Page, + expected: readonly TimestampName[], +) => { + const lines = timestampLines(page); + await expect(lines).toHaveCount(expected.length); + for (const [index, name] of expected.entries()) { + await expect(lines.nth(index)).toHaveAttribute('data-timestamp', name); + await expect(lines.nth(index)).toHaveText( + new RegExp(`^${TIMESTAMP[name].prefix} .+`), + ); + } +}; + +/** + * Opens the filter dropdown. + * + * @param page - The dashboard page. + */ +const openFilterDropdown = async (page: Page) => { + await page.getByRole('button', { name: FILTER_BUTTON_NAME }).click(); + await expect(page.getByRole('menu')).toBeVisible(); +}; + +/** + * Locates a timestamp's checkbox in an open filter dropdown. + * + * @param page - The dashboard page. + * @param name - The timestamp to locate. + */ +const timestampOption = (page: Page, name: TimestampName) => + page.getByRole('menuitemcheckbox', { + exact: true, + name: TIMESTAMP[name].label, + }); + +/** + * Toggles timestamps from the filter dropdown, closing it afterwards. + * + * @param page - The dashboard page. + * @param names - The timestamps to toggle. + */ +const toggleTimestamps = async (page: Page, ...names: TimestampName[]) => { + await openFilterDropdown(page); + for (const name of names) await timestampOption(page, name).click(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('menu')).toHaveCount(0); +}; test.describe('Dashboard', () => { test('has the correct title', async ({ page }) => { @@ -9,7 +100,97 @@ test.describe('Dashboard', () => { test('dashboard is visible', async ({ page }) => { await page.goto('/'); await expect( - page.getByRole('heading', { exact: true, name: 'release-argus/Argus' }), + page.getByRole('heading', { exact: true, name: EXAMPLE_SERVICE_ID }), ).toBeVisible(); }); }); + +test.describe('Dashboard card timestamps', () => { + test.beforeEach(async ({ page }) => { + // GIVEN: the grid view, with a loaded service card. + await page.goto('/approvals'); + await expectServiceLoaded(page, EXAMPLE_SERVICE_ID); + }); + + test('only the queried timestamp is shown until changed', async ({ + page, + }) => { + // WHEN: nothing has been toggled. + // THEN: the card only carries the 'queried' timestamp. + await expectTimestamps(page, ['queried']); + + // AND: the dropdown reports the same. + await openFilterDropdown(page); + await expect(page.getByText('Timestamps:')).toBeVisible(); + await expect(timestampOption(page, 'deployed')).not.toBeChecked(); + await expect(timestampOption(page, 'found')).not.toBeChecked(); + await expect(timestampOption(page, 'queried')).toBeChecked(); + }); + + test('each timestamp can be shown, in a fixed order', async ({ page }) => { + // WHEN: the other two timestamps are enabled, deployed last. + await toggleTimestamps(page, 'found', 'deployed'); + + // THEN: all three show, in card order rather than click order. + await expectTimestamps(page, CARD_ORDER); + }); + + test('a timestamp can be hidden again', async ({ page }) => { + // WHEN: 'found' is enabled and the default 'queried' is disabled. + await toggleTimestamps(page, 'found', 'queried'); + + // THEN: only 'found' remains. + await expectTimestamps(page, ['found']); + }); + + test('every timestamp can be hidden', async ({ page }) => { + // WHEN: the only enabled (default) timestamp is disabled. + await toggleTimestamps(page, 'queried'); + + // THEN: the card shows no timestamps. + await expectTimestamps(page, []); + }); + + test('toggling a timestamp leaves the dropdown open', async ({ page }) => { + // WHEN: a timestamp is toggled with the dropdown open. + await openFilterDropdown(page); + await timestampOption(page, 'found').click(); + + // THEN: the dropdown stays open, with the change applied, so more can + // be toggled without reopening it. + await expect(page.getByRole('menu')).toBeVisible(); + await expect(timestampOption(page, 'found')).toBeChecked(); + + await timestampOption(page, 'deployed').click(); + await expect(page.getByRole('menu')).toBeVisible(); + await expect(timestampOption(page, 'deployed')).toBeChecked(); + + await page.keyboard.press('Escape'); + await expectTimestamps(page, CARD_ORDER); + }); + + test('the choice survives a reload', async ({ page }) => { + // WHEN: the defaults are swapped for 'found' alone, and the page reloads. + await toggleTimestamps(page, 'found', 'queried'); + await expectTimestamps(page, ['found']); + await page.reload(); + await expectServiceLoaded(page, EXAMPLE_SERVICE_ID); + + // THEN: the card, and the dropdown, keep that choice. + await expectTimestamps(page, ['found']); + await openFilterDropdown(page); + await expect(timestampOption(page, 'deployed')).not.toBeChecked(); + await expect(timestampOption(page, 'found')).toBeChecked(); + await expect(timestampOption(page, 'queried')).not.toBeChecked(); + }); + + test('hiding every timestamp survives a reload', async ({ page }) => { + // WHEN: every timestamp is disabled, and the page reloads. + await toggleTimestamps(page, 'queried'); + await page.reload(); + await expectServiceLoaded(page, EXAMPLE_SERVICE_ID); + + // THEN: none come back. + await expectTimestamps(page, []); + }); +}); diff --git a/web/ui/react-app/tests/refresh-deployed-versions.spec.ts b/web/ui/react-app/tests/refresh-deployed-versions.spec.ts index b6dfa5605..4694e76c7 100644 --- a/web/ui/react-app/tests/refresh-deployed-versions.spec.ts +++ b/web/ui/react-app/tests/refresh-deployed-versions.spec.ts @@ -111,6 +111,8 @@ const filterServices = (page: Page, term: string) => const toggleHideOption = async (page: Page, label: string) => { await page.getByRole('button', { name: FILTER_BUTTON_NAME }).click(); await page.getByRole('menuitemcheckbox', { name: label }).click(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('menu')).toHaveCount(0); }; test.describe('refresh visible deployed versions', () => { @@ -223,10 +225,7 @@ test.describe('refresh visible deployed versions', () => { await expectUpdateState(page, updatableID, 'AVAILABLE'); // WHEN: 'Hide up to date' removes the up-to-date service from view. - await page.getByRole('button', { name: FILTER_BUTTON_NAME }).click(); - await page - .getByRole('menuitemcheckbox', { name: 'Hide up to date' }) - .click(); + await toggleHideOption(page, 'Hide up to date'); await expect(page.getByRole('heading', { name: upToDateID })).toHaveCount( 0, ); @@ -277,8 +276,7 @@ test.describe('refresh visible deployed versions', () => { await expectUpdateState(page, skippedID, 'SKIPPED'); // WHEN: 'Hide skipped' removes it from view. - await page.getByRole('button', { name: FILTER_BUTTON_NAME }).click(); - await page.getByRole('menuitemcheckbox', { name: 'Hide skipped' }).click(); + await toggleHideOption(page, 'Hide skipped'); await expect(page.getByRole('heading', { name: skippedID })).toHaveCount(0); await expect( page.getByRole('heading', { name: updatableID }),