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();
+ });
});