Skip to content

Commit 645e398

Browse files
committed
Workspace search dialog: local/global scope switch (#426)
The Workspace filter chip in the search dialog becomes a working local/global scope switch: EU-Projekt GreenCat (workspace) scopes livesearch and the results page to the current workspace, Intranet Portal widens to the whole site. - @solr-suggest gains path_prefix support (kitconcept.solr pin fcae6395 on feature-ai-rag, includes the RAG think-tag stripper fix) - ragSearch/solrSearchSuggestions actions accept an optional pathPrefix - searchPathPrefix: explicit path_prefix URL param wins over the getPathPrefix language-root heuristic - Cypress: workspace-search-dialog.cy.js covers scoped livesearch, widening to global via the chip, and scoped/global Enter navigation - Backend: TestSolrSuggestPathPrefix unit tests for the suggest service path_prefix parameter
1 parent e2c43a6 commit 645e398

7 files changed

Lines changed: 258 additions & 19 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Update the kitconcept-solr pin to the feature-ai-rag tip with the local-scoping support (@solr-suggest path_prefix), required by the workspace-scope tests and the deployment. @reebalazs
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""@solr-suggest local (subtree) scoping via the path_prefix parameter.
2+
3+
Mirrors the TestSuggestPathPrefix integration test in kitconcept.solr,
4+
exercised here through the intranet stack with the distribution's
5+
example content. The assertions are content-independent invariants:
6+
a scoped call returns exactly the global suggestions that live under
7+
the prefix, and a prefix without matches returns nothing.
8+
"""
9+
10+
from urllib.parse import urlparse
11+
12+
import pytest
13+
14+
15+
@pytest.fixture(scope="class")
16+
def answers():
17+
return {
18+
"site_id": "solr",
19+
"title": "Intranet",
20+
"description": "Site created with A Plone distribution for Intranets with Plone. Created by kitconcept.", # noQA: E501
21+
"workflow": "public",
22+
"available_languages": ["en"],
23+
"portal_timezone": "Europe/Berlin",
24+
"setup_content": True,
25+
"authentication": {"provider": "internal"},
26+
"setup_solr": True,
27+
}
28+
29+
30+
@pytest.fixture(scope="class")
31+
def portal(functional_portal):
32+
yield functional_portal
33+
34+
35+
@pytest.mark.slow
36+
@pytest.mark.solr
37+
class TestSolrSuggestPathPrefix:
38+
@pytest.fixture(autouse=True)
39+
def _setup(self, portal, manager_request):
40+
self.portal = portal
41+
self.api_session = manager_request
42+
43+
def _suggest_paths(self, query: str, path_prefix: str | None = None):
44+
url = f"/@solr-suggest?query={query}"
45+
if path_prefix:
46+
url += f"&path_prefix={path_prefix}"
47+
response = self.api_session.get(url)
48+
data = response.json()
49+
return [urlparse(item["@id"]).path for item in data["suggestions"]]
50+
51+
def test_scoped_returns_the_subtree_subset_of_global(self):
52+
paths = self._suggest_paths("standort")
53+
assert len(paths) >= 1
54+
# scope to the top-level folder of the first suggestion
55+
top = "/" + paths[0].split("/")[2]
56+
scoped = self._suggest_paths("standort", path_prefix=top)
57+
expected = [p for p in paths if p.startswith(f"/solr{top}")]
58+
assert scoped == expected
59+
assert len(scoped) >= 1
60+
61+
def test_prefix_without_matches_returns_empty(self):
62+
assert self._suggest_paths("standort") != []
63+
assert self._suggest_paths("standort", path_prefix="/does-not-exist") == []

backend/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Tests for the workspace search dialog local/global scope switch
2+
// (ticket #426): inside a workspace the Workspace chip scopes the
3+
// livesearch and the Enter results page to the workspace subtree by
4+
// default; selecting "Intranet Portal" widens to a global search.
5+
//
6+
// The AI parts ("Ask AI") are not covered here: the acceptance backend
7+
// has no LLM configured, so rag_available is false and the button is
8+
// hidden by design (graceful degradation).
9+
//
10+
// The solr setup follows the style of search-persons.cy.js.
11+
12+
context('Workspace search dialog (local/global scope)', () => {
13+
beforeEach(() => {
14+
// The solr connection parameters are provided by the COLLECTIVE_SOLR_*
15+
// environment variables (docker compose setup) or by the registry
16+
// defaults (local setup, solr listening on localhost:8983).
17+
cy.setRegistry('collective.solr.active', true);
18+
cy.reindexSolr();
19+
20+
cy.createContent({
21+
contentType: 'Workspace',
22+
contentId: 'greencat',
23+
contentTitle: 'GreenCat Workspace',
24+
});
25+
cy.createContent({
26+
contentType: 'WikiPage',
27+
contentId: 'vacation-team-rules',
28+
contentTitle: 'Vacation rules of the GreenCat team',
29+
path: '/greencat',
30+
});
31+
// content outside the workspace, matching the same search term
32+
cy.createContent({
33+
contentType: 'Document',
34+
contentId: 'vacation-form',
35+
contentTitle: 'Vacation request form',
36+
path: '/',
37+
});
38+
39+
cy.autologin();
40+
});
41+
afterEach(() => {
42+
cy.clearSolr();
43+
});
44+
45+
it('scopes the livesearch to the workspace and can widen to global', () => {
46+
cy.visit('/greencat');
47+
cy.get('.header-search-button').click();
48+
cy.get('.header-search-input-row input').type('vacation');
49+
50+
// workspace scope (default, chip active): only the workspace page
51+
cy.get('.header-search-result-title').should('have.length', 1);
52+
cy.get('.header-search-result-title').contains(
53+
'Vacation rules of the GreenCat team',
54+
);
55+
cy.get('.header-search-chip.is-active').contains(
56+
'Workspace: GreenCat Workspace',
57+
);
58+
59+
// switch the Workspace chip to Intranet Portal -> global results
60+
cy.get('.header-search-chip').contains('Workspace:').click();
61+
cy.get('.header-search-chip-menu .react-aria-MenuItem')
62+
.contains('Intranet Portal')
63+
.click();
64+
cy.get('.header-search-result-title').should('have.length.at.least', 2);
65+
cy.get('.header-search-result-title').contains('Vacation request form');
66+
cy.get('.header-search-chip').contains('Workspace: Intranet Portal');
67+
});
68+
69+
it('Enter opens the workspace-scoped results page without a local toggle', () => {
70+
cy.visit('/greencat');
71+
cy.get('.header-search-button').click();
72+
cy.get('.header-search-input-row input').type('vacation{enter}');
73+
74+
cy.url().should('include', '/greencat/@@search');
75+
cy.url().should('include', 'local=true');
76+
77+
// only the workspace page in the classic results
78+
cy.contains('Vacation rules of the GreenCat team');
79+
cy.contains('Vacation request form').should('not.exist');
80+
// the legacy local/global radio stays hidden (allow_local unset)
81+
cy.get('.search-localized').should('not.exist');
82+
});
83+
84+
it('Enter searches globally when Intranet Portal is selected', () => {
85+
cy.visit('/greencat');
86+
cy.get('.header-search-button').click();
87+
cy.get('.header-search-input-row input').type('vacation');
88+
cy.get('.header-search-chip').contains('Workspace:').click();
89+
cy.get('.header-search-chip-menu .react-aria-MenuItem')
90+
.contains('Intranet Portal')
91+
.click();
92+
cy.get('.header-search-input-row input').type('{enter}');
93+
94+
cy.url().should('include', '/search?SearchableText=vacation');
95+
cy.contains('Vacation request form');
96+
});
97+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Workspace search dialog: the Workspace chip is the local/global scope switch — workspace scope (default) restricts livesearch and the Enter results page to the workspace subtree, "Intranet Portal" searches globally. Covered by a Cypress test and a backend @solr-suggest path_prefix test; requires the kitconcept.solr local-scoping support. @reebalazs

frontend/packages/kitconcept-intranet/src/components/Header/HeaderSearch.tsx

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,17 @@ const FilterChip = ({
271271
</MenuTrigger>
272272
);
273273

274-
const FilterChips = ({ workspaceTitle }: { workspaceTitle: string }) => {
274+
type SearchScope = 'workspace' | 'global';
275+
276+
const FilterChips = ({
277+
workspaceTitle,
278+
scope,
279+
onScopeChange,
280+
}: {
281+
workspaceTitle: string;
282+
scope: SearchScope;
283+
onScopeChange: (scope: SearchScope) => void;
284+
}) => {
275285
const intl = useIntl();
276286
const filters: Array<{
277287
id: string;
@@ -342,19 +352,33 @@ const FilterChips = ({ workspaceTitle }: { workspaceTitle: string }) => {
342352
<div className="header-search-filters">
343353
<div className="header-search-chips">
344354
{filters.map((filter) => {
345-
const selected = selection[filter.id] || filter.options[0];
355+
// The Workspace chip is the real scope switch (local/global);
356+
// the other chips are inert demo data (facets deferred).
357+
const isScopeChip = filter.id === 'workspace';
358+
const selected = isScopeChip
359+
? scope === 'workspace'
360+
? workspaceTitle
361+
: intl.formatMessage(messages.intranetPortal)
362+
: selection[filter.id] || filter.options[0];
346363
return (
347364
<FilterChip
348365
key={filter.id}
349366
label={filter.label}
350367
options={filter.options}
351368
selected={selected}
352369
onSelect={(value) =>
353-
setSelection((current) => ({ ...current, [filter.id]: value }))
370+
isScopeChip
371+
? onScopeChange(
372+
value === workspaceTitle ? 'workspace' : 'global',
373+
)
374+
: setSelection((current) => ({
375+
...current,
376+
[filter.id]: value,
377+
}))
354378
}
355379
isActive={
356-
filter.id === 'workspace'
357-
? selected === workspaceTitle
380+
isScopeChip
381+
? scope === 'workspace'
358382
: selected !== filter.options[0]
359383
}
360384
/>
@@ -589,12 +613,28 @@ const HeaderSearch = () => {
589613
(state: any) =>
590614
state?.site?.data?.['kitconcept.solr.rag_available'] === true,
591615
);
592-
const workspaceTitle: string = useSelector(
593-
(state: any) =>
594-
state.content?.data?.['@components']?.inherit?.[
595-
'kitconcept.plate.workspace'
596-
]?.from?.title || '…',
616+
const workspace: { title: string; path: string | null } = useSelector(
617+
(state: any) => {
618+
const from =
619+
state.content?.data?.['@components']?.inherit?.[
620+
'kitconcept.plate.workspace'
621+
]?.from;
622+
return {
623+
title: from?.title || '…',
624+
path: from?.['@id'] ? flattenToAppURL(from['@id']) : null,
625+
};
626+
},
627+
// Value based comparison: the selector builds a fresh object on
628+
// every run, so reference equality would re-render on every store
629+
// change.
630+
(a: any, b: any) => a.title === b.title && a.path === b.path,
597631
);
632+
// The Workspace chip switches the scope: workspace (default, local
633+
// to the current workspace) or global ("Intranet Portal"). All three
634+
// searches - livesearch, Ask AI and the Enter results page - follow
635+
// it (see ticket #426 / kitconcept.solr local scoping).
636+
const [scope, setScope] = useState<SearchScope>('workspace');
637+
const scopePath = scope === 'workspace' ? workspace.path : null;
598638

599639
const term = searchText.trim();
600640
const showResults = term.length >= 2;
@@ -634,10 +674,10 @@ const HeaderSearch = () => {
634674
return;
635675
}
636676
const timeout = window.setTimeout(() => {
637-
dispatch(solrSearchSuggestions(encodeURIComponent(term)));
677+
dispatch(solrSearchSuggestions(encodeURIComponent(term), scopePath));
638678
}, 250);
639679
return () => window.clearTimeout(timeout);
640-
}, [dispatch, isSearchOpen, term]);
680+
}, [dispatch, isSearchOpen, term, scopePath]);
641681

642682
const resetAi = useCallback(() => {
643683
setAiAsked(false);
@@ -647,6 +687,7 @@ const HeaderSearch = () => {
647687
const closeSearch = useCallback(() => {
648688
setIsSearchOpen(false);
649689
setSearchText('');
690+
setScope('workspace');
650691
resetAi();
651692
}, [resetAi]);
652693

@@ -658,6 +699,14 @@ const HeaderSearch = () => {
658699
}
659700
};
660701

702+
const onScopeChange = (nextScope: SearchScope) => {
703+
setScope(nextScope);
704+
if (aiAsked) {
705+
// The AI answer was grounded in the previous scope.
706+
resetAi();
707+
}
708+
};
709+
661710
const onChangeText = (text: string) => {
662711
setSearchText(text);
663712
if (aiAsked) {
@@ -672,9 +721,25 @@ const HeaderSearch = () => {
672721

673722
const submitSearch = (event: FormEvent<HTMLFormElement>) => {
674723
event.preventDefault();
675-
navigateTo(
676-
term ? `/search?SearchableText=${encodeURIComponent(term)}` : '/search',
677-
);
724+
// Enter goes to the workspace-scoped search page: the nested
725+
// @@search route plus local=true restricts the classic results
726+
// (and, through the page's own wiring, the AI retrieval) to the
727+
// workspace subtree. Without a workspace path, plain global search.
728+
// is_multilingual=false: the intranet is monolingual, and the
729+
// backend's multilingual path handling would neutralize the
730+
// path_prefix filter on a site without plone.app.multilingual.
731+
if (scopePath) {
732+
const query = term
733+
? `?SearchableText=${encodeURIComponent(term)}&local=true` +
734+
`&path_prefix=${encodeURIComponent(`${scopePath}/`)}` +
735+
`&is_multilingual=false`
736+
: '';
737+
navigateTo(`${scopePath}/@@search${query}`);
738+
} else {
739+
navigateTo(
740+
term ? `/search?SearchableText=${encodeURIComponent(term)}` : '/search',
741+
);
742+
}
678743
};
679744

680745
const onAskAI = () => {
@@ -686,7 +751,7 @@ const HeaderSearch = () => {
686751
// answer below the loading indicator.
687752
dispatch(resetRagSearch());
688753
setAiAsked(true);
689-
dispatch(ragSearch('', term));
754+
dispatch(ragSearch('', term, scopePath || undefined));
690755
};
691756

692757
return (
@@ -740,7 +805,11 @@ const HeaderSearch = () => {
740805
</div>
741806
</form>
742807

743-
<FilterChips workspaceTitle={workspaceTitle} />
808+
<FilterChips
809+
workspaceTitle={workspace.title}
810+
scope={scope}
811+
onScopeChange={onScopeChange}
812+
/>
744813

745814
{showResults ? (
746815
<div className="header-search-results">

frontend/packages/kitconcept-intranet/src/customizations/@kitconcept/volto-solr/components/theme/SolrSearch/SolrSearch.jsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,19 @@ class SolrSearch extends Component {
285285
...params,
286286
sort_on: params.sort_on !== 'relevance' ? params.sort_on : '',
287287
b_start: (this.state.currentPage - 1) * config.settings.defaultPageSize,
288-
path_prefix: getPathPrefix(window.location),
288+
path_prefix: this.searchPathPrefix(params),
289289
doEmptySearch: this.props.doEmptySearch,
290290
});
291291
};
292292

293+
// An explicit path_prefix URL param wins over the URL heuristic:
294+
// getPathPrefix treats every single-segment path as a language root
295+
// (/de, /en), so a top-level subsite or workspace (/my-workspace)
296+
// would silently lose its prefix. The workspace search dialog uses
297+
// this for its workspace-scoped result pages.
298+
searchPathPrefix = (params) =>
299+
params.path_prefix || getPathPrefix(window.location);
300+
293301
updateSearch = () => {
294302
this.props.history.replace({
295303
search: qs.stringify(queryStateToParams(this.state)),

0 commit comments

Comments
 (0)