Skip to content

Add labels with colors, filtering, and bulk-manage modal for samples#2958

Merged
xispa merged 25 commits into
2.xfrom
feature/sample-label-chips
Jun 23, 2026
Merged

Add labels with colors, filtering, and bulk-manage modal for samples#2958
xispa merged 25 commits into
2.xfrom
feature/sample-label-chips

Conversation

@ramonski

@ramonski ramonski commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Description of the issue/feature this PR addresses

Adds a labels feature for samples spanning chip rendering, click-to-filter, a bulk-manage modal, and per-label colors. Companion changes in senaite.app.listing render the row chips and the active filter chips above the search box; this PR holds all the server-side and base-layer pieces.

Current behavior before PR

  • Labels can be assigned via the per-sample edit form but have no listing affordance, no filter mechanism, and no color metadata.
  • The Labels control panel shows plain title links with no visual hint of which label is which.
  • There is no bulk path to apply or remove labels across selected rows; clerks edit samples one at a time.
  • Renaming a Label in setup.labels orphans the previous name on every labeled content because storage holds the title string verbatim — listings keep showing dead chips and label-color lookups silently fall back to the default style.

Desired behavior after PR is merged

  • Lab personnel see labels rendered as colored chips below the Sample ID on every listing whose context type carries labels. Clicking a chip toggles a ?labels=<name> URL filter (additive on multi-click). Removal is done through the modal so row chips stay clean.

  • A Labels toolbar button on the samples listing opens a modal where the clerk picks from the existing palette or types a new label with a color (curated swatches + native picker + random). The submit applies the diff (add / remove) across every selected sample in one POST.

  • Labels gain an optional 6-digit hex color field. The Labels control-panel listing displays each entry as a chip in its own color; the Label view's <h1> is restyled to match.

  • A new senaite.core: View Labels permission gates chip visibility and the click-to-filter mechanism so client users do not see internal lab labels on shared samples. senaite.core: Manage Labels gates the modal and the REST endpoints.

  • senaite.core.api.label grows three helpers used by every chip-rendering consumer:

    • is_safe_color, chip_style — hex whitelist + inline CSS, single source of truth.
    • get_label_colors(names=None){name: color} map read from brain metadata.
    • parse_label_csv(raw) — request-shaped parsing returning sorted unique unicode names. Fixes a latent UnicodeDecodeError in get_label_by_name on non-ASCII inputs.
  • A single JSON endpoint @@senaite_labels (IPublishTraverse) replaces the three per-route browser views (@@add_label, @@remove_label, @@available_labels):

    • POST @@senaite_labels/add — auto-creates missing Labels in setup.labels. Requires ManageLabels.
    • POST @@senaite_labels/remove — removes labels from the context. Requires ManageLabels.
    • GET @@senaite_labels/available — returns {name, color, description} for every active label, used by the active-filter chip color sync. Requires ViewLabels.

    The browser:page registers with the lowest of the two permissions; the write routes re-check ManageLabels and return 403 when missing. Bodies go through bika.lims.decorators.returns_json so the JSON header / serialization stay out of the route handlers. The name is namespaced as senaite_labels (not the bare labels) so the for="*" registration cannot intercept traversal to e.g. ++plone++senaite.core.static/assets/icons/labels — a collision discovered while testing that produced a broken FTI icon.

Renaming a Label cascades to all labeled contents

Renaming a Label in setup.labels rewrites the stored name on every currently labeled content in the same transaction as the save. Two events handle this end-to-end:

  • on_label_edit_begun (DX IEditBegunEvent) — snapshots the current label.title onto the request annotation. This is the only safe place to read the old title; by the time IObjectModifiedEvent fires, applyChanges has already overwritten the field, and IObjectModifiedEvent.descriptions only lists changed attribute names, never old values.
  • on_label_modified (IObjectModifiedEvent) — reads the snapshot, compares to the new title, and walks senaite_catalog_label (which indexes only IHaveLabels-providing objects) to rewrite the stored name on every match. The labels index is reindexed in the same loop so listings, filter URLs and the color map all see the new name as soon as the save commits.

Edge cases:

  • Merge on rename — when the new title already exists on an object (a rename that collapses two labels into one), the old entry is dropped instead of duplicated.
  • Out-of-band edits — REST clients, scripts, doctests, etc. don't fire IEditBegunEvent, so the snapshot is absent and the cascade is skipped on purpose. The helper senaite.core.subscribers.label._rename_label_in_storage(old, new) is exported for callers that want to trigger the cascade explicitly.
  • User-visible warning — the title field on the Label edit form grows a description: "Renaming this label rewrites the stored name on every currently labeled content. The cascade runs in the same transaction as the save and may take a moment on large datasets."

