Turning filter values into checkboxes that leave the menu open broke the
`addFilter` helper in two ways, and with it the grouping test that uses it
to pin the list down to its own spools.
An explicit `role="menuitemcheckbox"` replaces a button's implicit role, so
the helper's `getByRole("button")` no longer matched a value row at all;
and having picked one, the helper handed back a toolbar with the menu still
covering it, which the next call could not open through. It now asks for
the role the row actually has, and closes the menu itself, restoring the
postcondition every caller was already relying on.
The row's accessible name needed a real space rather than the margin that
had been standing in for one: without it a filament read as one
run-together word, "Galaxy BlackPLA".
Narrowing a library to three filaments meant opening the filter menu,
stepping into Filament, picking one, and doing all of it again twice
more. The menu closed on every pick, and nothing in it said which values
were already on — the chips that say so sit in the toolbar, behind the
menu that covers them.
A value row now carries a checkbox and its state, the same way the "Show
archived" row already does, and picking one leaves the menu open. The two
go together: once the values are marked, the list reads as a multi-select,
and closing after a single pick is the surprising half. Escape and a click
outside still close it, and picking a filter property or a date range still
does too, since neither is a set.
Nothing has to be preserved by hand for this to work. Each toggle is a
query-only navigation that keeps the component mounted, and it already
asked to keep focus and not to scroll, so the search box stays focused and
the list stays where it was.
The Ctrl-modifier the issue suggested is deliberately not implemented: a
checkbox list that stays open needs no hint text to explain itself.
Closes#1090Closes#1089
The filament filter listed nothing but a label, which is the manufacturer
and the name. That is not enough to pick from: the colour is the thing
you actually recognise a filament by, and two filaments may legitimately
carry the same name in different materials, leaving the menu offering two
entries that read identically.
Each option now carries whatever it has beyond its label, so the menu
draws a swatch and the material where they exist and stays a plain list
of names where they don't (locations, lot numbers). The material sits
with the label rather than off at the row's right edge, since it is there
to qualify the name it follows. None of it costs a request: the option
list is built from whole filaments already fetched.
The search box narrows on the material too. It is on screen, so a query
that visibly matches a row has to keep it — and since terms match in any
order, "prusament petg" now finds one filament out of two same-named
ones.
Closes#1087Closes#1088
The change-filament dialog could only pick a filament that already existed,
in the catalog or in SpoolmanDB. But the only way to create one anywhere in
client_v2 is the add-spools flow, which always mints at least one spool — so
refilling a shelf slot with a roll nobody had catalogued meant adding a
throwaway spool and deleting it again, handing out exactly the new spool id
the feature exists to avoid (issue #1010).
The dialog now offers the same third path the add-spools flow does. Picking
it swaps the search for the new-filament form and applies as one action:
the filament (and its manufacturer) are created, then the spool is repointed
at it. The spool keeps its number, location, dates and usage history.
The form itself moves to NewFilamentCards, shared with AddSpoolModal so both
dialogs keep the per-record card layout from #1038, with its rules and its
mapping onto the API in lib/filament/draft.ts. The one difference is the
weight/spool weight/price trio: the add-spools flow writes those to the spool
it creates as well, and so keeps showing them in its spool block, while here
they belong to the filament alone.
Master reorganized the spool half of the add form into headline / fill /
where / money groups and added the roll-size presets. Kept that layout and
re-applied the required-field wiring on top of it: the data-field hooks, the
per-field touch handlers and the err() calls that decide when an error shows.
FIELD_ORDER follows the new reading order too, so Fill level now comes before
Spool Weight when the form picks which bad field to jump to.
Leaving Material blank sent you to Density instead. Density is only prefilled
for a material Spoolman recognises, so a blank material surfaced as "Density
is required" — an error on the field you didn't fill in because of the field
you did miss, and inside the collapsed Advanced block at that.
Material is now required on this form, so the error lands on the field that
actually needs attention and Advanced stays shut. Typing a material we don't
have specs for still asks for a density afterwards, which is the right order
to be asked in.
Client-side only: the API still accepts a filament with no material, so
existing ones are unaffected.
The summary panel above the add button was more machinery than the problem
needed, and it spent five translation strings restating what the inline
field errors already say. Drop it.
Pressing Add still reveals every outstanding error and still opens the
collapsed Advanced block when the problem is hiding in there; it just takes
you to the first bad field and lets the error under it do the talking. The
issue count on the Advanced toggle and the "* required" legend go too.
The change now adds no new translation strings at all.
An incomplete add-spool form was answered with a dead Add button, leaving
"which field haven't I filled in?" to be found by eye. Worst case the answer
was invisible: density is required but only prefilled for known materials,
and it lives inside the collapsed Advanced block, so the form could look
complete and still refuse.
Add stays clickable. Pressing it on an incomplete form reveals every
outstanding error at once, lists them above the button as rows that jump to
their field, opens the section hiding one, and puts the caret in the first.
The list stays as a checklist and shrinks as fields are fixed. Per-field
errors now appear once you leave a field rather than immediately, so a
freshly opened form is quiet.
Make the asterisk mean one thing: every required field carries it and
nothing else does. Count was missing one. A "* required" legend says what it
means, and the controls carry aria-required/aria-invalid so it is not
conveyed by a glyph alone.
The extra-fields editor in settings follows the same convention: asterisks
on key, name and choices, and a save error now outlines and focuses the
field it came from.
A `background` shorthand resets background-origin to the padding box, so
the slider's gradient was sized to the padding box but painted out under
its 1px border — and backgrounds repeat, so the strip under the border
showed the far edge of the next tile: a hairline of full-opacity colour
down the transparent end. It surfaced wherever the border doesn't cover
that strip exactly, i.e. Firefox at fractional display scaling.
Anchor every layer to the border box, and keep the colour gradient from
repeating at all.
The spool fields were one grid of interchangeable boxes, so the roll-size
shortcuts had nowhere to go but a second row under the weight field. Order
them by how easily they are answered instead: count and weight share the top
line, with the sizes beside the weight rather than under it; fill level and
location follow; spool weight and price drop to their own row as the numbers
that come off a product page; and lot number, usage dates and comment fold
into a 'More details' disclosure like the filament half above already does.
Net result is a shorter form than before the shortcuts existed.
The weight field in the add-spool modal now offers 250 g / 500 g / 750 g /
1 kg / 2 kg / 3 kg as one-click shortcuts, with the one matching the current
value marked. Closes#1051.
The legacy client could set a filament's alpha channel, and the API and
database have stored an 8-digit #RRGGBBAA code ever since the color_hex
alpha migration, but client_v2 had no way to edit it (#1059).
Each row of the colour editor gets an opacity slider and a percentage
field, both backed by the hex code's alpha byte, so a single- or
multi-colour filament can be marked translucent. Full opacity drops the
alpha channel again, keeping plain colours 6-digit for the API and for
ecosystem consumers, and the native RGB picker no longer wipes an alpha
that is already set.
Swatches layer a translucent colour over a checkerboard so it reads as
transparent rather than as a different, lighter colour.
The Library's sort and filter menus grow with the install. Every custom
extra field adds a sort field and a filter property, and a filter's
values are the whole library: every filament, every location, every lot
number. Past a point the only way through them was to scroll.
Each list now carries a search box once it is long enough to earn one,
so the group menu's five fixed entries stay as they were. Matching is a
plain substring test — these lists are on screen while you type, so a
fuzzy match that surfaced "Location" for "lot" would read as a bug —
widened only by folding diacritics and by letting whitespace-separated
terms match in any order, since a filament reads "<maker> <name>".
Escape clears the query, then closes the menu; Enter takes the first
entry still listed. The box takes focus as the menu opens, but only
where the pointer is fine: on a touch device that same focus would
raise the keyboard over the list it filters.
Closes#1045
The add-spool form can create a manufacturer, but offered no way to fill its
custom fields, unlike the filament and spool it creates alongside. Show them
in the manufacturer card, and send them with the POST that creates it.
The inputs only appear while the typed name is a new one: matching an
existing manufacturer links that record, and must never rewrite its values.
Fixes#1055
The previous commit claimed the built-in timestamps and the datetime extra
fields share a grammar, then wrote that grammar out twice: once in
add_where_clause_datetime_opt for the typed columns, once inline in
add_where_clause_extra_field for the JSON-stored ones. Two copies of a
grammar drift, which would quietly falsify the thing the change was for.
The `<start>|<end>` split and its "at least one end" rule now live in
split_datetime_range_filter, which both call.
Only the parsing is shared, because only the parsing can be: an extra
field is JSON text in a joined key/value table, reached through a subquery,
while a built-in is a typed column on the row. The comparison that follows
the split therefore stays separate, and the two are not equivalent. The
built-in path parses the bound and compares real datetimes; the extra-field
path compares decoded ISO strings, so an offset or a missing '.000' changes
the answer there and not here. That asymmetry is now written down at both
sites, and tracked in #1057.
The two commits before this one invented `<field>_after`, `<field>_before`
and `<field>_unset` for the built-in timestamps. That was a third way to
ask a question this API already answers: extra-field filters have taken
`<start>|<end>` for datetimes and `<min>:<max>` for numbers since v0.26.0,
with an empty value meaning "unset", the same as it means for `location`
or `lot_nr`.
Two grammars for one question is bad on its own, but the split was worse
than cosmetic. Someone who learned `extra.opened_on=2025-01-01T00:00:00Z|`
and tried `last_used=2025-01-01T00:00:00Z|` got every spool back, because
an unknown query param is silently ignored.
So the eight parameters become three, named after their columns and
taking the shipped grammar:
/spool?last_used=2026-08-01T00:00:00Z| since
/spool?last_used=|2026-01-01T00:00:00Z until
/spool?last_used=<start>|<end> between
/spool?last_used= never used
Commas OR several of those, and quotes are accepted and ignored, so every
filter on the endpoint is spelled the same way. `_unset` disappears: the
empty value already means unset everywhere else here. No new convention
enters the API, and the next filterable column costs one parameter rather
than three.
Two consequences worth naming. Malformed input is now a 400 with a message
instead of FastAPI's automatic 422, matching the other unparseable filters
on this endpoint. And "has a timestamp" (the old `_unset=false`) has no
spelling any more, because the shipped grammar has none; nothing offered
it in the UI.
The client is unchanged apart from how it builds the request. The chip
value in the app URL keeps its own grammar, since it also carries relative
offsets and "never", neither of which the API takes.
Step 2 of the add-spool modal creates up to three records at once, but laid
them out as one flat run of fields, so there was no way to tell which record
a field would land on. The vendor combobox in particular sat among the
filament fields while quietly creating a second entity.
Lay the step out as one labelled block per entity: the manufacturer and the
new filament each get their own card, and the spool fields get a heading of
their own instead of just following on. Weight, spool weight and price are
written to both the new filament and the spool, so say so rather than let
the headings imply otherwise.
Closes#1038