Skip to content

Commit 047ac98

Browse files
committed
Add vocabulary support for facet conditions (#63)
- Add useVocab/useVocabs hooks with VocabContext for caching and batched fetching - Extend fieldList config to support vocabulary references (name, isMultilingual) - Return vocabulary metadata from backend search endpoint - Wire vocabulary labels into SearchConditions and SearchConditionsField - Hide search prefix box for vocabulary-backed facet fields - Fix useSelector rerenders in SolrSearchAutosuggest and routes - Fix livesearch widget exception during text changes - Fix test_services_navigation.py which used the wrong layer and corrupted ZODB state - Add INSTRUCTIONS.md with documentation for vocabulary support and calculated fields - Add tests for VocabContext, useVocab, useVocabs, and backend vocabulary parsing
1 parent 08f2420 commit 047ac98

29 files changed

Lines changed: 1602 additions & 138 deletions

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Instructions for using kitconcept.solr
2+
3+
The file `./markdown-docs/INSTRUCTIONS.md` contains instructions for using kitconcept.solr in selected use cases. Refer to this file for more details.

backend/news/63.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix test_services_navigation.py which used the wrong layer and corrupted ZODB state @reebalazs

backend/news/63.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add vocabulary support for facet conditions. @reebalazs

backend/src/kitconcept/solr/interfaces.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,26 @@ class IKitconceptSolrLayer(IDefaultBrowserLayer):
1717
"properties": {
1818
"fieldList": {
1919
"type": "array",
20-
"items": {"type": "string"},
20+
"items": {
21+
"oneOf": [
22+
{"type": "string"},
23+
{
24+
"type": "object",
25+
"properties": {
26+
"name": {"type": "string"},
27+
"vocabulary": {
28+
"type": "object",
29+
"properties": {
30+
"name": {"type": "string"},
31+
"isMultilingual": {"type": "boolean"},
32+
},
33+
"required": ["name"],
34+
},
35+
},
36+
"required": ["name"],
37+
},
38+
],
39+
},
2140
},
2241
"searchTabs": {
2342
"type": "array",

backend/src/kitconcept/solr/services/solr.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ def enhance_result(
253253
result.get("response", {}).get("docs", []),
254254
result.get("highlighting", {}),
255255
)
256+
result["vocabularies"] = solr_config.vocabularies
256257
# Solr response is pruned of the unnecessary parts, unless explicitly requested.
257258
if not keep_full_solr_response:
258259
result.pop("facet_counts", None)

backend/src/kitconcept/solr/services/solr_utils.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,33 @@ def select_condition(self, group_select: int) -> str:
9090
condition = filters[group_select]
9191
return f"{base_query}{condition}"
9292

93+
@staticmethod
94+
def _field_name(item) -> str:
95+
"""Extract the field name from a fieldList item (string or dict)."""
96+
if isinstance(item, dict):
97+
return item["name"]
98+
return item
99+
93100
@property
94101
def field_list(self) -> str:
95102
raw_value = self.config.get("fieldList", [])
96-
invalid_fields = [item for item in raw_value if "," in item]
103+
names = [self._field_name(item) for item in raw_value]
104+
invalid_fields = [name for name in names if "," in name]
97105
if invalid_fields:
98106
raise SolrConfigError(
99107
"Error parsing solr config, fieldList item contains comma (,) "
100108
"which is prohibited"
101109
)
102-
return ",".join(raw_value)
110+
return ",".join(names)
111+
112+
@property
113+
def vocabularies(self) -> list:
114+
raw_value = self.config.get("fieldList", [])
115+
return [
116+
{"field": item["name"], **item["vocabulary"]}
117+
for item in raw_value
118+
if isinstance(item, dict) and "vocabulary" in item
119+
]
103120

104121
def select_layouts(self, group_select: int) -> list:
105122
return self.listOflayouts[group_select]

backend/tests/services/navigation/test_services_navigation.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,52 +5,70 @@
55
from plone.dexterity.utils import createContentInContainer
66
from plone.registry.interfaces import IRegistry
77
from plone.restapi.bbb import INavigationSchema
8-
from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
8+
from kitconcept.solr.testing import FUNCTIONAL_TESTING
99
from plone.restapi.testing import RelativeSession
1010
from zope.component import getUtility
1111

1212
import transaction
1313
import unittest
1414

1515

16+
LANG = "en"
17+
18+
19+
def create(container, portal_type, **kw):
20+
content = createContentInContainer(container, portal_type, **kw)
21+
content.language = LANG
22+
return content
23+
24+
1625
class TestServicesNavigation(unittest.TestCase):
17-
layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
26+
layer = FUNCTIONAL_TESTING
1827

1928
def setUp(self):
2029
self.app = self.layer["app"]
2130
self.portal = self.layer["portal"]
2231
self.portal_url = self.portal.absolute_url()
2332
setRoles(self.portal, TEST_USER_ID, ["Manager"])
2433

34+
# Re-enable Folder type (disabled by plone.volto profile)
35+
fti = self.portal.portal_types["Folder"]
36+
fti.global_allow = True
37+
38+
# Ensure Folder is in displayed_types for navigation
39+
registry = getUtility(IRegistry)
40+
settings = registry.forInterface(INavigationSchema, prefix="plone")
41+
displayed_types = settings.displayed_types
42+
if "Folder" not in displayed_types:
43+
settings.displayed_types = tuple(list(displayed_types) + ["Folder"])
44+
2545
self.api_session = RelativeSession(self.portal_url, test=self)
2646
self.api_session.headers.update({"Accept": "application/json"})
2747
self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD)
2848

29-
self.folder = createContentInContainer(
30-
self.portal, "Folder", id="folder", title="Some Folder"
31-
)
32-
self.folder2 = createContentInContainer(
49+
self.folder = create(self.portal, "Folder", id="folder", title="Some Folder")
50+
self.folder2 = create(
3351
self.portal, "Folder", id="folder2", title="Some Folder 2"
3452
)
35-
self.subfolder1 = createContentInContainer(
53+
self.subfolder1 = create(
3654
self.folder, "Folder", id="subfolder1", title="SubFolder 1"
3755
)
38-
self.subfolder2 = createContentInContainer(
56+
self.subfolder2 = create(
3957
self.folder, "Folder", id="subfolder2", title="SubFolder 2"
4058
)
41-
self.thirdlevelfolder = createContentInContainer(
59+
self.thirdlevelfolder = create(
4260
self.subfolder1,
4361
"Folder",
4462
id="thirdlevelfolder",
4563
title="Third Level Folder",
4664
)
47-
self.fourthlevelfolder = createContentInContainer(
65+
self.fourthlevelfolder = create(
4866
self.thirdlevelfolder,
4967
"Folder",
5068
id="fourthlevelfolder",
5169
title="Fourth Level Folder",
5270
)
53-
createContentInContainer(self.folder, "Document", id="doc1", title="A document")
71+
create(self.folder, "Document", id="doc1", title="A document")
5472
transaction.commit()
5573

5674
def tearDown(self):
@@ -93,13 +111,13 @@ def test_dont_broke_with_contents_without_review_state(self):
93111
settings = registry.forInterface(INavigationSchema, prefix="plone")
94112
displayed_types = settings.displayed_types
95113
settings.displayed_types = tuple(list(displayed_types) + ["File"])
96-
createContentInContainer(
114+
create(
97115
self.portal,
98116
"File",
99117
id="example-file",
100118
title="Example file",
101119
)
102-
createContentInContainer(
120+
create(
103121
self.folder,
104122
"File",
105123
id="example-file-1",
@@ -123,7 +141,7 @@ def test_show_excluded_items(self):
123141
# False for Plone 6.0 and True for Plone 5.2
124142
# explicitly set the value to False to avoid test failures
125143
settings.show_excluded_items = False
126-
createContentInContainer(
144+
create(
127145
self.folder,
128146
"Folder",
129147
id="excluded-subfolder",
@@ -161,13 +179,13 @@ def test_navigation_sorting(self):
161179
"Collection",
162180
"File",
163181
)
164-
createContentInContainer(
182+
create(
165183
self.portal,
166184
"File",
167185
id="example-file",
168186
title="Example file",
169187
)
170-
createContentInContainer(
188+
create(
171189
self.folder,
172190
"File",
173191
id="example-file-1",
@@ -208,7 +226,7 @@ def test_use_nav_title_when_available_and_set(self):
208226
title = "Example Document"
209227
nav_title = "Fancy title"
210228

211-
createContentInContainer(
229+
create(
212230
self.folder,
213231
"DXTestDocument",
214232
id="example-dx-document",
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import pytest
2+
3+
4+
solr_config_with_vocabs = {
5+
"fieldList": [
6+
"UID",
7+
{
8+
"name": "Title",
9+
"vocabulary": {
10+
"name": "kitconcept.solr.vocabularies.test",
11+
"isMultilingual": False,
12+
},
13+
},
14+
"Description",
15+
{
16+
"name": "Type",
17+
"vocabulary": {
18+
"name": "kitconcept.solr.vocabularies.types",
19+
},
20+
},
21+
"effective",
22+
"start",
23+
"created",
24+
"end",
25+
"path_string",
26+
"phone",
27+
"email",
28+
"location",
29+
],
30+
"searchTabs": [
31+
{
32+
"label": "All",
33+
"filter": "Type(*)",
34+
},
35+
{
36+
"label": "Pages",
37+
"filter": "Type:(Page)",
38+
},
39+
],
40+
}
41+
42+
solr_config_no_vocabs = {
43+
"fieldList": [
44+
"UID",
45+
"Title",
46+
"Description",
47+
"Type",
48+
"effective",
49+
"start",
50+
"created",
51+
"end",
52+
"path_string",
53+
"phone",
54+
"email",
55+
"location",
56+
],
57+
"searchTabs": [
58+
{
59+
"label": "All",
60+
"filter": "Type(*)",
61+
},
62+
{
63+
"label": "Pages",
64+
"filter": "Type:(Page)",
65+
},
66+
],
67+
}
68+
69+
70+
class TestVocabulariesEndpoint:
71+
@pytest.fixture(autouse=True)
72+
def _init(self, portal_with_content, manager_request):
73+
self.portal = portal_with_content
74+
response = manager_request.get(self.url)
75+
self.data = response.json()
76+
77+
78+
class TestVocabulariesInResponse(TestVocabulariesEndpoint):
79+
url = "/@solr?q=chomsky"
80+
81+
@pytest.fixture()
82+
def registry_config(self) -> dict:
83+
return {
84+
"collective.solr.active": 1,
85+
"kitconcept.solr.config": solr_config_with_vocabs,
86+
}
87+
88+
def test_vocabularies(self):
89+
assert self.data.get("vocabularies") == [
90+
{
91+
"field": "Title",
92+
"name": "kitconcept.solr.vocabularies.test",
93+
"isMultilingual": False,
94+
},
95+
{
96+
"field": "Type",
97+
"name": "kitconcept.solr.vocabularies.types",
98+
},
99+
]
100+
101+
102+
class TestVocabulariesEmptyInResponse(TestVocabulariesEndpoint):
103+
url = "/@solr?q=chomsky"
104+
105+
@pytest.fixture()
106+
def registry_config(self) -> dict:
107+
return {
108+
"collective.solr.active": 1,
109+
"kitconcept.solr.config": solr_config_no_vocabs,
110+
}
111+
112+
def test_vocabularies_empty(self):
113+
assert self.data.get("vocabularies") == []

backend/tests/utils/test_utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,34 @@ def test_field_list(self):
9090
)
9191

9292

93+
solr_config_with_dict_fields = {
94+
**solr_config,
95+
"fieldList": [
96+
"UID",
97+
{
98+
"name": "Title",
99+
"vocabulary": {
100+
"name": "kitconcept.solr.vocabularies.test",
101+
"isMultilingual": False,
102+
},
103+
},
104+
"Description",
105+
"Type",
106+
],
107+
}
108+
109+
110+
class TestUtilsFieldListWithDictFields(TestUtils):
111+
@pytest.fixture()
112+
def registry_config(self) -> dict:
113+
return {
114+
"kitconcept.solr.config": solr_config_with_dict_fields,
115+
}
116+
117+
def test_field_list(self):
118+
assert self.solr_config.field_list == "UID,Title,Description,Type"
119+
120+
93121
class TestUtilsSelectLayouts(TestUtils):
94122
def test_select_layouts(self):
95123
assert self.solr_config.select_layouts(0) is None

0 commit comments

Comments
 (0)