Companion PR

senaite.app.listing PR — senaite/senaite.app.listing#178

Commit walk-through

  1. Add ManageLabels and ViewLabels permissions. Permissions module, ZCML, rolemap.
  2. Add a z3c.form ColorField / ColorWidget package. Native HTML5 color picker, hex validator, fixed-size square footprint. Follows the existing PhoneField / PhoneWidget pattern in its own sub-package.
  3. Add the color attribute to the Label content type and restyle its view as a colored chip. Color hidden on the display form; a viewlet restyles body.portaltype-Label .documentFirstHeading so the heading itself looks like the chip. The Labels control-panel listing renders each row title as a colored chip.
  4. Extend the catalogs and label API. senaite_catalog_sample gets a labels KeywordIndex + getLabels metadata column; senaite_catalog_setup gets a color column. chip_style / is_safe_color / get_label_colors / parse_label_csv helpers go on senaite.core.api.label; get_label_by_name coerces to unicode to fix a UnicodeDecodeError on non-ASCII labels.
  5. Add chip rendering and filter support to the ListingView base class. labels_visible / labels_filterable infer entirely from catalog state and the ViewLabels permission; render_label_chips HTML-escapes every interpolated value; the URL-filter get_request_labels reads from request.form first then falls back to parsing QUERY_STRING directly because Zope's publisher doesn't populate request.form for the AJAX subpath POST.
  6. Add the Manage Labels modal and three REST routes; wire the Labels transition into SamplesView. Modal is the "what should the labels be after submit" editor — pre-selected chips marked for removal on un-toggle, free-text + color row creates a Label on the fly.
  7. Add the upgrade step (2745 → 2746), extend the API_label doctest, and write the CHANGES entry. Doctest covers is_safe_color, chip_style, parse_label_csv over the byte/unicode boundary, get_label_colors, and labels-index round-trip via search_objects_by_label.
  8. Consolidate label REST endpoints into a single @@senaite_labels traversal view. IPublishTraverse dispatch + returns_json decorator, mirroring AjaxListingView. Also adds Name / name to PRIMARY_COLUMN_CANDIDATES so setup-folder listings that use Name get chip placement out of the box.
  9. Cascade Label title renames across all labeled contents. Two-event subscriber pair (edit-begun + object-modified) keyed by a request annotation; cascade body uses senaite_catalog_label to find every labeled content; merge-on-rename behavior covered by the extended doctest.

Test plan

  • bin/test-senaite -s senaite.core -t API_label passes (extended with is_safe_color, chip_style, parse_label_csv byte/unicode boundary, get_label_colors, labels-index round-trip, end-to-end rename cascade including merge case).
  • Reviewer: portal_setup upgrade from 2745 to 2746 runs cleanly on an instance with existing Label content (the upgrade step adds the index + column and reindexes Label brains so color populates).
  • Reviewer: a Client user opens a samples listing where samples carry labels — no chips render, ?labels=X URL is ignored.
  • Reviewer: a LabManager user clicks a chip → URL gains ?labels=<name>, listing filters correctly; clicking the same chip again removes the filter; clicking a second chip ANDs them.
  • Reviewer: the Labels toolbar button is hidden when no rows are selected; with rows selected it opens the modal; toggling pre-selected chips removes them on submit; typing a new name + picking a preset color creates the Label and applies it across the selection.
  • Reviewer: rename an existing Label that is applied to several samples — every affected sample's chip and the active-filter chip update to the new name in the same request; the old name produces no catalog hits afterwards.

I confirm I have tested this PR thoroughly and coded it according to PEP8 and Plone's Python styleguide standards.

ramonski added 7 commits June 21, 2026 11:10
ManageLabels gates adding/removing labels through the Manage Labels
modal and the REST endpoints (LabClerk, LabManager, Manager).

