diff --git a/src/App.jsx b/src/App.jsx index 4a9ca6e..2f0c8d8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -97,7 +97,7 @@ export default class App extends Component { return ( - + logo diff --git a/src/DirectoryItems.jsx b/src/DirectoryItems.jsx index 6653cd4..28a3136 100644 --- a/src/DirectoryItems.jsx +++ b/src/DirectoryItems.jsx @@ -1,7 +1,8 @@ import React, { Component } from 'react'; import { Link } from "react-router-dom"; import MyTable from './Table'; -import { objectLink, rfc3339TimestampForDisplay, sizeWithFailures } from './uiutil'; +import { objectLink, rfc3339TimestampForDisplay } from './uiutil'; +import SizeDisplay from './SizeDisplay'; function objectName(name, typeID) { if (typeID === "d") { @@ -49,7 +50,7 @@ export class DirectoryItems extends Component { accessor: x => sizeInfo(x), Header: "Size", width: 100, - Cell: x => sizeWithFailures(x.cell.value, x.row.original.summ), + Cell: x => , }, { id: "files", accessor: "summ.files", diff --git a/src/EstimateResults.jsx b/src/EstimateResults.jsx index 9d9d5a8..857f5bc 100644 --- a/src/EstimateResults.jsx +++ b/src/EstimateResults.jsx @@ -6,8 +6,9 @@ import React, { Component } from 'react'; import Button from 'react-bootstrap/Button'; import Spinner from 'react-bootstrap/esm/Spinner'; import Form from 'react-bootstrap/Form'; +import SizeDisplay from './SizeDisplay'; import { TaskLogs } from './TaskLogs'; -import { cancelTask, redirectIfNotConnected, sizeDisplayName } from './uiutil'; +import { cancelTask, redirectIfNotConnected } from './uiutil'; export class EstimateResults extends Component { constructor() { @@ -97,7 +98,9 @@ export class EstimateResults extends Component { return <> {task.counters && - {this.taskStatusDescription(task)} Bytes: {sizeDisplayName(task.counters["Bytes"]?.value)} ({sizeDisplayName(task.counters["Excluded Bytes"]?.value)} excluded) + {this.taskStatusDescription(task)}{' '} + Bytes: {' '} + ( excluded) Files: {task.counters["Files"]?.value} ({task.counters["Excluded Files"]?.value} excluded) Directories: {task.counters["Directories"]?.value} ({task.counters["Excluded Directories"]?.value} excluded) Errors: {task.counters["Errors"]?.value} ({task.counters["Ignored Errors"]?.value} ignored) diff --git a/src/SizeDisplay.tsx b/src/SizeDisplay.tsx new file mode 100644 index 0000000..93235cf --- /dev/null +++ b/src/SizeDisplay.tsx @@ -0,0 +1,36 @@ +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useContext } from 'react'; +import { UIPreferencesContext } from './contexts/UIPreferencesContext'; +import { getErrorList, sizeDisplayName, Summary } from './utils/ui'; + +type SizeDisplayProps = { + size?: number; + summary?: Summary; +}; +const SizeDisplay = ({ size, summary }: SizeDisplayProps) => { + const { bytesStringBase2 } = useContext(UIPreferencesContext); + + if (size === undefined) return null; + + const sizeDisplay = sizeDisplayName(size, bytesStringBase2); + const errorList = getErrorList(summary); + + return ( + + {sizeDisplay} + {errorList && ( + <> +   + + + )} + + ); +}; + +export default SizeDisplay; diff --git a/src/SnapshotsTable.jsx b/src/SnapshotsTable.jsx index 54f0a55..4c4b5ee 100644 --- a/src/SnapshotsTable.jsx +++ b/src/SnapshotsTable.jsx @@ -8,11 +8,12 @@ import Col from 'react-bootstrap/Col'; import Spinner from 'react-bootstrap/Spinner'; import { Link } from "react-router-dom"; import MyTable from './Table'; -import { CLIEquivalent, compare, errorAlert, GoBackButton, objectLink, parseQuery, redirectIfNotConnected, rfc3339TimestampForDisplay, sizeWithFailures, sourceQueryStringParams } from './uiutil'; +import { CLIEquivalent, compare, errorAlert, GoBackButton, objectLink, parseQuery, redirectIfNotConnected, rfc3339TimestampForDisplay, sourceQueryStringParams } from './uiutil'; import { faSync, faThumbtack } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Modal from 'react-bootstrap/Modal'; import { faFileAlt } from '@fortawesome/free-regular-svg-icons'; +import SizeDisplay from './SizeDisplay'; function pillVariant(tag) { if (tag.startsWith("latest-")) { @@ -362,7 +363,7 @@ export class SnapshotsTable extends Component { Header: 'Size', accessor: 'summary.size', width: 100, - Cell: x => sizeWithFailures(x.cell.value, x.row.original.summary), + Cell: x => }, { Header: 'Files', accessor: 'summary.files', diff --git a/src/SourcesTable.jsx b/src/SourcesTable.jsx index 4369379..b8adb76 100644 --- a/src/SourcesTable.jsx +++ b/src/SourcesTable.jsx @@ -10,14 +10,19 @@ import Dropdown from 'react-bootstrap/Dropdown'; import Row from 'react-bootstrap/Row'; import Spinner from 'react-bootstrap/Spinner'; import { Link } from 'react-router-dom'; +import SizeDisplay from './SizeDisplay'; +import { UIPreferencesContext } from './contexts/UIPreferencesContext'; import { handleChange } from './forms'; import MyTable from './Table'; -import { CLIEquivalent, compare, errorAlert, ownerName, policyEditorURL, redirectIfNotConnected, sizeDisplayName, sizeWithFailures, sourceQueryStringParams } from './uiutil'; +import { sizeDisplayName } from './utils/ui'; +import { CLIEquivalent, compare, errorAlert, ownerName, policyEditorURL, redirectIfNotConnected, sourceQueryStringParams } from './uiutil'; const localSnapshots = "Local Snapshots" const allSnapshots = "All Snapshots" export class SourcesTable extends Component { + static contextType = UIPreferencesContext; + constructor() { super(); this.state = { @@ -110,15 +115,15 @@ export class SourcesTable extends Component { let title = ""; let totals = ""; if (u) { - title = " hashed " + u.hashedFiles + " files (" + sizeDisplayName(u.hashedBytes) + ")\n" + - " cached " + u.cachedFiles + " files (" + sizeDisplayName(u.cachedBytes) + ")\n" + + title = " hashed " + u.hashedFiles + " files (" + sizeDisplayName(u.hashedBytes, this.context.bytesStringBase2) + ")\n" + + " cached " + u.cachedFiles + " files (" + sizeDisplayName(u.cachedBytes, this.context.bytesStringBase2) + ")\n" + " dir " + u.directory; const totalBytes = u.hashedBytes + u.cachedBytes; - totals = sizeDisplayName(totalBytes); + totals = sizeDisplayName(totalBytes, this.context.bytesStringBase2); if (u.estimatedBytes) { - totals += "/" + sizeDisplayName(u.estimatedBytes); + totals += "/" + sizeDisplayName(u.estimatedBytes, this.context.bytesStringBase2); const percent = Math.round(totalBytes * 1000.0 / u.estimatedBytes) / 10.0; if (percent <= 100) { @@ -231,9 +236,16 @@ export class SourcesTable extends Component { Header: 'Size', width: 120, accessor: x => x.lastSnapshot ? x.lastSnapshot.stats.totalSize : 0, - Cell: x => sizeWithFailures( - x.cell.value, - x.row.original.lastSnapshot && x.row.original.lastSnapshot.rootEntry ? x.row.original.lastSnapshot.rootEntry.summ : null), + Cell: x => ( + + ), }, { id: 'lastSnapshotTime', Header: 'Last Snapshot', diff --git a/src/Table.jsx b/src/Table.jsx index 64260de..c52d4f6 100644 --- a/src/Table.jsx +++ b/src/Table.jsx @@ -1,6 +1,7 @@ import React, { useContext, useEffect } from 'react'; import Dropdown from 'react-bootstrap/Dropdown'; import Pagination from 'react-bootstrap/Pagination'; +import Stack from 'react-bootstrap/Stack'; import Table from 'react-bootstrap/Table'; import { usePagination, useSortBy, useTable } from 'react-table'; import { PAGE_SIZES, UIPreferencesContext } from './contexts/UIPreferencesContext'; @@ -47,7 +48,7 @@ function paginationItems(count, active, gotoPage) { } export default function MyTable({ columns, data }) { - const { pageSize, setPageSize } = useContext(UIPreferencesContext); + const { bytesStringBase2, setBytesStringBase2, pageSize, setPageSize } = useContext(UIPreferencesContext); const { getTableProps, @@ -93,8 +94,8 @@ export default function MyTable({ columns, data }) { gotoPage(pageCount - 1)} disabled={!canNextPage} /> )} - <> - + + Page Size: {pageSize} @@ -105,7 +106,18 @@ export default function MyTable({ columns, data }) { ))} - + + + Units: {bytesStringBase2 ? 'Binary' : 'Decimal'} + + + {[false, true].map(isBase2 => ( + setBytesStringBase2(isBase2)}> + Units: {isBase2 ? 'Binary' : 'Decimal'} + ))} + + + ; return ( diff --git a/src/TaskDetails.jsx b/src/TaskDetails.jsx index f6cf51a..fb674a7 100644 --- a/src/TaskDetails.jsx +++ b/src/TaskDetails.jsx @@ -11,7 +11,8 @@ import Row from 'react-bootstrap/Row'; import Table from 'react-bootstrap/Table'; import Spinner from 'react-bootstrap/Spinner'; import { TaskLogs } from './TaskLogs'; -import { cancelTask, formatDuration, GoBackButton, redirectIfNotConnected, sizeDisplayName } from './uiutil'; +import { cancelTask, formatDuration, GoBackButton, redirectIfNotConnected } from './uiutil'; +import SizeDisplay from './SizeDisplay'; export class TaskDetails extends Component { constructor() { @@ -113,7 +114,7 @@ export class TaskDetails extends Component { let formatted = c.value.toLocaleString(); if (c.units === "bytes") { - formatted = sizeDisplayName(c.value); + formatted = ; } return {label}{formatted}; diff --git a/src/contexts/UIPreferencesContext.tsx b/src/contexts/UIPreferencesContext.tsx index 28c965a..d594b83 100644 --- a/src/contexts/UIPreferencesContext.tsx +++ b/src/contexts/UIPreferencesContext.tsx @@ -8,13 +8,16 @@ export type Theme = "dark" | "light"; export type PageSize = 10 | 20 | 30 | 40 | 50 | 100; export interface UIPreferences { + get bytesStringBase2(): boolean get pageSize(): PageSize get theme(): Theme + setBytesStringBase2: (isSet: boolean) => void setTheme: (theme: Theme) => void setPageSize: (pageSize: number) => void } interface SerializedUIPreferences { + bytesStringBase2?: boolean pageSize?: number theme: Theme | undefined } @@ -23,7 +26,7 @@ export const UIPreferencesContext = React.createContext({} as UIP export interface UIPreferenceProviderProps { children: ReactNode, - initalValue: UIPreferences | undefined + initialValue: Partial | undefined } function getDefaultTheme(): Theme { @@ -54,7 +57,7 @@ function normalizePageSize(pageSize: number): PageSize { const PREFERENCES_URL = '/api/v1/ui-preferences'; -const DEFAULT_PREFERENCES = { pageSize: PAGE_SIZES[0], theme: getDefaultTheme() } as SerializedUIPreferences; +const DEFAULT_PREFERENCES = { bytesStringBase2: false, pageSize: PAGE_SIZES[0], theme: getDefaultTheme() } as SerializedUIPreferences; export function UIPreferenceProvider(props: UIPreferenceProviderProps) { const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES); @@ -62,6 +65,9 @@ export function UIPreferenceProvider(props: UIPreferenceProviderProps) { useEffect(() => { axios.get(PREFERENCES_URL).then(result => { let storedPreferences = result.data as SerializedUIPreferences; + if (storedPreferences.bytesStringBase2 === undefined) { + storedPreferences.bytesStringBase2 = DEFAULT_PREFERENCES.bytesStringBase2; + } if (!storedPreferences.theme || (storedPreferences.theme as string) === "") { storedPreferences.theme = getDefaultTheme(); } @@ -80,9 +86,13 @@ export function UIPreferenceProvider(props: UIPreferenceProviderProps) { return; } - axios.put(PREFERENCES_URL, preferences).then(result => {}).catch(err => console.error(err)); + axios.put(PREFERENCES_URL, preferences).catch(err => console.error(err)); }, [preferences]); + const setBytesStringBase2 = (bytesStringBase2: boolean) => setPreferences(oldPreferences => { + return { ...oldPreferences, bytesStringBase2 }; + }); + const setTheme = (theme: Theme) => setPreferences(oldPreferences => { return { ...oldPreferences, theme }; }); @@ -91,7 +101,7 @@ export function UIPreferenceProvider(props: UIPreferenceProviderProps) { return { ...oldPreferences, pageSize }; }); - const providedValue = { ...preferences, setTheme, setPageSize } as UIPreferences; + const providedValue = { ...preferences, setBytesStringBase2, setTheme, setPageSize } as UIPreferences; return {props.children} diff --git a/src/tests/SizeDisplay.test.tsx b/src/tests/SizeDisplay.test.tsx new file mode 100644 index 0000000..b84a99d --- /dev/null +++ b/src/tests/SizeDisplay.test.tsx @@ -0,0 +1,52 @@ +import { RenderResult, screen } from '@testing-library/react'; +import SizeDisplay from 'src/SizeDisplay'; +import { Summary } from 'src/utils/ui'; +//@ts-ignore +import { renderWithContext } from './testutils'; + +const renderSizeDisplay = (size?: number, summary?: Summary, bytesStringBase2 = false) => + renderWithContext(, { bytesStringBase2 }); + + +const getErrorIcon = (container: HTMLElement) => + container.querySelector('[data-icon="triangle-exclamation"]'); + +const expectNoErrorIcon = (container: HTMLElement) => + expect(getErrorIcon(container)).toBeFalsy(); + +describe('SizeDisplay', () => { + it('displays nothing if the size is undefined', async () => { + const { container } = await renderSizeDisplay(); + + expect(container.childElementCount).toEqual(0); + }); + + it('displays the size in base-10', async () => { + const { container } = await renderSizeDisplay(900); + + screen.getByText('0.9 KB'); + expectNoErrorIcon(container); + }); + + it('displays the size in base-2', async () => { + const { container } = await renderSizeDisplay(900, undefined, true); + + screen.getByText('900 B'); + expectNoErrorIcon(container); + }); + + it('displays errors with an icon', async () => { + const rootError = { path: '/', error: 'root path' }; + const homeError = { path: '/home', error: 'home path' }; + + const { container } = await renderSizeDisplay(900, { + errors: [rootError, homeError], + numFailed: 2, + }); + + screen.getByText('0.9 KB'); + expect(getErrorIcon(container)?.querySelector('title')?.textContent).toEqual( + 'Encountered 2 errors:\n\n- /: root path\n- /home: home path' + ); + }); +}); diff --git a/src/tests/Table.test.jsx b/src/tests/Table.test.jsx new file mode 100644 index 0000000..93aeca1 --- /dev/null +++ b/src/tests/Table.test.jsx @@ -0,0 +1,50 @@ +import { screen } from '@testing-library/react'; +import MyTable from '../Table'; +import { simulateClick } from './testutils'; +import { renderWithContext } from './testutils'; + +const renderTable = bytesStringBase2 => + renderWithContext(, { bytesStringBase2 }); + +describe('Table', () => { + describe('Units dropdown', () => { + const expectButtonCount = (unit, count) => + expect( + screen.getAllByRole('button', { name: new RegExp(`Units: ${unit}`) }) + ).toHaveLength(count); + + const getUnitButton = (unit) => + screen.getByRole('button', { name: new RegExp(`Units: ${unit}`) }); + + const openDropdown = () => simulateClick(getUnitButton('Decimal')); + + describe('labeled with the units value', () => { + it('decimal', async () => { + await renderTable(); + getUnitButton('Decimal'); + }); + + it('binary', async () => { + await renderTable(true); + getUnitButton('Binary'); + }); + }); + + it('has dropdown options for Decimal and Binary', async () => { + await renderTable(); + openDropdown(); + + expectButtonCount('Decimal', 2); + getUnitButton('Binary'); + }); + + it('updates the value when an option is selected', async () => { + await renderTable(); + openDropdown(); + simulateClick(getUnitButton('Binary')); + + expectButtonCount('Binary', 2); + getUnitButton('Decimal'); + }); + }); +}); diff --git a/src/tests/testutils.js b/src/tests/testutils.js index 72f6c3a..d00d517 100644 --- a/src/tests/testutils.js +++ b/src/tests/testutils.js @@ -1,4 +1,6 @@ -import { fireEvent, act } from '@testing-library/react'; +import { fireEvent, act, render } from '@testing-library/react'; +import { setupAPIMock } from './api_mocks'; +import { UIPreferenceProvider } from '../contexts/UIPreferencesContext'; export function changeControlValue(selector, value) { fireEvent.change(selector, { target: { value: value } }) @@ -11,3 +13,20 @@ export function toggleCheckbox(selector) { export function simulateClick(selector) { fireEvent.click(selector); } + +export const renderWithContext = async (children, value) => { + let serverMock = setupAPIMock(); + // Properly return values when UIPreferenceProvider makes the request + serverMock.onGet('/api/v1/ui-preferences').reply(200, value); + serverMock.onPut('/api/v1/ui-preferences').reply(204); + + let result; + await act(() => { + result = render( + + {children} + + ); + }); + return result; +}; diff --git a/src/tests/uiutil.test.js b/src/tests/uiutil.test.jsx similarity index 99% rename from src/tests/uiutil.test.js rename to src/tests/uiutil.test.jsx index 802930a..b8c9dd7 100644 --- a/src/tests/uiutil.test.js +++ b/src/tests/uiutil.test.jsx @@ -191,4 +191,4 @@ describe("formatMagnitudesUsingMultipleUnits", () => { magnitudes = { days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 1 }; expect(fn(magnitudes, true)).toBe("0.0s"); }); -}); \ No newline at end of file +}); diff --git a/src/uiutil.jsx b/src/uiutil.jsx index 833a253..2d305fd 100644 --- a/src/uiutil.jsx +++ b/src/uiutil.jsx @@ -1,4 +1,4 @@ -import { faBan, faCheck, faChevronLeft, faCopy, faExclamationCircle, faExclamationTriangle, faFolderOpen, faTerminal, faWindowClose } from '@fortawesome/free-solid-svg-icons'; +import { faBan, faCheck, faChevronLeft, faCopy, faExclamationCircle, faFolderOpen, faTerminal, faWindowClose } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import axios from 'axios'; import React, { useState } from 'react'; @@ -9,54 +9,6 @@ import InputGroup from 'react-bootstrap/InputGroup'; import Spinner from 'react-bootstrap/Spinner'; import { Link } from 'react-router-dom'; -const base10UnitPrefixes = ["", "K", "M", "G", "T"]; - -function niceNumber(f) { - return (Math.round(f * 10) / 10.0) + ''; -} - -function toDecimalUnitString(f, thousand, prefixes, suffix) { - for (var i = 0; i < prefixes.length; i++) { - if (f < 0.9 * thousand) { - return niceNumber(f) + ' ' + prefixes[i] + suffix; - } - f /= thousand - } - - return niceNumber(f) + ' ' + prefixes[prefixes.length - 1] + suffix; -} - -export function sizeWithFailures(size, summ) { - if (size === undefined) { - return ""; - } - - if (!summ || !summ.errors || !summ.numFailed) { - return {sizeDisplayName(size)} - } - - let caption = "Encountered " + summ.numFailed + " errors:\n\n"; - let prefix = "- " - if (summ.numFailed === 1) { - caption = "Error: "; - prefix = ""; - } - - caption += summ.errors.map(x => prefix + x.path + ": " + x.error).join("\n"); - - return - {sizeDisplayName(size)}  - - ; -} - -export function sizeDisplayName(s) { - if (s === undefined) { - return ""; - } - return toDecimalUnitString(s, 1000, base10UnitPrefixes, "B"); -} - export function intervalDisplayName(v) { return "-"; } diff --git a/src/utils/tests/ui.test.ts b/src/utils/tests/ui.test.ts new file mode 100644 index 0000000..4928358 --- /dev/null +++ b/src/utils/tests/ui.test.ts @@ -0,0 +1,82 @@ +import { sizeDisplayName, getErrorList } from '../ui'; + +const base10Cases = { + 0: '0 B', + 1: '1 B', + 2: '2 B', + 899: '899 B', + 900: '0.9 KB', + 999: '1 KB', + 1000: '1 KB', + 1200: '1.2 KB', + 899999: '900 KB', + 900000: '0.9 MB', + 999000: '1 MB', + 999999: '1 MB', + 1000000: '1 MB', + 99000000: '99 MB', + 990000000: '1 GB', + 9990000000: '10 GB', + 99900000000: '99.9 GB', + 1000000000000: '1 TB', + 99000000000000: '99 TB', +}; + +const base2Cases = { + 0: '0 B', + 1: '1 B', + 2: '2 B', + 899: '899 B', + 900: '900 B', + 999: '1 KiB', + 1024: '1 KiB', + 1400: '1.4 KiB', + [900 * 2 ** 10 - 1]: '900 KiB', + [900 * 2 ** 10]: '900 KiB', + 999000: '1 MiB', + 999999: '1 MiB', + 1000000: '1 MiB', + [99 * 2 ** 20]: '99 MiB', + [1 * 2 ** 30]: '1 GiB', + [10 * 2 ** 30]: '10 GiB', + 99900000000: '93 GiB', + 1000000000000: '0.9 TiB', + 99000000000000: '90 TiB', +}; + +describe('UI utils', () => { + describe('sizeDisplayName', () => { + it('returns an empty string when undefined', () => + expect(sizeDisplayName()).toEqual('')); + + it('returns the unit string in base-10', () => + Object.entries(base10Cases).forEach(([bytes, expected]) => + expect(sizeDisplayName(Number(bytes))).toEqual(expected) + )); + + it('returns the unit string in base-2', () => + Object.entries(base2Cases).forEach(([bytes, expected]) => + expect(sizeDisplayName(Number(bytes), true)).toEqual(expected) + )); + }); + + describe('getErrorList', () => { + const rootError = { path: '/', error: 'root path' }; + const homeError = { path: '/home', error: 'home path' }; + + it('returns nothing if there are no errors', () => + expect(getErrorList({ errors: [], numFailed: 0 })).toEqual('')); + + it('returns the errors as a list', () => + expect( + getErrorList({ errors: [rootError, homeError], numFailed: 2 }) + ).toEqual( + 'Encountered 2 errors:\n\n- /: root path\n- /home: home path' + )); + + it('returns one error by itself', () => + expect(getErrorList({ errors: [rootError], numFailed: 1 })).toEqual( + 'Error: /: root path' + )); + }); +}); diff --git a/src/utils/ui.ts b/src/utils/ui.ts new file mode 100644 index 0000000..7e254f5 --- /dev/null +++ b/src/utils/ui.ts @@ -0,0 +1,54 @@ +const base10UnitPrefixes = ['', 'K', 'M', 'G', 'T']; +const base2UnitPrefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti']; + +const niceNumber = (f: number) => Math.round(f * 10) / 10.0 + ''; + +const toUnitString = ( + num: number, + divisor: number, + prefixes: string[], + suffix: string +) => { + for (let i = 0; i < prefixes.length; i++) { + if (num < 0.9 * divisor) { + return niceNumber(num) + ' ' + prefixes[i] + suffix; + } + num /= divisor; + } + + return niceNumber(num) + ' ' + prefixes[prefixes.length - 1] + suffix; +}; + +export const sizeDisplayName = (size?: number, bytesStringBase2?: boolean) => { + if (size === undefined) return ''; + + if (bytesStringBase2) + return toUnitString(size, 1024, base2UnitPrefixes, 'B'); + + return toUnitString(size, 1000, base10UnitPrefixes, 'B'); +}; + +type Error = { + path: string; + error: string; +}; +export type Summary = { + errors: Error[]; + numFailed: number; +}; +export const getErrorList = (summ?: Summary): string => { + if (!summ?.errors?.length && !summ?.numFailed) return ''; + + let caption = 'Encountered ' + summ.numFailed + ' errors:\n\n'; + let prefix = '- '; + if (summ.numFailed === 1) { + caption = 'Error: '; + prefix = ''; + } + + caption += summ.errors + .map((err: Error) => prefix + err.path + ': ' + err.error) + .join('\n'); + + return caption; +};