mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
feat(print-modal): show each printer slot's colour in the filament mapping (issue #3159)
The Print / Schedule dialog's filament mapping is where the colour a slice asked for is compared against the colour actually loaded, and only the left-hand side of that comparison had a swatch. The slot, and every slot in its dropdown, was text -- and the text cannot be trusted: a slot's colour name is resolved from the Color Catalog or, failing that, from hue, so a third-party beige is announced as "Orange". A "Color mismatch" warning then gives no way to tell a real mismatch from two names for the same hex without opening the printer card in another tab, which on a farm swapping twenty or thirty non-Bambu colours between machines is a check made many times a day. Each slot now carries its colour and its hex, and the slot whose colour is exactly the one the slice asked for is ticked. This works for a slot bound to an inventory spool and for one configured through Configure Slot or on the printer itself: the second kind has no inventory row behind it, and the printer's own tray colour is then what draws. A bound spool contributes what a tray record cannot -- SlotSpoolIdentity gains extra_colors and effect_type, so a two-tone or glittery spool draws as itself rather than as its base colour. The same treatment goes to the filament-override picker used for model-based assignment. It is the same choice on the other dispatch path, and leaving it text-only would have made one decision read two ways. Both controls stop being <select>s to do it, because an <option> renders text and nothing else. SlotPicker keeps what the select gave for free -- arrow, Home/End, Enter and Escape keys, listbox semantics, and the border colouring that encodes match, same-type-different-colour and not-loaded -- and is portaled with position:fixed so it is not clipped by the dialog's own scroll container, flipping above the row when there is no room below.
This commit is contained in:
@@ -10,13 +10,29 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
|
||||
import { screen, waitFor, cleanup, fireEvent, within } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
|
||||
import type { PrinterStatus } from '../../api/client';
|
||||
|
||||
/**
|
||||
* Open the slot picker and hand back its listbox.
|
||||
*
|
||||
* The picker replaced a native `<select>` (#3159). A `<select>` keeps every
|
||||
* `<option>` mounted whether or not it is open, so these assertions used to
|
||||
* read the closed control; a listbox renders its rows only while open. They
|
||||
* are about what the user is *offered*, so they open it first — and scope to
|
||||
* the listbox, because the chosen slot's label now also appears on the
|
||||
* trigger.
|
||||
*/
|
||||
async function openSlotPicker(): Promise<HTMLElement> {
|
||||
const trigger = await waitFor(() => screen.getAllByRole('combobox')[0]);
|
||||
fireEvent.click(trigger);
|
||||
return await waitFor(() => screen.getByRole('listbox'));
|
||||
}
|
||||
|
||||
const mockFilamentReqs = {
|
||||
filaments: [
|
||||
// Required filament asks for the LEFT extruder (nozzle_id=1).
|
||||
@@ -92,19 +108,18 @@ describe('FilamentMapping — FTS routing', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Both PLA and PETG slots must appear in the dropdown despite ams_extruder_map
|
||||
// being empty and the requirement asking for nozzle 1. Without the FTS guard
|
||||
// the dropdown would render only the "-- Select slot --" placeholder.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
|
||||
// Both PLA and PETG slots must be offered despite ams_extruder_map being
|
||||
// empty and the requirement asking for nozzle 1. Without the FTS guard the
|
||||
// picker would offer only the "-- Select slot --" placeholder.
|
||||
const listbox = await openSlotPicker();
|
||||
expect(within(listbox).getByText(/Bambu PLA/)).toBeInTheDocument();
|
||||
expect(within(listbox).getByText(/Bambu PETG/)).toBeInTheDocument();
|
||||
|
||||
// Each slot is badged for the switch INLET its AMS is plumbed into, using
|
||||
// the same L-for-In-A lettering as the printer card. Both slots are in
|
||||
// AMS 0, which is on In-A.
|
||||
expect(screen.getByText(/Bambu PETG/).textContent).toMatch(/\[L\]/);
|
||||
expect(screen.getByText(/Bambu PLA/).textContent).toMatch(/\[L\]/);
|
||||
expect(within(listbox).getByText(/Bambu PETG/).textContent).toMatch(/\[L\]/);
|
||||
expect(within(listbox).getByText(/Bambu PLA/).textContent).toMatch(/\[L\]/);
|
||||
});
|
||||
|
||||
it('does not badge slots whose AMS has no inlet binding yet', async () => {
|
||||
@@ -135,10 +150,8 @@ describe('FilamentMapping — FTS routing', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Bambu PETG/).textContent).not.toMatch(/\[[LR]\]/);
|
||||
const listbox = await openSlotPicker();
|
||||
expect(within(listbox).getByText(/Bambu PETG/).textContent).not.toMatch(/\[[LR]\]/);
|
||||
});
|
||||
|
||||
it('renders the per-slot force-color-match checkbox in printer mode (#1717)', async () => {
|
||||
@@ -258,12 +271,11 @@ describe('FilamentMapping — FTS routing', () => {
|
||||
);
|
||||
|
||||
// Required nozzle is 1 (LEFT) and AMS 0 is wired to extruder 0 (RIGHT).
|
||||
// Both slots must STILL appear so the user can pick them — explicitly the
|
||||
// cross-extruder scenario the #1722 fix unblocks.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
|
||||
// Both slots must STILL be offered so the user can pick them — explicitly
|
||||
// the cross-extruder scenario the #1722 fix unblocks.
|
||||
const listbox = await openSlotPicker();
|
||||
expect(within(listbox).getByText(/Bambu PLA/)).toBeInTheDocument();
|
||||
expect(within(listbox).getByText(/Bambu PETG/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sub-brand + material-disambiguated colour on the required side (#1718)', async () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import { screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
@@ -27,6 +27,33 @@ const defaultAvailable = [
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
/**
|
||||
* Open an override picker and hand back its rows.
|
||||
*
|
||||
* These assertions used to read `select.querySelectorAll('option')`. The
|
||||
* control gained a swatch per choice (#3159), which an `<option>` cannot
|
||||
* render, so it is a listbox now: its rows exist only while it is open, and
|
||||
* each carries its identity on `data-value` the way `<option value>` did.
|
||||
*/
|
||||
function openPicker(index = 0): HTMLElement[] {
|
||||
fireEvent.click(screen.getAllByRole('combobox')[index]);
|
||||
return within(screen.getByRole('listbox')).getAllByRole('option');
|
||||
}
|
||||
|
||||
/** The identities the picker offers, in order — the old `option.value` list. */
|
||||
function optionValues(index = 0): (string | null)[] {
|
||||
return openPicker(index).map((o) => o.getAttribute('data-value'));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the closed control reads as. With nothing overridden this is the
|
||||
* "original filament" row, which is where the #1718 label resolution lands —
|
||||
* previously read off `option[value=""]`.
|
||||
*/
|
||||
function triggerText(index = 0): string {
|
||||
return screen.getAllByRole('combobox')[index].textContent ?? '';
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
@@ -122,15 +149,10 @@ describe('FilamentOverride', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
const options = select.querySelectorAll('option');
|
||||
|
||||
// 1 default "Original" option + 2 PLA options (not PETG)
|
||||
expect(options).toHaveLength(3);
|
||||
|
||||
// Verify no PETG option values exist
|
||||
const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
|
||||
expect(optionValues).not.toContain('PETG|#0000FF');
|
||||
// 1 default "Original" row + 2 PLA rows (not PETG)
|
||||
const values = optionValues();
|
||||
expect(values).toHaveLength(3);
|
||||
expect(values).not.toContain('PETG|#0000FF');
|
||||
});
|
||||
|
||||
it('shows all same-type options regardless of color', () => {
|
||||
@@ -149,11 +171,8 @@ describe('FilamentOverride', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
const options = select.querySelectorAll('option');
|
||||
|
||||
// 1 default "Original" option + 3 PLA color options
|
||||
expect(options).toHaveLength(4);
|
||||
// 1 default "Original" row + 3 PLA colour rows
|
||||
expect(optionValues()).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,9 +192,7 @@ describe('FilamentOverride', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
const options = Array.from(select.querySelectorAll('option'));
|
||||
const optionTexts = options.map((o) => o.textContent);
|
||||
const optionTexts = openPicker().map((o) => o.textContent);
|
||||
|
||||
// Should show "PLA Basic" and "PLA Matte", not just "PLA"
|
||||
expect(optionTexts.some((t) => t?.includes('PLA Basic'))).toBe(true);
|
||||
@@ -196,11 +213,9 @@ describe('FilamentOverride', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
const options = Array.from(select.querySelectorAll('option'));
|
||||
// Non-default option should show "PLA" as the type fallback
|
||||
const nonDefaultOptions = options.filter((o) => o.getAttribute('value') !== '');
|
||||
expect(nonDefaultOptions[0].textContent).toContain('PLA');
|
||||
// Non-default row should show "PLA" as the type fallback
|
||||
const nonDefault = openPicker().filter((o) => o.getAttribute('data-value') !== '');
|
||||
expect(nonDefault[0].textContent).toContain('PLA');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,15 +241,11 @@ describe('FilamentOverride', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
const options = select.querySelectorAll('option');
|
||||
|
||||
// 1 default + 1 PLA with extruder_id=0 (extruder_id=1 is filtered out)
|
||||
expect(options).toHaveLength(2);
|
||||
|
||||
const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
|
||||
expect(optionValues).toContain('PLA|#FF0000');
|
||||
expect(optionValues).not.toContain('PLA|#00FF00');
|
||||
const values = optionValues();
|
||||
expect(values).toHaveLength(2);
|
||||
expect(values).toContain('PLA|#FF0000');
|
||||
expect(values).not.toContain('PLA|#00FF00');
|
||||
});
|
||||
|
||||
it('shows all filaments when nozzle_id is undefined', () => {
|
||||
@@ -258,11 +269,8 @@ describe('FilamentOverride', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
const options = select.querySelectorAll('option');
|
||||
|
||||
// 1 default + 2 PLA options (no nozzle filtering)
|
||||
expect(options).toHaveLength(3);
|
||||
// 1 default + 2 PLA rows (no nozzle filtering)
|
||||
expect(optionValues()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('includes filaments with null extruder_id', () => {
|
||||
@@ -287,16 +295,12 @@ describe('FilamentOverride', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
const options = select.querySelectorAll('option');
|
||||
|
||||
// 1 default + extruder_id=0 + extruder_id=null (extruder_id=1 filtered out)
|
||||
expect(options).toHaveLength(3);
|
||||
|
||||
const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
|
||||
expect(optionValues).toContain('PLA|#FF0000');
|
||||
expect(optionValues).toContain('PLA|#00FF00');
|
||||
expect(optionValues).not.toContain('PLA|#FFFFFF');
|
||||
const values = optionValues();
|
||||
expect(values).toHaveLength(3);
|
||||
expect(values).toContain('PLA|#FF0000');
|
||||
expect(values).toContain('PLA|#00FF00');
|
||||
expect(values).not.toContain('PLA|#FFFFFF');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -311,8 +315,8 @@ describe('FilamentOverride', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
fireEvent.change(select, { target: { value: 'PLA|#00FF00' } });
|
||||
const row = openPicker().find((o) => o.getAttribute('data-value') === 'PLA|#00FF00');
|
||||
fireEvent.click(row as HTMLElement);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
1: { type: 'PLA', color: '#00FF00' },
|
||||
@@ -333,13 +337,60 @@ describe('FilamentOverride', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
fireEvent.change(select, { target: { value: '' } });
|
||||
const row = openPicker().find((o) => o.getAttribute('data-value') === '');
|
||||
fireEvent.click(row as HTMLElement);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('colour swatches (#3159)', () => {
|
||||
it('draws the colour of every filament it offers, and of the original', () => {
|
||||
render(
|
||||
<FilamentOverride
|
||||
filamentReqs={defaultFilamentReqs}
|
||||
availableFilaments={defaultAvailable}
|
||||
overrides={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const rows = openPicker();
|
||||
// The original-filament row plus the two same-type candidates: model
|
||||
// mode pools across printers, so there is no slot binding behind an
|
||||
// entry and each swatch is the plain tray colour.
|
||||
expect(rows).toHaveLength(3);
|
||||
for (const row of rows) {
|
||||
expect(within(row).getByTestId('filament-swatch')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('prints each candidate hex and marks the one the slice asked for', () => {
|
||||
render(
|
||||
<FilamentOverride
|
||||
filamentReqs={defaultFilamentReqs}
|
||||
availableFilaments={defaultAvailable}
|
||||
overrides={{}}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const rows = openPicker();
|
||||
const listbox = screen.getByRole('listbox');
|
||||
expect(within(listbox).getByText('#00FF00')).toBeInTheDocument();
|
||||
// #FF0000 twice: the original-filament row states what the slice asked
|
||||
// for, and one candidate carries it.
|
||||
expect(within(listbox).getAllByText('#FF0000')).toHaveLength(2);
|
||||
|
||||
// The marker belongs on the candidate, not on the original — it answers
|
||||
// "which of these is the colour the slice asked for", which is the
|
||||
// judgement the colour names cannot be trusted to make.
|
||||
const marked = rows.filter((r) => within(r).queryByTitle('Exact colour match'));
|
||||
expect(marked).toHaveLength(1);
|
||||
expect(marked[0].getAttribute('data-value')).toBe('PLA|#FF0000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('original-label SKU resolution (#1718)', () => {
|
||||
it('uses the builtin filament name when tray_info_idx maps to a known SKU', async () => {
|
||||
// Stamped by Bambu Studio when slicing with PLA Matte Charcoal: 3MF
|
||||
@@ -367,15 +418,11 @@ describe('FilamentOverride', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for the queries to resolve and the resolved label to land in
|
||||
// the dropdown's "original" placeholder option. The tooltip on the
|
||||
// color swatch carries the same text, so we scope to the option to
|
||||
// avoid the multi-match.
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox');
|
||||
const placeholder = select.querySelector('option[value=""]');
|
||||
expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
|
||||
});
|
||||
// Wait for the queries to resolve and the resolved label to land on the
|
||||
// control, which reads as the "original filament" row while nothing is
|
||||
// overridden. Reading the closed trigger rather than the open list keeps
|
||||
// this clear of the swatch tooltip, which carries the same text.
|
||||
await waitFor(() => expect(triggerText()).toMatch(/Bambu PLA Matte/));
|
||||
});
|
||||
|
||||
it('prefers the cloud user-preset name over the builtin entry for the same id', async () => {
|
||||
@@ -405,11 +452,7 @@ describe('FilamentOverride', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox');
|
||||
const placeholder = select.querySelector('option[value=""]');
|
||||
expect(placeholder?.textContent).toMatch(/My House PLA/);
|
||||
});
|
||||
await waitFor(() => expect(triggerText()).toMatch(/My House PLA/));
|
||||
// The builtin fallback must NOT bleed through anywhere — neither the
|
||||
// placeholder option nor the tooltip.
|
||||
expect(screen.queryByText(/Bambu PLA Basic/)).not.toBeInTheDocument();
|
||||
@@ -452,11 +495,7 @@ describe('FilamentOverride', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox');
|
||||
const placeholder = select.querySelector('option[value=""]');
|
||||
expect(placeholder?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
|
||||
});
|
||||
await waitFor(() => expect(triggerText()).toMatch(/Bambu PLA Matte \(Charcoal\)/));
|
||||
});
|
||||
|
||||
it('disambiguates per slot when two slots share a hex but differ in material', async () => {
|
||||
@@ -497,10 +536,9 @@ describe('FilamentOverride', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
expect(selects).toHaveLength(2);
|
||||
expect(selects[0].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
|
||||
expect(selects[1].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Basic \(Black\)/);
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(2);
|
||||
expect(triggerText(0)).toMatch(/Bambu PLA Matte \(Charcoal\)/);
|
||||
expect(triggerText(1)).toMatch(/Bambu PLA Basic \(Black\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -536,10 +574,8 @@ describe('FilamentOverride', () => {
|
||||
// Wait for the builtin lookup to land so we know the row mounted; the
|
||||
// colour fallback to getColorName for #FF0000 produces "Red"-shaped text.
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox');
|
||||
const placeholder = select.querySelector('option[value=""]');
|
||||
expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
|
||||
expect(placeholder?.textContent).not.toMatch(/null/);
|
||||
expect(triggerText()).toMatch(/Bambu PLA Matte/);
|
||||
expect(triggerText()).not.toMatch(/null/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -571,9 +607,7 @@ describe('FilamentOverride', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('(25g)')).toBeInTheDocument();
|
||||
});
|
||||
const select = screen.getByRole('combobox');
|
||||
const placeholder = select.querySelector('option[value=""]');
|
||||
expect(placeholder?.textContent).toMatch(/PLA \(/);
|
||||
expect(triggerText()).toMatch(/PLA \(/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type React from 'react';
|
||||
import { screen, waitFor, fireEvent, render as rtlRender } from '@testing-library/react';
|
||||
import { screen, waitFor, fireEvent, within, render as rtlRender } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
@@ -59,6 +59,32 @@ const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueu
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Choose a row in a SlotPicker (#3159).
|
||||
*
|
||||
* The slot and override controls were native `<select>`s until a swatch had to
|
||||
* go next to each choice, which an `<option>` cannot render. They are listboxes
|
||||
* now, so a test picks by opening the control and clicking a row instead of
|
||||
* calling `selectOptions` with a value. `triggerName` matches the control's
|
||||
* aria-label ("Printer slot for …" / "Filament override for …"), which is a
|
||||
* steadier handle than the old "find the select that has an option with this
|
||||
* value".
|
||||
*/
|
||||
async function pickFromSlotPicker(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
triggerName: RegExp,
|
||||
rowText: RegExp,
|
||||
): Promise<void> {
|
||||
const trigger = await waitFor(() => {
|
||||
const found = screen.getAllByRole('combobox', { name: triggerName });
|
||||
if (found.length === 0) throw new Error(`no picker matching ${triggerName}`);
|
||||
return found[0];
|
||||
});
|
||||
await user.click(trigger);
|
||||
const listbox = await screen.findByRole('listbox');
|
||||
await user.click(within(listbox).getByText(rowText));
|
||||
}
|
||||
|
||||
describe('PrintModal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnSuccess = vi.fn();
|
||||
@@ -1778,15 +1804,10 @@ describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => {
|
||||
await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 1/)).toBeInTheDocument());
|
||||
|
||||
// Force plate 1's slot 1 onto the black tray (1) instead of the auto-matched red (0).
|
||||
// A2 is AMS 0 / tray 1, i.e. global tray id 1 — the value the old
|
||||
// `selectOptions(..., '1')` picked.
|
||||
await user.click(screen.getByText(/Filament Mapping — Plate 1/));
|
||||
const traySelects = await waitFor(() => {
|
||||
const found = screen.getAllByRole('combobox').filter((el) =>
|
||||
Array.from((el as HTMLSelectElement).options).some((o) => o.value === '1'),
|
||||
);
|
||||
if (found.length === 0) throw new Error('no tray select rendered');
|
||||
return found;
|
||||
});
|
||||
await user.selectOptions(traySelects[0], '1');
|
||||
await pickFromSlotPicker(user, /printer slot for/i, /^A2:/);
|
||||
|
||||
// Now move the job to the other printer.
|
||||
await user.click(screen.getByText('X1 Carbon')); // deselect
|
||||
@@ -2347,6 +2368,47 @@ describe('PrintModal — override survives "Any model" -> "Specific Printer" (#3
|
||||
await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
|
||||
};
|
||||
|
||||
// The slot picker's list is portaled to <body>, outside the dialog's own
|
||||
// DOM, and the dialog closes both on a click outside itself and on Escape.
|
||||
// Either would be a bad regression: picking a slot, or dismissing the list,
|
||||
// would throw away everything typed into the dialog (#3159).
|
||||
it('does not close the dialog when a slot is picked from the portaled list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
|
||||
|
||||
await moveToPrinter01(user);
|
||||
// The panel is collapsed by default; the picker only exists once open.
|
||||
await user.click(await screen.findByText(/filament mapping/i));
|
||||
|
||||
const trigger = await screen.findByRole('combobox', { name: /printer slot for/i });
|
||||
await user.click(trigger);
|
||||
const listbox = await screen.findByRole('listbox');
|
||||
await user.click(within(listbox).getAllByRole('option')[1]);
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/filament mapping/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes only the slot list on Escape, not the dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
|
||||
|
||||
await moveToPrinter01(user);
|
||||
await user.click(await screen.findByText(/filament mapping/i));
|
||||
|
||||
await user.click(await screen.findByRole('combobox', { name: /printer slot for/i }));
|
||||
await screen.findByRole('listbox');
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
|
||||
// And a second Escape, with no list open, still closes the dialog — the
|
||||
// picker must not have swallowed the key permanently.
|
||||
await user.keyboard('{Escape}');
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches the chosen printer against the override and keeps it on the item', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
|
||||
@@ -2486,14 +2548,13 @@ describe('PrintModal — override survives "Any model" -> "Specific Printer" (#3
|
||||
return select as HTMLSelectElement;
|
||||
});
|
||||
await user.selectOptions(modelSelect, 'P2S');
|
||||
const overrideSelect = await waitFor(() => {
|
||||
const select = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((el) => [...(el as HTMLSelectElement).options].some((o) => o.value === `PLA|${BONE_WHITE}`));
|
||||
if (!select) throw new Error('override select not rendered');
|
||||
return select as HTMLSelectElement;
|
||||
});
|
||||
await user.selectOptions(overrideSelect, `PLA|${BONE_WHITE}`);
|
||||
// Matched on the hex the row now prints (#3159) rather than on the option
|
||||
// value, so the assertion names the colour the user is actually picking.
|
||||
await pickFromSlotPicker(
|
||||
user,
|
||||
/filament override for/i,
|
||||
new RegExp(BONE_WHITE.toUpperCase().replace('#', '#?')),
|
||||
);
|
||||
|
||||
await moveToPrinter01(user);
|
||||
await waitFor(() => expect(screen.getByText(/filament mapping/i)).toBeInTheDocument());
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { screen, waitFor, fireEvent, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
@@ -94,8 +94,14 @@ describe('PrintModal cross-model mode', () => {
|
||||
|
||||
// PLA Matte is loaded only on the H2D. It has to be offered anyway: the job
|
||||
// can land there, and choosing it simply narrows which candidates match.
|
||||
// The control is a listbox since it gained a swatch per choice (#3159), so
|
||||
// its rows exist only while it is open.
|
||||
const trigger = await screen.findByRole('combobox', { name: /filament override for/i });
|
||||
fireEvent.click(trigger);
|
||||
await waitFor(() => {
|
||||
const options = screen.getAllByRole('option').map((o) => o.textContent ?? '');
|
||||
const options = within(screen.getByRole('listbox'))
|
||||
.getAllByRole('option')
|
||||
.map((o) => o.textContent ?? '');
|
||||
expect(options.some((o) => o.includes('PLA Matte'))).toBe(true);
|
||||
expect(options.some((o) => o.includes('PLA Basic'))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Tests for SlotPicker — the filament chooser that replaced the Print /
|
||||
* Schedule dialog's native `<select>`s so each choice can carry its colour
|
||||
* (#3159).
|
||||
*
|
||||
* The point of the control is the swatch, so these pin that a colour is drawn
|
||||
* for every row and for the chosen one; that a slot with no inventory binding
|
||||
* still gets the tray's own colour; and that the keyboard still works, because
|
||||
* the `<select>` this replaced was operable without a mouse and losing that
|
||||
* would be a regression dressed up as a feature.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, within, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { SlotPicker, type SlotPickerOption } from '../../components/PrintModal/SlotPicker';
|
||||
import { displayHex } from '../../components/PrintModal/displayHex';
|
||||
|
||||
const OPTIONS: SlotPickerOption[] = [
|
||||
{
|
||||
value: '0',
|
||||
label: 'A1: Devil Design PLA Basic (Orange)',
|
||||
meta: ' - 820g left',
|
||||
rgba: '#FEC600',
|
||||
hex: '#FEC600',
|
||||
},
|
||||
{
|
||||
value: '1',
|
||||
// A slot configured on the printer with no inventory spool behind it: the
|
||||
// tray colour is all there is, and it still has to draw.
|
||||
label: 'A2: PLA (Black)',
|
||||
rgba: '#000000',
|
||||
hex: '#000000',
|
||||
},
|
||||
{
|
||||
value: '2',
|
||||
label: 'A3: eSUN PLA Silk (Rainbow)',
|
||||
rgba: '#FF0000',
|
||||
extraColors: '00FF00,0000FF',
|
||||
subtype: 'Multicolor',
|
||||
hex: '#FF0000',
|
||||
exactMatch: true,
|
||||
},
|
||||
];
|
||||
|
||||
function renderPicker(props: Partial<React.ComponentProps<typeof SlotPicker>> = {}) {
|
||||
const onChange = props.onChange ?? vi.fn();
|
||||
render(
|
||||
<SlotPicker
|
||||
value=""
|
||||
options={OPTIONS}
|
||||
placeholder="-- Select slot --"
|
||||
ariaLabel="Printer slot for PLA"
|
||||
exactMatchLabel="Exact colour match"
|
||||
{...props}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
async function open(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('combobox', { name: /printer slot for pla/i }));
|
||||
return await screen.findByRole('listbox');
|
||||
}
|
||||
|
||||
describe('displayHex', () => {
|
||||
it('prints six digits for an opaque colour and eight for a see-through one', () => {
|
||||
// Alpha is kept only when it carries information: a Clear filament must
|
||||
// not read as if it were solid, and an opaque spool must not be given a
|
||||
// meaningless "FF" tail.
|
||||
expect(displayHex('#FEC600')).toBe('#FEC600');
|
||||
expect(displayHex('FEC600FF')).toBe('#FEC600');
|
||||
expect(displayHex('#00AAFF80')).toBe('#00AAFF80');
|
||||
expect(displayHex('#00000000')).toBe('#00000000');
|
||||
});
|
||||
|
||||
it('prints nothing rather than something misleading', () => {
|
||||
expect(displayHex(null)).toBeNull();
|
||||
expect(displayHex('')).toBeNull();
|
||||
expect(displayHex('#ABC')).toBeNull();
|
||||
expect(displayHex('not-a-colour')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SlotPicker', () => {
|
||||
it('draws a swatch for every offered slot', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPicker();
|
||||
|
||||
const listbox = await open(user);
|
||||
// One per option. The placeholder has no colour and deliberately draws
|
||||
// nothing rather than a grey swatch, which would read as a grey spool.
|
||||
expect(within(listbox).getAllByTestId('filament-swatch')).toHaveLength(OPTIONS.length);
|
||||
});
|
||||
|
||||
it('draws the chosen slot\'s colour on the closed control', async () => {
|
||||
renderPicker({ value: '1' });
|
||||
|
||||
const trigger = screen.getByRole('combobox', { name: /printer slot for pla/i });
|
||||
const swatch = within(trigger).getByTestId('filament-swatch');
|
||||
expect(swatch).toBeInTheDocument();
|
||||
expect(trigger).toHaveTextContent('A2: PLA (Black)');
|
||||
});
|
||||
|
||||
it('renders a multi-colour spool as a gradient, not its base colour', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPicker();
|
||||
|
||||
const listbox = await open(user);
|
||||
const rainbow = within(listbox).getByRole('option', { name: /eSUN PLA Silk/ });
|
||||
const swatch = within(rainbow).getByTestId('filament-swatch');
|
||||
// `Multicolor` is what turns the stops into a conic sweep; a slot with no
|
||||
// extra stops would be a flat linear-gradient of one colour.
|
||||
expect(swatch.style.backgroundImage).toContain('conic-gradient');
|
||||
expect(swatch.style.backgroundImage).toContain('#00FF00');
|
||||
});
|
||||
|
||||
it('shows each slot\'s hex and marks the one that matches exactly', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPicker();
|
||||
|
||||
const listbox = await open(user);
|
||||
expect(within(listbox).getByText('#FEC600')).toBeInTheDocument();
|
||||
// The marker is what answers "is this a real mismatch or two names for the
|
||||
// same hex" without leaving the dialog.
|
||||
const exact = within(listbox).getByTitle('Exact colour match');
|
||||
expect(within(listbox).getByRole('option', { name: /eSUN PLA Silk/ })).toContainElement(exact);
|
||||
});
|
||||
|
||||
it('picks a slot with the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderPicker();
|
||||
|
||||
const trigger = screen.getByRole('combobox', { name: /printer slot for pla/i });
|
||||
trigger.focus();
|
||||
// Row 0 is the placeholder, so two presses land on the second slot.
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await screen.findByRole('listbox');
|
||||
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('1');
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('closes on Escape without letting the dialog around it see the key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDialogEscape = vi.fn();
|
||||
document.addEventListener('keydown', onDialogEscape);
|
||||
try {
|
||||
renderPicker();
|
||||
await open(user);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
|
||||
// The picker stops propagation, so a dialog listening for Escape on the
|
||||
// document never sees it and stays open (#3159).
|
||||
expect(onDialogEscape).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
document.removeEventListener('keydown', onDialogEscape);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports the value the caller gave the row, not its position', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderPicker();
|
||||
|
||||
const listbox = await open(user);
|
||||
await user.click(within(listbox).getByRole('option', { name: /Devil Design/ }));
|
||||
expect(onChange).toHaveBeenCalledWith('0');
|
||||
});
|
||||
|
||||
it('offers the placeholder as a real choice so a slot can be cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderPicker({ value: '1' });
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: /printer slot for pla/i }));
|
||||
const listbox = await screen.findByRole('listbox');
|
||||
await user.click(within(listbox).getByRole('option', { name: /Select slot/ }));
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
@@ -397,6 +397,82 @@ describe('computeAmsMapping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLoadedFilaments - slot colour for the picker (#3159)', () => {
|
||||
it('carries the bound spool\'s extra stops and effect onto the slot', () => {
|
||||
// A tray record holds one hex and nothing else, so a two-tone or glittery
|
||||
// spool would draw as its base colour in the Print dialog's slot picker.
|
||||
// The binding is the only place the rest of the colour exists.
|
||||
const status = createPrinterStatus([
|
||||
{ id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' }] },
|
||||
]);
|
||||
const slotSpools = new Map([
|
||||
[
|
||||
0,
|
||||
{
|
||||
brand: 'eSUN',
|
||||
material: 'PLA',
|
||||
subtype: 'Multicolor',
|
||||
color_name: 'Rainbow',
|
||||
rgba: 'FF0000FF',
|
||||
extra_colors: '00FF00,0000FF',
|
||||
effect_type: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const [slot] = buildLoadedFilaments(status, slotSpools);
|
||||
|
||||
expect(slot).toMatchObject({
|
||||
extraColors: '00FF00,0000FF',
|
||||
spoolSubtype: 'Multicolor',
|
||||
colorName: 'Rainbow',
|
||||
});
|
||||
expect(slot.effectType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a slot with no binding to its own tray colour', () => {
|
||||
// Configure Slot / set-on-the-printer slots have no inventory spool behind
|
||||
// them. The firmware reports no stops and never will, so the picker draws a
|
||||
// solid swatch -- which is the honest one, not a missing feature.
|
||||
const status = createPrinterStatus([
|
||||
{ id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '00AAFFFF', tray_info_idx: 'GFA00' }] },
|
||||
]);
|
||||
|
||||
const [slot] = buildLoadedFilaments(status);
|
||||
|
||||
expect(slot.color).toBe('#00AAFF');
|
||||
expect(slot.extraColors).toBeUndefined();
|
||||
expect(slot.effectType).toBeUndefined();
|
||||
expect(slot.spoolSubtype).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries them for the external spool holder too', () => {
|
||||
const status = createPrinterStatus(
|
||||
[],
|
||||
[{ id: 254, tray_type: 'PLA', tray_color: 'AABBCCFF', tray_info_idx: 'GFA00' }] as PrinterStatus['vt_tray'],
|
||||
);
|
||||
const slotSpools = new Map([
|
||||
[
|
||||
254,
|
||||
{
|
||||
brand: 'Devil Design',
|
||||
material: 'PLA',
|
||||
subtype: 'Galaxy',
|
||||
color_name: 'Galaxy Black',
|
||||
rgba: 'AABBCCFF',
|
||||
extra_colors: null,
|
||||
effect_type: 'Glitter',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const [slot] = buildLoadedFilaments(status, slotSpools);
|
||||
|
||||
expect(slot.effectType).toBe('Glitter');
|
||||
expect(slot.spoolSubtype).toBe('Galaxy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLoadedFilaments - nozzle awareness', () => {
|
||||
it('sets extruderId from ams_extruder_map', () => {
|
||||
const status = createPrinterStatus([
|
||||
|
||||
@@ -3730,6 +3730,15 @@ export interface SlotSpoolIdentity {
|
||||
subtype: string | null;
|
||||
color_name: string | null;
|
||||
rgba: string | null;
|
||||
/** Comma-separated hex stops for a multi-colour spool, and the effect
|
||||
* overlay — the two things a tray record cannot carry, so a slot swatch
|
||||
* can be drawn the way the inventory row draws it (#3159).
|
||||
*
|
||||
* Optional rather than required: the backend sends both keys on every
|
||||
* binding, but a frontend running against an older one gets neither, and
|
||||
* every reader here already falls back to a solid swatch. */
|
||||
extra_colors?: string | null;
|
||||
effect_type?: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryRemainResponse {
|
||||
|
||||
@@ -5,8 +5,10 @@ import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palett
|
||||
import { api } from '../../api/client';
|
||||
import type { SlotSpoolIdentity } from '../../api/client';
|
||||
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
|
||||
import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE } from '../../utils/amsHelpers';
|
||||
import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE, normalizeColorForCompare } from '../../utils/amsHelpers';
|
||||
import { disambiguateColorNames, getColorName } from '../../utils/colors';
|
||||
import { SlotPicker } from './SlotPicker';
|
||||
import { displayHex } from './displayHex';
|
||||
import { useFilamentLabels } from './useFilamentLabels';
|
||||
import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
|
||||
import type { FilamentMappingProps, RackGroupInfo } from './types';
|
||||
@@ -490,11 +492,32 @@ export function FilamentMapping({
|
||||
</span>
|
||||
{/* Arrow */}
|
||||
<span className="text-bambu-gray">→</span>
|
||||
{/* Slot selector dropdown */}
|
||||
<select
|
||||
value={item.loaded?.globalTrayId ?? ''}
|
||||
onChange={(e) => handleSlotChange(slotId, e.target.value)}
|
||||
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
|
||||
{/*
|
||||
Slot picker. Not a <select>: an <option> renders text only,
|
||||
and the colour of the slot is the whole point of this row
|
||||
(#3159) -- colour names come from the catalogue or from hue,
|
||||
so a third-party beige reads as "Orange" and the mismatch
|
||||
warning cannot be judged without seeing both swatches.
|
||||
|
||||
#1722: every loaded slot is offered for every filament row,
|
||||
regardless of which extruder the slot is wired to. Before that
|
||||
change a slot was only listed when its extruder matched the
|
||||
filament's slicer-assigned nozzle (item.nozzle_id), which
|
||||
locked users out of cross-extruder picks even when they'd
|
||||
intentionally loaded the required filament into the "other"
|
||||
AMS. The L/R badge on the filament row still tells the user
|
||||
what the slicer planned; the picker trusts the user to pick
|
||||
based on their physical setup. Printer firmware accepts or
|
||||
rejects the ams_mapping at start-print -- failure is loud,
|
||||
not silent.
|
||||
*/}
|
||||
<SlotPicker
|
||||
value={item.loaded?.globalTrayId != null ? String(item.loaded.globalTrayId) : ''}
|
||||
onChange={(next) => handleSlotChange(slotId, next)}
|
||||
placeholder={`-- ${t('printModal.selectSlot')} --`}
|
||||
ariaLabel={t('printModal.slotForFilament', { name: resolvedName })}
|
||||
exactMatchLabel={t('printModal.exactColorMatch')}
|
||||
className={`${
|
||||
item.status === 'match'
|
||||
? 'border-bambu-green/50 text-bambu-green'
|
||||
: item.status === 'type_only'
|
||||
@@ -502,24 +525,7 @@ export function FilamentMapping({
|
||||
: 'border-orange-500 dark:border-orange-400/50 text-orange-700 dark:text-orange-400'
|
||||
} ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
|
||||
title={item.isManual ? t('printModal.manuallySelected') : t('printModal.autoMatched')}
|
||||
>
|
||||
<option value="" className="bg-bambu-dark text-bambu-gray">
|
||||
-- {t('printModal.selectSlot')} --
|
||||
</option>
|
||||
{/*
|
||||
#1722: every loaded slot is offered for every filament row,
|
||||
regardless of which extruder the slot is wired to. Before this
|
||||
change a slot was only listed when its extruder matched the
|
||||
filament's slicer-assigned nozzle (item.nozzle_id), which
|
||||
locked users out of cross-extruder picks even when they'd
|
||||
intentionally loaded the required filament into the "other"
|
||||
AMS. The L/R badge on the filament row still tells the user
|
||||
what the slicer planned; the dropdown now trusts the user to
|
||||
pick based on their physical setup. Printer firmware accepts
|
||||
or rejects the ams_mapping at start-print — failure is loud,
|
||||
not silent.
|
||||
*/}
|
||||
{loadedFilaments.map((f) => {
|
||||
options={loadedFilaments.map((f) => {
|
||||
const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
|
||||
const remainingLabel = remainingWeight != null
|
||||
? t('printModal.slotRemainingShort', {
|
||||
@@ -536,13 +542,24 @@ export function FilamentMapping({
|
||||
// two views agree. Not translated: L and R are the letters on
|
||||
// the machine.
|
||||
const ftsBadge = ftsInlet == null ? '' : ` [${FTS_INLET_SIDE[ftsInlet]}]`;
|
||||
return (
|
||||
<option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
|
||||
{f.label}: {f.spoolName || f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
|
||||
</option>
|
||||
);
|
||||
return {
|
||||
value: String(f.globalTrayId),
|
||||
label: `${f.label}: ${f.spoolName || f.traySubBrands || f.type} (${f.colorName})`,
|
||||
meta: `${remainingLabel}${ftsBadge}`,
|
||||
rgba: f.color,
|
||||
extraColors: f.extraColors,
|
||||
effectType: f.effectType,
|
||||
subtype: f.spoolSubtype,
|
||||
hex: displayHex(f.color),
|
||||
// The hex the slice asked for, against the hex the
|
||||
// printer reports -- the comparison the colour names
|
||||
// cannot be trusted to make.
|
||||
exactMatch:
|
||||
normalizeColorForCompare(item.color) !== '' &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(item.color),
|
||||
};
|
||||
})}
|
||||
</select>
|
||||
/>
|
||||
{/* Status icon */}
|
||||
{item.status === 'match' ? (
|
||||
<Check className="w-3 h-3 text-bambu-green" />
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Circle, RotateCcw, Palette } from 'lucide-react';
|
||||
import { getColorName } from '../../utils/colors';
|
||||
import { canonicalFilamentType } from '../../utils/amsHelpers';
|
||||
import { canonicalFilamentType, normalizeColorForCompare } from '../../utils/amsHelpers';
|
||||
import { SlotPicker } from './SlotPicker';
|
||||
import { displayHex } from './displayHex';
|
||||
import { useFilamentLabels } from './useFilamentLabels';
|
||||
import type { FilamentReqsData } from './types';
|
||||
|
||||
@@ -123,30 +125,42 @@ export function FilamentOverride({
|
||||
</span>
|
||||
{/* Arrow */}
|
||||
<span className="text-bambu-gray">→</span>
|
||||
{/* Override dropdown — only compatible (same-type) filaments */}
|
||||
<select
|
||||
{/* Override picker — only compatible (same-type) filaments.
|
||||
Carries a swatch for the same reason the mapping row does
|
||||
(#3159): this is the model-mode half of the same choice,
|
||||
and leaving it text-only would make the two paths read
|
||||
differently for one decision. Model mode pools filaments
|
||||
across every printer of the model, so there is no slot
|
||||
binding behind an entry and the swatch is the plain tray
|
||||
colour -- solid, with no multi-colour stops to draw. */}
|
||||
<SlotPicker
|
||||
value={isOverridden ? `${override.type}|${override.color}` : ''}
|
||||
onChange={(e) => handleChange(req.slot_id, e.target.value)}
|
||||
onChange={(next) => handleChange(req.slot_id, next)}
|
||||
placeholder={`${t('printModal.originalFilament')}: ${resolvedName} (${colorLabel})`}
|
||||
placeholderOption={{ rgba: req.color, hex: displayHex(req.color) }}
|
||||
disabled={compatible.length === 0}
|
||||
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
|
||||
ariaLabel={t('printModal.overrideForFilament', { name: resolvedName })}
|
||||
exactMatchLabel={t('printModal.exactColorMatch')}
|
||||
className={
|
||||
isOverridden
|
||||
? 'border-blue-500/50 dark:border-blue-400/50 text-blue-700 dark:text-blue-400'
|
||||
: 'border-bambu-gray/30 text-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<option value="" className="bg-bambu-dark text-bambu-gray">
|
||||
{t('printModal.originalFilament')}: {resolvedName} ({colorLabel})
|
||||
</option>
|
||||
{compatible.map((f, idx) => (
|
||||
<option
|
||||
key={`${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`}
|
||||
value={`${f.type}|${f.color}`}
|
||||
className="bg-bambu-dark text-white"
|
||||
>
|
||||
{f.tray_sub_brands || f.type} ({getColorName(f.color, f.tray_sub_brands)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
options={compatible.map((f, idx) => ({
|
||||
// The value has to stay `TYPE|COLOR` -- `handleChange`
|
||||
// parses it -- but two spools of the same type and colour
|
||||
// on different printers collapse to one value, so the key
|
||||
// keeps the sub-brand and the index as it did before.
|
||||
value: `${f.type}|${f.color}`,
|
||||
key: `${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`,
|
||||
label: `${f.tray_sub_brands || f.type} (${getColorName(f.color, f.tray_sub_brands)})`,
|
||||
rgba: f.color,
|
||||
hex: displayHex(f.color),
|
||||
exactMatch:
|
||||
normalizeColorForCompare(req.color) !== '' &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color),
|
||||
}))}
|
||||
/>
|
||||
{/* Reset button */}
|
||||
{isOverridden ? (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, Check } from 'lucide-react';
|
||||
import { FilamentSwatch } from '../FilamentSwatch';
|
||||
|
||||
/**
|
||||
* One-line filament picker that can show a colour, which a `<select>` cannot.
|
||||
*
|
||||
* The Print / Schedule dialog is where the slicer's colour is compared against
|
||||
* what is actually in the machine, and until now the right-hand side of that
|
||||
* comparison was text only (#3159). Colour names are not a substitute: they are
|
||||
* resolved from the Color Catalog or from hue, so a third-party beige reads as
|
||||
* "Orange" and a "Color mismatch" warning gives no way to tell a real mismatch
|
||||
* from two names for the same hex. `<option>` renders text and nothing else, so
|
||||
* the swatch means this cannot be a native select.
|
||||
*
|
||||
* What is kept from the select it replaces: full keyboard operation (arrows,
|
||||
* Home/End, Enter, Escape), `role="listbox"` semantics, and the caller's own
|
||||
* border colouring, which encodes match / type-only / mismatch on the mapping
|
||||
* row and overridden / original on the override row.
|
||||
*
|
||||
* Deliberately not a shared "Select": the rows are a swatch, a name, a hex and
|
||||
* a badge, and this is the second such control in the app after
|
||||
* `spool-form/PresetPicker` — which documents the same reasoning. A shared
|
||||
* abstraction over two call sites with different row shapes would be guessing
|
||||
* at the third.
|
||||
*/
|
||||
|
||||
export interface SlotPickerOption {
|
||||
/** The value handed back to `onChange`; mirrors the old `<option value>`. */
|
||||
value: string;
|
||||
/** Main text of the row. */
|
||||
label: string;
|
||||
/** RRGGBBAA (or RRGGBB) without `#`. Absent renders no swatch at all rather
|
||||
* than a grey one, so "no colour known" never looks like a grey spool. */
|
||||
rgba?: string | null;
|
||||
/** Comma-separated extra hex stops for a multi-colour spool. */
|
||||
extraColors?: string | null;
|
||||
/** Effect overlay name, as `FilamentSwatch` understands it. */
|
||||
effectType?: string | null;
|
||||
/** Filament subtype; `Multicolor` renders the stops as a conic sweep. */
|
||||
subtype?: string | null;
|
||||
/** Muted suffix after the label — remaining grams, FTS inlet badge. */
|
||||
meta?: string;
|
||||
/** Shown in the dropdown row so two similar colours can be told apart by
|
||||
* their hex rather than by two names that resolved the same way. */
|
||||
hex?: string | null;
|
||||
/** Marks the row whose colour is exactly the one the slice asked for. */
|
||||
exactMatch?: boolean;
|
||||
/** React key, when `value` is not unique. Model-mode pools filaments across
|
||||
* every printer of a model, so two spools of the same type and colour
|
||||
* collapse to one `TYPE|COLOR` value while still being two rows. */
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface SlotPickerProps {
|
||||
/** Currently chosen option value, or '' for the placeholder row. */
|
||||
value: string;
|
||||
options: SlotPickerOption[];
|
||||
/** What the '' row reads as. */
|
||||
placeholder: string;
|
||||
/** Colour for the '' row, when it has one (the override panel's '' row is
|
||||
* the 3MF's own filament, which does have a colour). */
|
||||
placeholderOption?: Omit<SlotPickerOption, 'value' | 'label'>;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
/** Native tooltip on the trigger. */
|
||||
title?: string;
|
||||
/** Border / text classes the caller uses to encode status. */
|
||||
className?: string;
|
||||
/** Tooltip for the exact-match marker. */
|
||||
exactMatchLabel?: string;
|
||||
}
|
||||
|
||||
const SWATCH_CLASS = 'w-3 h-3';
|
||||
|
||||
export function SlotPicker({
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
placeholderOption,
|
||||
onChange,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
title,
|
||||
className = '',
|
||||
exactMatchLabel,
|
||||
}: SlotPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [coords, setCoords] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The placeholder is row 0, so an index addresses both it and the options
|
||||
// with one number — which is what the arrow keys and aria-activedescendant
|
||||
// need.
|
||||
const rows: SlotPickerOption[] = [
|
||||
{ ...(placeholderOption ?? {}), value: '', label: placeholder },
|
||||
...options,
|
||||
];
|
||||
const selectedIndex = Math.max(
|
||||
0,
|
||||
rows.findIndex((r) => r.value === value),
|
||||
);
|
||||
const selected = rows[selectedIndex];
|
||||
|
||||
const place = useCallback(() => {
|
||||
const el = triggerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Flip above when the row sits low enough that a downward list would run
|
||||
// off the viewport. The dialog body scrolls, so a mapping row near its
|
||||
// bottom is the ordinary case, not the edge one.
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
const height = Math.min(listRef.current?.scrollHeight ?? 0, 240);
|
||||
const flip = below < height + 8 && rect.top > below;
|
||||
setCoords({
|
||||
top: flip ? Math.max(8, rect.top - height - 4) : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open) place();
|
||||
}, [open, place]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (triggerRef.current?.contains(target) || listRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
// Escape closes this control WITHOUT closing the dialog around it, which a
|
||||
// bare document listener would otherwise let happen — same guard
|
||||
// `PresetPicker` documents.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
// The list is portaled to <body>, so it does not travel with the dialog's
|
||||
// own scroll container: follow it rather than leaving it behind.
|
||||
const reposition = () => place();
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('scroll', reposition, true);
|
||||
window.addEventListener('resize', reposition);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
}, [open, place]);
|
||||
|
||||
const choose = (index: number) => {
|
||||
onChange(rows[index].value);
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const openAt = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleTriggerKey = (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (!open) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openAt(selectedIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(rows.length - 1, i + 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(0, i - 1));
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
setActiveIndex(0);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
setActiveIndex(rows.length - 1);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
choose(activeIndex);
|
||||
} else if (e.key === 'Tab') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const listId = `slot-picker-${ariaLabel.replace(/\W+/g, '-')}`;
|
||||
|
||||
const renderSwatch = (row: SlotPickerOption) =>
|
||||
row.rgba ? (
|
||||
<FilamentSwatch
|
||||
rgba={row.rgba}
|
||||
extraColors={row.extraColors}
|
||||
effectType={row.effectType}
|
||||
subtype={row.subtype}
|
||||
className={SWATCH_CLASS}
|
||||
effectSize="table"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
// On the combobox, not on the listbox: focus stays on the trigger
|
||||
// while the list is open, and this is what tells a screen reader which
|
||||
// row the arrow keys are on.
|
||||
aria-activedescendant={open ? `${listId}-${activeIndex}` : undefined}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onClick={() => (open ? setOpen(false) : openAt(selectedIndex))}
|
||||
onKeyDown={handleTriggerKey}
|
||||
className={`flex-1 min-w-0 flex items-center gap-1.5 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary text-left focus:outline-none focus:ring-1 focus:ring-bambu-green disabled:opacity-50 ${className}`}
|
||||
>
|
||||
{renderSwatch(selected)}
|
||||
<span className="flex-1 min-w-0 truncate">
|
||||
{selected.label}
|
||||
{selected.meta ? <span className="text-bambu-gray">{selected.meta}</span> : null}
|
||||
</span>
|
||||
<ChevronDown className="w-3 h-3 shrink-0 opacity-70" />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed z-[60] max-h-60 overflow-y-auto rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary p-1 shadow-xl"
|
||||
style={{
|
||||
top: coords?.top ?? -9999,
|
||||
left: coords?.left ?? -9999,
|
||||
width: coords?.width,
|
||||
// Hide until placed, so there is no flash at (-9999,-9999) —
|
||||
// same trick FilamentHoverCard uses.
|
||||
visibility: coords ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{rows.map((row, index) => (
|
||||
<button
|
||||
key={row.key ?? `${row.value}-${index}`}
|
||||
id={`${listId}-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
// What `<option value>` carried before this was a listbox: the
|
||||
// identity of the choice, separate from how it reads.
|
||||
data-value={row.value}
|
||||
aria-selected={index === selectedIndex}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => choose(index)}
|
||||
className={`w-full flex items-center gap-1.5 text-left px-2 py-1.5 rounded-md text-xs ${
|
||||
index === selectedIndex
|
||||
? 'bg-bambu-green/15 text-bambu-green'
|
||||
: index === activeIndex
|
||||
? 'bg-bambu-dark text-white'
|
||||
: 'text-white'
|
||||
}`}
|
||||
>
|
||||
{renderSwatch(row)}
|
||||
<span className="flex-1 min-w-0 truncate" title={row.label}>
|
||||
{row.label}
|
||||
{row.meta ? <span className="text-bambu-gray">{row.meta}</span> : null}
|
||||
</span>
|
||||
{row.hex ? (
|
||||
<span className="shrink-0 font-mono text-[10px] uppercase text-bambu-gray">{row.hex}</span>
|
||||
) : null}
|
||||
{row.exactMatch ? (
|
||||
<span title={exactMatchLabel} className="shrink-0 text-bambu-green">
|
||||
<Check className="w-3 h-3" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlotPicker;
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The hex to print next to a swatch in the slot picker (#3159).
|
||||
*
|
||||
* Shown because two slots whose colour names resolved the same way -- both
|
||||
* "Orange", one a beige -- are otherwise indistinguishable in the list, and
|
||||
* comparing them against the slice's colour is the reason the picker exists.
|
||||
*
|
||||
* Alpha is kept only when it carries information: a translucent spool prints
|
||||
* all eight digits, an opaque one prints six, so a Clear filament is not shown
|
||||
* as if it were solid. Returns null for anything unreadable, and the caller
|
||||
* then prints no hex rather than a misleading one.
|
||||
*/
|
||||
export function displayHex(color: string | null | undefined): string | null {
|
||||
if (!color) return null;
|
||||
const clean = color.trim().replace(/^#/, '');
|
||||
if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(clean)) return null;
|
||||
const keepAlpha = clean.length === 8 && clean.substring(6, 8).toLowerCase() !== 'ff';
|
||||
return `#${(keepAlpha ? clean : clean.substring(0, 6)).toUpperCase()}`;
|
||||
}
|
||||
@@ -85,6 +85,9 @@ export function buildLoadedFilaments(
|
||||
trayInfoIdx: tray.tray_info_idx || '',
|
||||
traySubBrands: tray.tray_sub_brands || '',
|
||||
spoolName: spoolDisplayName(identity) ?? undefined,
|
||||
extraColors: identity?.extra_colors ?? undefined,
|
||||
effectType: identity?.effect_type ?? undefined,
|
||||
spoolSubtype: identity?.subtype ?? undefined,
|
||||
extruderId: amsExtruderMap?.[String(amsUnit.id)],
|
||||
remain: tray.remain ?? -1,
|
||||
});
|
||||
@@ -114,6 +117,9 @@ export function buildLoadedFilaments(
|
||||
trayInfoIdx: extTray.tray_info_idx || '',
|
||||
traySubBrands: extTray.tray_sub_brands || '',
|
||||
spoolName: spoolDisplayName(identity) ?? undefined,
|
||||
extraColors: identity?.extra_colors ?? undefined,
|
||||
effectType: identity?.effect_type ?? undefined,
|
||||
spoolSubtype: identity?.subtype ?? undefined,
|
||||
extruderId: hasDualNozzle ? (255 - trayId) : undefined,
|
||||
remain: extTray.remain ?? -1,
|
||||
});
|
||||
@@ -184,6 +190,16 @@ export interface LoadedFilament {
|
||||
extruderId?: number;
|
||||
/** Remaining filament percentage (0-100), -1 = unknown */
|
||||
remain: number;
|
||||
/** The bound spool's remaining colour stops and effect overlay, when the
|
||||
* slot has a binding (#3159). Display only, like `spoolName`: a tray record
|
||||
* carries one hex, so without these a two-tone spool draws as its base
|
||||
* colour. Absent for a slot configured on the printer -- the firmware has
|
||||
* nothing to report, and a solid swatch is then the honest one. */
|
||||
extraColors?: string;
|
||||
effectType?: string;
|
||||
/** The bound spool's subtype ("PLA Silk", "Multicolor"), which decides
|
||||
* whether the swatch renders its stops as a conic sweep. */
|
||||
spoolSubtype?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5034,6 +5034,9 @@ export default {
|
||||
manuallySelected: 'Manuell ausgewählt',
|
||||
autoMatched: 'Automatisch zugeordnet',
|
||||
selectSlot: 'Slot wählen',
|
||||
exactColorMatch: 'Exakte Farbübereinstimmung',
|
||||
slotForFilament: 'Drucker-Slot für {{name}}',
|
||||
overrideForFilament: 'Filament-Überschreibung für {{name}}',
|
||||
sameTypeDifferentColorDetail: 'Gleicher Typ, andere Farbe: benötigt {{required}}, im Slot ist {{loaded}}',
|
||||
requiredTypeNotInPrinter: 'Benötigter Filamenttyp im Drucker nicht gefunden.',
|
||||
whenToPrint: 'Wann drucken',
|
||||
|
||||
@@ -5079,6 +5079,9 @@ export default {
|
||||
manuallySelected: 'Manually selected',
|
||||
autoMatched: 'Auto-matched',
|
||||
selectSlot: 'Select slot',
|
||||
exactColorMatch: 'Exact colour match',
|
||||
slotForFilament: 'Printer slot for {{name}}',
|
||||
overrideForFilament: 'Filament override for {{name}}',
|
||||
sameTypeDifferentColorDetail: 'Same type, different color: needs {{required}}, slot has {{loaded}}',
|
||||
requiredTypeNotInPrinter: 'Required filament type not found in printer.',
|
||||
whenToPrint: 'When to print',
|
||||
|
||||
@@ -5041,6 +5041,9 @@ export default {
|
||||
manuallySelected: 'Seleccionado manualmente',
|
||||
autoMatched: 'Emparejado automáticamente',
|
||||
selectSlot: 'Elegir ranura',
|
||||
exactColorMatch: 'Coincidencia exacta de color',
|
||||
slotForFilament: 'Ranura de impresora para {{name}}',
|
||||
overrideForFilament: 'Anulación de filamento para {{name}}',
|
||||
sameTypeDifferentColorDetail: 'Mismo tipo, color distinto: necesita {{required}}, la ranura tiene {{loaded}}',
|
||||
requiredTypeNotInPrinter: 'El tipo de filamento requerido no se encuentra en la impresora.',
|
||||
whenToPrint: 'Cuándo imprimir',
|
||||
|
||||
@@ -5023,6 +5023,9 @@ export default {
|
||||
manuallySelected: 'Sélectionné manuellement',
|
||||
autoMatched: 'Associé automatiquement',
|
||||
selectSlot: 'Choisir un emplacement',
|
||||
exactColorMatch: 'Correspondance exacte des couleurs',
|
||||
slotForFilament: 'Emplacement imprimante pour {{name}}',
|
||||
overrideForFilament: 'Remplacement de filament pour {{name}}',
|
||||
sameTypeDifferentColorDetail: 'Même type, couleur différente : requiert {{required}}, l’emplacement contient {{loaded}}',
|
||||
requiredTypeNotInPrinter: 'Type de filament requis introuvable dans l’imprimante.',
|
||||
whenToPrint: 'Quand imprimer',
|
||||
|
||||
@@ -5022,6 +5022,9 @@ export default {
|
||||
manuallySelected: 'Selezionato manualmente',
|
||||
autoMatched: 'Abbinato automaticamente',
|
||||
selectSlot: 'Scegli slot',
|
||||
exactColorMatch: 'Corrispondenza esatta del colore',
|
||||
slotForFilament: 'Slot stampante per {{name}}',
|
||||
overrideForFilament: 'Sostituzione filamento per {{name}}',
|
||||
sameTypeDifferentColorDetail: 'Stesso tipo, colore diverso: richiede {{required}}, lo slot ha {{loaded}}',
|
||||
requiredTypeNotInPrinter: 'Tipo di filamento richiesto non trovato nella stampante.',
|
||||
whenToPrint: 'Quando stampare',
|
||||
|
||||
@@ -5034,6 +5034,9 @@ export default {
|
||||
manuallySelected: '手動で選択',
|
||||
autoMatched: '自動で一致',
|
||||
selectSlot: 'スロットを選択',
|
||||
exactColorMatch: '色が完全に一致',
|
||||
slotForFilament: '{{name}} のプリンタースロット',
|
||||
overrideForFilament: '{{name}} のフィラメントオーバーライド',
|
||||
sameTypeDifferentColorDetail: '同じ種類、異なる色: 必要な色は {{required}}、スロットは {{loaded}}',
|
||||
requiredTypeNotInPrinter: '必要なフィラメントタイプがプリンターに見つかりません。',
|
||||
whenToPrint: '印刷タイミング',
|
||||
|
||||
@@ -4804,6 +4804,9 @@ export default {
|
||||
manuallySelected: '수동으로 선택됨',
|
||||
autoMatched: '자동으로 일치됨',
|
||||
selectSlot: '슬롯 선택',
|
||||
exactColorMatch: '색상 정확히 일치',
|
||||
slotForFilament: '{{name}}의 프린터 슬롯',
|
||||
overrideForFilament: '{{name}}의 필라멘트 재정의',
|
||||
sameTypeDifferentColorDetail: '같은 유형, 다른 색상: {{required}} 필요, 슬롯에는 {{loaded}}',
|
||||
requiredTypeNotInPrinter: '필요한 필라멘트 유형을 프린터에서 찾을 수 없습니다.',
|
||||
whenToPrint: '인쇄 시점',
|
||||
|
||||
@@ -5079,6 +5079,9 @@ export default {
|
||||
manuallySelected: 'Handmatig geselecteerd',
|
||||
autoMatched: 'Automatisch gekoppeld',
|
||||
selectSlot: 'Sleuf selecteren',
|
||||
exactColorMatch: 'Exacte kleurovereenkomst',
|
||||
slotForFilament: 'Printersleuf voor {{name}}',
|
||||
overrideForFilament: 'Filament overschrijven voor {{name}}',
|
||||
sameTypeDifferentColorDetail: 'Zelfde type, andere kleur: vereist {{required}}, sleuf bevat {{loaded}}',
|
||||
requiredTypeNotInPrinter: 'Vereist filamenttype niet gevonden in printer.',
|
||||
whenToPrint: 'Wanneer afdrukken',
|
||||
|
||||
@@ -5022,6 +5022,9 @@ export default {
|
||||
manuallySelected: 'Selecionado manualmente',
|
||||
autoMatched: 'Correspondido automaticamente',
|
||||
selectSlot: 'Escolher slot',
|
||||
exactColorMatch: 'Correspondência exata de cor',
|
||||
slotForFilament: 'Slot da impressora para {{name}}',
|
||||
overrideForFilament: 'Substituição de filamento para {{name}}',
|
||||
sameTypeDifferentColorDetail: 'Mesmo tipo, cor diferente: precisa de {{required}}, o slot tem {{loaded}}',
|
||||
requiredTypeNotInPrinter: 'Tipo de filamento necessário não encontrado na impressora.',
|
||||
whenToPrint: 'Quando imprimir',
|
||||
|
||||
@@ -4793,6 +4793,9 @@ export default {
|
||||
manuallySelected: "Выбрано вручную",
|
||||
autoMatched: "Подобрано автоматически",
|
||||
selectSlot: "Выбрать слот",
|
||||
exactColorMatch: "Точное совпадение цвета",
|
||||
slotForFilament: "Слот принтера для {{name}}",
|
||||
overrideForFilament: "Переопределение филамента для {{name}}",
|
||||
sameTypeDifferentColorDetail: "Тот же тип, другой цвет: нужен {{required}}, в слоте {{loaded}}",
|
||||
requiredTypeNotInPrinter: "Требуемый тип филамента не найден в принтере.",
|
||||
whenToPrint: "Когда печатать",
|
||||
|
||||
@@ -5079,6 +5079,9 @@ errors: {
|
||||
manuallySelected: 'Manuellt vald',
|
||||
autoMatched: 'Auto-matchad',
|
||||
selectSlot: 'Välj fack',
|
||||
exactColorMatch: 'Exakt färgmatchning',
|
||||
slotForFilament: 'Skrivarfack för {{name}}',
|
||||
overrideForFilament: 'Åsidosätt filament för {{name}}',
|
||||
sameTypeDifferentColorDetail: 'Samma typ, annan färg: behöver {{required}}, facket har {{loaded}}',
|
||||
requiredTypeNotInPrinter: 'Krävd filamenttyp hittades inte i skrivaren.',
|
||||
whenToPrint: 'När utskriften ska starta',
|
||||
|
||||
@@ -5011,6 +5011,9 @@ export default {
|
||||
manuallySelected: 'Elle seçildi',
|
||||
autoMatched: 'Otomatik eşleşti',
|
||||
selectSlot: 'Yuva seç',
|
||||
exactColorMatch: 'Tam renk eşleşmesi',
|
||||
slotForFilament: '{{name}} için yazıcı yuvası',
|
||||
overrideForFilament: '{{name}} için filament geçersiz kılma',
|
||||
sameTypeDifferentColorDetail: 'Aynı tür, farklı renk: {{required}} gerekiyor, yuvada {{loaded}} var',
|
||||
requiredTypeNotInPrinter: 'Gereken filament türü yazıcıda bulunamadı.',
|
||||
whenToPrint: 'Ne zaman yazdırılsın',
|
||||
|
||||
@@ -5076,6 +5076,9 @@ export default {
|
||||
manuallySelected: "Вибрано вручну",
|
||||
autoMatched: "Підібрано автоматично",
|
||||
selectSlot: "Вибрати слот",
|
||||
exactColorMatch: "Точний збіг кольору",
|
||||
slotForFilament: "Слот принтера для {{name}}",
|
||||
overrideForFilament: "Перевизначення філаменту для {{name}}",
|
||||
sameTypeDifferentColorDetail: "Того самого типу, іншого кольору: потрібен {{required}}, у слоті {{loaded}}",
|
||||
requiredTypeNotInPrinter: "Потрібний тип філаменту не знайдено у принтері.",
|
||||
whenToPrint: "Коли друкувати",
|
||||
|
||||
@@ -5022,6 +5022,9 @@ export default {
|
||||
manuallySelected: '手动选择',
|
||||
autoMatched: '自动匹配',
|
||||
selectSlot: '选择槽位',
|
||||
exactColorMatch: '颜色完全匹配',
|
||||
slotForFilament: '{{name}} 的打印机槽位',
|
||||
overrideForFilament: '{{name}} 的耗材覆盖',
|
||||
sameTypeDifferentColorDetail: '相同类型,不同颜色:需要 {{required}},槽位为 {{loaded}}',
|
||||
requiredTypeNotInPrinter: '打印机中未找到所需的耗材类型。',
|
||||
whenToPrint: '何时打印',
|
||||
|
||||
@@ -5022,6 +5022,9 @@ export default {
|
||||
manuallySelected: '手動選擇',
|
||||
autoMatched: '自動比對',
|
||||
selectSlot: '選擇插槽',
|
||||
exactColorMatch: '顏色完全相符',
|
||||
slotForFilament: '{{name}} 的印表機插槽',
|
||||
overrideForFilament: '{{name}} 的耗材覆蓋',
|
||||
sameTypeDifferentColorDetail: '相同類型,不同顏色:需要 {{required}},插槽為 {{loaded}}',
|
||||
requiredTypeNotInPrinter: '印表機中找不到所需的耗材類型。',
|
||||
whenToPrint: '何時列印',
|
||||
|
||||
Reference in New Issue
Block a user