ViewLabels gates seeing label chips and using the click-to-filter
mechanism so client users do not see internal lab labels on shared
samples (Analyst, LabClerk, LabManager, Manager, Preserver,
Publisher, RegulatoryInspector, Sampler, SamplingCoordinator,
Verifier — not Client / ClientGuest).
ColorField subclasses TextLineField and validates that the value is
a 6-digit hex string (#rrggbb) or empty. ColorWidget renders the
native HTML5 <input type="color"> picker with a hex-code preview
next to it, and is fixed at a 2.25rem square footprint so it does
not stretch to the full row width.

Follows the existing PhoneWidget/PhoneField pattern: separate
IColorField / IColorWidget interfaces, an adapter factory, and
input / display / hidden widget templates in a dedicated package
under z3cform/widgets/color/.
Adds an optional ColorField 'color' to ILabelSchema. The field is
hidden on the display form via directives.mode(IDisplayForm); a new
LabelColorViewlet on IBelowContentTitle restyles the page's <h1>
.documentFirstHeading element so the heading itself looks like the
chip the Label represents.

The Labels control-panel listing renders each row's Title as a
colored chip (linking to the Label's view) instead of a plain
title link, so admins can see the configured palette at a glance.
Adds a 'labels' KeywordIndex and 'getLabels' metadata column to
senaite_catalog_sample so sample listings can filter by label
without waking objects. Adds 'color' as a metadata column on
senaite_catalog_setup so chip-color lookups read brain metadata.

Extends senaite.core.api.label with the helpers needed by every
consumer of the chip rendering:

- HEX_COLOR_RE, is_safe_color(value), chip_style(color): single
  source of truth for the hex whitelist and the CSS style string.
  Callers go through chip_style so user-supplied color values
  cannot break out of the style attribute.
- get_label_colors(names=None): returns a {label_name: color}
  map from the setup catalog without waking Label objects.
- parse_label_csv(raw): parses request-shaped values (str, list,
  or list-of-str) into a sorted unique list of unicode names.
  Coerces to unicode because the setup catalog 'title' FieldIndex
  rejects utf-8 byte strings post-#2901.

Fixes get_label_by_name() to coerce 'name' to unicode before
querying the title index, which otherwise raises
UnicodeDecodeError on any non-ASCII label.
The senaite.core ListingView base now renders label chips inline
under the primary column of any listing whose context type carries
labels:

- labels_visible(): gated by ViewLabels. Default True for the
  listing's catalog; transposed views (worksheet manage view)
  return False because their 'after' slot is not a sample
  identifier.
- labels_filterable(): True when labels_visible AND the listing's
  catalog has a 'labels' index. When False, chips render as plain
  spans; when True they render as click-to-filter spans with the
  'is-filterable' class (navigation is wired client-side by
  senaite.app.listing).
- get_request_labels(): reads from request.form first, falls back
  to parsing QUERY_STRING directly because the AJAX subpath POST
  (/view/folderitems) carries the query in QUERY_STRING but Zope's
  publisher does not populate request.form for the subpath JSON
  request.
- render_label_chips(): HTML-escapes every interpolated value;
  user-supplied colors pass is_safe_color before any inline style
  is emitted. Removal is done through the manage-labels modal, so
  chips carry no inline X.
- _attach_label_chips(): wraps the cell value and chips inside a
  single block-level div via 'replace' so chips break to a new
  line below the primary value. Raw cell text from the catalog
  metadata column is HTML-escaped before being inlined.

Subclasses control where chips land with the new label_target_column
class attribute (defaults to the first column in
PRIMARY_COLUMN_CANDIDATES that exists in self.columns).
ManageLabelsModal is registered as @@manage_labels_modal and
follows the senaite.core.browser.modals.Modal pattern used by the
Create Worksheet modal. The form is the 'what should the labels
be after submit' editor:

- Available labels render as a clickable chip grid pre-selected by
  the union of labels currently set on the selected samples.
  Toggling a pre-selected chip schedules it for removal; toggling
  a non-pre-selected chip schedules it for addition.
- A free-text input plus a color row (curated preset swatches +
  native HTML5 color picker + random button) creates a new Label
  via senaite.core.api.label.create_label on the fly. If the name
  already exists, only the color is updated.
- handle_submit() computes add and remove sets by diffing the
  posted 'selected_labels' against the hidden 'initial_labels' it
  captured at render time, applies them per sample via
  add_obj_labels / del_obj_labels, and reindexes the 'labels'
  column on the sample catalog.

Three companion REST endpoints land in the browser/label/
package, all gated by senaite.core: Manage Labels (add_label,
remove_label) or senaite.core: View Labels (available_labels):

- @@add_label: POST; accepts free-text names and auto-creates the
  corresponding Label in setup.labels if missing.
- @@remove_label: POST; removes label(s) from the context object.
- @@available_labels: GET; returns the full {name, color,
  description} catalogue used by app.listing to color the active
  filter chips.

SamplesView is wired up via two two-line additions:

- label_target_column = 'getId' so chips render under the Sample
  ID rather than the default Title fallback.
- add_custom_transitions() appends a 'Labels' custom-transition
  entry pointing to @@manage_labels_modal, gated by
  can_manage_labels().
…NGES

The new setup_sample_labels upgrade step registers the two new
permissions (re-imports rolemap), adds the 'labels' KeywordIndex
and 'getLabels' column to senaite_catalog_sample, adds the 'color'
column to senaite_catalog_setup, and refreshes Label brains so the
color metadata column populates on existing installs.

The API_label doctest is extended with:

- is_safe_color whitelist behavior (safe, 3-digit reject, name,
  empty, None, and XSS-shaped input).
- chip_style returning the CSS string or empty when unsafe.
- parse_label_csv over the three input shapes plus a non-ASCII
  utf-8 round-trip (Furth -> u'F\xfcrth') so a regression on the
  byte/unicode boundary fails fast.
- Setting Label.color and reading it back via get_label_colors,
  with the no-color filter behavior of the global map.
- Verifying the 'labels' index exists on senaite_catalog_sample
  and that search_objects_by_label round-trips an add/remove on
  a labeled object.
The three per-route browser pages (@@add_label, @@remove_label,
@@available_labels) are folded into one LabelsAPI view with
IPublishTraverse dispatch, mirroring the pattern used by
senaite.app.listing.AjaxListingView:

- POST <context>/@@labels/add       (auto-creates missing Labels)
- POST <context>/@@labels/remove
- GET  @@labels/available

Returned bodies go through bika.lims.decorators.returns_json so the
JSON header and serialization stay out of the route methods.

The ZCML page registration uses the lowest of the two permissions
(ViewLabels) so the read-only /available route is reachable; the
two write routes re-check ManageLabels and return 403 when missing.
The 'label' / 'labels' form-input parsing shape is unchanged.

Also extends PRIMARY_COLUMN_CANDIDATES in ListingView.base with
'Name' and 'name', which are commonly used in place of Title
across SENAITE setup-folder content listings.
ramonski added a commit to senaite/senaite.app.listing that referenced this pull request Jun 21, 2026
Tracks senaite/senaite.core#2958 (LabelsAPI traversal view): the
listing controller now fetches the active-filter chip color map
from ./@@labels/available instead of ./@@available_labels. No
behavioral change; the response shape (name, color, description)
is identical.
The bare for='*' registration of name='labels' intercepted Plone
traversal to e.g. ++plone++senaite.core.static/assets/icons/labels
because bootstrap.py::resource_exists() walks that path while
resolving Label FTI icons. The browser-view fallback made
resource_exists return True for the extension-less basename,
producing a broken <img src=...assets/icons/labels> instead of the
correct labels.svg URL.

Renaming the view to @@senaite_labels avoids the collision and
makes the namespace clear; the subpath dispatch and per-route
permission gating are unchanged.
ramonski added a commit to senaite/senaite.app.listing that referenced this pull request Jun 21, 2026
Tracks the rename in senaite/senaite.core#2958: the listing
controller now fetches the active-filter chip color map from
./@@senaite_labels/available. No behavioral change; the rename
on the server side was made to avoid an icon-traversal collision
with the bare /labels path.
ramonski added 14 commits June 21, 2026 12:19
Renaming a Label in setup used to leave every labeled content
pinned to the previous name in its 'senaite.core.labels' storage
tuple. The label catalog stayed indexed by the old string, so
listing chips kept showing the dead name and color lookup
silently fell through to the default style.

A two-event subscriber pair now keeps storage in sync:

- on_label_edit_begun (IEditBegunEvent): snapshots the current
  title onto the request annotation. We need the snapshot
  because Plone's IObjectModifiedEvent fires after applyChanges
  has already overwritten the field; descriptions only list the
  changed attribute names, never the old values.
- on_label_modified (IObjectModifiedEvent): reads the snapshot,
  compares to the new title, and walks the label catalog
  ('senaite_catalog_label' indexes only IHaveLabels-providing
  objects) to rewrite the stored name on every match. The
  'labels' index is reindexed in the same loop so listings,
  filter URLs and the color map all see the new name as soon as
  the save commits. When the new title already exists on an
  object (a rename that collapses two labels into one), the
  old entry is dropped instead of duplicated.

Edits not driven by the edit form (REST, scripts, doctests) do
not fire IEditBegunEvent, so the snapshot is absent and the
cascade is skipped on purpose — such callers are expected to
keep storage and title in sync themselves. The helper
_rename_label_in_storage(old, new) is exported for those code
paths.

The Label title field grows a translatable help text warning
that the rename rewrites every labeled content in the same
transaction and may take a moment on large datasets. The
extended API_label doctest exercises the cascade end-to-end
plus the merge-on-rename behavior.
The request-scoped annotation pattern split across IEditBegunEvent
and IObjectModifiedEvent did not survive the GET/POST boundary of
the Plone edit form: IAnnotations(request) is per-request, and the
GET render that snapshotted the title and the POST that fired the
modified event used two different requests. The cascade silently
skipped on every title change driven by the form.

Switching to a persistent annotation on the Label object itself
fixes the root cause and removes the GET/POST coupling entirely:

- on_label_added (IObjectAddedEvent on ILabel): seeds the
  PREVIOUS_TITLE_KEY annotation when the Label is first added so
  the rename detection has a baseline.
- on_label_modified (IObjectModifiedEvent on ILabel): reads the
  annotation, compares to the current title, cascades if they
  differ, and updates the annotation in the same step. Works for
  every edit path (form, REST, scripts) and is insensitive to
  subscriber ordering against the catalog reindexer.
- Pre-existing Labels (created before the subscriber was
  installed) have no annotation yet; the first modification seeds
  the baseline rather than firing a false-positive cascade.

The setup_sample_labels upgrade step also seeds the baseline for
every Label that already lives in setup.labels so the *first*
post-upgrade rename actually cascades instead of being treated as
the baseline.
LabeledObjectsView and its object_labels.pt template were rendering
each label as a flat 'bg-light border rounded' pill, which ignored
the per-Label color set in setup.labels. The view now memoizes the
{name: color} map for the request via label_api.get_label_colors()
and the template builds the inline style through
label_api.chip_style, matching how the sample listing and the
manage-labels modal paint chips.

The template is also restructured to use the shared .sample-label
and .sample-label-text class names so the CSS already shipped in
senaite.app.listing styles it consistently with row chips in
sample listings.
TAL path expressions auto-invoke zero-argument callables on resolve;
the bare  binding fired chip_style()
with no args during the tal:define step before the python:
expression that actually passes the color in. Adding the nocall:
prefix keeps the callable as a value until the per-chip python:
expression invokes it explicitly.
…rmissions

The behavior schema field (DX) and the AT extender both rendered
the Labels QuerySelectWidget on every sample edit form regardless
of role, so client users without ViewLabels could still see the
chips and remove them via the X button on each pill.

Aligns the field with the rest of the labels feature:

- ILabelSchema.Labels: directives.read_permission(Labels=ViewLabels),
  directives.write_permission(Labels=ManageLabels) on the DX
  behavior schema.
- ExtLabelField on the AT extender: read_permission=ViewLabels,
  write_permission=ManageLabels.
- LabelSchema getter/setter security.protected() declarations also
  upgraded to ViewLabels / ManageLabels (were CMFCore View /
  ModifyPortalContent).

Client users now see neither the Labels fieldset nor the chooser
on the edit form. LabClerk / LabManager / Manager keep full
read+write access. Reader-only lab roles (Analyst, Verifier, etc.)
keep read access via ViewLabels but cannot mutate.
…ranted

get_field_mode initialized mode='view' before the permission
checks, and only swapped to 'edit' when checkPermission('edit')
returned True. The read-permission branch existed but was dead
code in the no-permission-at-all path — when both checks failed,
mode stayed at 'view' and the field rendered in view mode anyway.

This made the per-field read_permission gating ineffective for the
sample header viewlet, so a client user without ViewLabels could
still see the Labels chip group on the sample view tab even though
the edit form correctly suppressed it.

Adds an explicit else branch that returns the default ('hidden')
when the user has neither read nor write permission, so the field
disappears entirely. Edit / view branches are unchanged.
get_configuration() already filtered prominent_fields and
standard_fields by the per-field visibility setting from
manage-sample-fields, but not by ACL. Fields the user had no read
or write permission on still passed through to the template, which
rendered an empty <td> for the value cell and the full label cell
next to it — a client user without ViewLabels still saw an
'Etiketten' row with an empty value.

Adds a permission check at the configuration step so unauthorized
fields are dropped before the template's per-field loop runs.
Neither the label cell nor the value cell renders, the row simply
doesn't exist. get_field_mode keeps its existing 'hidden' fallback
as a safety net for callers that bypass the filter.
The chip-toggle + selected-field-sync + color preset / random
wiring lived as a ~45 line inline <script> at the bottom of
manage_labels.pt. Inline scripts are CSP-hostile, harder to lint,
and (when the modal markup is injected by the listing controller
after DOM-ready) reach the user as a literal string blob inside a
larger HTML payload.

Moves the body into
senaite/core/browser/static/js/senaite.core.modal.manage_labels.js
served via ++plone++senaite.core.static/js/... — the same
resource path the rest of senaite.core's JS uses. The template
now just references the script via a tal:attributes src= so the
Plone portal_url prefix is resolved per-instance.

Behavior is identical:

- Pre-applies the .is-selected class on toggles flagged with
  data-selected='1' at render time.
- Click toggles data-selected, toggles .is-selected / .is-removed
  classes, rebuilds the hidden selected_labels CSV.
- Preset swatches set new_label_color to data-color.
- Random button generates a #rrggbb and writes it to
  new_label_color.
- Focuses new_label on init.

Tolerant to both DOMContentLoaded (initial page load) and
post-ready injection (listing modal opens after DOM-ready) — runs
init() immediately when document.readyState is past 'loading'.
The curated chip-color palette consumed by the Manage Labels
modal lived as a module-level constant inside the modal handler.
Move it to senaite.core.config alongside the other shared
project-wide constants so the same tuple list can be reused if /
when other consumers want the same swatch set (e.g. a future
inline picker on the Label edit form, or a custom add-on
override). Tuple order is stable; importers continue to receive
the same 9-color sequence.
The earlier LabelColorViewlet emitted a <style> block on
IBelowContentTitle that restyled body.portaltype-Label
.documentFirstHeading from the outside. It worked but coupled the
chip look to a global selector, a body class, and an injected
stylesheet — three indirections to dress one element.

Plone's main_template.pt renders the page <h1> via
`<h1 tal:replace="structure context/@@title" />`. Overriding that
browser page for ILabel makes the rendered HTML our chip directly:

  <h1 class="documentFirstHeading">
    <span class="sample-label sample-label--lg" style="...">
      <span class="sample-label-text">{title}</span>
    </span>
  </h1>

LabelColorViewlet, its template and the IBelowContentTitle
registration are removed. The chip CSS already shipped with
senaite.app.listing styles the markup so the appearance is
unchanged from the user's side.
The setup catalog now indexes Label.getColor() instead of the bare
.color attribute. This matches SENAITE's existing 'get<Field>'
metadata convention used across other content types (getId,
getTitle, getDescription, getAnalysesNum, ...) and decouples the
catalog column from the attribute storage shape.

Label.getColor() returns a unicode hex string (empty when unset
or non-unicode-decodable), so chip-rendering consumers can read
the brain column with no further coercion.

Consumers updated to the method-call column:

- senaite.core.api.label.get_label_colors
- senaite.core.browser.label.api.LabelsAPI._route_available

The setup_sample_labels upgrade step now adds the getColor column
and drops the bare 'color' column if a pre-release install of this
PR seeded it. Pre-existing installs without either column get just
the getColor column.
The curated 9-color palette was sitting at the senaite.core.config
package root, mixed in with the cross-cutting PROFILE_ID and
PROJECTNAME constants. Promote it to its own submodule so the
labels feature owns the file outright and future label-related
config (e.g. a registry default palette, a default chip CSS class
list) has a natural home.

The modal handler now imports
`from senaite.core.config.labels import LABEL_COLOR_PRESETS`.
No other consumers exist yet, so this is a clean move.
Four constants that were either duplicated or hard-coded across
the labels feature now have a single home next to LABEL_COLOR_PRESETS:

- SAMPLE_LABEL_REINDEX = ["labels"]
  Was duplicated in browser/label/api.py, browser/modals/manage_labels.py
  and subscribers/label.py. Every consumer that mutates labels on a
  sample and then reindexes now imports the shared list.

- LABEL_STORAGE = "senaite.core.labels"
  Was a module-level constant in api/label.py. Moved to config so the
  storage key is a label-config concept rather than an API-module
  detail.

- PREVIOUS_TITLE_KEY = "senaite.core.label.previous_title"
  Was a module-level constant in subscribers/label.py and re-imported
  from there by the upgrade step. Both consumers now go through
  config.labels.

- DEFAULT_LABEL_COLOR = u"#0d6efd"
  Was hard-coded as `value="#0d6efd"` in the modal's Chameleon
  template. The modal now exposes `view.default_new_label_color` and
  the template binds the color input's `value` attribute through it
  so changing the default is a one-line edit in config.labels.
Aligns with the rest of the labels feature's docstring style.
@ramonski ramonski requested a review from xispa June 21, 2026 19:33
@ramonski ramonski added the Enhancement ✨ Improvement to existing functionality label Jun 21, 2026
After renaming the setup-catalog metadata column from `color` to
`getColor` (4fa8d12), instances where the upgrade step had not
yet re-run lost their chip colors: brains carried neither the old
`color` value nor the new `getColor` one, so `get_label_colors`
returned an empty map and every chip rendered in the neutral
fallback style.

`get_label_colors` now prefers `brain.getColor` (the fast,
metadata-only path) and falls back to waking the Label and reading
`obj.color` when the brain has no value. Once the upgrade step has
been re-run, the fallback never fires and the cost reverts to the
single setup-catalog query. Bridges the window between code deploy
and `portal_setup` re-import without requiring the operator to
intervene.
The active-filter chips above the listing search box fetch their
color map from the LabelsAPI `available` route. The previous
commit (8726719) put a live-attribute fallback into
`get_label_colors`, but `_route_available` was still reading
`brain.getColor` directly and missed the same brain-metadata
window. Mirrors the fallback in the JSON endpoint so the chip
color sync recovers without an upgrade-step re-run.

@xispa xispa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work, thanks!

@xispa xispa merged commit 3b2a5ca into 2.x Jun 23, 2026
3 checks passed
xispa pushed a commit to senaite/senaite.app.listing that referenced this pull request Jun 23, 2026
…178)

* Support label chips, click-to-filter, and label-aware saved filters

Adds the client-side half of the labels feature implemented in
senaite/senaite.core#2958.

Listing controller and template:

- on_click delegates a 'sample-label.is-filterable' click into
  on_label_filter_click, which toggles the chip's label in the URL
  ?labels= comma list (additive: clicking a chip adds it, clicking
  again removes it) and reloads the page so the server-side filter
  applies.
- A new active-filter chip row renders before the SearchBox when
  ?labels= is non-empty. Each chip carries an X that removes that
  label from the URL on click. The chips are painted in the
  matching Label's color via @@available_labels (fetched once,
  cached), so the inline filter chip visually matches the row chip
  of the same name.
