diff --git a/app/components/ImageDetailSideModal.tsx b/app/components/ImageDetailSideModal.tsx index 0629e2810..99968c49d 100644 --- a/app/components/ImageDetailSideModal.tsx +++ b/app/components/ImageDetailSideModal.tsx @@ -41,7 +41,7 @@ export function ImageDetailSideModal({ > - + {visibility} {image.os} {image.version} diff --git a/app/components/IpPoolDetailSideModal.tsx b/app/components/IpPoolDetailSideModal.tsx index 3b97a7828..05020812a 100644 --- a/app/components/IpPoolDetailSideModal.tsx +++ b/app/components/IpPoolDetailSideModal.tsx @@ -35,7 +35,7 @@ export function IpPoolDetailSideModal({ pool, onDismiss }: IpPoolDetailSideModal > - + diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 16d1b92c5..769eb7eb0 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -86,7 +86,7 @@ Sidebar.Nav = ({ children, heading }: SidebarNav) => (
{heading && (
- +
)}
-
+
{file && !dragOver ? ( -
- - ({formatBytes(file.size).label}) +
+ + + ({formatBytes(file.size).label}) +
diff --git a/app/ui/lib/Truncate.spec.tsx b/app/ui/lib/Truncate.spec.tsx new file mode 100644 index 000000000..0f307b2d3 --- /dev/null +++ b/app/ui/lib/Truncate.spec.tsx @@ -0,0 +1,64 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Truncate } from './Truncate' + +const measureText = vi.fn((text: string) => ({ width: text.length })) + +Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: () => ({ font: '', letterSpacing: '', measureText }), +}) + +afterEach(() => { + vi.restoreAllMocks() + measureText.mockClear() +}) + +describe('Truncate', () => { + it('preserves complete Unicode characters when truncating in the middle', () => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(6) + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(16) + const text = '😀'.repeat(8) + + render() + + const displayedText = screen + .getByLabelText(text) + .querySelector('.absolute')?.textContent + expect(displayedText).toBeDefined() + expect(hasUnpairedSurrogate(displayedText ?? '')).toBe(false) + }) + + it('truncates whenever the rendered text is wider than its container', () => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(9) + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(10) + const text = 'abcdefghij' + + render() + + expect(screen.getByLabelText(text).querySelector('.absolute')).not.toBeNull() + }) +}) + +function hasUnpairedSurrogate(text: string) { + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + if (code >= 0xd800 && code <= 0xdbff) { + const next = text.charCodeAt(i + 1) + if (next < 0xdc00 || next > 0xdfff) return true + i++ + } else if (code >= 0xdc00 && code <= 0xdfff) { + return true + } + } + return false +} diff --git a/app/ui/lib/Truncate.tsx b/app/ui/lib/Truncate.tsx index 27f4bc994..41c2ab651 100644 --- a/app/ui/lib/Truncate.tsx +++ b/app/ui/lib/Truncate.tsx @@ -6,6 +6,9 @@ * Copyright Oxide Computer Company */ +import cn from 'classnames' +import { useLayoutEffect, useRef, useState } from 'react' + import { CopyToClipboard } from './CopyToClipboard' import { Tooltip } from './Tooltip' @@ -13,36 +16,98 @@ type TruncatePosition = 'middle' | 'end' interface TruncateProps { text: string - maxLength: number position?: TruncatePosition hasCopyButton?: boolean tooltipDelay?: number + /** + * Extra classes for the wrapper, most commonly a `max-w-*` cap on how wide + * the text can grow. Constrained containers (side modals, toasts) don't need + * one, but in auto-layout tables the column sizes itself to the text, so + * table cells need a cap for truncation to ever kick in. + */ + className?: string } export const Truncate = ({ text, - maxLength, position = 'end', hasCopyButton, tooltipDelay = 300, + className, }: TruncateProps) => { - // Only use the tooltip if the text is longer than maxLength - // "truncate" class used for CSS truncation when cell rendered narrowly - const content = - text.length <= maxLength ? ( -
{text}
+ const ref = useRef(null) + // for middle truncation, the ellipsized string; null means the full text fits + const [middleText, setMiddleText] = useState(null) + const [truncated, setTruncated] = useState(false) + + // Middle truncation has to be computed up front in order to render at all, + // and recomputed whenever the container resizes + useLayoutEffect(() => { + const el = ref.current + if (position !== 'middle' || !el) return + + const update = () => { + const fitted = truncateToFit(text, el) + setMiddleText(fitted === text ? null : fitted) + setTruncated(fitted !== text) + } + + update() + const observer = new ResizeObserver(update) + observer.observe(el) + return () => observer.disconnect() + }, [text, position]) + + // For end truncation, CSS does the actual truncating and the only decision + // JS makes is whether to show the tooltip — which only matters at hover + // time. Checking lazily here avoids a per-instance ResizeObserver and can't + // go stale the way an observer-updated value can between resize and hover. + const checkEndTruncation = + position === 'end' + ? () => { + const el = ref.current + if (el) setTruncated(el.scrollWidth > el.clientWidth) + } + : undefined + + const inner = + position === 'end' ? ( +
+ {text} +
) : ( - -
- {truncate(text, maxLength, position)} -
-
+
+ {/* invisible copy of the full text keeps the layout width stable, so + swapping in the shorter ellipsized text can't shrink the container + and trigger another round of truncation */} + + {text} + + {middleText && ( + + {middleText} + + )} +
) return ( // overflow-hidden required to make inner truncate work -
- {content} +
+ {/* Tooltip stays mounted with content gated on `truncated` so its hover + tracking is already running when the lazy check flips it on. With no + content it renders just the child. */} + + {inner} + {hasCopyButton && (
@@ -52,6 +117,61 @@ export const Truncate = ({ ) } +let canvasCtx: CanvasRenderingContext2D | null = null +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + +/** null in environments without canvas support, like jsdom */ +function getCanvasCtx(): CanvasRenderingContext2D | null { + if (!canvasCtx) canvasCtx = document.createElement('canvas').getContext('2d') + return canvasCtx +} + +/** + * Middle-truncate `text` to fit the rendered width of `el`, measuring + * candidate strings with canvas `measureText`, which accounts for font + * shaping, kerning, and letter-spacing. + */ +function truncateToFit(text: string, el: HTMLElement): string { + const ctx = getCanvasCtx() + // if we can't measure (jsdom) or the element isn't laid out yet, leave it alone + if (!ctx || el.clientWidth === 0) return text + + const style = getComputedStyle(el) + // build the font shorthand from parts; `style.font` is empty in Firefox + ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}` + ctx.letterSpacing = style.letterSpacing === 'normal' ? '0px' : style.letterSpacing + + const width = el.clientWidth + if (el.scrollWidth <= width) return text + + const graphemes = Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment) + const fits = (keep: number) => + ctx.measureText(middleEllipsis(graphemes, keep)).width <= width + + // binary search for the largest number of kept graphemes that fits + let lo = 0 + let hi = graphemes.length - 1 + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2) + if (fits(mid)) { + lo = mid + } else { + hi = mid - 1 + } + } + return middleEllipsis(graphemes, lo) +} + +function middleEllipsis(graphemes: string[], keep: number) { + return ( + graphemes.slice(0, Math.ceil(keep / 2)).join('') + + '…' + + graphemes.slice(graphemes.length - Math.floor(keep / 2)).join('') + ) +} + +/** Truncate `text` to `maxLength` characters. For truncation that adapts to + * the rendered width instead, use the `Truncate` component. */ export function truncate( text: string, maxLength: number, diff --git a/package-lock.json b/package-lock.json index d594b4180..bedf0c931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8369,18 +8369,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "11.1.15", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", - "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", diff --git a/test/e2e/access-tokens.e2e.ts b/test/e2e/access-tokens.e2e.ts index 2f3f80b92..f24b96994 100644 --- a/test/e2e/access-tokens.e2e.ts +++ b/test/e2e/access-tokens.e2e.ts @@ -25,17 +25,17 @@ test('Access tokens', async ({ page }) => { const table = page.getByRole('table') await expectRowVisible(table, { - ID: token1, + ID: expect.stringContaining(token1), created: expect.stringContaining('May 27, 2025'), Expires: expect.stringContaining('Jul 3, 2025'), }) await expectRowVisible(table, { - ID: token2, + ID: expect.stringContaining(token2), created: expect.stringContaining('May 20, 2025'), Expires: expect.stringContaining('Aug 2, 2025'), }) await expectRowVisible(table, { - ID: token3, + ID: expect.stringContaining(token3), created: expect.stringContaining('May 31, 2025'), Expires: 'Never', }) @@ -49,6 +49,6 @@ test('Access tokens', async ({ page }) => { await expect(page.getByRole('cell', { name: token1 })).toBeHidden() // Other two tokens should still be there - await expectRowVisible(table, { ID: token2 }) - await expectRowVisible(table, { ID: token3 }) + await expectRowVisible(table, { ID: expect.stringContaining(token2) }) + await expectRowVisible(table, { ID: expect.stringContaining(token3) }) }) diff --git a/test/e2e/inventory.e2e.ts b/test/e2e/inventory.e2e.ts index b77f9e65d..3a7753062 100644 --- a/test/e2e/inventory.e2e.ts +++ b/test/e2e/inventory.e2e.ts @@ -23,28 +23,28 @@ test('Sled inventory page', async ({ page }) => { // expectRowVisible currently only looks at the last header row in case of // grouping, hence the slightly weird column names await expectRowVisible(sledsTable, { - id: sleds[0].id, + id: expect.stringContaining(sleds[0].id), 'serial number': sleds[0].baseboard.serial, Kind: 'In service', 'Provision policy': 'Provisionable', state: 'active', }) await expectRowVisible(sledsTable, { - id: sleds[1].id, + id: expect.stringContaining(sleds[1].id), 'serial number': sleds[1].baseboard.serial, Kind: 'In service', 'Provision policy': 'Not provisionable', state: 'active', }) await expectRowVisible(sledsTable, { - id: sleds[2].id, + id: expect.stringContaining(sleds[2].id), 'serial number': sleds[2].baseboard.serial, Kind: 'Expunged', 'Provision policy': '—', state: 'active', }) await expectRowVisible(sledsTable, { - id: sleds[3].id, + id: expect.stringContaining(sleds[3].id), 'serial number': sleds[3].baseboard.serial, Kind: 'Expunged', 'Provision policy': '—', @@ -77,21 +77,24 @@ test('Disk inventory page', async ({ page }) => { await expect(disksTab).toHaveClass(/is-selected/) const table = page.getByRole('table') - await expectRowVisible(table, { id: physicalDisks[0].id, 'Form factor': 'U.2' }) await expectRowVisible(table, { - id: physicalDisks[3].id, + id: expect.stringContaining(physicalDisks[0].id), + 'Form factor': 'U.2', + }) + await expectRowVisible(table, { + id: expect.stringContaining(physicalDisks[3].id), 'Form factor': 'M.2', policy: 'in service', state: 'active', }) await expectRowVisible(table, { - id: physicalDisks[4].id, + id: expect.stringContaining(physicalDisks[4].id), 'Form factor': 'M.2', policy: 'expunged', state: 'active', }) await expectRowVisible(table, { - id: physicalDisks[5].id, + id: expect.stringContaining(physicalDisks[5].id), 'Form factor': 'M.2', policy: 'expunged', state: 'decommissioned', diff --git a/test/e2e/scim-tokens.e2e.ts b/test/e2e/scim-tokens.e2e.ts index 9948ab335..cf6abfe1c 100644 --- a/test/e2e/scim-tokens.e2e.ts +++ b/test/e2e/scim-tokens.e2e.ts @@ -15,8 +15,8 @@ import { test, } from './utils' -const tokenId1 = 'a1b2c3d4…34567890' -const tokenId2 = 'b2c3d4e5…45678901' +const tokenId1 = 'a1b2c3d4-e5f6-4890-abcd-ef1234567890' +const tokenId2 = 'b2c3d4e5-f6a7-4901-bcde-f12345678901' test('SCIM tokens tab', async ({ page }) => { await page.goto('/system/silos/maze-war/scim') @@ -26,8 +26,8 @@ test('SCIM tokens tab', async ({ page }) => { const table = page.getByRole('table', { name: 'SCIM Tokens' }) // Check that existing tokens are visible - await expectRowVisible(table, { ID: tokenId1 }) - await expectRowVisible(table, { ID: tokenId2 }) + await expectRowVisible(table, { ID: expect.stringContaining(tokenId1) }) + await expectRowVisible(table, { ID: expect.stringContaining(tokenId2) }) }) test('SCIM tokens tab empty state', async ({ page }) => { @@ -104,10 +104,10 @@ test('Delete SCIM token', async ({ page }) => { await expect(table.getByRole('row')).toHaveCount(2) // header + 1 token // The deleted token should not be visible - await expectNotVisible(page, [page.getByText('a1b2c3d4…34567890')]) + await expectNotVisible(page, [page.getByText(tokenId1)]) // The other token should still be visible - await expectRowVisible(table, { ID: 'b2c3d4e5…45678901' }) + await expectRowVisible(table, { ID: expect.stringContaining(tokenId2) }) // Delete the second token await clickRowAction(page, 'b2c3d4e5', 'Delete') @@ -122,7 +122,7 @@ test('Delete SCIM token', async ({ page }) => { test('Only fleet or silo admin can view SCIM tokens', async ({ page, browser }) => { await page.goto('/system/silos/maze-war/scim') - await expect(page.getByText(tokenId1)).toBeVisible() + await expect(page.getByLabel(tokenId1)).toBeVisible() // Jane Austen is a fleet viewer but not a silo admin on maze-war const page2 = await getPageAsUser(browser, 'Jane Austen') @@ -134,5 +134,5 @@ test('Only fleet or silo admin can view SCIM tokens', async ({ page, browser }) await expect(page2.getByRole('button', { name: 'Create token' })).toBeHidden() // Tokens should not be visible - await expect(page2.getByText(tokenId1)).toBeHidden() + await expect(page2.getByLabel(tokenId1)).toBeHidden() })