diff --git a/src/app/components/Form/QuotaField.vue b/src/app/components/Form/QuotaField.vue index 4331839f..78c5e3da 100644 --- a/src/app/components/Form/QuotaField.vue +++ b/src/app/components/Form/QuotaField.vue @@ -12,13 +12,16 @@ >
({ required: true }); const minimumGiB = quotaBytesToGiB(1)!; const maximumGiB = quotaBytesToGiB(Number.MAX_SAFE_INTEGER)!; -const quotaGiB = computed({ - get: () => quotaBytesToGiB(quotaBytes.value), - set: (value) => { - const normalized = (value as number | string | null) === '' ? null : value; - quotaBytes.value = quotaGiBToBytes(normalized); - }, +const isFocused = ref(false); +const quotaInput = ref(quotaBytesToGiBInput(quotaBytes.value)); + +watch(quotaBytes, (value) => { + if (!isFocused.value) { + quotaInput.value = quotaBytesToGiBInput(value); + } }); + +function updateQuotaInput(value: unknown) { + const nextValue = typeof value === 'string' ? value : String(value ?? ''); + quotaInput.value = nextValue; + + const nextBytes = quotaGiBInputToBytes(nextValue); + if (nextBytes !== undefined) { + quotaBytes.value = nextBytes; + } +} + +function onBlur() { + isFocused.value = false; + quotaInput.value = quotaBytesToGiBInput(quotaBytes.value); +} diff --git a/src/app/utils/math.ts b/src/app/utils/math.ts index a07ec749..5e4c670a 100644 --- a/src/app/utils/math.ts +++ b/src/app/utils/math.ts @@ -4,12 +4,45 @@ export function quotaBytesToGiB(bytes: number | null): number | null { return bytes === null ? null : bytes / GIBIBYTE_IN_BYTES; } +export function quotaBytesToGiBInput(bytes: number | null): string { + if (bytes === null) { + return ''; + } + + return quotaBytesToGiB(bytes)! + .toFixed(9) + .replace(/\.?0+$/, ''); +} + export function quotaGiBToBytes(gibibytes: number | null): number | null { - if (gibibytes === null || gibibytes === 0) { + if (gibibytes === null || gibibytes <= 0) { return null; } - return Math.round(gibibytes * GIBIBYTE_IN_BYTES); + const bytes = Math.round(gibibytes * GIBIBYTE_IN_BYTES); + return bytes > 0 ? bytes : null; +} + +export function quotaGiBInputToBytes( + gibibytes: string +): number | null | undefined { + const value = gibibytes.trim(); + + if (value === '') { + return null; + } + + const quotaGiB = Number(value); + if (!Number.isFinite(quotaGiB) || quotaGiB < 0) { + return undefined; + } + + const quotaBytes = quotaGiBToBytes(quotaGiB); + if (quotaBytes !== null && !Number.isSafeInteger(quotaBytes)) { + return undefined; + } + + return quotaBytes; } export function bytes( diff --git a/src/test/unit/math.app.spec.ts b/src/test/unit/math.app.spec.ts index 355af87c..f94f69f8 100644 --- a/src/test/unit/math.app.spec.ts +++ b/src/test/unit/math.app.spec.ts @@ -3,20 +3,28 @@ import { describe, expect, test } from 'vitest'; import { GIBIBYTE_IN_BYTES, quotaBytesToGiB, + quotaBytesToGiBInput, + quotaGiBInputToBytes, quotaGiBToBytes, } from '#app/utils/math'; describe('quota conversions', () => { test('keeps disabled quotas null', () => { expect(quotaBytesToGiB(null)).toBeNull(); + expect(quotaBytesToGiBInput(null)).toBe(''); + expect(quotaGiBInputToBytes('')).toBeNull(); + expect(quotaGiBInputToBytes('0')).toBeNull(); expect(quotaGiBToBytes(null)).toBeNull(); expect(quotaGiBToBytes(0)).toBeNull(); + expect(quotaGiBToBytes(0.1 / GIBIBYTE_IN_BYTES)).toBeNull(); }); test('converts whole and fractional GiB to bytes', () => { expect(quotaGiBToBytes(1)).toBe(GIBIBYTE_IN_BYTES); expect(quotaGiBToBytes(0.5)).toBe(GIBIBYTE_IN_BYTES / 2); expect(quotaGiBToBytes(1 / GIBIBYTE_IN_BYTES)).toBe(1); + expect(quotaGiBInputToBytes('1')).toBe(GIBIBYTE_IN_BYTES); + expect(quotaGiBInputToBytes('0.5')).toBe(GIBIBYTE_IN_BYTES / 2); }); test('rounds edited GiB values to the nearest byte', () => { @@ -27,6 +35,19 @@ describe('quota conversions', () => { 'round-trips the exact byte value %s', (bytes) => { expect(quotaGiBToBytes(quotaBytesToGiB(bytes))).toBe(bytes); + expect(quotaGiBInputToBytes(quotaBytesToGiBInput(bytes))).toBe(bytes); } ); + + test('formats quota input values without noisy floating point decimals', () => { + expect(quotaBytesToGiBInput(GIBIBYTE_IN_BYTES)).toBe('1'); + expect(quotaBytesToGiBInput(GIBIBYTE_IN_BYTES / 2)).toBe('0.5'); + expect(quotaBytesToGiBInput(107374182)).toBe('0.1'); + }); + + test('ignores invalid quota input values', () => { + expect(quotaGiBInputToBytes('-1')).toBeUndefined(); + expect(quotaGiBInputToBytes('invalid')).toBeUndefined(); + expect(quotaGiBInputToBytes('9007199254740992')).toBeUndefined(); + }); });