- resetView clears ?labels= via navigation when the URL carries it,
  in addition to the existing in-memory reset.

Listing CSS:

- .sample-label / .sample-label-text / .sample-id-with-labels:
  flat square-cornered pill (no badge-pill, no padding-fattening),
  block-level so chips stack under the primary value cell.
- .sample-label--lg: bigger variant used by the Label view's
  restyled <h1>.
- .label-color-swatch: 14px square inline before the Label title
  in the controlpanel listing.
- .manage-labels-grid / .manage-labels-toggle / .manage-labels-color
  / .manage-labels-presets / .manage-labels-preset: chip-grid and
  picker chrome for the Manage Labels modal. The 'is-selected' /
  'is-removed' classes mark the add / remove sets the user is
  about to apply.
- .active-label-filter / .active-label-filter__remove: inline
  filter chip rendered next to the SearchBox.

Saved filter preset payload (preset_storage.js):

- normalize_payload / capture_payload include 'labels' (sorted
  array), so payloads_equal compares two captures of the same UI
  state as equal regardless of label order.
- The ListingController's 'current' prop and applySavedFilter both
  flow the active labels through. Applying a preset whose labels
  differ from the URL triggers a navigation so the server-side
  filter takes effect; same-labels presets apply via set_state
  exactly as before.
