diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e761205d..69776d5a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ All notable changes to Bambuddy will be documented in this file. - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows. ### Fixed +- **Number fields can be cleared and retyped (#3182, reported by @Carter3DP)** — Every number field corrected its value on each keystroke, so erasing the "1" in the print dialog's Quantity snapped straight back to 1, and getting to 6 meant typing 16 and deleting the 1. It was worse where the minimum is above 1: typing the "6" of 60 into the AMS drying temperature turned it into 45, so the field could only be set with the arrows. Fields now keep what you type while you edit, use it once it is a number in range, and settle on leaving the field: out-of-range numbers are pulled into range, and an empty field goes back to its default. This covers the 38 number fields across the print dialog, per-plate quantities, scheduling, drying, Settings, smart plugs, backups, spools, projects and SpoolBuddy. Two fields could not be set to 0 even though 0 is allowed, the smart plug's delay after drying and AMS humidity; they can now. The drying presets now also keep to their own limits, 30–65 °C on an AMS 2 Pro and 30–85 °C on an AMS HT. - **Installing or updating no longer runs out of memory on 2 GB machines (#3181, reported by @PhilippeP62)** — Every installer and updater builds the frontend with `npm run build`, which also ran the TypeScript type check. That check alone needs about 1 GB of Node memory, the whole default allowance on a 2 GB machine such as the standard Proxmox LXC or a 2 GB Raspberry Pi, so the build crashed with "JavaScript heap out of memory". On the Proxmox helper script that left the install without its database and nobody could sign in; Bambuddy's own update script rolled back but could never finish an update. The build now only bundles the frontend, and the type check runs on its own in development and CI (`npm run typecheck`). That also fixes CI's type-check job and the pre-commit hook, which ran `tsc --noEmit` against a config that lists no files and so had been passing without checking anything. - **Remember Me is back on the login page when only SSO sign-in is allowed (#2784, reported and contributed by @vuthanhtrung2010 in #3117)** — Turning off local username/password login hid the whole sign-in form, and the Remember Me checkbox went with it. An SSO sign-in could then only ever last until the browser closed, so SSO users signed in again every time they came back. The checkbox now sits above the SSO buttons when local login is off, and the "or continue with" divider, which had nothing left to divide, is hidden. Signing in with Remember Me unticked now also clears a choice left over from an abandoned attempt; before, ticking it, backing out of the sign-in provider and then signing in unticked still kept you signed in. With automatic SSO sign-in configured, Bambuddy now also remembers the page you were trying to open and returns you there after signing in, instead of to the dashboard. Automatic sign-in redirects before the page can show the checkbox, so those sign-ins still last only until the browser closes. - **A queued file whose only plate isn't plate 1 now prints instead of hanging the printer (#2947, reported and contributed by @sgiffhorn in #2951)** — A queue item with no plate chosen was always sent as plate 1. A file exported with a single plate keeps that plate's original number, so a file holding only `plate_2.gcode` made the printer look for a plate that isn't there. The printer accepted the command, raised an HMS error and sat idle until it was power-cycled, while the queue blamed the SD card. An item with no plate now prints the lowest plate the file actually contains, and falls back to plate 1 only when the file can't be read. A stored plate of 0 counts as no plate. The same plate is now used everywhere a queued print is prepared: the G-code tweaks, filament usage, the H2C nozzle-rack checks, the external-spool check and the print command. Before, each one assumed plate 1 on its own. diff --git a/frontend/src/__tests__/components/EditArchiveModal.test.tsx b/frontend/src/__tests__/components/EditArchiveModal.test.tsx index 4380aa8fc..69cf42713 100644 --- a/frontend/src/__tests__/components/EditArchiveModal.test.tsx +++ b/frontend/src/__tests__/components/EditArchiveModal.test.tsx @@ -567,12 +567,16 @@ describe('EditArchiveModal', () => { ); const field = screen.getByLabelText(/items printed/i) as HTMLInputElement; await user.clear(field); + // Empty while editing (#3182), and 0 rather than 1 once the field is left. + expect(field.value).toBe(''); + await user.tab(); expect(field.value).toBe('0'); }); it('still refuses a negative count', async () => { const user = userEvent.setup(); + const seen = patchSpy(); render( { const field = screen.getByLabelText(/items printed/i) as HTMLInputElement; await user.clear(field); await user.type(field, '-3'); + await user.click(screen.getByRole('button', { name: /save/i })); - expect(Number(field.value)).toBeGreaterThanOrEqual(0); + expect(field.value).toBe('0'); + await waitFor(() => expect(seen.body?.quantity).toBe(0)); }); }); diff --git a/frontend/src/__tests__/components/NumberInput.test.tsx b/frontend/src/__tests__/components/NumberInput.test.tsx new file mode 100644 index 000000000..10cc55a0e --- /dev/null +++ b/frontend/src/__tests__/components/NumberInput.test.tsx @@ -0,0 +1,151 @@ +/** + * NumberInput keeps what was typed as a draft, so a number field can be + * cleared and retyped (#3182). Clamping inside onChange used to snap an + * emptied field straight back to its minimum. + */ +import { useState } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NumberInput } from '../../components/NumberInput'; + +function Harness({ + initial, + onCommit, + ...props +}: { + initial: number; + onCommit?: (v: number) => void; + min?: number; + max?: number; + fallback?: number; + integer?: boolean; +}) { + const [value, setValue] = useState(initial); + return ( + <> + { + setValue(v); + onCommit?.(v); + }} + {...props} + /> + {value} + + ); +} + +const input = () => screen.getByLabelText('qty') as HTMLInputElement; +const committed = () => screen.getByTestId('committed').textContent; + +describe('NumberInput', () => { + it('can be emptied and retyped without snapping back to the minimum', async () => { + const user = userEvent.setup(); + render(); + + await user.click(input()); + await user.keyboard('{Backspace}'); + expect(input().value).toBe(''); + expect(committed()).toBe('1'); + + await user.keyboard('6'); + expect(input().value).toBe('6'); + expect(committed()).toBe('6'); + }); + + it('passes nothing up while the draft is below the minimum, then the finished number', async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await user.clear(input()); + await user.keyboard('6'); + expect(input().value).toBe('6'); + expect(onCommit).not.toHaveBeenCalled(); + + await user.keyboard('0'); + expect(input().value).toBe('60'); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenLastCalledWith(60); + }); + + it('clamps an out-of-range draft into range on blur', async () => { + const user = userEvent.setup(); + render(); + + await user.clear(input()); + await user.keyboard('120'); + await user.tab(); + expect(input().value).toBe('90'); + expect(committed()).toBe('90'); + + await user.clear(input()); + await user.keyboard('3'); + await user.tab(); + expect(input().value).toBe('45'); + expect(committed()).toBe('45'); + }); + + it('commits the fallback when left empty', async () => { + const user = userEvent.setup(); + render(); + + await user.clear(input()); + await user.tab(); + expect(input().value).toBe('7'); + expect(committed()).toBe('7'); + }); + + it('reverts to the current value when left empty without a fallback', async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await user.clear(input()); + await user.tab(); + expect(input().value).toBe('40'); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('accepts 0 where the range allows it', async () => { + const user = userEvent.setup(); + render(); + + await user.clear(input()); + await user.keyboard('0'); + await user.tab(); + expect(input().value).toBe('0'); + expect(committed()).toBe('0'); + }); + + it('keeps fractions when integer is false', async () => { + const user = userEvent.setup(); + render(); + + await user.clear(input()); + await user.keyboard('27.5'); + await user.tab(); + expect(input().value).toBe('27.5'); + expect(committed()).toBe('27.5'); + }); + + it('follows a value changed from outside while not being edited', () => { + const { rerender } = render( {}} />); + expect(input().value).toBe('3'); + rerender( {}} />); + expect(input().value).toBe('8'); + }); + + it('still calls a caller-supplied onBlur', async () => { + const user = userEvent.setup(); + const onBlur = vi.fn(); + render( {}} onBlur={onBlur} />); + + await user.click(input()); + await user.tab(); + expect(onBlur).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/__tests__/components/PrintModal.test.tsx b/frontend/src/__tests__/components/PrintModal.test.tsx index 25d6eb478..21a9ed628 100644 --- a/frontend/src/__tests__/components/PrintModal.test.tsx +++ b/frontend/src/__tests__/components/PrintModal.test.tsx @@ -1205,6 +1205,49 @@ describe('PrintModal', () => { await user.keyboard('5'); expect(input.value).toBe('5'); }); + + it('quantity can be erased and retyped instead of snapping back to 1 (#3182)', async () => { + const user = userEvent.setup(); + render( + + ); + + const input = screen.getByLabelText('Quantity') as HTMLInputElement; + await user.click(input); + await user.keyboard('{Backspace}'); + expect(input.value).toBe(''); + + await user.keyboard('6'); + expect(input.value).toBe('6'); + expect(screen.getByText('Creates 6 queue items')).toBeInTheDocument(); + }); + + it('an emptied quantity settles back to 1 when the field is left', async () => { + const user = userEvent.setup(); + render( + + ); + + const input = screen.getByLabelText('Quantity') as HTMLInputElement; + await user.click(input); + await user.keyboard('{Backspace}'); + await user.tab(); + expect(input.value).toBe('1'); + }); }); describe('reprint G-code injection dispatch (#422 / auto-eject)', () => { diff --git a/frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx b/frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx index 2df632251..09193d2d3 100644 --- a/frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx +++ b/frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx @@ -129,6 +129,25 @@ describe('PrintersPage - drying start modes', () => { ); }); + it('lets the temperature be retyped instead of snapping to the 45 °C minimum (#3182)', async () => { + const user = userEvent.setup(); + render(); + await openDryingPopover(user); + + const temp = screen + .getAllByRole('spinbutton') + .find((el) => el.getAttribute('min') === '45') as HTMLInputElement; + await user.clear(temp); + await user.keyboard('6'); + expect(temp.value).toBe('6'); + await user.keyboard('0'); + expect(temp.value).toBe('60'); + + await user.tab(); + expect(temp.value).toBe('60'); + expect((screen.getAllByRole('slider')[0] as HTMLInputElement).value).toBe('60'); + }); + it('reveals the delay chips when After delay is selected, with 2h preselected', async () => { const user = userEvent.setup(); render(); diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index e689c5bdd..5f4409f4b 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -9,6 +9,7 @@ import { PrintLogTable } from './PrintLogTable'; import { invalidateArchiveAndProjectViews } from '../utils/projectQueries'; import { assignableProjects } from '../utils/projectTree'; import { verdictSourceKey } from '../utils/verdictSource'; +import { NumberInput } from './NumberInput'; // Keys for failure reasons - translated at render time. // Exported so the Print Log per-row classification editor (#1687 part 4) @@ -375,15 +376,15 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr {t('editArchive.itemsPrinted')} - setQuantity(Math.max(0, parseInt(e.target.value) || 0))} + onChange={setQuantity} + fallback={0} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" placeholder="1" /> diff --git a/frontend/src/components/EmailSettings.tsx b/frontend/src/components/EmailSettings.tsx index c87877208..bfe4081f4 100644 --- a/frontend/src/components/EmailSettings.tsx +++ b/frontend/src/components/EmailSettings.tsx @@ -9,6 +9,7 @@ import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; import { useEffect } from 'react'; import { useAuth } from '../contexts/AuthContext'; +import { NumberInput } from './NumberInput'; const SECURITY_PORT_MAP: Record = { starttls: 587, @@ -316,10 +317,12 @@ export function EmailSettings() { - handlePortChange(parseInt(e.target.value) || 587)} + onChange={handlePortChange} + min={1} + max={65535} + fallback={587} placeholder="587" className={inputClasses} /> diff --git a/frontend/src/components/FailureDetectionSettings.tsx b/frontend/src/components/FailureDetectionSettings.tsx index 1045cbd96..b22e5fbdf 100644 --- a/frontend/src/components/FailureDetectionSettings.tsx +++ b/frontend/src/components/FailureDetectionSettings.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader } from './Card'; import { Button } from './Button'; import { Toggle } from './Toggle'; import { useToast } from '../contexts/ToastContext'; +import { NumberInput } from './NumberInput'; type TestResult = { ok: boolean; message: string } | null; @@ -251,10 +252,10 @@ export function FailureDetectionSettings() { - setPollInterval(Math.max(5, Math.min(120, Number(e.target.value) || 10)))} + onChange={setPollInterval} + fallback={10} min={5} max={120} disabled={!enabled} diff --git a/frontend/src/components/GitHubBackupSettings.tsx b/frontend/src/components/GitHubBackupSettings.tsx index 5eb285e18..0682e4c60 100644 --- a/frontend/src/components/GitHubBackupSettings.tsx +++ b/frontend/src/components/GitHubBackupSettings.tsx @@ -44,6 +44,7 @@ import { ConfirmModal } from './ConfirmModal'; import { GitHubRestoreModal } from './GitHubRestoreModal'; import { useToast } from '../contexts/ToastContext'; import { formatRelativeTime, parseUTCDate } from '../utils/date'; +import { NumberInput } from './NumberInput'; function formatDateTime(dateStr: string | null): string { if (!dateStr) return '-'; @@ -1288,14 +1289,12 @@ export function GitHubBackupSettings() { )}
- { - const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5)); + onChange={async (val) => { try { await api.updateSettings({ local_backup_retention: val }); showToast(t('backup.settingsSaved')); @@ -1304,6 +1303,7 @@ export function GitHubBackupSettings() { } refetchLocalStatus(); }} + fallback={5} />

{t('backup.retentionDescription')}

diff --git a/frontend/src/components/NumberInput.tsx b/frontend/src/components/NumberInput.tsx new file mode 100644 index 000000000..04732fa20 --- /dev/null +++ b/frontend/src/components/NumberInput.tsx @@ -0,0 +1,66 @@ +import { useState, type InputHTMLAttributes } from 'react'; + +type NativeInputProps = Omit, 'type' | 'value' | 'onChange' | 'min' | 'max'>; + +interface NumberInputProps extends NativeInputProps { + value: number | null | undefined; + onChange: (value: number) => void; + min?: number; + max?: number; + /** Parse with parseInt (default); pass false for fields with a fractional step. */ + integer?: boolean; + /** Committed when the field is left empty. Without it an empty field reverts to `value`. */ + fallback?: number; +} + +/** + * A number field that can be cleared and retyped (#3182). + * + * Clamping inside onChange rewrites the field on every keystroke: clearing a + * "1" snaps straight back to 1, and typing the "6" of 60 into a field with a + * minimum of 45 turns it into 45. This keeps what was typed as a draft while + * the field is being edited, passes a value up only once the draft is a number + * inside [min, max], and settles the field on blur — clamped into range, or + * `fallback` when left empty. + */ +export function NumberInput({ + value, + onChange, + min, + max, + integer = true, + fallback, + onBlur, + ...rest +}: NumberInputProps) { + const [draft, setDraft] = useState(null); + + const parse = (raw: string) => (integer ? parseInt(raw, 10) : parseFloat(raw)); + const inRange = (n: number) => (min === undefined || n >= min) && (max === undefined || n <= max); + const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n)); + + return ( + { + const raw = e.target.value; + setDraft(raw); + const n = parse(raw); + if (!Number.isNaN(n) && inRange(n) && n !== value) onChange(n); + }} + onBlur={(e) => { + if (draft !== null) { + const n = parse(draft); + const settled = Number.isNaN(n) ? fallback : n; + if (settled !== undefined && clamp(settled) !== value) onChange(clamp(settled)); + setDraft(null); + } + onBlur?.(e); + }} + /> + ); +} diff --git a/frontend/src/components/PrintModal/PlateSelector.tsx b/frontend/src/components/PrintModal/PlateSelector.tsx index 70463f5f5..e8f41896c 100644 --- a/frontend/src/components/PrintModal/PlateSelector.tsx +++ b/frontend/src/components/PrintModal/PlateSelector.tsx @@ -4,6 +4,7 @@ import type { PlateSelectorProps } from './types'; import { formatDuration } from '../../utils/date'; import { withMediaToken } from '../../api/client'; import { getBedTypeInfo } from '../../utils/bedType'; +import { NumberInput } from '../NumberInput'; /** * Plate selection grid for multi-plate 3MF files. @@ -130,17 +131,15 @@ export function PlateSelector({ {showQuantities && isSelected && (
- - onQuantityChange(plate.index, Math.max(1, Math.min(999, parseInt(e.target.value) || 1))) - } + onChange={(v) => onQuantityChange(plate.index, v)} + fallback={1} className="w-14 px-1.5 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center focus:outline-none focus:ring-1 focus:ring-bambu-green" />
diff --git a/frontend/src/components/PrintModal/ScheduleOptions.tsx b/frontend/src/components/PrintModal/ScheduleOptions.tsx index 6ac2199c0..e6d89cc85 100644 --- a/frontend/src/components/PrintModal/ScheduleOptions.tsx +++ b/frontend/src/components/PrintModal/ScheduleOptions.tsx @@ -13,6 +13,7 @@ import { type DateFormat, type TimeFormat, } from '../../utils/date'; +import { NumberInput } from '../NumberInput'; /** * Schedule options component for queue items. @@ -312,23 +313,23 @@ export function ScheduleOptionsPanel({
- onChange({ ...options, staggerGroupSize: Math.max(1, parseInt(e.target.value) || 1) })} + onChange={(v) => onChange({ ...options, staggerGroupSize: v })} + fallback={1} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" />
- onChange({ ...options, staggerIntervalMinutes: Math.max(1, parseInt(e.target.value) || 1) })} + onChange={(v) => onChange({ ...options, staggerIntervalMinutes: v })} + fallback={1} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" />
diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index 9d4401fa7..397e437a4 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -40,6 +40,7 @@ import type { ScheduleType, } from './types'; import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types'; +import { NumberInput } from '../NumberInput'; /** Same filament: type ignoring case, colour as RRGGBB ignoring `#`, case and alpha. */ function isSameFilament(a: { type: string; color: string }, b: { type: string; color: string }): boolean { @@ -1951,13 +1952,13 @@ export function PrintModal({ - setQuantity(Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))} + onChange={setQuantity} + fallback={1} className="w-20 px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green" /> {quantity > 1 && ( diff --git a/frontend/src/components/SmartPlugCard.tsx b/frontend/src/components/SmartPlugCard.tsx index 5890018b3..e224a2cdd 100644 --- a/frontend/src/components/SmartPlugCard.tsx +++ b/frontend/src/components/SmartPlugCard.tsx @@ -8,6 +8,7 @@ import { Card, CardContent } from './Card'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; +import { NumberInput } from './NumberInput'; interface SmartPlugCardProps { plug: SmartPlug; @@ -447,24 +448,24 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { {plug.off_delay_mode === 'time' ? (
- updateMutation.mutate({ off_delay_minutes: parseInt(e.target.value) || 5 })} + onChange={(v) => updateMutation.mutate({ off_delay_minutes: v })} + fallback={5} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none" />
) : (
- updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })} + onChange={(v) => updateMutation.mutate({ off_temp_threshold: v })} + fallback={70} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none" />

{t('smartPlugs.tempThresholdDescription')}

@@ -498,12 +499,12 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { {plug.auto_off_after_drying && (
- updateMutation.mutate({ off_delay_after_drying_minutes: parseInt(e.target.value) || 10 })} + onChange={(v) => updateMutation.mutate({ off_delay_after_drying_minutes: v })} + fallback={10} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none" />
diff --git a/frontend/src/components/StreamOverlayBuilder.tsx b/frontend/src/components/StreamOverlayBuilder.tsx index a3cf9dd46..c9c7810c0 100644 --- a/frontend/src/components/StreamOverlayBuilder.tsx +++ b/frontend/src/components/StreamOverlayBuilder.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'; import { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react'; import { api, type Printer } from '../api/client'; import { useToast } from '../contexts/ToastContext'; +import { NumberInput } from './NumberInput'; type OverlaySize = 'small' | 'medium' | 'large'; @@ -165,13 +166,13 @@ export function StreamOverlayBuilder() { - setFps(Math.min(Math.max(Number(e.target.value) || 1, 1), 30))} + onChange={setFps} + fallback={1} className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none" />

diff --git a/frontend/src/components/spool-form/FilamentSection.tsx b/frontend/src/components/spool-form/FilamentSection.tsx index ece3354f7..393403ae3 100644 --- a/frontend/src/components/spool-form/FilamentSection.tsx +++ b/frontend/src/components/spool-form/FilamentSection.tsx @@ -5,6 +5,7 @@ import type { FilamentSectionProps, FilamentOption } from './types'; import { KNOWN_VARIANTS } from './constants'; import { parsePresetName } from './utils'; import { PresetSourceBadge } from './PresetPicker'; +import { NumberInput } from '../NumberInput'; // The identity fields a slicer preset can auto-fill. type PresetFilledField = 'material' | 'brand' | 'subtype'; @@ -521,16 +522,13 @@ export function FilamentSection({ {quickAdd && (

- { - const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1)); - onQuantityChange(val); - }} + onChange={onQuantityChange} + fallback={1} />
)} diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 2bc0b3d26..4279c46dc 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -200,6 +200,7 @@ import { FilamentSlotCircle } from '../components/FilamentSlotCircle'; import { Collapsible } from '../components/Collapsible'; import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic'; import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors'; +import { NumberInput } from '../components/NumberInput'; // The status filter's options, and the only values it may hold. One list so a // saved filter cannot be validated against a set the dropdown has since moved @@ -7507,12 +7508,12 @@ function PrinterCard({
- setDryingTemp(Math.min(maxTemp, Math.max(45, Number(e.target.value) || 45)))} + onChange={setDryingTemp} + fallback={45} className="w-12 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[length:var(--pc-t11,11px)] text-center focus:outline-none focus:border-bambu-green [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> °C @@ -7536,12 +7537,12 @@ function PrinterCard({
- setDryingDuration(Math.min(24, Math.max(1, Number(e.target.value) || 1)))} + onChange={setDryingDuration} + fallback={1} className="w-10 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[length:var(--pc-t11,11px)] text-center focus:outline-none focus:border-bambu-green [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> {t('printers.drying.hours')} diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx index 59cc94e9e..ba640efb0 100644 --- a/frontend/src/pages/ProjectDetailPage.tsx +++ b/frontend/src/pages/ProjectDetailPage.tsx @@ -48,6 +48,7 @@ import { PrintModal } from '../components/PrintModal'; import { ProjectModal } from './ProjectsPage'; import { getCurrencySymbol } from '../utils/currency'; import { isSlicedLibraryFile } from '../utils/libraryFiles'; +import { NumberInput } from '../components/NumberInput'; function formatFilament(grams: number): string { if (grams >= 1000) { @@ -1205,12 +1206,12 @@ export function ProjectDetailPage() { autoFocus />
- setNewBomQty(parseInt(e.target.value) || 1)} + onChange={setNewBomQty} + fallback={1} className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green" - min="1" + min={1} placeholder={t('projectDetail.bom.qty')} />
- setEditBomQty(parseInt(e.target.value) || 1)} + onChange={setEditBomQty} + fallback={1} className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green" - min="1" + min={1} placeholder={t('projectDetail.bom.qty')} /> {getCurrencySymbol(localSettings.currency)} - - updateSetting('default_filament_cost', parseFloat(e.target.value) || 0) - } + onChange={(v) => updateSetting('default_filament_cost', v)} + integer={false} + fallback={0} style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }} className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" /> @@ -2436,14 +2436,13 @@ export function SettingsPage() { {getCurrencySymbol(localSettings.currency)} - - updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0) - } + onChange={(v) => updateSetting('energy_cost_per_kwh', v)} + integer={false} + fallback={0} style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }} className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" /> @@ -2649,13 +2648,14 @@ export function SettingsPage() { {t('settings.lowDiskSpaceWarning')}
- updateSetting('library_disk_warning_gb', parseFloat(e.target.value) || 5)} + onChange={(v) => updateSetting('library_disk_warning_gb', v)} + integer={false} + fallback={5} className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" /> GB @@ -3452,12 +3452,12 @@ export function SettingsPage() { - updateSetting('mqtt_port', Math.min(65535, Math.max(1, parseInt(e.target.value) || 1883)))} + onChange={(v) => updateSetting('mqtt_port', v)} + fallback={1883} className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
@@ -5182,12 +5182,12 @@ export function SettingsPage() { - updateSetting('stagger_group_size', Math.max(1, Math.min(50, parseInt(e.target.value) || 1)))} + onChange={(v) => updateSetting('stagger_group_size', v)} + fallback={1} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" />

