From 993d28f7c81770cb1d9fae7142c98bd7ec9ab331 Mon Sep 17 00:00:00 2001 From: Sowaret Date: Thu, 13 Oct 2022 19:42:10 -0600 Subject: [PATCH 1/7] Add storage value base-10/2 display to ui-preferences --- src/DirectoryItems.jsx | 5 ++++- src/EstimateResults.jsx | 7 +++++- src/SnapshotsTable.jsx | 5 ++++- src/SourcesTable.jsx | 14 +++++++----- src/TaskDetails.jsx | 5 ++++- src/contexts/UIPreferencesContext.tsx | 15 ++++++++++--- src/tests/{uiutil.test.js => uiutil.test.jsx} | 22 +++++++++++++++++-- src/uiutil.jsx | 16 +++++++++----- 8 files changed, 69 insertions(+), 20 deletions(-) rename src/tests/{uiutil.test.js => uiutil.test.jsx} (91%) diff --git a/src/DirectoryItems.jsx b/src/DirectoryItems.jsx index 6653cd4..697b841 100644 --- a/src/DirectoryItems.jsx +++ b/src/DirectoryItems.jsx @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import { Link } from "react-router-dom"; import MyTable from './Table'; import { objectLink, rfc3339TimestampForDisplay, sizeWithFailures } from './uiutil'; +import {UIPreferencesContext} from './contexts/UIPreferencesContext'; function objectName(name, typeID) { if (typeID === "d") { @@ -32,6 +33,8 @@ function directoryLinkOrDownload(x) { } export class DirectoryItems extends Component { + static contextType = UIPreferencesContext; + render() { const columns = [{ id: "name", @@ -49,7 +52,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 => sizeWithFailures(x.cell.value, x.row.original.summ, this.context.bytesStringBase2), }, { id: "files", accessor: "summ.files", diff --git a/src/EstimateResults.jsx b/src/EstimateResults.jsx index 9d9d5a8..0f6df3a 100644 --- a/src/EstimateResults.jsx +++ b/src/EstimateResults.jsx @@ -6,10 +6,13 @@ 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 { UIPreferencesContext } from './contexts/UIPreferencesContext'; import { TaskLogs } from './TaskLogs'; import { cancelTask, redirectIfNotConnected, sizeDisplayName } from './uiutil'; export class EstimateResults extends Component { + static contextType = UIPreferencesContext; + constructor() { super(); this.state = { @@ -97,7 +100,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: {sizeDisplayName(task.counters["Bytes"]?.value, this.context.bytesStringBase2)}{' '} + ({sizeDisplayName(task.counters["Excluded Bytes"]?.value, this.context.bytesStringBase2)} 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/SnapshotsTable.jsx b/src/SnapshotsTable.jsx index 54f0a55..a613c32 100644 --- a/src/SnapshotsTable.jsx +++ b/src/SnapshotsTable.jsx @@ -7,6 +7,7 @@ import Button from 'react-bootstrap/Button'; import Col from 'react-bootstrap/Col'; import Spinner from 'react-bootstrap/Spinner'; import { Link } from "react-router-dom"; +import { UIPreferencesContext } from './contexts/UIPreferencesContext'; import MyTable from './Table'; import { CLIEquivalent, compare, errorAlert, GoBackButton, objectLink, parseQuery, redirectIfNotConnected, rfc3339TimestampForDisplay, sizeWithFailures, sourceQueryStringParams } from './uiutil'; import { faSync, faThumbtack } from '@fortawesome/free-solid-svg-icons'; @@ -34,6 +35,8 @@ function pillVariant(tag) { } export class SnapshotsTable extends Component { + static contextType = UIPreferencesContext; + constructor() { super(); this.state = { @@ -362,7 +365,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 => sizeWithFailures(x.cell.value, x.row.original.summary, this.context.bytesStringBase2), }, { Header: 'Files', accessor: 'summary.files', diff --git a/src/SourcesTable.jsx b/src/SourcesTable.jsx index 4369379..17dfa7d 100644 --- a/src/SourcesTable.jsx +++ b/src/SourcesTable.jsx @@ -10,6 +10,7 @@ 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 { UIPreferencesContext } from './contexts/UIPreferencesContext'; import { handleChange } from './forms'; import MyTable from './Table'; import { CLIEquivalent, compare, errorAlert, ownerName, policyEditorURL, redirectIfNotConnected, sizeDisplayName, sizeWithFailures, sourceQueryStringParams } from './uiutil'; @@ -18,6 +19,8 @@ const localSnapshots = "Local Snapshots" const allSnapshots = "All Snapshots" export class SourcesTable extends Component { + static contextType = UIPreferencesContext; + constructor() { super(); this.state = { @@ -110,15 +113,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) { @@ -233,7 +236,8 @@ export class SourcesTable extends Component { 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), + x.row.original.lastSnapshot && x.row.original.lastSnapshot.rootEntry ? x.row.original.lastSnapshot.rootEntry.summ : null, + this.context.bytesStringBase2), }, { id: 'lastSnapshotTime', Header: 'Last Snapshot', diff --git a/src/TaskDetails.jsx b/src/TaskDetails.jsx index f6cf51a..2c4c5c0 100644 --- a/src/TaskDetails.jsx +++ b/src/TaskDetails.jsx @@ -10,10 +10,13 @@ import Form from 'react-bootstrap/Form'; import Row from 'react-bootstrap/Row'; import Table from 'react-bootstrap/Table'; import Spinner from 'react-bootstrap/Spinner'; +import { UIPreferencesContext } from './contexts/UIPreferencesContext'; import { TaskLogs } from './TaskLogs'; import { cancelTask, formatDuration, GoBackButton, redirectIfNotConnected, sizeDisplayName } from './uiutil'; export class TaskDetails extends Component { + static contextType = UIPreferencesContext; + constructor() { super(); this.state = { @@ -113,7 +116,7 @@ export class TaskDetails extends Component { let formatted = c.value.toLocaleString(); if (c.units === "bytes") { - formatted = sizeDisplayName(c.value); + formatted = sizeDisplayName(c.value, this.context.bytesStringBase2); } return {label}{formatted}; diff --git a/src/contexts/UIPreferencesContext.tsx b/src/contexts/UIPreferencesContext.tsx index 28c965a..5298efc 100644 --- a/src/contexts/UIPreferencesContext.tsx +++ b/src/contexts/UIPreferencesContext.tsx @@ -8,6 +8,7 @@ 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 setTheme: (theme: Theme) => void @@ -15,6 +16,7 @@ export interface UIPreferences { } interface SerializedUIPreferences { + bytesStringBase2?: boolean pageSize?: number theme: Theme | undefined } @@ -54,7 +56,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 +64,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 +85,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 +100,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/uiutil.test.js b/src/tests/uiutil.test.jsx similarity index 91% rename from src/tests/uiutil.test.js rename to src/tests/uiutil.test.jsx index 802930a..808dd0a 100644 --- a/src/tests/uiutil.test.js +++ b/src/tests/uiutil.test.jsx @@ -1,4 +1,22 @@ -import { formatMilliseconds, separateMillisecondsIntoMagnitudes, formatMagnitudesUsingMultipleUnits } from "../uiutil"; +import { formatMilliseconds, separateMillisecondsIntoMagnitudes, formatMagnitudesUsingMultipleUnits, sizeDisplayName } from "../uiutil"; + +describe("sizeDisplayNames", () => { + it('returns an empty string when undefined', () => { + expect(sizeDisplayName()).toEqual(''); + }); + + it('returns the decimal unit string in base-10', () => { + expect(sizeDisplayName(1024)).toEqual('1 KB'); + expect(sizeDisplayName(9999)).toEqual('10 KB'); + expect(sizeDisplayName(1234567)).toEqual('1.2 MB'); + }); + + it('returns the decimal unit string in base-2', () => { + expect(sizeDisplayName(1024, true)).toEqual('1 KiB'); + expect(sizeDisplayName(9999, true)).toEqual('9.8 KiB'); + expect(sizeDisplayName(1234567, true)).toEqual('1.2 MiB'); + }); +}); describe("formatMilliseconds", () => { it("uses 'XXs' format by default", () => { @@ -191,4 +209,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..bbad038 100644 --- a/src/uiutil.jsx +++ b/src/uiutil.jsx @@ -10,6 +10,7 @@ import Spinner from 'react-bootstrap/Spinner'; import { Link } from 'react-router-dom'; const base10UnitPrefixes = ["", "K", "M", "G", "T"]; +const base2UnitPrefixes = ["", "Ki", "Mi", "Gi", "Ti"]; function niceNumber(f) { return (Math.round(f * 10) / 10.0) + ''; @@ -26,13 +27,13 @@ function toDecimalUnitString(f, thousand, prefixes, suffix) { return niceNumber(f) + ' ' + prefixes[prefixes.length - 1] + suffix; } -export function sizeWithFailures(size, summ) { +export function sizeWithFailures(size, summ, bytesStringBase2) { if (size === undefined) { return ""; } if (!summ || !summ.errors || !summ.numFailed) { - return {sizeDisplayName(size)} + return {sizeDisplayName(size, bytesStringBase2)} } let caption = "Encountered " + summ.numFailed + " errors:\n\n"; @@ -45,16 +46,19 @@ export function sizeWithFailures(size, summ) { caption += summ.errors.map(x => prefix + x.path + ": " + x.error).join("\n"); return - {sizeDisplayName(size)}  + {sizeDisplayName(size, bytesStringBase2)}  ; } -export function sizeDisplayName(s) { - if (s === undefined) { +export function sizeDisplayName(size, bytesStringBase2) { + if (size === undefined) { return ""; } - return toDecimalUnitString(s, 1000, base10UnitPrefixes, "B"); + if (bytesStringBase2) { + return toDecimalUnitString(size, 1024, base2UnitPrefixes, "B"); + } + return toDecimalUnitString(size, 1000, base10UnitPrefixes, "B"); } export function intervalDisplayName(v) { From afabbf72b991328a87a43b08fd883f1e7909ab08 Mon Sep 17 00:00:00 2001 From: Sowaret Date: Thu, 13 Oct 2022 21:42:44 -0600 Subject: [PATCH 2/7] Create SizeDisplay component --- src/DirectoryItems.jsx | 5 ++- src/EstimateResults.jsx | 7 ++-- src/SizeDisplay.tsx | 15 ++++++++ src/SnapshotsTable.jsx | 7 ++-- src/SourcesTable.jsx | 19 +++++++--- src/TaskDetails.jsx | 5 ++- src/tests/uiutil.test.jsx | 20 +--------- src/uiutil.jsx | 54 +-------------------------- src/utils/tests/ui.test.tsx | 63 +++++++++++++++++++++++++++++++ src/utils/ui.tsx | 74 +++++++++++++++++++++++++++++++++++++ 10 files changed, 182 insertions(+), 87 deletions(-) create mode 100644 src/SizeDisplay.tsx create mode 100644 src/utils/tests/ui.test.tsx create mode 100644 src/utils/ui.tsx diff --git a/src/DirectoryItems.jsx b/src/DirectoryItems.jsx index 697b841..5dbe627 100644 --- a/src/DirectoryItems.jsx +++ b/src/DirectoryItems.jsx @@ -1,8 +1,9 @@ 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 {UIPreferencesContext} from './contexts/UIPreferencesContext'; +import SizeDisplay from './SizeDisplay'; function objectName(name, typeID) { if (typeID === "d") { @@ -52,7 +53,7 @@ export class DirectoryItems extends Component { accessor: x => sizeInfo(x), Header: "Size", width: 100, - Cell: x => sizeWithFailures(x.cell.value, x.row.original.summ, this.context.bytesStringBase2), + Cell: x => , }, { id: "files", accessor: "summ.files", diff --git a/src/EstimateResults.jsx b/src/EstimateResults.jsx index 0f6df3a..259e0ff 100644 --- a/src/EstimateResults.jsx +++ b/src/EstimateResults.jsx @@ -7,8 +7,9 @@ import Button from 'react-bootstrap/Button'; import Spinner from 'react-bootstrap/esm/Spinner'; import Form from 'react-bootstrap/Form'; import { UIPreferencesContext } from './contexts/UIPreferencesContext'; +import SizeDisplay from './SizeDisplay'; import { TaskLogs } from './TaskLogs'; -import { cancelTask, redirectIfNotConnected, sizeDisplayName } from './uiutil'; +import { cancelTask, redirectIfNotConnected } from './uiutil'; export class EstimateResults extends Component { static contextType = UIPreferencesContext; @@ -101,8 +102,8 @@ export class EstimateResults extends Component { return <> {task.counters && {this.taskStatusDescription(task)}{' '} - Bytes: {sizeDisplayName(task.counters["Bytes"]?.value, this.context.bytesStringBase2)}{' '} - ({sizeDisplayName(task.counters["Excluded Bytes"]?.value, this.context.bytesStringBase2)} excluded) + 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..0af0a5e --- /dev/null +++ b/src/SizeDisplay.tsx @@ -0,0 +1,15 @@ +import React, { useContext } from 'react'; +import { UIPreferencesContext } from './contexts/UIPreferencesContext'; +import { sizeWithFailures } from './utils/ui'; + +type SizeDisplayProps = { + size: number; + summary: any; +}; + +const SizeDisplay = ({ size, summary }: SizeDisplayProps) => { + const { bytesStringBase2 } = useContext(UIPreferencesContext); + return sizeWithFailures(size, summary, bytesStringBase2); +}; + +export default SizeDisplay; diff --git a/src/SnapshotsTable.jsx b/src/SnapshotsTable.jsx index a613c32..0a3de16 100644 --- a/src/SnapshotsTable.jsx +++ b/src/SnapshotsTable.jsx @@ -9,11 +9,12 @@ import Spinner from 'react-bootstrap/Spinner'; import { Link } from "react-router-dom"; import { UIPreferencesContext } from './contexts/UIPreferencesContext'; 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-")) { @@ -349,7 +350,7 @@ export class SnapshotsTable extends Component { width: "", accessor: x => x.rootID, Cell: x => <>{x.cell.value} - {x.row.original.description &&
{x.row.original.description}
}, + {x.row.original.description &&
{x.row.original.description}
}, }, { Header: 'Retention', accessor: 'retention', @@ -365,7 +366,7 @@ export class SnapshotsTable extends Component { Header: 'Size', accessor: 'summary.size', width: 100, - Cell: x => sizeWithFailures(x.cell.value, x.row.original.summary, this.context.bytesStringBase2), + Cell: x => }, { Header: 'Files', accessor: 'summary.files', diff --git a/src/SourcesTable.jsx b/src/SourcesTable.jsx index 17dfa7d..116e8e1 100644 --- a/src/SourcesTable.jsx +++ b/src/SourcesTable.jsx @@ -10,10 +10,12 @@ 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" @@ -234,10 +236,17 @@ 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, - this.context.bytesStringBase2), + Cell: x => ( + + ), }, { id: 'lastSnapshotTime', Header: 'Last Snapshot', diff --git a/src/TaskDetails.jsx b/src/TaskDetails.jsx index 2c4c5c0..31559d9 100644 --- a/src/TaskDetails.jsx +++ b/src/TaskDetails.jsx @@ -12,7 +12,8 @@ import Table from 'react-bootstrap/Table'; import Spinner from 'react-bootstrap/Spinner'; import { UIPreferencesContext } from './contexts/UIPreferencesContext'; 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 { static contextType = UIPreferencesContext; @@ -116,7 +117,7 @@ export class TaskDetails extends Component { let formatted = c.value.toLocaleString(); if (c.units === "bytes") { - formatted = sizeDisplayName(c.value, this.context.bytesStringBase2); + formatted = ; } return {label}{formatted}; diff --git a/src/tests/uiutil.test.jsx b/src/tests/uiutil.test.jsx index 808dd0a..b8c9dd7 100644 --- a/src/tests/uiutil.test.jsx +++ b/src/tests/uiutil.test.jsx @@ -1,22 +1,4 @@ -import { formatMilliseconds, separateMillisecondsIntoMagnitudes, formatMagnitudesUsingMultipleUnits, sizeDisplayName } from "../uiutil"; - -describe("sizeDisplayNames", () => { - it('returns an empty string when undefined', () => { - expect(sizeDisplayName()).toEqual(''); - }); - - it('returns the decimal unit string in base-10', () => { - expect(sizeDisplayName(1024)).toEqual('1 KB'); - expect(sizeDisplayName(9999)).toEqual('10 KB'); - expect(sizeDisplayName(1234567)).toEqual('1.2 MB'); - }); - - it('returns the decimal unit string in base-2', () => { - expect(sizeDisplayName(1024, true)).toEqual('1 KiB'); - expect(sizeDisplayName(9999, true)).toEqual('9.8 KiB'); - expect(sizeDisplayName(1234567, true)).toEqual('1.2 MiB'); - }); -}); +import { formatMilliseconds, separateMillisecondsIntoMagnitudes, formatMagnitudesUsingMultipleUnits } from "../uiutil"; describe("formatMilliseconds", () => { it("uses 'XXs' format by default", () => { diff --git a/src/uiutil.jsx b/src/uiutil.jsx index bbad038..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,58 +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"]; -const base2UnitPrefixes = ["", "Ki", "Mi", "Gi", "Ti"]; - -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, bytesStringBase2) { - if (size === undefined) { - return ""; - } - - if (!summ || !summ.errors || !summ.numFailed) { - return {sizeDisplayName(size, bytesStringBase2)} - } - - 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, bytesStringBase2)}  - - ; -} - -export function sizeDisplayName(size, bytesStringBase2) { - if (size === undefined) { - return ""; - } - if (bytesStringBase2) { - return toDecimalUnitString(size, 1024, base2UnitPrefixes, "B"); - } - return toDecimalUnitString(size, 1000, base10UnitPrefixes, "B"); -} - export function intervalDisplayName(v) { return "-"; } diff --git a/src/utils/tests/ui.test.tsx b/src/utils/tests/ui.test.tsx new file mode 100644 index 0000000..da96ad0 --- /dev/null +++ b/src/utils/tests/ui.test.tsx @@ -0,0 +1,63 @@ +import { sizeDisplayName } 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', + // TODO: Unfamiliar with this syntax from Go + // 900:<10 - 1, "900 KiB", + // 900:<< 10, "900 KiB", + 999000: '1 MiB', + 999999: '1 MiB', + 1000000: '1 MiB', + // 99:<< 20, "99 MiB", + // 1:<< 30, "1 GiB", + // 10:<< 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 decimal unit string in base-10', () => + Object.entries(base10Cases).forEach(([bytes, expected]) => + expect(sizeDisplayName(Number(bytes))).toEqual(expected) + )); + + it('returns the decimal unit string in base-2', () => + Object.entries(base2Cases).forEach(([bytes, expected]) => + expect(sizeDisplayName(Number(bytes), true)).toEqual(expected) + )); + }); +}); diff --git a/src/utils/ui.tsx b/src/utils/ui.tsx new file mode 100644 index 0000000..627715d --- /dev/null +++ b/src/utils/ui.tsx @@ -0,0 +1,74 @@ +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React from 'react'; + +const base10UnitPrefixes = ['', 'K', 'M', 'G', 'T']; +const base2UnitPrefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti']; + +const niceNumber = (f: number) => Math.round(f * 10) / 10.0 + ''; + +const toDecimalUnitString = ( + num: number, + divisor: number, + prefixes: string[], + suffix: string +) => { + for (var 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 toDecimalUnitString(size, 1024, base2UnitPrefixes, 'B'); + + return toDecimalUnitString(size, 1000, base10UnitPrefixes, 'B'); +}; + +type Error = { + path: string; + error: string; +} +type Summary = { + errors: Error[]; + numFailed: number; +} +export const sizeWithFailures = ( + size?: number, + summ?: Summary, + bytesStringBase2?: boolean +) => { + if (size === undefined) return ''; + + if (!summ || !summ.errors || !summ.numFailed) + return {sizeDisplayName(size, bytesStringBase2)}; + + 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 ( + + {sizeDisplayName(size, bytesStringBase2)}  + + + ); +}; From ade0eefd4886bfb776335ea89efa82e41f6d2185 Mon Sep 17 00:00:00 2001 From: Sowaret Date: Fri, 14 Oct 2022 05:26:39 -0600 Subject: [PATCH 3/7] Add unit toggle to Table --- src/Table.jsx | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Table.jsx b/src/Table.jsx index 64260de..a1a2d72 100644 --- a/src/Table.jsx +++ b/src/Table.jsx @@ -1,7 +1,10 @@ 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 ToggleButton from 'react-bootstrap/ToggleButton'; +import ToggleButtonGroup from 'react-bootstrap/ToggleButtonGroup'; import { usePagination, useSortBy, useTable } from 'react-table'; import { PAGE_SIZES, UIPreferencesContext } from './contexts/UIPreferencesContext'; @@ -47,7 +50,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 +96,21 @@ export default function MyTable({ columns, data }) { gotoPage(pageCount - 1)} disabled={!canNextPage} /> )} - <> - + +
+ {/* TODO: Any more elegant way for this label? */} + Storage Unit: + + Decimal + Binary + +
+ Page Size: {pageSize} @@ -105,7 +121,7 @@ export default function MyTable({ columns, data }) { ))} - +
; return ( From e04d7b73da7d5993cc84be88fe96c80159889d08 Mon Sep 17 00:00:00 2001 From: Sowaret Date: Fri, 14 Oct 2022 06:19:10 -0600 Subject: [PATCH 4/7] Final cleanup --- src/DirectoryItems.jsx | 3 --- src/EstimateResults.jsx | 3 --- src/SnapshotsTable.jsx | 5 +---- src/SourcesTable.jsx | 3 +-- src/TaskDetails.jsx | 3 --- src/utils/tests/ui.test.tsx | 4 ++-- src/utils/ui.tsx | 8 ++++---- 7 files changed, 8 insertions(+), 21 deletions(-) diff --git a/src/DirectoryItems.jsx b/src/DirectoryItems.jsx index 5dbe627..28a3136 100644 --- a/src/DirectoryItems.jsx +++ b/src/DirectoryItems.jsx @@ -2,7 +2,6 @@ import React, { Component } from 'react'; import { Link } from "react-router-dom"; import MyTable from './Table'; import { objectLink, rfc3339TimestampForDisplay } from './uiutil'; -import {UIPreferencesContext} from './contexts/UIPreferencesContext'; import SizeDisplay from './SizeDisplay'; function objectName(name, typeID) { @@ -34,8 +33,6 @@ function directoryLinkOrDownload(x) { } export class DirectoryItems extends Component { - static contextType = UIPreferencesContext; - render() { const columns = [{ id: "name", diff --git a/src/EstimateResults.jsx b/src/EstimateResults.jsx index 259e0ff..857f5bc 100644 --- a/src/EstimateResults.jsx +++ b/src/EstimateResults.jsx @@ -6,14 +6,11 @@ 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 { UIPreferencesContext } from './contexts/UIPreferencesContext'; import SizeDisplay from './SizeDisplay'; import { TaskLogs } from './TaskLogs'; import { cancelTask, redirectIfNotConnected } from './uiutil'; export class EstimateResults extends Component { - static contextType = UIPreferencesContext; - constructor() { super(); this.state = { diff --git a/src/SnapshotsTable.jsx b/src/SnapshotsTable.jsx index 0a3de16..4c4b5ee 100644 --- a/src/SnapshotsTable.jsx +++ b/src/SnapshotsTable.jsx @@ -7,7 +7,6 @@ import Button from 'react-bootstrap/Button'; import Col from 'react-bootstrap/Col'; import Spinner from 'react-bootstrap/Spinner'; import { Link } from "react-router-dom"; -import { UIPreferencesContext } from './contexts/UIPreferencesContext'; import MyTable from './Table'; import { CLIEquivalent, compare, errorAlert, GoBackButton, objectLink, parseQuery, redirectIfNotConnected, rfc3339TimestampForDisplay, sourceQueryStringParams } from './uiutil'; import { faSync, faThumbtack } from '@fortawesome/free-solid-svg-icons'; @@ -36,8 +35,6 @@ function pillVariant(tag) { } export class SnapshotsTable extends Component { - static contextType = UIPreferencesContext; - constructor() { super(); this.state = { @@ -350,7 +347,7 @@ export class SnapshotsTable extends Component { width: "", accessor: x => x.rootID, Cell: x => <>{x.cell.value} - {x.row.original.description &&
{x.row.original.description}
}, + {x.row.original.description &&
{x.row.original.description}
}, }, { Header: 'Retention', accessor: 'retention', diff --git a/src/SourcesTable.jsx b/src/SourcesTable.jsx index 116e8e1..b8adb76 100644 --- a/src/SourcesTable.jsx +++ b/src/SourcesTable.jsx @@ -240,8 +240,7 @@ export class SourcesTable extends Component { { it('returns an empty string when undefined', () => expect(sizeDisplayName()).toEqual('')); - it('returns the decimal unit string in base-10', () => + it('returns the unit string in base-10', () => Object.entries(base10Cases).forEach(([bytes, expected]) => expect(sizeDisplayName(Number(bytes))).toEqual(expected) )); - it('returns the decimal unit string in base-2', () => + it('returns the unit string in base-2', () => Object.entries(base2Cases).forEach(([bytes, expected]) => expect(sizeDisplayName(Number(bytes), true)).toEqual(expected) )); diff --git a/src/utils/ui.tsx b/src/utils/ui.tsx index 627715d..ea35b08 100644 --- a/src/utils/ui.tsx +++ b/src/utils/ui.tsx @@ -7,13 +7,13 @@ const base2UnitPrefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti']; const niceNumber = (f: number) => Math.round(f * 10) / 10.0 + ''; -const toDecimalUnitString = ( +const toUnitString = ( num: number, divisor: number, prefixes: string[], suffix: string ) => { - for (var i = 0; i < prefixes.length; i++) { + for (let i = 0; i < prefixes.length; i++) { if (num < 0.9 * divisor) { return niceNumber(num) + ' ' + prefixes[i] + suffix; } @@ -27,9 +27,9 @@ export const sizeDisplayName = (size?: number, bytesStringBase2?: boolean) => { if (size === undefined) return ''; if (bytesStringBase2) - return toDecimalUnitString(size, 1024, base2UnitPrefixes, 'B'); + return toUnitString(size, 1024, base2UnitPrefixes, 'B'); - return toDecimalUnitString(size, 1000, base10UnitPrefixes, 'B'); + return toUnitString(size, 1000, base10UnitPrefixes, 'B'); }; type Error = { From a72909edccd5997052882bf18a3972b545cc6b12 Mon Sep 17 00:00:00 2001 From: Sowaret Date: Sat, 15 Oct 2022 08:31:40 -0600 Subject: [PATCH 5/7] Move `sizeWithFailures` logic to SizeDisplay A component is used now, so it makes sense to separate the error string logic from the JSX output - Add missing `setBytesStringBase2` field to `UIPreferencesContext` - Update Provider `initialValue` to accept `Partial` (e.g. in test) - Add tests; add missing base-2 cases in `ui.test.ts` - Update the empty error string logic from `sizeWithFailures` to check that all fields are empty: `if (!summ || !summ.errors || !summ.numFailed)` to `if (!summ?.errors?.length && !summ?.numFailed)` --- src/App.jsx | 2 +- src/SizeDisplay.tsx | 33 +++++++++-- src/contexts/UIPreferencesContext.tsx | 3 +- src/tests/SizeDisplay.test.tsx | 59 +++++++++++++++++++ src/utils/tests/ui.test.ts | 82 +++++++++++++++++++++++++++ src/utils/tests/ui.test.tsx | 63 -------------------- src/utils/{ui.tsx => ui.ts} | 36 ++++-------- 7 files changed, 181 insertions(+), 97 deletions(-) create mode 100644 src/tests/SizeDisplay.test.tsx create mode 100644 src/utils/tests/ui.test.ts delete mode 100644 src/utils/tests/ui.test.tsx rename src/utils/{ui.tsx => ui.ts} (67%) 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/SizeDisplay.tsx b/src/SizeDisplay.tsx index 0af0a5e..93235cf 100644 --- a/src/SizeDisplay.tsx +++ b/src/SizeDisplay.tsx @@ -1,15 +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 { sizeWithFailures } from './utils/ui'; +import { getErrorList, sizeDisplayName, Summary } from './utils/ui'; type SizeDisplayProps = { - size: number; - summary: any; + size?: number; + summary?: Summary; }; - const SizeDisplay = ({ size, summary }: SizeDisplayProps) => { - const { bytesStringBase2 } = useContext(UIPreferencesContext); - return sizeWithFailures(size, summary, bytesStringBase2); + 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/contexts/UIPreferencesContext.tsx b/src/contexts/UIPreferencesContext.tsx index 5298efc..d594b83 100644 --- a/src/contexts/UIPreferencesContext.tsx +++ b/src/contexts/UIPreferencesContext.tsx @@ -11,6 +11,7 @@ export interface UIPreferences { get bytesStringBase2(): boolean get pageSize(): PageSize get theme(): Theme + setBytesStringBase2: (isSet: boolean) => void setTheme: (theme: Theme) => void setPageSize: (pageSize: number) => void } @@ -25,7 +26,7 @@ export const UIPreferencesContext = React.createContext({} as UIP export interface UIPreferenceProviderProps { children: ReactNode, - initalValue: UIPreferences | undefined + initialValue: Partial | undefined } function getDefaultTheme(): Theme { diff --git a/src/tests/SizeDisplay.test.tsx b/src/tests/SizeDisplay.test.tsx new file mode 100644 index 0000000..4dd1a78 --- /dev/null +++ b/src/tests/SizeDisplay.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react'; +import { UIPreferenceProvider } from 'src/contexts/UIPreferencesContext'; +import SizeDisplay from 'src/SizeDisplay'; +import { Summary } from 'src/utils/ui'; + +const renderWithContext = ( + size?: number, + summary?: Summary, + bytesStringBase2 = false +) => + render( + + + + ); + +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', () => { + const { container } = renderWithContext(); + + expect(container.childElementCount).toEqual(0); + }); + + it('displays the size in base-10', () => { + const { container } = renderWithContext(900); + + screen.getByText('0.9 KB'); + expectNoErrorIcon(container); + }); + + it('displays the size in base-2', () => { + const { container } = renderWithContext(900, undefined, true); + + screen.getByText('900 B'); + expectNoErrorIcon(container); + }); + + it('displays errors with an icon', () => { + const rootError = { path: '/', error: 'root path' }; + const homeError = { path: '/home', error: 'home path' }; + + const { container } = renderWithContext(900, { + errors: [rootError, homeError], + numFailed: 2, + }); + + screen.getByText('0.9 KB'); + expect(getErrorIcon(container)).toHaveAttribute( + 'title', + 'Encountered 2 errors:\n\n- /: root path\n- /home: home path' + ); + }); +}); 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/tests/ui.test.tsx b/src/utils/tests/ui.test.tsx deleted file mode 100644 index 7950948..0000000 --- a/src/utils/tests/ui.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { sizeDisplayName } 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', - // TODO: Unfamiliar with this syntax from Go - // 900:<10 - 1, "900 KiB", - // 900:<< 10, "900 KiB", - 999000: '1 MiB', - 999999: '1 MiB', - 1000000: '1 MiB', - // 99:<< 20, "99 MiB", - // 1:<< 30, "1 GiB", - // 10:<< 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) - )); - }); -}); diff --git a/src/utils/ui.tsx b/src/utils/ui.ts similarity index 67% rename from src/utils/ui.tsx rename to src/utils/ui.ts index ea35b08..0651105 100644 --- a/src/utils/ui.tsx +++ b/src/utils/ui.ts @@ -33,22 +33,15 @@ export const sizeDisplayName = (size?: number, bytesStringBase2?: boolean) => { }; type Error = { - path: string; - error: string; -} -type Summary = { - errors: Error[]; - numFailed: number; -} -export const sizeWithFailures = ( - size?: number, - summ?: Summary, - bytesStringBase2?: boolean -) => { - if (size === undefined) return ''; - - if (!summ || !summ.errors || !summ.numFailed) - return {sizeDisplayName(size, bytesStringBase2)}; + 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 = '- '; @@ -61,14 +54,5 @@ export const sizeWithFailures = ( .map((err: Error) => prefix + err.path + ': ' + err.error) .join('\n'); - return ( - - {sizeDisplayName(size, bytesStringBase2)}  - - - ); + return caption; }; From 33dc785a24ad88c5fd6d9f1f21ba10e6c5806cb5 Mon Sep 17 00:00:00 2001 From: Sowaret Date: Sun, 16 Oct 2022 10:40:43 -0600 Subject: [PATCH 6/7] Swap toggle for dropdown, add test, remove unused imports --- src/Table.jsx | 26 +++++++++------------ src/tests/Table.test.jsx | 49 ++++++++++++++++++++++++++++++++++++++++ src/utils/ui.ts | 4 ---- 3 files changed, 60 insertions(+), 19 deletions(-) create mode 100644 src/tests/Table.test.jsx diff --git a/src/Table.jsx b/src/Table.jsx index a1a2d72..c52d4f6 100644 --- a/src/Table.jsx +++ b/src/Table.jsx @@ -3,8 +3,6 @@ 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 ToggleButton from 'react-bootstrap/ToggleButton'; -import ToggleButtonGroup from 'react-bootstrap/ToggleButtonGroup'; import { usePagination, useSortBy, useTable } from 'react-table'; import { PAGE_SIZES, UIPreferencesContext } from './contexts/UIPreferencesContext'; @@ -97,19 +95,6 @@ export default function MyTable({ columns, data }) { )} -
- {/* TODO: Any more elegant way for this label? */} - Storage Unit: - - Decimal - Binary - -
Page Size: {pageSize} @@ -121,6 +106,17 @@ export default function MyTable({ columns, data }) { ))} + + + Units: {bytesStringBase2 ? 'Binary' : 'Decimal'} + + + {[false, true].map(isBase2 => ( + setBytesStringBase2(isBase2)}> + Units: {isBase2 ? 'Binary' : 'Decimal'} + ))} + +
; diff --git a/src/tests/Table.test.jsx b/src/tests/Table.test.jsx new file mode 100644 index 0000000..ed74ab4 --- /dev/null +++ b/src/tests/Table.test.jsx @@ -0,0 +1,49 @@ +import { fireEvent } from '@testing-library/dom'; +import { screen, render } from '@testing-library/react'; +import { UIPreferenceProvider } from '../contexts/UIPreferencesContext'; +import MyTable from '../Table'; + +const renderWithContext = bytesStringBase2 => + render( + + + + ); + +describe('Table', () => { + describe('Units dropdown', () => { + const getUnitButton = (unit) => + screen.getByRole('button', { name: `Units: ${unit}` }); + const openDropdown = () => + fireEvent.click(getUnitButton('Decimal')); + + describe('labeled with the units value', () => { + it('decimal', () => { + renderWithContext(); + getUnitButton('Decimal'); + }); + + it('binary', () => { + renderWithContext(true); + getUnitButton('Binary'); + }); + }); + + it('has dropdown options for Decimal and Binary', () => { + renderWithContext(); + openDropdown(); + + expect(screen.getAllByRole('button', { name: 'Units: Decimal' })).toHaveLength(2); + getUnitButton('Binary'); + }); + + it('updates the value when an option is selected', () => { + renderWithContext(); + openDropdown(); + fireEvent.click(screen.getAllByRole('button', { name: 'Units: Decimal' })[0]); + + expect(screen.queryByRole('button', { name: 'Units: Decimal' })).toBeFalsy(); + getUnitButton('Binary'); + }) + }); +}); diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 0651105..7e254f5 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -1,7 +1,3 @@ -import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import React from 'react'; - const base10UnitPrefixes = ['', 'K', 'M', 'G', 'T']; const base2UnitPrefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti']; From d59e9bbd40eab88637984745cf1b476cf44144cb Mon Sep 17 00:00:00 2001 From: Sowaret Date: Sun, 16 Oct 2022 14:16:11 -0600 Subject: [PATCH 7/7] Fix tests and create test context provider --- src/tests/SizeDisplay.test.tsx | 37 ++++++++++-------------- src/tests/Table.test.jsx | 51 +++++++++++++++++----------------- src/tests/testutils.js | 21 +++++++++++++- 3 files changed, 61 insertions(+), 48 deletions(-) diff --git a/src/tests/SizeDisplay.test.tsx b/src/tests/SizeDisplay.test.tsx index 4dd1a78..b84a99d 100644 --- a/src/tests/SizeDisplay.test.tsx +++ b/src/tests/SizeDisplay.test.tsx @@ -1,18 +1,12 @@ -import { render, screen } from '@testing-library/react'; -import { UIPreferenceProvider } from 'src/contexts/UIPreferencesContext'; +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 renderWithContext = ( - size?: number, - summary?: Summary, - bytesStringBase2 = false -) => - render( - - - - ); const getErrorIcon = (container: HTMLElement) => container.querySelector('[data-icon="triangle-exclamation"]'); @@ -21,38 +15,37 @@ const expectNoErrorIcon = (container: HTMLElement) => expect(getErrorIcon(container)).toBeFalsy(); describe('SizeDisplay', () => { - it('displays nothing if the size is undefined', () => { - const { container } = renderWithContext(); + 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', () => { - const { container } = renderWithContext(900); + 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', () => { - const { container } = renderWithContext(900, undefined, true); + 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', () => { + it('displays errors with an icon', async () => { const rootError = { path: '/', error: 'root path' }; const homeError = { path: '/home', error: 'home path' }; - const { container } = renderWithContext(900, { + const { container } = await renderSizeDisplay(900, { errors: [rootError, homeError], numFailed: 2, }); screen.getByText('0.9 KB'); - expect(getErrorIcon(container)).toHaveAttribute( - 'title', + 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 index ed74ab4..93aeca1 100644 --- a/src/tests/Table.test.jsx +++ b/src/tests/Table.test.jsx @@ -1,49 +1,50 @@ -import { fireEvent } from '@testing-library/dom'; -import { screen, render } from '@testing-library/react'; -import { UIPreferenceProvider } from '../contexts/UIPreferencesContext'; +import { screen } from '@testing-library/react'; import MyTable from '../Table'; +import { simulateClick } from './testutils'; +import { renderWithContext } from './testutils'; -const renderWithContext = bytesStringBase2 => - render( - - - - ); +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: `Units: ${unit}` }); - const openDropdown = () => - fireEvent.click(getUnitButton('Decimal')); + screen.getByRole('button', { name: new RegExp(`Units: ${unit}`) }); + + const openDropdown = () => simulateClick(getUnitButton('Decimal')); describe('labeled with the units value', () => { - it('decimal', () => { - renderWithContext(); + it('decimal', async () => { + await renderTable(); getUnitButton('Decimal'); }); - it('binary', () => { - renderWithContext(true); + it('binary', async () => { + await renderTable(true); getUnitButton('Binary'); }); }); - it('has dropdown options for Decimal and Binary', () => { - renderWithContext(); + it('has dropdown options for Decimal and Binary', async () => { + await renderTable(); openDropdown(); - expect(screen.getAllByRole('button', { name: 'Units: Decimal' })).toHaveLength(2); + expectButtonCount('Decimal', 2); getUnitButton('Binary'); }); - it('updates the value when an option is selected', () => { - renderWithContext(); + it('updates the value when an option is selected', async () => { + await renderTable(); openDropdown(); - fireEvent.click(screen.getAllByRole('button', { name: 'Units: Decimal' })[0]); + simulateClick(getUnitButton('Binary')); - expect(screen.queryByRole('button', { name: 'Units: Decimal' })).toBeFalsy(); - 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; +};