mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 17:50:12 +02:00
Merge remote-tracking branch 'upstream/0.2.1b' into refactor-all-pages
This commit is contained in:
@@ -20,6 +20,9 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation.
|
||||
- **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
|
||||
|
||||
### Changed
|
||||
- **Filament Catalog API Renamed** ([#427](https://github.com/maziggy/bambuddy/issues/427)) — Renamed `/api/v1/filaments/` to `/api/v1/filament-catalog/` to avoid confusion with the inventory spools page (labeled "Filament" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory.
|
||||
|
||||
### Improved
|
||||
- **AMS Mapping Test Coverage** — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for `useFilamentMapping` hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).
|
||||
- **Tray Now Disambiguation Test Coverage** — Added 28 MQTT message replay tests covering all `tray_now` disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, `ams_extruder_map` fallback, active extruder switching, and full multi-color print lifecycles.
|
||||
|
||||
@@ -14,7 +14,7 @@ from backend.app.schemas.filament import (
|
||||
FilamentUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/filaments", tags=["filaments"])
|
||||
router = APIRouter(prefix="/filament-catalog", tags=["filament-catalog"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[FilamentResponse])
|
||||
|
||||
@@ -1185,6 +1185,12 @@ async def run_migrations(conn):
|
||||
except OperationalError:
|
||||
pass # Already applied
|
||||
|
||||
# Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER"))
|
||||
except OperationalError:
|
||||
pass # Already applied
|
||||
|
||||
# Migration: Create spool_usage_history table for filament consumption tracking
|
||||
try:
|
||||
await conn.execute(
|
||||
|
||||
@@ -19,6 +19,9 @@ class Spool(Base):
|
||||
brand: Mapped[str | None] = mapped_column(String(100)) # "Polymaker"
|
||||
label_weight: Mapped[int] = mapped_column(Integer, default=1000) # Advertised net weight (g)
|
||||
core_weight: Mapped[int] = mapped_column(Integer, default=250) # Empty spool weight (g)
|
||||
core_weight_catalog_id: Mapped[int | None] = mapped_column(
|
||||
Integer
|
||||
) # Reference to spool_catalog entry for core weight
|
||||
weight_used: Mapped[float] = mapped_column(Float, default=0) # Consumed grams
|
||||
slicer_filament: Mapped[str | None] = mapped_column(String(50)) # Preset ID (e.g. "GFL99")
|
||||
slicer_filament_name: Mapped[str | None] = mapped_column(String(100)) # Preset name for slicer
|
||||
|
||||
@@ -11,6 +11,7 @@ class SpoolBase(BaseModel):
|
||||
brand: str | None = None
|
||||
label_weight: int = 1000
|
||||
core_weight: int = 250
|
||||
core_weight_catalog_id: int | None = None
|
||||
weight_used: float = 0
|
||||
slicer_filament: str | None = None
|
||||
slicer_filament_name: str | None = None
|
||||
@@ -35,6 +36,7 @@ class SpoolUpdate(BaseModel):
|
||||
brand: str | None = None
|
||||
label_weight: int | None = None
|
||||
core_weight: int | None = None
|
||||
core_weight_catalog_id: int | None = None
|
||||
weight_used: float | None = None
|
||||
slicer_filament: str | None = None
|
||||
slicer_filament_name: str | None = None
|
||||
|
||||
@@ -70,7 +70,7 @@ class TestEndpointAuthenticationEnforcement:
|
||||
async def test_filaments_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
|
||||
"""Verify filaments list is accessible when auth is disabled."""
|
||||
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
|
||||
response = await async_client.get("/api/v1/filaments/")
|
||||
response = await async_client.get("/api/v1/filament-catalog/")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -153,7 +153,7 @@ class TestAuthenticationPatterns:
|
||||
"""Verify require_permission_if_auth_enabled allows access when auth disabled."""
|
||||
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
|
||||
# Test a protected endpoint
|
||||
response = await async_client.get("/api/v1/filaments/")
|
||||
response = await async_client.get("/api/v1/filament-catalog/")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -162,7 +162,7 @@ class TestAuthenticationPatterns:
|
||||
"""Verify multiple protected endpoints are accessible when auth is disabled."""
|
||||
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
|
||||
endpoints = [
|
||||
"/api/v1/filaments/",
|
||||
"/api/v1/filament-catalog/",
|
||||
"/api/v1/external-links/",
|
||||
"/api/v1/notifications/",
|
||||
"/api/v1/maintenance/types",
|
||||
|
||||
@@ -5,7 +5,7 @@ from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestFilamentsAPI:
|
||||
"""Integration tests for /api/v1/filaments/ endpoints."""
|
||||
"""Integration tests for /api/v1/filament-catalog/ (material types) endpoints."""
|
||||
|
||||
@pytest.fixture
|
||||
async def filament_factory(self, db_session):
|
||||
@@ -36,7 +36,7 @@ class TestFilamentsAPI:
|
||||
@pytest.mark.integration
|
||||
async def test_list_filaments_empty(self, async_client: AsyncClient):
|
||||
"""Verify empty list when no filaments exist."""
|
||||
response = await async_client.get("/api/v1/filaments/")
|
||||
response = await async_client.get("/api/v1/filament-catalog/")
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
@@ -45,7 +45,7 @@ class TestFilamentsAPI:
|
||||
async def test_list_filaments_with_data(self, async_client: AsyncClient, filament_factory, db_session):
|
||||
"""Verify list returns existing filaments."""
|
||||
await filament_factory(name="Test Filament")
|
||||
response = await async_client.get("/api/v1/filaments/")
|
||||
response = await async_client.get("/api/v1/filament-catalog/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert any(f["name"] == "Test Filament" for f in data)
|
||||
@@ -62,7 +62,7 @@ class TestFilamentsAPI:
|
||||
"brand": "Bambu",
|
||||
"cost_per_kg": 30.0,
|
||||
}
|
||||
response = await async_client.post("/api/v1/filaments/", json=data)
|
||||
response = await async_client.post("/api/v1/filament-catalog/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["name"] == "New PETG"
|
||||
@@ -73,7 +73,7 @@ class TestFilamentsAPI:
|
||||
async def test_get_filament(self, async_client: AsyncClient, filament_factory, db_session):
|
||||
"""Verify single filament can be retrieved."""
|
||||
filament = await filament_factory(name="Get Test")
|
||||
response = await async_client.get(f"/api/v1/filaments/{filament.id}")
|
||||
response = await async_client.get(f"/api/v1/filament-catalog/{filament.id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Get Test"
|
||||
|
||||
@@ -81,7 +81,7 @@ class TestFilamentsAPI:
|
||||
@pytest.mark.integration
|
||||
async def test_get_filament_not_found(self, async_client: AsyncClient):
|
||||
"""Verify 404 for non-existent filament."""
|
||||
response = await async_client.get("/api/v1/filaments/9999")
|
||||
response = await async_client.get("/api/v1/filament-catalog/9999")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -90,7 +90,7 @@ class TestFilamentsAPI:
|
||||
"""Verify filament can be updated."""
|
||||
filament = await filament_factory(name="Original")
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/filaments/{filament.id}", json={"name": "Updated", "cost_per_kg": 35.0}
|
||||
f"/api/v1/filament-catalog/{filament.id}", json={"name": "Updated", "cost_per_kg": 35.0}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
@@ -102,8 +102,8 @@ class TestFilamentsAPI:
|
||||
async def test_delete_filament(self, async_client: AsyncClient, filament_factory, db_session):
|
||||
"""Verify filament can be deleted."""
|
||||
filament = await filament_factory()
|
||||
response = await async_client.delete(f"/api/v1/filaments/{filament.id}")
|
||||
response = await async_client.delete(f"/api/v1/filament-catalog/{filament.id}")
|
||||
assert response.status_code == 200
|
||||
# Verify deleted
|
||||
response = await async_client.get(f"/api/v1/filaments/{filament.id}")
|
||||
response = await async_client.get(f"/api/v1/filament-catalog/{filament.id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -62,6 +62,7 @@ const existingSpool: InventorySpool = {
|
||||
rgba: 'FF0000FF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: null,
|
||||
weight_used: 300,
|
||||
slicer_filament: 'GFL99',
|
||||
slicer_filament_name: 'Generic PLA',
|
||||
@@ -183,4 +184,138 @@ describe('SpoolFormModal weightTouched', () => {
|
||||
// weight_used MUST be included for new spools (default value 0)
|
||||
expect(payload).toHaveProperty('weight_used', 0);
|
||||
});
|
||||
|
||||
it('preserves core_weight_catalog_id when editing other fields', async () => {
|
||||
const spoolWithCatalogId: InventorySpool = {
|
||||
...existingSpool,
|
||||
core_weight_catalog_id: 5,
|
||||
};
|
||||
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={spoolWithCatalogId}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change the note field (unrelated to catalog ID)
|
||||
const noteInputs = screen.getAllByPlaceholderText(/note/i);
|
||||
expect(noteInputs.length).toBeGreaterThan(0);
|
||||
fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });
|
||||
|
||||
// Click Save
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSpool).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
|
||||
expect(spoolId).toBe(1);
|
||||
// core_weight_catalog_id MUST be preserved when editing other fields
|
||||
expect(payload).toHaveProperty('core_weight_catalog_id', 5);
|
||||
// Other changes should also be present
|
||||
expect(payload).toHaveProperty('note', 'Updated note');
|
||||
});
|
||||
|
||||
it('includes core_weight_catalog_id when selecting from catalog', async () => {
|
||||
const mockCatalog = [
|
||||
{ id: 1, name: 'Generic 250g', weight: 250 },
|
||||
{ id: 2, name: 'Bambu Lab 250g', weight: 250 },
|
||||
{ id: 3, name: 'Standard 300g', weight: 300 },
|
||||
];
|
||||
|
||||
vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
|
||||
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for catalog to load
|
||||
await waitFor(() => {
|
||||
expect(api.getSpoolCatalog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click on the empty spool weight field to open dropdown
|
||||
const weightInputs = screen.getAllByPlaceholderText(/search/i);
|
||||
const weightPicker = weightInputs.find(input =>
|
||||
input.getAttribute('placeholder')?.toLowerCase().includes('spool')
|
||||
);
|
||||
expect(weightPicker).toBeTruthy();
|
||||
fireEvent.focus(weightPicker!);
|
||||
|
||||
// Click on "Bambu Lab 250g" option
|
||||
const bambuOption = await screen.findByText('Bambu Lab 250g');
|
||||
fireEvent.click(bambuOption);
|
||||
|
||||
// Click the add spool button
|
||||
const addButtons = screen.getAllByRole('button', { name: /add spool/i });
|
||||
const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
|
||||
expect(submitButton).toBeTruthy();
|
||||
fireEvent.click(submitButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.createSpool).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [payload] = vi.mocked(api.createSpool).mock.calls[0];
|
||||
// Both weight AND catalog ID should be sent
|
||||
expect(payload).toHaveProperty('core_weight', 250);
|
||||
expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
|
||||
});
|
||||
|
||||
it('displays correct catalog name when duplicates exist', async () => {
|
||||
const spoolWithCatalogId: InventorySpool = {
|
||||
...existingSpool,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
|
||||
};
|
||||
|
||||
const mockCatalog = [
|
||||
{ id: 1, name: 'Generic 250g', weight: 250 },
|
||||
{ id: 2, name: 'Bambu Lab 250g', weight: 250 },
|
||||
{ id: 3, name: 'Standard 300g', weight: 300 },
|
||||
];
|
||||
|
||||
vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
|
||||
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={spoolWithCatalogId}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for catalog to load
|
||||
await waitFor(() => {
|
||||
expect(api.getSpoolCatalog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
|
||||
await waitFor(() => {
|
||||
const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
|
||||
const bambuFound = weightInputs.some(input =>
|
||||
input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
|
||||
);
|
||||
expect(bambuFound).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1771,6 +1771,7 @@ export interface InventorySpool {
|
||||
brand: string | null;
|
||||
label_weight: number;
|
||||
core_weight: number;
|
||||
core_weight_catalog_id: number | null;
|
||||
weight_used: number;
|
||||
slicer_filament: string | null;
|
||||
slicer_filament_name: string | null;
|
||||
@@ -3312,10 +3313,10 @@ export const api = {
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// Filaments
|
||||
listFilaments: () => request<Filament[]>('/filaments/'),
|
||||
getFilament: (id: number) => request<Filament>(`/filaments/${id}`),
|
||||
getFilamentsByType: (type: string) => request<Filament[]>(`/filaments/by-type/${type}`),
|
||||
// Filament Catalog (material types with cost/temp data)
|
||||
listFilaments: () => request<Filament[]>('/filament-catalog/'),
|
||||
getFilament: (id: number) => request<Filament>(`/filament-catalog/${id}`),
|
||||
getFilamentsByType: (type: string) => request<Filament[]>(`/filament-catalog/by-type/${type}`),
|
||||
|
||||
// Notification Providers
|
||||
getNotificationProviders: () => request<NotificationProvider[]>('/notifications/'),
|
||||
|
||||
@@ -171,6 +171,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
rgba: spool.rgba || '808080FF',
|
||||
label_weight: spool.label_weight || 1000,
|
||||
core_weight: spool.core_weight || 250,
|
||||
core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
|
||||
weight_used: spool.weight_used || 0,
|
||||
slicer_filament: spool.slicer_filament || '',
|
||||
note: spool.note || '',
|
||||
@@ -336,6 +337,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
rgba: formData.rgba || null,
|
||||
label_weight: formData.label_weight,
|
||||
core_weight: formData.core_weight,
|
||||
core_weight_catalog_id: formData.core_weight_catalog_id,
|
||||
slicer_filament: formData.slicer_filament || null,
|
||||
slicer_filament_name: presetName,
|
||||
nozzle_temp_min: null,
|
||||
|
||||
@@ -8,15 +8,18 @@ function SpoolWeightPicker({
|
||||
catalog,
|
||||
value,
|
||||
onChange,
|
||||
catalogId,
|
||||
onCatalogIdChange,
|
||||
}: {
|
||||
catalog: { id: number; name: string; weight: number }[];
|
||||
value: number;
|
||||
onChange: (weight: number) => void;
|
||||
catalogId: number | null;
|
||||
onCatalogIdChange: (id: number | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -30,6 +33,35 @@ function SpoolWeightPicker({
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
// When value changes, auto-select if there's only one matching entry or keep selection if it still matches
|
||||
useEffect(() => {
|
||||
// If no catalog loaded yet, skip matching logic
|
||||
if (catalog.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = catalog.filter(e => e.weight === value);
|
||||
|
||||
// If currently selected entry still matches the weight, keep it selected
|
||||
if (catalogId) {
|
||||
const selected = catalog.find(e => e.id === catalogId);
|
||||
if (selected && selected.weight === value) {
|
||||
return; // Keep current selection
|
||||
}
|
||||
}
|
||||
|
||||
// If exactly one match, auto-select it
|
||||
if (matches.length === 1) {
|
||||
onCatalogIdChange(matches[0].id);
|
||||
} else if (matches.length === 0) {
|
||||
// No matches, clear selection to prevent stale catalog ID
|
||||
if (catalogId !== null) {
|
||||
onCatalogIdChange(null);
|
||||
}
|
||||
}
|
||||
// If multiple matches, don't auto-select - let user choose
|
||||
}, [value, catalog, catalogId, onCatalogIdChange]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return catalog;
|
||||
const s = search.toLowerCase();
|
||||
@@ -39,17 +71,29 @@ function SpoolWeightPicker({
|
||||
);
|
||||
}, [catalog, search]);
|
||||
|
||||
// Display value: show catalog name if selected, or the weight
|
||||
// Find all entries matching the current weight
|
||||
const matchingEntries = useMemo(() => {
|
||||
return catalog.filter(e => e.weight === value);
|
||||
}, [catalog, value]);
|
||||
|
||||
// Display value: show catalog name if selected by ID, otherwise show first match
|
||||
const displayValue = useMemo(() => {
|
||||
if (isOpen) return search;
|
||||
if (selectedId) {
|
||||
const entry = catalog.find(e => e.id === selectedId);
|
||||
|
||||
// If a catalog ID is explicitly selected, use that
|
||||
if (catalogId) {
|
||||
const entry = catalog.find(e => e.id === catalogId);
|
||||
if (entry) return entry.name;
|
||||
}
|
||||
const match = catalog.find(e => e.weight === value);
|
||||
if (match) return match.name;
|
||||
|
||||
// Otherwise, show the first matching entry as a suggestion
|
||||
if (matchingEntries.length > 0) {
|
||||
return matchingEntries[0].name;
|
||||
}
|
||||
|
||||
// Leave empty if there are no matches
|
||||
return '';
|
||||
}, [isOpen, search, selectedId, catalog, value]);
|
||||
}, [isOpen, search, catalogId, catalog, matchingEntries]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -86,12 +130,12 @@ function SpoolWeightPicker({
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
|
||||
(selectedId ? entry.id === selectedId : entry.weight === value)
|
||||
(catalogId ? entry.id === catalogId : entry.weight === value)
|
||||
? 'bg-bambu-green/10 text-bambu-green'
|
||||
: 'text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedId(entry.id);
|
||||
onCatalogIdChange(entry.id);
|
||||
onChange(entry.weight);
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
@@ -150,6 +194,8 @@ export function AdditionalSection({
|
||||
catalog={spoolCatalog}
|
||||
value={formData.core_weight}
|
||||
onChange={(weight) => updateField('core_weight', weight)}
|
||||
catalogId={formData.core_weight_catalog_id}
|
||||
onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
|
||||
/>
|
||||
|
||||
{/* Current Weight (remaining filament) */}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SpoolFormData {
|
||||
rgba: string;
|
||||
label_weight: number;
|
||||
core_weight: number;
|
||||
core_weight_catalog_id: number | null;
|
||||
weight_used: number;
|
||||
slicer_filament: string;
|
||||
note: string;
|
||||
@@ -22,6 +23,7 @@ export const defaultFormData: SpoolFormData = {
|
||||
rgba: '808080FF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: null,
|
||||
weight_used: 0,
|
||||
slicer_filament: '',
|
||||
note: '',
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-xpjLLAhl.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-ph3tf3Mr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-tulFiIvt.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user