diff --git a/CHANGES.rst b/CHANGES.rst
index a8cfa264a8..674692a40f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,6 +4,7 @@ Changelog
2.7.0 (unreleased)
------------------
+- #2958 Add labels with colors, filtering, and bulk-manage modal for samples
- #2953 Fix IClientAwareMixin interface mismatch breaking client access on DX content
- #2955 Defer workflow transition lookup with a React content-menu component
- #2954 Declare i18n:domain on duration widget templates
diff --git a/src/senaite/core/api/label.py b/src/senaite/core/api/label.py
index 17d4c32e4e..7fffd991e0 100644
--- a/src/senaite/core/api/label.py
+++ b/src/senaite/core/api/label.py
@@ -18,6 +18,8 @@
# Copyright 2018-2025 by it's authors.
# Some rights reserved, see README and LICENSE.
+import re
+
from bika.lims import api
from bika.lims import logger
from bika.lims.api import create
@@ -31,15 +33,47 @@
from Products.CMFPlone.utils import classImplements
from senaite.core.catalog import LABEL_CATALOG
from senaite.core.catalog import SETUP_CATALOG
+from senaite.core.config.labels import LABEL_STORAGE
from senaite.core.interfaces import ICanHaveLabels
from senaite.core.interfaces import IHaveLabels
from senaite.core.interfaces import ILabel
from zope.interface import alsoProvides
from zope.interface import noLongerProvides
-LABEL_STORAGE = "senaite.core.labels"
BEHAVIOR_ID = ICanHaveLabels.__identifier__
+# 6-digit hex color string `#rrggbb`. Used to validate the optional
+# `color` attribute of a Label and to filter user-supplied values
+# before they are inlined into `style=""` attributes anywhere chips
+# are rendered.
+HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
+
+
+def is_safe_color(value):
+ """Return True if `value` is a 6-digit hex color (`#rrggbb`).
+
+ Empty / None values are treated as unsafe so callers can fall
+ back to the default chip styling.
+ """
+ if not value:
+ return False
+ return bool(HEX_COLOR_RE.match(value))
+
+
+def chip_style(color):
+ """Inline CSS for a colored label chip, or empty string.
+
+ Returned string contains `;` separators, so callers that embed
+ it via Chameleon `tal:attributes` must go through a view method
+ (semicolons collide with TAL's attribute separator) rather than
+ inlining the expression directly.
+ """
+ if not is_safe_color(color):
+ return u""
+ return (
+ u"background-color: {c}; border-color: {c}; color: #fff"
+ ).format(c=color)
+
def get_storage(obj, default=None):
"""Get label storage for the given object
@@ -84,7 +118,11 @@ def get_label_by_name(name, inactive=True):
:param name: Name of the label
:returns: Label object or None
"""
- found = query_labels(title=name)
+ # The setup catalog's `title` FieldIndex stores unicode keys
+ # (post #2901 rebuild). Form-submitted names arrive as utf-8
+ # byte strings, so coerce before querying or the index raises
+ # `UnicodeDecodeError` on ASCII comparison.
+ found = query_labels(title=api.safe_unicode(name))
if len(found) == 0:
return None
elif len(found) > 1:
@@ -202,6 +240,28 @@ def add_obj_labels(obj, labels):
return get_obj_labels(obj)
+def parse_label_csv(raw):
+ """Parse a comma-separated label string (or list of strings) into a
+ sorted unique list of unicode names. Whitespace is stripped,
+ empties dropped.
+
+ Accepts the two shapes that come over HTTP:
+ - a single string `"foo, bar"`
+ - a list of strings `["foo", "bar,baz"]` (repeated query params)
+
+ Values are coerced to unicode because the setup catalog's `title`
+ FieldIndex expects unicode keys; comparing utf-8 bytes against
+ unicode raises `UnicodeDecodeError` inside `PluginIndexes`.
+ """
+ if isinstance(raw, (list, tuple)):
+ values = []
+ for entry in raw:
+ values.extend(api.safe_unicode(entry or u"").split(u","))
+ else:
+ values = api.safe_unicode(raw or u"").split(u",")
+ return sorted({v.strip() for v in values if v and v.strip()})
+
+
def del_obj_labels(obj, labels):
"""Remove labels from the object
@@ -219,6 +279,34 @@ def del_obj_labels(obj, labels):
return get_obj_labels(obj)
+def get_label_colors(names=None):
+ """Return a `{label_name: color}` map for the given label names.
+
+ When `names` is None, returns the map for all active labels.
+ Colors are read from the brain `color` metadata column to avoid
+ waking the Label objects. Empty / missing colors are omitted.
+ """
+ query = {"portal_type": "Label"}
+ if names:
+ query["title"] = list(names)
+ brains = search(query, catalog=SETUP_CATALOG)
+ out = {}
+ for brain in brains:
+ # Prefer the `getColor` metadata column — populated by the
+ # 2.7 upgrade step `setup_sample_labels`. Falls back to
+ # waking the Label and reading the attribute directly for
+ # instances where the catalog rebuild has not yet run
+ # (e.g. between code deploy and `portal_setup` re-import).
+ color = getattr(brain, "getColor", None)
+ if not color:
+ obj = api.get_object(brain, default=None)
+ if obj is not None:
+ color = getattr(obj, "color", None)
+ if color:
+ out[api.safe_unicode(brain.Title)] = api.safe_unicode(color)
+ return out
+
+
def search_objects_by_label(label, **kw):
"""Search for objects having one or more of the given labels
diff --git a/src/senaite/core/behaviors/label.py b/src/senaite/core/behaviors/label.py
index 359309e2d8..52e2a8d4d2 100644
--- a/src/senaite/core/behaviors/label.py
+++ b/src/senaite/core/behaviors/label.py
@@ -28,10 +28,11 @@
from plone.dexterity.schema import SCHEMA_CACHE
from plone.supermodel import model
from plone.supermodel.directives import fieldset
-from Products.CMFCore import permissions
from senaite.core.api import label as label_api
from senaite.core.catalog import SETUP_CATALOG
from senaite.core.interfaces import ICanHaveLabels
+from senaite.core.permissions import ManageLabels
+from senaite.core.permissions import ViewLabels
from senaite.core.z3cform.widgets.queryselect import QuerySelectWidgetFactory
from zope import schema
from zope.component import adapter
@@ -86,6 +87,13 @@ class ILabelSchema(model.Schema):
label=u"Labels",
fields=["Labels"])
+ # Gate the inline Labels chooser on the edit form by the same
+ # permissions used for chip visibility (ViewLabels) and chip
+ # add/remove (ManageLabels). Client users without ViewLabels
+ # neither see the field nor receive the chooser in form data.
+ directives.read_permission(Labels=ViewLabels)
+ directives.write_permission(Labels=ManageLabels)
+
directives.widget(
"Labels",
QuerySelectWidgetFactory,
@@ -131,11 +139,11 @@ class LabelSchema(object):
def __init__(self, context):
self.context = context
- @security.protected(permissions.View)
+ @security.protected(ViewLabels)
def getLabels(self):
return label_api.get_obj_labels(self.context)
- @security.protected(permissions.ModifyPortalContent)
+ @security.protected(ManageLabels)
def setLabels(self, value):
labels = label_api.to_labels(value)
return label_api.set_obj_labels(self.context, labels)
diff --git a/src/senaite/core/browser/controlpanel/labels/view.py b/src/senaite/core/browser/controlpanel/labels/view.py
index 79eaca3a74..330faf38a7 100644
--- a/src/senaite/core/browser/controlpanel/labels/view.py
+++ b/src/senaite/core/browser/controlpanel/labels/view.py
@@ -19,10 +19,11 @@
# Some rights reserved, see README and LICENSE.
import collections
+from cgi import escape as html_escape
from bika.lims import api
from bika.lims import bikaMessageFactory as _
-from bika.lims.utils import get_link_for
+from senaite.core.api import label as label_api
from senaite.core.browser.controlpanel.listing import ControlPanelListingView
from senaite.core.catalog import SETUP_CATALOG
@@ -84,18 +85,27 @@ def __init__(self, context, request):
]
def folderitem(self, obj, item, index):
- """Service triggered each time an item is iterated in folderitems.
- The use of this service prevents the extra-loops in child objects.
- :obj: the instance of the class to be foldered
- :item: dict containing the properties of the object to be used by
- the template
- :index: current index of the item
- """
+ # Always wake the object: we need Title / URL / Description
+ # anyway, so the brain-metadata shortcut would not save a wake.
obj = api.get_object(obj)
-
- item["replace"]["Title"] = get_link_for(obj)
+ color = getattr(obj, "color", u"") or u""
+ if not label_api.is_safe_color(color):
+ color = u""
+
+ title = api.safe_unicode(api.get_title(obj))
+ url = api.safe_unicode(api.get_url(obj))
+ style = label_api.chip_style(color)
+ style_attr = u' style="{}"'.format(style) if style else u""
+
+ item["replace"]["Title"] = (
+ u''
+ u'{title}'
+ ).format(
+ url=html_escape(url, quote=True),
+ style=style_attr,
+ title=html_escape(title),
+ )
item["Description"] = api.get_description(obj)
-
return item
def folderitems(self):
diff --git a/src/senaite/core/browser/label/api.py b/src/senaite/core/browser/label/api.py
new file mode 100644
index 0000000000..994abf1b91
--- /dev/null
+++ b/src/senaite/core/browser/label/api.py
@@ -0,0 +1,176 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.CORE.
+#
+# SENAITE.CORE is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, version 2.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program; if not, write to the Free Software Foundation, Inc., 51
+# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+# Copyright 2018-2025 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+from bika.lims import api
+from bika.lims.api.security import check_permission
+from bika.lims.decorators import returns_json
+from Products.Five.browser import BrowserView
+from senaite.core.api import label as label_api
+from senaite.core.config.labels import SAMPLE_LABEL_REINDEX
+from senaite.core.permissions import ManageLabels
+from zope.interface import implementer
+from zope.publisher.interfaces import IPublishTraverse
+
+
+@implementer(IPublishTraverse)
+class LabelsAPI(BrowserView):
+ """JSON endpoint for label management.
+
+ Routes are dispatched via subpath traversal:
+
+ - `/@@labels/add` — POST: add one or more labels to
+ the context object. Auto-creates the corresponding `Label`
+ in `setup.labels` when the name is new. Requires
+ `senaite.core: Manage Labels`.
+
+ - `/@@labels/remove` — POST: remove one or more
+ labels from the context object. Requires
+ `senaite.core: Manage Labels`.
+
+ - `@@labels/available` — GET: returns `{name, color,
+ description}` for every active label so consumers (e.g. the
+ active-filter chips above the listing search box) can paint
+ chips in the matching color. Requires `senaite.core: View
+ Labels`.
+
+ The browser page is registered at the lowest of the two
+ permissions (`ViewLabels`); the write routes re-check
+ `ManageLabels` and return 403 when missing. This keeps the
+ JSON contract symmetric (every route returns a JSON body) and
+ avoids exposing two ZCML page registrations for what is
+ logically one endpoint.
+
+ Labels are accepted in either of the two request shapes that
+ HTTP can deliver: `label=foo&label=bar` (repeated key) or
+ `labels=foo,bar` (comma-separated). Both are parsed through
+ `senaite.core.api.label.parse_label_csv`.
+ """
+
+ def __init__(self, context, request):
+ super(LabelsAPI, self).__init__(context, request)
+ self.traverse_subpath = []
+
+ def publishTraverse(self, request, name):
+ self.traverse_subpath.append(name)
+ return self
+
+ def __call__(self):
+ if len(self.traverse_subpath) != 1:
+ return self._not_found()
+ route = self.traverse_subpath[0]
+ handler = getattr(self, "_route_{}".format(route), None)
+ if handler is None:
+ return self._not_found()
+ return handler()
+
+ # ------------------------------------------------------------------
+ # Routes
+ # ------------------------------------------------------------------
+
+ @returns_json
+ def _route_add(self):
+ if not check_permission(ManageLabels, self.context):
+ return self._forbidden()
+ labels = self._read_submitted_labels()
+ if not labels:
+ return self._empty_input()
+ for name in labels:
+ if label_api.get_label_by_name(name) is None:
+ label_api.create_label(name)
+ new_labels = label_api.add_obj_labels(self.context, labels)
+ self.context.reindexObject(idxs=SAMPLE_LABEL_REINDEX)
+ return {"success": True, "labels": list(new_labels)}
+
+ @returns_json
+ def _route_remove(self):
+ if not check_permission(ManageLabels, self.context):
+ return self._forbidden()
+ labels = self._read_submitted_labels()
+ if not labels:
+ return self._empty_input()
+ new_labels = label_api.del_obj_labels(self.context, labels)
+ self.context.reindexObject(idxs=SAMPLE_LABEL_REINDEX)
+ return {"success": True, "labels": list(new_labels)}
+
+ @returns_json
+ def _route_available(self):
+ brains = label_api.query_labels()
+ labels = []
+ for brain in brains:
+ # Prefer the `getColor` metadata column. Falls back to
+ # waking the Label and reading the live attribute when
+ # the catalog rebuild has not yet propagated the new
+ # column to existing brains (matches the fallback in
+ # `senaite.core.api.label.get_label_colors`).
+ color = getattr(brain, "getColor", u"") or u""
+ if not color:
+ obj = api.get_object(brain, default=None)
+ if obj is not None:
+ color = getattr(obj, "color", u"") or u""
+ labels.append({
+ "name": api.safe_unicode(brain.Title),
+ "color": api.safe_unicode(color),
+ "description": api.safe_unicode(brain.Description or u""),
+ })
+ return {"labels": labels}
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _read_submitted_labels(self):
+ """Parse labels from the request form into a sorted unique list.
+
+ Accepts both `label=foo&label=bar` (repeated keys) and
+ `labels=foo,bar` (comma-separated) shapes; both go through
+ the same parser to return unicode names.
+ """
+ values = []
+ single = self.request.form.get("label")
+ if isinstance(single, (list, tuple)):
+ values.extend(single)
+ elif single:
+ values.append(single)
+ multi = self.request.form.get("labels")
+ if multi:
+ values.append(multi)
+ return label_api.parse_label_csv(values)
+
+ @returns_json
+ def _empty_input(self):
+ self.request.response.setStatus(400)
+ return {
+ "success": False,
+ "error": "No labels submitted",
+ "labels": list(label_api.get_obj_labels(self.context)),
+ }
+
+ @returns_json
+ def _forbidden(self):
+ self.request.response.setStatus(403)
+ return {"success": False, "error": "Forbidden"}
+
+ @returns_json
+ def _not_found(self):
+ self.request.response.setStatus(404)
+ return {
+ "success": False,
+ "error": "Unknown route. Use one of: add, remove, available",
+ }
diff --git a/src/senaite/core/browser/label/configure.zcml b/src/senaite/core/browser/label/configure.zcml
index d17efd387c..407f1b01f8 100644
--- a/src/senaite/core/browser/label/configure.zcml
+++ b/src/senaite/core/browser/label/configure.zcml
@@ -10,4 +10,39 @@
permission="senaite.core.permissions.ManageBika"
layer="senaite.core.interfaces.ISenaiteCore"/>
+
+
+
+
+
+
diff --git a/src/senaite/core/browser/label/labeled_objects.py b/src/senaite/core/browser/label/labeled_objects.py
index d2b7e915d2..4463df2175 100644
--- a/src/senaite/core/browser/label/labeled_objects.py
+++ b/src/senaite/core/browser/label/labeled_objects.py
@@ -23,6 +23,7 @@
from bika.lims import api
from bika.lims import bikaMessageFactory as _
from bika.lims.utils import get_link_for
+from plone.memoize import view
from senaite.core.i18n import translate as t
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
from senaite.core.browser.listing.base import ListingView
@@ -77,13 +78,6 @@ def __init__(self, context, request):
]
def folderitem(self, obj, item, index):
- """Service triggered each time an item is iterated in folderitems.
- The use of this service prevents the extra-loops in child objects.
- :obj: the instance of the class to be foldered
- :item: dict containing the properties of the object to be used by
- the template
- :index: current index of the item
- """
obj = api.get_object(obj)
labels = label_api.get_obj_labels(obj)
portal_type = api.get_portal_type(obj)
@@ -98,9 +92,15 @@ def folderitem(self, obj, item, index):
return item
+ @view.memoize
+ def _label_colors(self):
+ return label_api.get_label_colors()
+
def render_labels(self, labels):
+ colors = self._label_colors()
template = ViewPageTemplateFile("templates/object_labels.pt")
- return template(self, labels=labels)
+ return template(self, labels=labels, colors=colors,
+ chip_style=label_api.chip_style)
def folderitems(self):
items = super(LabeledObjectsView, self).folderitems()
diff --git a/src/senaite/core/browser/label/templates/object_labels.pt b/src/senaite/core/browser/label/templates/object_labels.pt
index 40ef5e64c9..f724dc910e 100644
--- a/src/senaite/core/browser/label/templates/object_labels.pt
+++ b/src/senaite/core/browser/label/templates/object_labels.pt
@@ -1,13 +1,15 @@
-
diff --git a/src/senaite/core/browser/label/title.py b/src/senaite/core/browser/label/title.py
new file mode 100644
index 0000000000..581f7c1cfb
--- /dev/null
+++ b/src/senaite/core/browser/label/title.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.CORE.
+#
+# SENAITE.CORE is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, version 2.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program; if not, write to the Free Software Foundation, Inc., 51
+# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+# Copyright 2018-2025 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+from cgi import escape as html_escape
+
+from Products.Five.browser import BrowserView
+from senaite.core.api import label as label_api
+
+
+class LabelTitleView(BrowserView):
+ """Override of `@@title` for Label content.
+
+ Plone's `main_template.pt` renders the page heading via
+ ``. Overriding the
+ view for `ILabel` lets us emit the heading already styled as a
+ colored chip — same look as the row chips and the chip grid in
+ the manage-labels modal — without resorting to a CSS-injection
+ viewlet or a body-class selector.
+ """
+
+ def __call__(self):
+ title = self.context.title or u""
+ color = getattr(self.context, "color", u"") or u""
+ style = label_api.chip_style(color)
+ style_attr = u' style="{}"'.format(style) if style else u""
+ return (
+ u'
'
+ u''
+ u'{title}'
+ u''
+ u'
'
+ ).format(style=style_attr, title=html_escape(title))
diff --git a/src/senaite/core/browser/listing/base.py b/src/senaite/core/browser/listing/base.py
index fd9f3c3930..b38c878a5c 100644
--- a/src/senaite/core/browser/listing/base.py
+++ b/src/senaite/core/browser/listing/base.py
@@ -18,10 +18,41 @@
# Copyright 2018-2025 by it's authors.
# Some rights reserved, see README and LICENSE.
+from cgi import escape as html_escape
+from urlparse import parse_qs
+
+from bika.lims import api
from bika.lims.api.security import check_permission
+from plone.memoize import view
from Products.CMFCore.permissions import ModifyPortalContent
from Products.CMFPlone.utils import safe_unicode
from senaite.app.listing import ListingView as BaseListingView
+from senaite.core.api import label as label_api
+from senaite.core.permissions import ManageLabels
+from senaite.core.permissions import ViewLabels
+
+LABEL_INDEX = "labels"
+LABEL_COLUMN = "getLabels"
+PRIMARY_COLUMN_CANDIDATES = ("Title", "title", "Name", "name", "getId")
+
+
+def _read_labels(obj):
+ """Return labels for the given brain/object without unnecessary wake.
+
+ Prefers the `getLabels` brain metadata column when present;
+ otherwise falls back to waking the object and calling
+ `getLabels()`.
+ """
+ value = getattr(obj, LABEL_COLUMN, None)
+ if value is not None:
+ # brain metadata is the bare value; never a method here
+ return value or ()
+ live = api.get_object(obj, default=None)
+ if live is None:
+ return ()
+ if hasattr(live, "getLabels"):
+ return live.getLabels() or ()
+ return ()
class ListingView(BaseListingView):
@@ -32,31 +63,225 @@ class ListingView(BaseListingView):
object's edit form inside a Bootstrap modal showing only the edit
form content (#content or #content-core).
- Subclasses may set ``edit_icon_column`` to the column key that should
+ Subclasses may set `edit_icon_column` to the column key that should
receive the icon. When not set, the first non-empty key among
- ``("Title", "title", "getId")`` is used.
+ `("Title", "title", "getId")` is used.
- Subclasses may set ``edit_view`` to control which view is opened:
- - ``"edit"`` (default) — appends ``/edit`` to the object URL; the modal
+ Subclasses may set `edit_view` to control which view is opened:
+ - `"edit"` (default) — appends `/edit` to the object URL; the modal
auto-closes when the form navigates away after save/cancel.
- - ``""`` — opens the object's base URL (e.g. the SENAITE sample view
+ - `""` — opens the object's base URL (e.g. the SENAITE sample view
with inline editing); the modal stays open until dismissed manually.
+
+ Label chips are rendered under the primary column automatically when
+ the listing's catalog carries a `labels` KeywordIndex and a
+ `getLabels` metadata column. Subclasses may override
+ `label_target_column` to pin chips to a specific column.
"""
edit_icon_column = None
edit_view = "edit"
+ # Column key that receives label chips. `None` resolves to the first
+ # non-empty key among `PRIMARY_COLUMN_CANDIDATES`.
+ label_target_column = None
+
def folderitems(self):
items = super(ListingView, self).folderitems()
if not check_permission(ModifyPortalContent, self.context):
return items
return [self._add_iframe_edit_link(item) for item in items]
+ def before_render(self):
+ super(ListingView, self).before_render()
+ if self.labels_filterable():
+ labels = self.get_request_labels()
+ if labels:
+ self.contentFilter["labels"] = {
+ "query": labels,
+ "operator": "and",
+ }
+
+ def folderitem(self, obj, item, index):
+ item = super(ListingView, self).folderitem(obj, item, index)
+ if not item:
+ return item
+ if self.labels_visible():
+ self._attach_label_chips(obj, item)
+ return item
+
+ # ------------------------------------------------------------------
+ # Label support
+ # ------------------------------------------------------------------
+
+ @view.memoize
+ def labels_visible(self):
+ """Whether this listing renders label chips.
+
+ Gated by the `ViewLabels` permission so that client users do
+ not see internal lab labels on shared samples. Transposed
+ listings (e.g. worksheet manage view) are also skipped because
+ their `after` slot is not a sample identifier.
+ """
+ if getattr(self, "transposed_view", False):
+ return False
+ return self.can_view_labels()
+
+ @view.memoize
+ def can_view_labels(self):
+ return check_permission(ViewLabels, self.context)
+
+ @view.memoize
+ def labels_filterable(self):
+ """Whether chips link to the `?labels=` filter.
+
+ Requires the listing's catalog to carry a `labels` index
+ AND the current user to have `ViewLabels`.
+ """
+ if not self.can_view_labels():
+ return False
+ catalog_id = getattr(self, "catalog", None)
+ if not catalog_id:
+ return False
+ catalog = api.get_tool(catalog_id, default=None)
+ if catalog is None:
+ return False
+ return LABEL_INDEX in catalog.indexes()
+
+ @view.memoize
+ def can_manage_labels(self):
+ return check_permission(ManageLabels, self.context)
+
+ def get_request_labels(self):
+ """Parse the `labels` query string into a sorted unique list.
+
+ Reads `request.form` first (initial page render) and 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.
+ """
+ raw = self.request.form.get("labels")
+ if not raw:
+ qs = self.request.get("QUERY_STRING") or ""
+ raw = parse_qs(qs).get("labels", [])
+ return label_api.parse_label_csv(raw)
+
+ def get_label_target_column(self, item):
+ if self.label_target_column:
+ return self.label_target_column
+ for key in PRIMARY_COLUMN_CANDIDATES:
+ if key in self.columns:
+ return key
+ return None
+
+ def render_label_chips(self, obj):
+ """Render the inline chip block for the given brain/object.
+
+ Every interpolated value is HTML-escaped before being
+ concatenated. Label titles are free-text content on the Label
+ type and the brain `color` value is admin-supplied, so both
+ must be treated as untrusted in this context — color values
+ pass through `is_safe_color` before any inline style is built.
+
+ Chips render as plain `` when the listing's catalog
+ does not support label filtering (no `labels` index); as
+ clickable filter chips (`is-filterable` class, navigation
+ wired client-side) otherwise. Label removal is done through
+ the manage-labels modal, so the chip body carries no `×`.
+ """
+ labels = _read_labels(obj)
+ if not labels:
+ return u""
+
+ filterable = self.labels_filterable()
+ active = set(self.get_request_labels()) if filterable else set()
+ uid_attr = self._chip_uid_attr(obj)
+ colors = self._get_label_colors_map()
+
+ chips = [
+ self._render_chip(label, colors.get(safe_unicode(label)),
+ active, filterable)
+ for label in labels
+ ]
+ return (
+ u'