mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
fix(inventory): allow editing and duplicating stock spools without a slicer preset (#1905)
A spool created by Quick Add, a CSV import or an RFID scan has no slicer preset, brand or subtype. Reopening it in Edit Spool demanded all three before anything could be saved, so changing its storage location, cost or notes was impossible - and Copy Spool had the same gate with no Quick Add toggle to waive it. The preset you were then forced to pick auto- filled material, brand and subtype from the preset name, silently rewriting a hand-entered manufacturer (Elegoo -> Generic) so the spool no longer appeared where it had been filed. Editing and copying now require only what the backend requires: the material. Preset, brand and subtype stay fully visible and editable - nothing is hidden the way Quick Add hides it - and the required-field markers no longer advertise a rule that isn't enforced. Selecting a preset fills only fields that are still empty or that a previously selected preset had filled, so values the user (or the saved spool) provided survive; switching between presets still replaces what the earlier one contributed. The brand and material dropdowns also no longer filter themselves down to the brand/material pairs known to the color catalog and slicer presets. Elegoo is catalogued only for PLA, which made a real product like Elegoo ASA look impossible to enter. Both lists now always offer everything known, with paired entries ranked first under Suggested and the rest under All, and a spool's own custom brand or material is always present in its own dropdown. The SpoolBuddy write-tag form shares these fields and gets the same treatment. Lastly the Quick Add layout no longer leaks out of create mode: quick- adding a spool and then opening Edit left the edit form in the reduced layout with no toggle to leave it, because the toggle is create-only. Frontend only. Translated in all locales; wiki updated. Covered by validation and form-interaction tests.
This commit is contained in:
@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Energy usage now feeds the statistics that previously only knew about filament (#1432)** — Bambuddy has measured per-print energy via an attached smart plug for a while (the plug's lifetime counter is captured at print start and the delta stored with the print), but two stats surfaces ignored it. First, the **Most Expensive** record on the Statistics page ranked prints by filament cost alone, so a cheap-filament print with hours of heated-chamber time could never win; it now ranks by filament + measured energy cost (prints without a smart plug simply compete on filament cost, as before). Second, **Filament Trends** gained an **Energy Over Time** chart — per-day kWh (per-hour for short ranges, per-week for long ones), with the range's total kWh and energy cost in the header. The chart only appears when the selected range actually contains measured energy data, so setups without smart plugs see no change. The `/archives/slim` stats feed now carries each run's `energy_kwh`/`energy_cost`. Translated in all locales. Covered by backend and frontend tests.
|
||||
|
||||
### Fixed
|
||||
- **Spools added as stock could not be edited or duplicated without assigning a slicer preset, and that preset then overwrote the spool's own manufacturer (#1905, reporter @rocstat1979)** — A spool created via **Quick Add**, a CSV import or an RFID scan has no slicer preset, brand or subtype. Reopening it in **Edit Spool** demanded all three before anything could be saved — so changing the storage location, the cost or the note was impossible — and **Copy Spool** had the same gate with no Quick Add toggle to waive it. Worse, the preset you were forced to pick auto-filled material, brand and subtype from the preset name, silently rewriting a hand-entered manufacturer ("Elegoo" → "Generic") so the spool no longer appeared where it had been filed. **Fix.** Editing and copying now require only what the backend requires — the material; the preset, brand and subtype fields stay fully visible and editable (nothing is hidden the way Quick Add hides it), and the required-field markers no longer advertise a rule that isn't enforced. Selecting a preset now fills only fields that are still empty or that a previously selected preset had filled, so values you (or the saved spool) provided survive; switching between presets still replaces what the earlier one contributed. **Also fixed:** the brand and material dropdowns no longer *filter* themselves down to the brand/material pairs known to the color catalog and slicer presets — a real combination like Elegoo ASA looked impossible to enter because Elegoo was catalogued only for PLA. Both lists now always offer everything known, with the catalog-paired entries ranked first under a **Suggested** heading and the rest under **All**, and a spool's own custom brand or material is always present in its dropdown. The same change applies to the SpoolBuddy write-tag form, which shares these fields. Lastly, the Quick Add layout no longer leaks out of create mode: quick-adding a spool and then opening Edit used to strand the edit form in the reduced layout with no toggle to leave it. Frontend-only. Translated in all locales; wiki updated. Covered by validation and form-interaction tests.
|
||||
- **The spool PA-Profil (Pressure Advance) picker only ever offered the 0.4mm K-profile, hiding nozzle-specific profiles for the same filament on multi-nozzle printers (#2618)** — When a printer had two K-profiles for one filament differing only in nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the **Edit Spool → PA-Profil** tab (and the SpoolBuddy write-tag page, which shares the picker) showed only the 0.4mm entry ("1 match, K=0.042"), regardless of the nozzle actually installed. **Root cause.** Both surfaces fetched a printer's calibrations with `getKProfiles(printer.id)`, which defaults the nozzle filter to `0.4` — and the printer/MQTT layer filters strictly by that diameter, so the 0.6mm profile was never retrieved. (The AMS-Slot config dialog was already fixed for this in #1899; these two pickers were not.) **Fix.** The picker now queries every nozzle the printer reports installed (`0.4`, `0.6`, …) and merges the results, falling back to `0.4` only when the printer hasn't reported its nozzle hardware. Each profile row now also shows a nozzle-diameter badge so two identically-named profiles are distinguishable. Frontend-only. Covered by tests for the nozzle enumeration and the two-profile rendering.
|
||||
- **Print-archive backups to a Gitea or Forgejo instance hosted under a URL path prefix could not be configured — the repository URL failed to parse (#2642, reporter @M1ndHunteR)** — Self-hosted Gitea/Forgejo is often served under a subpath (`ROOT_URL` like `https://host/gitea`), so repositories live at `https://host/gitea/owner/repo` rather than at the host root. **Root cause.** The Gitea backend (shared by Forgejo) assumed the repo sat directly under the host: URL parsing required exactly two path segments after the hostname, so a subpath URL's three segments (`gitea/owner/repo`) matched nothing and raised "Cannot parse repository URL". Even had it parsed, the API base was derived from scheme+host only, yielding `https://host/api/v1` instead of `https://host/gitea/api/v1`, so every API call would have 404'd. **Fix.** The Gitea/Forgejo backend now treats the final two path segments as `owner`/`repo` and keeps any leading segments as a base-path prefix, deriving the API base as `{scheme}://{host}{prefix}/api/v1`. Root-hosted instances are unaffected (empty prefix). GitHub/GitLab are untouched. Covered by parse and API-base tests for both providers.
|
||||
- **The Print Queue's History tab showed a count of all prints but only ever displayed the first 50, with no way to reach the rest (#2682, reporter @pchulpjoost)** — The History header read e.g. `History (311 items)`, but only 50 rows rendered and there was no "load more" control, so 261 finished prints were unreachable. **Root cause.** The full history is already loaded client-side (the queue endpoint has no limit) and sorted correctly — the header counts the whole list — but the row builder hard-sliced it to `items.slice(0, 50)`, a fixed cap with no accompanying control. Nothing was missing server-side; it simply wasn't drawn. **Fix.** History now paginates: it draws the 50 most-recent prints and, when there are more, shows a **Show more** button (with a `Showing X of Y` count) that loads the next 50, repeating until the whole history is on screen. The page size resets to the first page only when you re-sort or change the location filter — deliberately not on the periodic queue poll, so an expanded view doesn't collapse mid-scroll. Frontend-only; batch grouping and per-row actions are unchanged. Covered by a test asserting the 50-row cap, the `Showing 50 of 60` count, and that Show more reveals the remainder. Wiki updated.
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Tests for #1905 — editing a spool that was created without a slicer preset.
|
||||
*
|
||||
* Covers:
|
||||
* - edit/copy no longer demand a slicer preset, brand or subtype
|
||||
* - picking a preset never overwrites identity fields the user already set
|
||||
* - the Quick Add layout can't leak from create mode into an edit
|
||||
* - brand/material dropdowns rank catalog pairings instead of filtering by them
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { SpoolFormModal } from '../../components/SpoolFormModal';
|
||||
import type { InventorySpool } from '../../api/client';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
|
||||
getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
|
||||
orcaCloudStatus: vi.fn().mockResolvedValue({ connected: false }),
|
||||
orcaCloudListProfiles: vi.fn().mockResolvedValue({ filament: [] }),
|
||||
getFilamentPresets: vi.fn().mockResolvedValue([]),
|
||||
getSpoolCatalog: vi.fn().mockResolvedValue([]),
|
||||
getLocations: vi.fn().mockResolvedValue([]),
|
||||
// Elegoo is only known for PLA here — the pairing that used to hide it
|
||||
// from the brand list as soon as ASA was selected.
|
||||
getColorCatalog: vi.fn().mockResolvedValue([
|
||||
{ manufacturer: 'Elegoo', color_name: 'Red', hex_color: 'FF0000', material: 'PLA' },
|
||||
{ manufacturer: 'Polymaker', color_name: 'Blue', hex_color: '0000FF', material: 'ASA' },
|
||||
]),
|
||||
getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
|
||||
getBuiltinFilaments: vi.fn().mockResolvedValue([
|
||||
{ filament_id: 'GFA05', name: 'Generic ASA' },
|
||||
]),
|
||||
getPrinters: vi.fn().mockResolvedValue([]),
|
||||
getPrinterStatus: vi.fn().mockResolvedValue(null),
|
||||
getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
|
||||
createSpool: vi.fn().mockResolvedValue({ id: 99 }),
|
||||
updateSpool: vi.fn().mockResolvedValue({ id: 7 }),
|
||||
saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
|
||||
getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
|
||||
getAssignments: vi.fn().mockResolvedValue([]),
|
||||
unassignSpool: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const mockShowToast = vi.fn();
|
||||
vi.mock('../../contexts/ToastContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
|
||||
return {
|
||||
...actual,
|
||||
useToast: () => ({ showToast: mockShowToast }),
|
||||
};
|
||||
});
|
||||
|
||||
import { api } from '../../api/client';
|
||||
|
||||
// A spool as produced by Quick Add / CSV import / an RFID scan: material only.
|
||||
const quickAddedSpool: InventorySpool = {
|
||||
id: 7,
|
||||
material: 'ASA',
|
||||
subtype: null,
|
||||
brand: null,
|
||||
color_name: null,
|
||||
rgba: '808080FF',
|
||||
extra_colors: null,
|
||||
effect_type: null,
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: null,
|
||||
weight_used: 0,
|
||||
slicer_filament: null,
|
||||
slicer_filament_name: null,
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: null,
|
||||
added_full: null,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
archived_at: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
k_profiles: [],
|
||||
} as unknown as InventorySpool;
|
||||
|
||||
const elegooAsaSpool: InventorySpool = {
|
||||
...quickAddedSpool,
|
||||
id: 8,
|
||||
brand: 'Elegoo',
|
||||
subtype: 'Basic',
|
||||
} as unknown as InventorySpool;
|
||||
|
||||
describe('SpoolFormModal relaxed edit/copy validation (#1905)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('saves an edit of a preset-less spool without demanding a slicer preset', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={quickAddedSpool}
|
||||
mode="edit"
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
|
||||
expect(screen.queryByText('Slicer preset is required')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies a preset-less spool without demanding a slicer preset', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={quickAddedSpool}
|
||||
mode="copy"
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy Spool' }));
|
||||
|
||||
await waitFor(() => expect(api.createSpool).toHaveBeenCalled());
|
||||
expect(screen.queryByText('Slicer preset is required')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still requires a slicer preset when creating a spool', async () => {
|
||||
render(
|
||||
<SpoolFormModal isOpen={true} onClose={vi.fn()} mode="create" currencySymbol="$" />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Spool' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Slicer preset is required')).toBeInTheDocument());
|
||||
expect(api.createSpool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the spool brand when a preset is picked during an edit', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={elegooAsaSpool}
|
||||
mode="edit"
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
|
||||
|
||||
const presetInput = screen.getByPlaceholderText('Search filament presets...');
|
||||
fireEvent.focus(presetInput);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Generic ASA' })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generic ASA' }));
|
||||
|
||||
// parsePresetName('Generic ASA') yields brand "Generic" — it must not
|
||||
// replace the manufacturer the spool already carries.
|
||||
expect(screen.getByPlaceholderText('Search brand...')).toHaveValue('Elegoo');
|
||||
});
|
||||
|
||||
it('auto-fills empty identity fields from the preset when creating', async () => {
|
||||
render(
|
||||
<SpoolFormModal isOpen={true} onClose={vi.fn()} mode="create" currencySymbol="$" />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument());
|
||||
|
||||
const presetInput = screen.getByPlaceholderText('Search filament presets...');
|
||||
fireEvent.focus(presetInput);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Generic ASA' })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generic ASA' }));
|
||||
|
||||
expect(screen.getByPlaceholderText('Search brand...')).toHaveValue('Generic');
|
||||
expect(screen.getByPlaceholderText('Select material...')).toHaveValue('ASA');
|
||||
});
|
||||
|
||||
it('offers brands the catalog does not pair with the selected material', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={quickAddedSpool}
|
||||
mode="edit"
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
|
||||
|
||||
fireEvent.focus(screen.getByPlaceholderText('Search brand...'));
|
||||
|
||||
// Polymaker is the known ASA brand, Elegoo is only catalogued for PLA —
|
||||
// both are selectable, the pairing only decides the order.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Polymaker' })).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Elegoo' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Suggested')).toBeInTheDocument();
|
||||
expect(screen.getByText('All')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not carry Quick Add layout from a create into a later edit', async () => {
|
||||
const { rerender } = render(
|
||||
<SpoolFormModal isOpen={true} onClose={vi.fn()} mode="create" currencySymbol="$" />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument());
|
||||
|
||||
// Turn Quick Add on — the preset field disappears.
|
||||
fireEvent.click(screen.getByText('Quick Add (Stock)').closest('div')!.parentElement!.querySelector('button')!);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Search filament presets...')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Close, then reopen on an existing spool. The toggle only renders in
|
||||
// create mode, so a leaked quickAdd would strand the edit form.
|
||||
rerender(<SpoolFormModal isOpen={false} onClose={vi.fn()} mode="create" currencySymbol="$" />);
|
||||
rerender(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={quickAddedSpool}
|
||||
mode="edit"
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
|
||||
expect(screen.getByPlaceholderText('Search filament presets...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -93,4 +93,46 @@ describe('validateForm', () => {
|
||||
expect(result.errors.material).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// #1905: a spool created by quick-add / CSV import / RFID scan has no preset,
|
||||
// brand or subtype. Demanding them on every later edit blocked changes to
|
||||
// unrelated fields, and the forced preset pick then rewrote the spool's
|
||||
// manufacturer. Edit and copy now match what the backend requires: material.
|
||||
describe('edit and copy modes', () => {
|
||||
it('only requires material when editing', () => {
|
||||
const result = validateForm(defaultFormData, false, false, 'edit');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.material).toBeDefined();
|
||||
expect(result.errors.slicer_filament).toBeUndefined();
|
||||
expect(result.errors.brand).toBeUndefined();
|
||||
expect(result.errors.subtype).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes when editing a spool that only has material', () => {
|
||||
const data = { ...defaultFormData, material: 'ASA' };
|
||||
const result = validateForm(data, false, false, 'edit');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes when copying a spool that only has material', () => {
|
||||
const data = { ...defaultFormData, material: 'ASA' };
|
||||
const result = validateForm(data, false, false, 'copy');
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('still requires the full details in create mode', () => {
|
||||
const data = { ...defaultFormData, material: 'ASA' };
|
||||
const result = validateForm(data, false, false, 'create');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.slicer_filament).toBeDefined();
|
||||
expect(result.errors.brand).toBeDefined();
|
||||
expect(result.errors.subtype).toBeDefined();
|
||||
});
|
||||
|
||||
it('defaults to create mode when no mode is given', () => {
|
||||
const data = { ...defaultFormData, material: 'ASA' };
|
||||
expect(validateForm(data).isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,9 @@ import { api, ApiError } from '../api/client';
|
||||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
|
||||
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset, SpoolFormMode } from './spool-form/types';
|
||||
import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
|
||||
import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
|
||||
import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, pairedOptions, parsePresetName, saveRecentColor, withCurrentValue } from './spool-form/utils';
|
||||
import { MATERIALS } from './spool-form/constants';
|
||||
import { FilamentSection } from './spool-form/FilamentSection';
|
||||
import { ColorSection } from './spool-form/ColorSection';
|
||||
@@ -25,7 +25,7 @@ type TabId = 'filament' | 'pa-profile';
|
||||
|
||||
const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
|
||||
|
||||
export type SpoolFormMode = 'create' | 'edit' | 'copy';
|
||||
export type { SpoolFormMode };
|
||||
|
||||
interface SpoolFormModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -300,21 +300,33 @@ export function SpoolFormModal({
|
||||
return map;
|
||||
}, [brandMaterialPairs]);
|
||||
|
||||
const availableBrands = useMemo(() => {
|
||||
if (!formData.material) return baseAvailableBrands;
|
||||
const materialKey = formData.material.toLowerCase();
|
||||
const brandKeys = materialToBrands.get(materialKey);
|
||||
if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
|
||||
return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
|
||||
}, [baseAvailableBrands, formData.material, materialToBrands]);
|
||||
// #1905: the brand and material dropdowns used to be filtered down to the
|
||||
// pairs seen in the color catalog / slicer presets, which hid perfectly valid
|
||||
// combinations — "Elegoo" exists (as a PLA brand) but vanished from the list
|
||||
// once ASA was selected, making the entry look impossible. Both lists now
|
||||
// always offer everything we know about, plus whatever the spool already has
|
||||
// stored (a custom brand saved earlier was missing from its own dropdown).
|
||||
// The pairing knowledge survives as `suggestedBrands`/`suggestedMaterials`,
|
||||
// which the dropdowns sort to the top instead of filtering by.
|
||||
const availableBrands = useMemo(
|
||||
() => withCurrentValue(baseAvailableBrands, formData.brand),
|
||||
[baseAvailableBrands, formData.brand],
|
||||
);
|
||||
|
||||
const availableMaterials = useMemo(() => {
|
||||
if (!formData.brand) return baseAvailableMaterials;
|
||||
const brandKey = formData.brand.toLowerCase();
|
||||
const materialKeys = brandToMaterials.get(brandKey);
|
||||
if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
|
||||
return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
|
||||
}, [baseAvailableMaterials, formData.brand, brandToMaterials]);
|
||||
const availableMaterials = useMemo(
|
||||
() => withCurrentValue(baseAvailableMaterials, formData.material),
|
||||
[baseAvailableMaterials, formData.material],
|
||||
);
|
||||
|
||||
const suggestedBrands = useMemo(
|
||||
() => pairedOptions(availableBrands, formData.material, materialToBrands),
|
||||
[availableBrands, formData.material, materialToBrands],
|
||||
);
|
||||
|
||||
const suggestedMaterials = useMemo(
|
||||
() => pairedOptions(availableMaterials, formData.brand, brandToMaterials),
|
||||
[availableMaterials, formData.brand, brandToMaterials],
|
||||
);
|
||||
|
||||
// Find selected preset option
|
||||
const selectedPresetOption = useMemo(
|
||||
@@ -378,9 +390,14 @@ export function SpoolFormModal({
|
||||
setFormData(defaultFormData);
|
||||
setPresetInputValue('');
|
||||
setSelectedProfiles(new Set());
|
||||
setQuickAdd(false);
|
||||
setQuantity(1);
|
||||
}
|
||||
// Reset on every open, not just the create path (#1905). The modal keeps
|
||||
// its state while closed, and the Quick Add toggle only renders in create
|
||||
// mode — so quick-adding a spool and then opening Edit left the edit form
|
||||
// stuck in quick-add layout (no preset field, no PA-profile tab) with no
|
||||
// control to switch back.
|
||||
setQuickAdd(false);
|
||||
setQuantity(1);
|
||||
setErrors({});
|
||||
setActiveTab('filament');
|
||||
setWeightTouched(false);
|
||||
@@ -717,7 +734,7 @@ export function SpoolFormModal({
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const validation = validateForm(formData, quickAdd, spoolmanMode);
|
||||
const validation = validateForm(formData, quickAdd, spoolmanMode, mode);
|
||||
if (!validation.isValid) {
|
||||
setErrors(validation.errors);
|
||||
if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
|
||||
@@ -896,7 +913,10 @@ export function SpoolFormModal({
|
||||
filamentOptions={filamentOptions}
|
||||
availableBrands={availableBrands}
|
||||
availableMaterials={availableMaterials}
|
||||
suggestedBrands={suggestedBrands}
|
||||
suggestedMaterials={suggestedMaterials}
|
||||
quickAdd={quickAdd}
|
||||
detailsRequired={!quickAdd && !spoolmanMode && mode === 'create'}
|
||||
quantity={quantity}
|
||||
onQuantityChange={setQuantity}
|
||||
errors={errors}
|
||||
|
||||
@@ -5,6 +5,30 @@ import type { FilamentSectionProps, FilamentOption } from './types';
|
||||
import { KNOWN_VARIANTS } from './constants';
|
||||
import { parsePresetName } from './utils';
|
||||
|
||||
// The identity fields a slicer preset can auto-fill.
|
||||
type PresetFilledField = 'material' | 'brand' | 'subtype';
|
||||
|
||||
// Split a dropdown's options into the ones the catalog/presets pair with the
|
||||
// other field's current value and everything else (#1905). Suggestions are only
|
||||
// a sort order — nothing is ever hidden, so an unusual-but-real combination
|
||||
// (Elegoo ASA) stays one click away.
|
||||
function splitSuggested(options: string[], suggested: string[]): { top: string[]; rest: string[] } {
|
||||
if (suggested.length === 0) return { top: [], rest: options };
|
||||
const keys = new Set(suggested.map(s => s.toLowerCase()));
|
||||
return {
|
||||
top: options.filter(o => keys.has(o.toLowerCase())),
|
||||
rest: options.filter(o => !keys.has(o.toLowerCase())),
|
||||
};
|
||||
}
|
||||
|
||||
function GroupHeading({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-bambu-gray/70 bg-bambu-dark-tertiary/40">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilamentSection({
|
||||
formData,
|
||||
updateField,
|
||||
@@ -16,7 +40,10 @@ export function FilamentSection({
|
||||
filamentOptions,
|
||||
availableBrands,
|
||||
availableMaterials,
|
||||
suggestedBrands,
|
||||
suggestedMaterials,
|
||||
quickAdd,
|
||||
detailsRequired,
|
||||
quantity,
|
||||
onQuantityChange,
|
||||
errors,
|
||||
@@ -31,6 +58,10 @@ export function FilamentSection({
|
||||
const [materialSearch, setMaterialSearch] = useState('');
|
||||
const [labelInput, setLabelInput] = useState(String(formData.label_weight));
|
||||
const [isLabelFocused, setIsLabelFocused] = useState(false);
|
||||
// Which identity fields the currently selected preset filled in (#1905).
|
||||
// Anything outside this set was set by the user (or loaded from the spool
|
||||
// being edited) and must survive picking a preset.
|
||||
const [presetFilled, setPresetFilled] = useState<Set<PresetFilledField>>(new Set());
|
||||
const presetRef = useRef<HTMLDivElement>(null);
|
||||
const brandRef = useRef<HTMLDivElement>(null);
|
||||
const subtypeRef = useRef<HTMLDivElement>(null);
|
||||
@@ -101,6 +132,16 @@ export function FilamentSection({
|
||||
});
|
||||
}, [materialSearch, availableMaterials]);
|
||||
|
||||
const brandGroups = useMemo(
|
||||
() => splitSuggested(filteredBrands, suggestedBrands),
|
||||
[filteredBrands, suggestedBrands],
|
||||
);
|
||||
|
||||
const materialGroups = useMemo(
|
||||
() => splitSuggested(filteredMaterials, suggestedMaterials),
|
||||
[filteredMaterials, suggestedMaterials],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLabelFocused) {
|
||||
setLabelInput(String(formData.label_weight));
|
||||
@@ -113,13 +154,74 @@ export function FilamentSection({
|
||||
setPresetInputValue(option.displayName);
|
||||
setPresetDropdownOpen(false);
|
||||
|
||||
// Auto-fill material, brand, subtype from preset name
|
||||
// Auto-fill material, brand and subtype from the preset name — but only
|
||||
// where the value is still empty or was itself put there by the previously
|
||||
// selected preset (#1905). Overwriting unconditionally silently rewrote the
|
||||
// spool's own manufacturer ("Elegoo" → "Generic") whenever the user was
|
||||
// forced to assign a preset, so the spool vanished from where they filed it.
|
||||
// Switching between presets still replaces what the earlier one filled in.
|
||||
const parsed = parsePresetName(option.name);
|
||||
if (parsed.material) updateField('material', parsed.material);
|
||||
if (parsed.brand) updateField('brand', parsed.brand);
|
||||
if (parsed.variant) updateField('subtype', parsed.variant);
|
||||
const filled = new Set<PresetFilledField>();
|
||||
const entries: [PresetFilledField, string][] = [
|
||||
['material', parsed.material],
|
||||
['brand', parsed.brand],
|
||||
['subtype', parsed.variant],
|
||||
];
|
||||
for (const [field, value] of entries) {
|
||||
if (!value) continue;
|
||||
if (formData[field] && !presetFilled.has(field)) continue;
|
||||
updateField(field, value);
|
||||
filled.add(field);
|
||||
}
|
||||
setPresetFilled(filled);
|
||||
};
|
||||
|
||||
// A manual edit to an identity field takes it back out of the preset's hands.
|
||||
const updateIdentityField = (field: PresetFilledField, value: string) => {
|
||||
if (presetFilled.has(field)) {
|
||||
setPresetFilled(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(field);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
updateField(field, value);
|
||||
};
|
||||
|
||||
const renderBrandOption = (brand: string) => (
|
||||
<button
|
||||
key={brand}
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
|
||||
formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateIdentityField('brand', brand);
|
||||
setBrandDropdownOpen(false);
|
||||
setBrandSearch('');
|
||||
}}
|
||||
>
|
||||
{brand}
|
||||
</button>
|
||||
);
|
||||
|
||||
const renderMaterialOption = (material: string) => (
|
||||
<button
|
||||
key={material}
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
|
||||
formData.material === material ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateIdentityField('material', material);
|
||||
setMaterialDropdownOpen(false);
|
||||
setMaterialSearch('');
|
||||
}}
|
||||
>
|
||||
{material}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Cloud status indicator */}
|
||||
@@ -139,7 +241,7 @@ export function FilamentSection({
|
||||
{!quickAdd && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">
|
||||
{t('inventory.slicerPreset')} *
|
||||
{t('inventory.slicerPreset')}{detailsRequired && ' *'}
|
||||
</label>
|
||||
<div className="relative" ref={presetRef}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
|
||||
@@ -215,22 +317,20 @@ export function FilamentSection({
|
||||
{filteredMaterials.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
|
||||
) : (
|
||||
filteredMaterials.map((material) => (
|
||||
<button
|
||||
key={material}
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
|
||||
formData.material === material ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateField('material', material);
|
||||
setMaterialDropdownOpen(false);
|
||||
setMaterialSearch('');
|
||||
}}
|
||||
>
|
||||
{material}
|
||||
</button>
|
||||
))
|
||||
<>
|
||||
{materialGroups.top.length > 0 && (
|
||||
<>
|
||||
<GroupHeading label={t('inventory.suggestedOptions')} />
|
||||
{materialGroups.top.map(renderMaterialOption)}
|
||||
</>
|
||||
)}
|
||||
{materialGroups.rest.length > 0 && (
|
||||
<>
|
||||
{materialGroups.top.length > 0 && <GroupHeading label={t('inventory.allOptions')} />}
|
||||
{materialGroups.rest.map(renderMaterialOption)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Allow custom material */}
|
||||
{materialSearch && !filteredMaterials.includes(materialSearch) && (
|
||||
@@ -238,7 +338,7 @@ export function FilamentSection({
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
|
||||
onClick={() => {
|
||||
updateField('material', materialSearch);
|
||||
updateIdentityField('material', materialSearch);
|
||||
setMaterialDropdownOpen(false);
|
||||
setMaterialSearch('');
|
||||
}}
|
||||
@@ -257,7 +357,7 @@ export function FilamentSection({
|
||||
{/* Brand (dropdown with search) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">
|
||||
{t('inventory.brand')}{!quickAdd && ' *'}
|
||||
{t('inventory.brand')}{detailsRequired && ' *'}
|
||||
</label>
|
||||
<div className="relative" ref={brandRef}>
|
||||
<input
|
||||
@@ -280,22 +380,20 @@ export function FilamentSection({
|
||||
{filteredBrands.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
|
||||
) : (
|
||||
filteredBrands.map(brand => (
|
||||
<button
|
||||
key={brand}
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
|
||||
formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateField('brand', brand);
|
||||
setBrandDropdownOpen(false);
|
||||
setBrandSearch('');
|
||||
}}
|
||||
>
|
||||
{brand}
|
||||
</button>
|
||||
))
|
||||
<>
|
||||
{brandGroups.top.length > 0 && (
|
||||
<>
|
||||
<GroupHeading label={t('inventory.suggestedOptions')} />
|
||||
{brandGroups.top.map(renderBrandOption)}
|
||||
</>
|
||||
)}
|
||||
{brandGroups.rest.length > 0 && (
|
||||
<>
|
||||
{brandGroups.top.length > 0 && <GroupHeading label={t('inventory.allOptions')} />}
|
||||
{brandGroups.rest.map(renderBrandOption)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Allow custom brand */}
|
||||
{brandSearch && !filteredBrands.includes(brandSearch) && (
|
||||
@@ -303,7 +401,7 @@ export function FilamentSection({
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
|
||||
onClick={() => {
|
||||
updateField('brand', brandSearch);
|
||||
updateIdentityField('brand', brandSearch);
|
||||
setBrandDropdownOpen(false);
|
||||
setBrandSearch('');
|
||||
}}
|
||||
@@ -322,7 +420,7 @@ export function FilamentSection({
|
||||
{/* Variant / Subtype */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">
|
||||
{t('inventory.subtype')}{!quickAdd && ' *'}
|
||||
{t('inventory.subtype')}{detailsRequired && ' *'}
|
||||
</label>
|
||||
<div className="relative" ref={subtypeRef}>
|
||||
<input
|
||||
@@ -353,7 +451,7 @@ export function FilamentSection({
|
||||
formData.subtype === variant ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateField('subtype', variant);
|
||||
updateIdentityField('subtype', variant);
|
||||
setSubtypeDropdownOpen(false);
|
||||
setSubtypeSearch('');
|
||||
}}
|
||||
@@ -367,7 +465,7 @@ export function FilamentSection({
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
|
||||
onClick={() => {
|
||||
updateField('subtype', subtypeSearch);
|
||||
updateIdentityField('subtype', subtypeSearch);
|
||||
setSubtypeDropdownOpen(false);
|
||||
setSubtypeSearch('');
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
|
||||
import type { Printer, SpoolKProfile } from '../../api/client';
|
||||
|
||||
// Which operation the spool form is performing. Lives here (rather than in
|
||||
// SpoolFormModal) so validateForm can key off it without a circular import;
|
||||
// SpoolFormModal re-exports it for existing consumers.
|
||||
export type SpoolFormMode = 'create' | 'edit' | 'copy';
|
||||
|
||||
// Catalog color display type (moved from component)
|
||||
export interface CatalogDisplayColor {
|
||||
name: string;
|
||||
@@ -112,7 +117,17 @@ export interface FilamentSectionProps extends SectionProps {
|
||||
filamentOptions: FilamentOption[];
|
||||
availableBrands: string[];
|
||||
availableMaterials: string[];
|
||||
// Brands/materials the catalog and slicer presets know to pair with the other
|
||||
// field's current value (#1905). These sort to the top under a "Suggested"
|
||||
// heading — they are never used to hide the rest, because doing so made
|
||||
// legitimate combinations (Elegoo ASA) look impossible to enter.
|
||||
suggestedBrands: string[];
|
||||
suggestedMaterials: string[];
|
||||
quickAdd: boolean;
|
||||
// Whether preset/brand/subtype are mandatory for this submission — see
|
||||
// validateForm. Drives the " *" markers so the form never advertises a
|
||||
// requirement it won't enforce (#1905).
|
||||
detailsRequired: boolean;
|
||||
quantity: number;
|
||||
onQuantityChange: (value: number) => void;
|
||||
errors?: Partial<Record<keyof SpoolFormData, string>>;
|
||||
@@ -181,11 +196,17 @@ export function validateForm(
|
||||
formData: SpoolFormData,
|
||||
quickAdd = false,
|
||||
spoolmanMode = false,
|
||||
mode: SpoolFormMode = 'create',
|
||||
): ValidationResult {
|
||||
const errors: Partial<Record<keyof SpoolFormData, string>> = {};
|
||||
|
||||
// Quick-add and Spoolman mode only require material (unless a catalog entry is pre-selected)
|
||||
if (quickAdd || spoolmanMode) {
|
||||
// Quick-add and Spoolman mode only require material (unless a catalog entry
|
||||
// is pre-selected). Edit and copy relax the same way (#1905): the spool
|
||||
// already exists, and a row created by quick-add, CSV import or an RFID scan
|
||||
// has no preset/brand/subtype — demanding them here blocked every later edit,
|
||||
// even one that only changed the storage location. The backend only ever
|
||||
// required material (SpoolCreate/SpoolUpdate in schemas/spool.py).
|
||||
if (quickAdd || spoolmanMode || mode !== 'create') {
|
||||
if (!formData.material && !formData.spoolman_filament_id) {
|
||||
errors.material = 'Material is required';
|
||||
}
|
||||
|
||||
@@ -304,6 +304,31 @@ export function findPresetOption(
|
||||
return option;
|
||||
}
|
||||
|
||||
// Keep the value a spool already carries selectable in its own dropdown (#1905).
|
||||
// A brand or material entered as a custom value isn't part of the color catalog
|
||||
// or any slicer preset, so without this the edit form offered no way back to it
|
||||
// once the user opened the dropdown.
|
||||
export function withCurrentValue(options: string[], current: string): string[] {
|
||||
const trimmed = current.trim();
|
||||
if (!trimmed || options.some(o => o.toLowerCase() === trimmed.toLowerCase())) return options;
|
||||
return [...options, trimmed].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
// Brands/materials the catalog and slicer presets pair with the other field's
|
||||
// current value (#1905). Used to rank the dropdown, never to filter it — the
|
||||
// pairs are incomplete (Elegoo ships ASA even though the catalog only knows its
|
||||
// PLA), and hiding the rest made valid entries look impossible.
|
||||
export function pairedOptions(
|
||||
options: string[],
|
||||
counterpart: string,
|
||||
pairMap: Map<string, Set<string>>,
|
||||
): string[] {
|
||||
if (!counterpart) return [];
|
||||
const keys = pairMap.get(counterpart.toLowerCase());
|
||||
if (!keys || keys.size === 0) return [];
|
||||
return options.filter(o => keys.has(o.toLowerCase()));
|
||||
}
|
||||
|
||||
// Recent colors management
|
||||
export function loadRecentColors(): ColorPreset[] {
|
||||
try {
|
||||
|
||||
@@ -4330,6 +4330,8 @@ export default {
|
||||
searchBrand: 'Marke suchen...',
|
||||
useCustomBrand: '"{{brand}}" verwenden',
|
||||
useCustomMaterial: 'Benutzerdefiniertes Material verwenden: {{material}}',
|
||||
suggestedOptions: 'Vorgeschlagen',
|
||||
allOptions: 'Alle',
|
||||
colorName: 'Farbname',
|
||||
colorNamePlaceholder: 'Jadeweiß, Feuerrot...',
|
||||
color: 'Farbe',
|
||||
|
||||
@@ -4364,6 +4364,8 @@ export default {
|
||||
searchBrand: 'Search brand...',
|
||||
useCustomBrand: 'Use "{{brand}}"',
|
||||
useCustomMaterial: 'Use custom material: {{material}}',
|
||||
suggestedOptions: 'Suggested',
|
||||
allOptions: 'All',
|
||||
colorName: 'Color Name',
|
||||
colorNamePlaceholder: 'Jade White, Fire Red...',
|
||||
color: 'Color',
|
||||
|
||||
@@ -4333,6 +4333,8 @@ export default {
|
||||
searchBrand: 'Buscar marca...',
|
||||
useCustomBrand: 'Usar "{{brand}}"',
|
||||
useCustomMaterial: 'Usar material personalizado: {{material}}',
|
||||
suggestedOptions: 'Sugeridos',
|
||||
allOptions: 'Todos',
|
||||
colorName: 'Nombre del color',
|
||||
colorNamePlaceholder: 'Blanco jade, Rojo fuego...',
|
||||
color: 'Color',
|
||||
|
||||
@@ -4319,6 +4319,8 @@ export default {
|
||||
searchBrand: 'Chercher marque...',
|
||||
useCustomBrand: 'Utiliser "{{brand}}"',
|
||||
useCustomMaterial: 'Utiliser un matériau personnalisé : {{material}}',
|
||||
suggestedOptions: 'Suggérés',
|
||||
allOptions: 'Tous',
|
||||
colorName: 'Nom de couleur',
|
||||
colorNamePlaceholder: 'Blanc Jade, Rouge Feu...',
|
||||
color: 'Couleur',
|
||||
|
||||
@@ -4318,6 +4318,8 @@ export default {
|
||||
searchBrand: 'Cerca marchio...',
|
||||
useCustomBrand: 'Usa "{{brand}}"',
|
||||
useCustomMaterial: 'Usa materiale personalizzato: {{material}}',
|
||||
suggestedOptions: 'Suggeriti',
|
||||
allOptions: 'Tutti',
|
||||
colorName: 'Nome Colore',
|
||||
colorNamePlaceholder: 'Bianco Giada, Rosso Fuoco...',
|
||||
color: 'Colore',
|
||||
|
||||
@@ -4330,6 +4330,8 @@ export default {
|
||||
searchBrand: 'ブランドを検索...',
|
||||
useCustomBrand: '「{{brand}}」を使用',
|
||||
useCustomMaterial: 'カスタム素材を使用: {{material}}',
|
||||
suggestedOptions: 'おすすめ',
|
||||
allOptions: 'すべて',
|
||||
colorName: '色名',
|
||||
colorNamePlaceholder: 'ジェイドホワイト、ファイアレッド...',
|
||||
color: '色',
|
||||
|
||||
@@ -4119,6 +4119,8 @@ export default {
|
||||
searchBrand: '브랜드 검색...',
|
||||
useCustomBrand: '"{{brand}}" 사용',
|
||||
useCustomMaterial: '사용자 지정 재료 사용: {{material}}',
|
||||
suggestedOptions: '추천',
|
||||
allOptions: '전체',
|
||||
colorName: '색상 이름',
|
||||
colorNamePlaceholder: '제이드 화이트, 파이어 레드...',
|
||||
color: '색상',
|
||||
|
||||
@@ -4318,6 +4318,8 @@ export default {
|
||||
searchBrand: 'Pesquisar marca...',
|
||||
useCustomBrand: 'Usar "{{brand}}"',
|
||||
useCustomMaterial: 'Usar material personalizado: {{material}}',
|
||||
suggestedOptions: 'Sugeridos',
|
||||
allOptions: 'Todos',
|
||||
colorName: 'Nome da Cor',
|
||||
colorNamePlaceholder: 'Branco Jade, Vermelho Fogo...',
|
||||
color: 'Cor',
|
||||
|
||||
@@ -4109,6 +4109,8 @@ export default {
|
||||
searchBrand: "Поиск бренда…",
|
||||
useCustomBrand: "Использовать «{{brand}}»",
|
||||
useCustomMaterial: "Использовать свой материал: {{material}}",
|
||||
suggestedOptions: "Рекомендуемые",
|
||||
allOptions: "Все",
|
||||
colorName: "Название цвета",
|
||||
colorNamePlaceholder: "Jade White, Fire Red…",
|
||||
color: "Цвет",
|
||||
|
||||
@@ -4320,6 +4320,8 @@ export default {
|
||||
searchBrand: 'Marka ara...',
|
||||
useCustomBrand: '"{{brand}}" kullan',
|
||||
useCustomMaterial: 'Özel malzeme kullan: {{material}}',
|
||||
suggestedOptions: 'Önerilen',
|
||||
allOptions: 'Tümü',
|
||||
colorName: 'Renk Adı',
|
||||
colorNamePlaceholder: 'Yeşim Beyazı, Ateş Kırmızısı...',
|
||||
color: 'Renk',
|
||||
|
||||
@@ -4318,6 +4318,8 @@ export default {
|
||||
searchBrand: '搜索品牌...',
|
||||
useCustomBrand: '使用"{{brand}}"',
|
||||
useCustomMaterial: '使用自定义材料:{{material}}',
|
||||
suggestedOptions: '推荐',
|
||||
allOptions: '全部',
|
||||
colorName: '颜色名称',
|
||||
colorNamePlaceholder: '翡翠白、烈焰红...',
|
||||
color: '颜色',
|
||||
|
||||
@@ -4318,6 +4318,8 @@ export default {
|
||||
searchBrand: '搜尋品牌...',
|
||||
useCustomBrand: '使用"{{brand}}"',
|
||||
useCustomMaterial: '使用自訂材料:{{material}}',
|
||||
suggestedOptions: '推薦',
|
||||
allOptions: '全部',
|
||||
colorName: '顏色名稱',
|
||||
colorNamePlaceholder: '翡翠白、烈焰紅...',
|
||||
color: '顏色',
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
fetchPrinterCalibrations,
|
||||
findPresetOption,
|
||||
loadRecentColors,
|
||||
pairedOptions,
|
||||
parsePresetName,
|
||||
saveRecentColor,
|
||||
withCurrentValue,
|
||||
} from '../../components/spool-form/utils';
|
||||
import { MATERIALS } from '../../components/spool-form/constants';
|
||||
|
||||
@@ -613,21 +615,28 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
|
||||
return map;
|
||||
}, [brandMaterialPairs]);
|
||||
|
||||
const availableBrands = useMemo(() => {
|
||||
if (!formData.material) return baseAvailableBrands;
|
||||
const materialKey = formData.material.toLowerCase();
|
||||
const brandKeys = materialToBrands.get(materialKey);
|
||||
if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
|
||||
return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
|
||||
}, [baseAvailableBrands, formData.material, materialToBrands]);
|
||||
// #1905: offer every known brand/material and rank the catalog-paired ones
|
||||
// first, rather than filtering the others out — same behaviour as the
|
||||
// Inventory spool form, which this page mirrors field for field.
|
||||
const availableBrands = useMemo(
|
||||
() => withCurrentValue(baseAvailableBrands, formData.brand),
|
||||
[baseAvailableBrands, formData.brand],
|
||||
);
|
||||
|
||||
const availableMaterials = useMemo(() => {
|
||||
if (!formData.brand) return baseAvailableMaterials;
|
||||
const brandKey = formData.brand.toLowerCase();
|
||||
const materialKeys = brandToMaterials.get(brandKey);
|
||||
if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
|
||||
return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
|
||||
}, [baseAvailableMaterials, formData.brand, brandToMaterials]);
|
||||
const availableMaterials = useMemo(
|
||||
() => withCurrentValue(baseAvailableMaterials, formData.material),
|
||||
[baseAvailableMaterials, formData.material],
|
||||
);
|
||||
|
||||
const suggestedBrands = useMemo(
|
||||
() => pairedOptions(availableBrands, formData.material, materialToBrands),
|
||||
[availableBrands, formData.material, materialToBrands],
|
||||
);
|
||||
|
||||
const suggestedMaterials = useMemo(
|
||||
() => pairedOptions(availableMaterials, formData.brand, brandToMaterials),
|
||||
[availableMaterials, formData.brand, brandToMaterials],
|
||||
);
|
||||
|
||||
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }));
|
||||
@@ -913,7 +922,10 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
|
||||
filamentOptions={filamentOptions}
|
||||
availableBrands={availableBrands}
|
||||
availableMaterials={availableMaterials}
|
||||
suggestedBrands={suggestedBrands}
|
||||
suggestedMaterials={suggestedMaterials}
|
||||
quickAdd={quickAdd}
|
||||
detailsRequired={!quickAdd}
|
||||
quantity={quantity}
|
||||
onQuantityChange={setQuantity}
|
||||
errors={errors}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DLc2EliX.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BTdVtpDX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user