- 'labels' is added to PRESET_URL_PARAMS so a bookmarked share-link
  with ?labels= correctly suppresses the user's default-preset
  auto-apply.

* Rebuild listing bundle

senaite.app.listing.f0772ab7711d8e304faf.js (current 2.x bundle)
replaced by senaite.app.listing.74a49469a8a88fc9761b.js with the
client-side label-chip support added in the previous commit.

* Update changelog with PR number

* Switch to consolidated @@labels/available endpoint

Tracks senaite/senaite.core#2958 (LabelsAPI traversal view): the
listing controller now fetches the active-filter chip color map
from ./@@labels/available instead of ./@@available_labels. No
behavioral change; the response shape (name, color, description)
is identical.

* Switch label color fetch to @@senaite_labels/available

Tracks the rename in senaite/senaite.core#2958: the listing
controller now fetches the active-filter chip color map from
./@@senaite_labels/available. No behavioral change; the rename
on the server side was made to avoid an icon-traversal collision
with the bare /labels path.

* Extend preset_storage tests for the new labels field

capture_payload now returns a 'labels' array alongside the
existing review_state / column_filters / sort / pagesize / filter
fields. The pre-existing 'canonical shape with defaults' test was
asserting the old shape and failed in CI; updated to include
labels: [] and added three positive tests covering the sort +
empty-strip behavior, the clone-on-capture guarantee, and the
fallback for missing / non-array labels.