@@ -5198,12 +5198,12 @@ export function SettingsPage() { - updateSetting('stagger_interval_minutes', Math.max(1, Math.min(60, parseInt(e.target.value) || 1)))} + onChange={(v) => updateSetting('stagger_interval_minutes', v)} + fallback={1} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" />

@@ -5230,12 +5230,12 @@ export function SettingsPage() { - updateSetting('queue_max_concurrent_uploads', Math.max(1, Math.min(16, parseInt(e.target.value) || 1)))} + onChange={(v) => updateSetting('queue_max_concurrent_uploads', v)} + fallback={1} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" />

@@ -5281,12 +5281,12 @@ export function SettingsPage() { - updateSetting('preheat_max_wait_seconds', Math.max(60, Math.min(3600, parseInt(e.target.value) || 900)))} + onChange={(v) => updateSetting('preheat_max_wait_seconds', v)} + fallback={900} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50" disabled={!(localSettings.preheat_enabled ?? false)} /> @@ -5298,12 +5298,12 @@ export function SettingsPage() { - updateSetting('preheat_soak_seconds', Math.max(0, Math.min(1800, parseInt(e.target.value) || 0)))} + onChange={(v) => updateSetting('preheat_soak_seconds', v)} + fallback={0} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50" disabled={!(localSettings.preheat_enabled ?? false)} /> @@ -5338,12 +5338,12 @@ export function SettingsPage() { - updateSetting('queue_keep_warm_bed_temp', Math.max(40, Math.min(110, parseInt(e.target.value) || 90)))} + onChange={(v) => updateSetting('queue_keep_warm_bed_temp', v)} + fallback={90} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50" disabled={!(localSettings.preheat_enabled ?? false)} /> @@ -5355,12 +5355,12 @@ export function SettingsPage() { - updateSetting('queue_keep_warm_max_minutes', Math.max(5, Math.min(480, parseInt(e.target.value) || 120)))} + onChange={(v) => updateSetting('queue_keep_warm_max_minutes', v)} + fallback={120} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50" disabled={!(localSettings.queue_keep_bed_warm ?? false) || !(localSettings.preheat_enabled ?? false) || !(localSettings.require_plate_clear ?? false)} /> @@ -5821,8 +5821,8 @@ export function SettingsPage() { {fil}

- updatePreset(fil, 'n3f', Math.max(1, parseInt(e.target.value) || 0))} + updatePreset(fil, 'n3f', v)} className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> °C @@ -5830,8 +5830,8 @@ export function SettingsPage() {
- updatePreset(fil, 'n3f_hours', Math.max(1, parseInt(e.target.value) || 0))} + updatePreset(fil, 'n3f_hours', v)} className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> h @@ -5839,8 +5839,8 @@ export function SettingsPage() {
- updatePreset(fil, 'n3s', Math.max(1, parseInt(e.target.value) || 0))} + updatePreset(fil, 'n3s', v)} className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> °C @@ -5848,8 +5848,8 @@ export function SettingsPage() {
- updatePreset(fil, 'n3s_hours', Math.max(1, parseInt(e.target.value) || 0))} + updatePreset(fil, 'n3s_hours', v)} className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> h @@ -6178,12 +6178,12 @@ export function SettingsPage() { {t('settings.goodGreen')} ≤
- updateSetting('ams_humidity_good', parseInt(e.target.value) || 40)} + onChange={(v) => updateSetting('ams_humidity_good', v)} + fallback={40} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" /> % @@ -6194,12 +6194,12 @@ export function SettingsPage() { {t('settings.fairOrange')} ≤
- updateSetting('ams_humidity_fair', parseInt(e.target.value) || 60)} + onChange={(v) => updateSetting('ams_humidity_fair', v)} + fallback={60} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" /> % @@ -6237,13 +6237,14 @@ export function SettingsPage() { {t('settings.goodBlue')} ≤
- updateSetting('ams_temp_good', parseFloat(e.target.value) || 28)} + onChange={(v) => updateSetting('ams_temp_good', v)} + integer={false} + fallback={28} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" /> °C @@ -6254,13 +6255,14 @@ export function SettingsPage() { {t('settings.fairOrange')} ≤
- updateSetting('ams_temp_fair', parseFloat(e.target.value) || 35)} + onChange={(v) => updateSetting('ams_temp_fair', v)} + integer={false} + fallback={35} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" /> °C @@ -6324,12 +6326,12 @@ export function SettingsPage() { {t('settings.keepSensorHistory')}
- updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)} + onChange={(v) => updateSetting('ams_history_retention_days', v)} + fallback={30} className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" /> {t('common.days')} diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx index 296490622..3baa59319 100644 --- a/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx +++ b/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx @@ -53,6 +53,7 @@ import { withCurrentValue, } from '../../components/spool-form/utils'; import { MATERIALS } from '../../components/spool-form/constants'; +import { NumberInput } from '../../components/NumberInput'; type Tab = 'existing' | 'new' | 'replace'; type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error'; @@ -906,10 +907,10 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
- updateField('label_weight', parseInt(e.target.value) || 0)} + onChange={(v) => updateField('label_weight', v)} + fallback={0} min={0} max={10000} className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"