Add labels with colors, filtering, and bulk-manage modal for samples#2958
Merged
Conversation
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.
eef2bda to
3121ce0
Compare
Merged
6 tasks
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.
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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
setup.labelsorphans 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
Labelstoolbar 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
colorfield. 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 Labelspermission gates chip visibility and the click-to-filter mechanism so client users do not see internal lab labels on shared samples.senaite.core: Manage Labelsgates the modal and the REST endpoints.senaite.core.api.labelgrows 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 latentUnicodeDecodeErroringet_label_by_nameon 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 insetup.labels. RequiresManageLabels.POST @@senaite_labels/remove— removes labels from the context. RequiresManageLabels.GET @@senaite_labels/available— returns{name, color, description}for every active label, used by the active-filter chip color sync. RequiresViewLabels.The browser:page registers with the lowest of the two permissions; the write routes re-check
ManageLabelsand return 403 when missing. Bodies go throughbika.lims.decorators.returns_jsonso the JSON header / serialization stay out of the route handlers. The name is namespaced assenaite_labels(not the barelabels) so thefor="*"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.labelsrewrites 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(DXIEditBegunEvent) — snapshots the currentlabel.titleonto the request annotation. This is the only safe place to read the old title; by the timeIObjectModifiedEventfires,applyChangeshas already overwritten the field, andIObjectModifiedEvent.descriptionsonly lists changed attribute names, never old values.on_label_modified(IObjectModifiedEvent) — reads the snapshot, compares to the new title, and walkssenaite_catalog_label(which indexes onlyIHaveLabels-providing objects) to rewrite the stored name on every match. Thelabelsindex 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:
IEditBegunEvent, so the snapshot is absent and the cascade is skipped on purpose. The helpersenaite.core.subscribers.label._rename_label_in_storage(old, new)is exported for callers that want to trigger the cascade explicitly.Companion PR
senaite.app.listing PR — senaite/senaite.app.listing#178
Commit walk-through
ManageLabelsandViewLabelspermissions. Permissions module, ZCML, rolemap.ColorField/ColorWidgetpackage. Native HTML5 color picker, hex validator, fixed-size square footprint. Follows the existingPhoneField/PhoneWidgetpattern in its own sub-package.colorattribute to theLabelcontent type and restyle its view as a colored chip. Color hidden on the display form; a viewlet restylesbody.portaltype-Label .documentFirstHeadingso the heading itself looks like the chip. The Labels control-panel listing renders each row title as a colored chip.senaite_catalog_samplegets alabelsKeywordIndex +getLabelsmetadata column;senaite_catalog_setupgets acolorcolumn.chip_style/is_safe_color/get_label_colors/parse_label_csvhelpers go onsenaite.core.api.label;get_label_by_namecoerces to unicode to fix aUnicodeDecodeErroron non-ASCII labels.ListingViewbase class.labels_visible/labels_filterableinfer entirely from catalog state and theViewLabelspermission;render_label_chipsHTML-escapes every interpolated value; the URL-filterget_request_labelsreads fromrequest.formfirst then falls back to parsingQUERY_STRINGdirectly because Zope's publisher doesn't populaterequest.formfor the AJAX subpath POST.Manage Labelsmodal and three REST routes; wire theLabelstransition intoSamplesView. 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.API_labeldoctest, and write the CHANGES entry. Doctest coversis_safe_color,chip_style,parse_label_csvover the byte/unicode boundary,get_label_colors, andlabels-index round-trip viasearch_objects_by_label.@@senaite_labelstraversal view.IPublishTraversedispatch +returns_jsondecorator, mirroringAjaxListingView. Also addsName/nametoPRIMARY_COLUMN_CANDIDATESso setup-folder listings that useNameget chip placement out of the box.senaite_catalog_labelto find every labeled content; merge-on-rename behavior covered by the extended doctest.Test plan
bin/test-senaite -s senaite.core -t API_labelpasses (extended withis_safe_color,chip_style,parse_label_csvbyte/unicode boundary,get_label_colors,labels-index round-trip, end-to-end rename cascade including merge case).portal_setupupgrade from 2745 to 2746 runs cleanly on an instance with existing Label content (the upgrade step adds the index + column and reindexes Label brains socolorpopulates).?labels=XURL is ignored.?labels=<name>, listing filters correctly; clicking the same chip again removes the filter; clicking a second chip ANDs them.Labelstoolbar 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.I confirm I have tested this PR thoroughly and coded it according to PEP8 and Plone's Python styleguide standards.