* Scale the chip-styled Label heading to the parent H1 size

The .sample-label--lg variant was a fixed .85rem chip used when the
LabelColorViewlet rendered a separate pill next to the heading.
Now that the heading itself IS the chip (the @@title override
wraps the <h1>'s content in the chip span), the chip should
inherit the H1's font-size so it reads as a proper page heading
rather than a small pill stuck where the title used to be.

font-size: inherit lets the cascade do its job; padding bumped to
.25rem .9rem and border-radius to 6px so the proportions hold at
the larger H1 size. The row-chip body (.sample-label without --lg)
is untouched.

* Tone down the chip heading to 1.25rem

Inheriting the H1 font-size let the chip take on the full default
Plone heading scale, which read as too dominant. 1.25rem lands
between the row-chip body size (.65rem) and the default H1 (~2rem)
— big enough to register as a heading, small enough to leave
breathing room above the description text.

* Match the default active-filter chip palette to the row chips

A Label without a configured color rendered as a grey pill in the
listing row but as a blue accent pill above the search box,
because the .active-label-filter rule defaulted to the SENAITE
accent palette while .sample-label defaulted to the neutral
hairline palette. Same Label, two looks.

Aligns the .active-label-filter defaults to the row-chip neutral
palette so the two render identically when no color is set. The
listing controller still resolves the per-label color from
@@senaite_labels/available and sets an inline style that wins
over these fallbacks when a color exists.

* Support `label:` prefixes in the listing search box

Typing `label:Test` (or `labels:Foo,Bar`) into the search box and
hitting Enter now lifts the matched names into the URL ?labels=
filter — the same channel the click-to-filter chip uses — and
treats the remainder of the input as the regular search filter.
The active-filter chip row above the search box picks them up on
the next render, exactly like a chip click would.

filterBySearchterm peels the prefixes via parse_label_search_prefixes
and history.replaceState's a fresh URL so the next folderitems
fetch carries the new labels in QUERY_STRING (api.coffee already
reads location.search on every call). The residual term becomes
the new @State.filter, so a query like "label:Test water" filters
to samples labeled Test containing the word "water".

Whitespace inside a single label token is not supported — the
parser splits on /\s+/. Labels with spaces in the name should be
applied via chip click instead.

* Apply label filter changes in place; size active filter chips to the search box

Two fixes around the cross-listing labels URL filter:

1. Applying a saved preset that carried a labels filter used to
   `window.location.assign` the new URL. The reload discarded the
   listing's local state, including `applied_preset_id`, so the
   preset visibly de-activated the moment it took effect. Selecting
   the same preset a second time worked because the URL labels
   already matched and the reload path was skipped.

   `applySavedFilter`, `resetView`, `on_label_filter_click` and the
   active-filter chip `×` handler now use `window.history.replaceState`
   to update the URL in place. The next folderitems fetch reads
   `location.search` fresh (api.coffee:#get_api_url), so the new
   labels still reach the server, but the rest of the React state
   (preset id, column filters, sort, pagination) survives the
   change. The fetch is triggered either by `set_state` (preset
   apply, reset) or by an explicit `fetch_folderitems()` +
   `forceUpdate?()` (chip click / chip ×).

2. The `.active-label-filter` chip was 1.6rem tall and visually
   shorter than the Bootstrap form-control-sm search box next to
   it. Height now uses the same `calc(1.5em + .5rem + 2px)`
   formula Bootstrap uses for the search input, font-size bumped
   to .875rem, padding adjusted so the text sits centered. Chip,
   bookmark icon and search input share one baseline.

* Revert active filter chip back to the compact 1.6rem pill

Matching the search box height made the chip dominate the row,
especially with a saturated label color. Reverts to the previous
1.6rem / .75rem font footprint so the chip reads as a status
indicator riding alongside the input rather than a peer button.
align-items: center on the parent flex row already keeps the chip
mid-line with the search box, which is the alignment cue that
matters.

* Push the default-preset labels into the URL on auto-apply

When a saved preset was flagged as the user's default and that
preset carried labels, the constructor's
apply_default_preset_unless_url_state hook mutated the React
state fields (filter, review_state, sort, ...) but never touched
the URL's ?labels= query. Labels live outside React state — every
folderitems fetch reads them fresh from location.search — so the
first listing render came up with no label filter applied. The
user then had to open the SavedFilters menu and click the preset
explicitly to push the labels into the URL.

The constructor now history.replaceState's the URL with the
preset's labels before @State is built (no listener wired yet, so
this is a silent setup step). The first folderitems fetch picks
them up like any other ?labels= filter; the active-filter chip
row above the search box renders on the first mount.
@xispa xispa deleted the feature/sample-label-chips branch June 23, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement ✨ Improvement to existing functionality

Development

Successfully merging this pull request may close these issues.

2 participants