diff --git a/frontend/cypress/tests/main/workspace-search-dialog.cy.js b/frontend/cypress/tests/main/workspace-search-dialog.cy.js index a745183c..d748b5e4 100644 --- a/frontend/cypress/tests/main/workspace-search-dialog.cy.js +++ b/frontend/cypress/tests/main/workspace-search-dialog.cy.js @@ -1,7 +1,9 @@ -// Tests for the workspace search dialog local/global scope switch -// (ticket #426): inside a workspace the Workspace chip scopes the -// livesearch and the Enter results page to the workspace subtree by -// default; selecting "Intranet Portal" widens to a global search. +// Tests for the workspace search dialog scope switch (tickets #426 and +// #570): inside a workspace the Workspace chip scopes the livesearch +// and the Enter results page to the workspace subtree by default; the +// chip's dropdown offers "Everywhere" plus every workspace the user +// can access. Result rows carry a location label, and an empty scoped +// search offers a one-click "Search everywhere" escape. // // The AI parts ("Ask AI") are not covered here: the acceptance backend // has no LLM configured, so rag_available is false and the button is @@ -9,7 +11,7 @@ // // The solr setup follows the style of search-persons.cy.js. -context('Workspace search dialog (local/global scope)', () => { +context('Workspace search dialog (scope dropdown)', () => { beforeEach(() => { // The solr connection parameters are provided by the COLLECTIVE_SOLR_* // environment variables (docker compose setup) or by the registry @@ -28,7 +30,19 @@ context('Workspace search dialog (local/global scope)', () => { contentTitle: 'Vacation rules of the GreenCat team', path: '/greencat', }); - // content outside the workspace, matching the same search term + // a second workspace, to pick from the scope dropdown + cy.createContent({ + contentType: 'Workspace', + contentId: 'quantum', + contentTitle: 'Quantum Workspace', + }); + cy.createContent({ + contentType: 'WikiPage', + contentId: 'vacation-quantum', + contentTitle: 'Vacation policy of the Quantum group', + path: '/quantum', + }); + // content outside the workspaces, matching the same search term cy.createContent({ contentType: 'Document', contentId: 'vacation-form', @@ -42,7 +56,7 @@ context('Workspace search dialog (local/global scope)', () => { cy.clearSolr(); }); - it('scopes the livesearch to the workspace and can widen to global', () => { + it('scopes the livesearch to the workspace and can widen to everywhere', () => { cy.visit('/greencat'); cy.get('.header-search-button').click(); cy.get('.header-search-input-row input').type('vacation'); @@ -56,14 +70,66 @@ context('Workspace search dialog (local/global scope)', () => { 'Workspace: GreenCat Workspace', ); - // switch the Workspace chip to Intranet Portal -> global results + // the dropdown lists Everywhere plus all workspaces + cy.get('.header-search-chip').contains('Workspace:').click(); + cy.get('.header-search-scope-menu').contains('Search in all areas'); + cy.get('.header-search-scope-menu').contains('GreenCat Workspace'); + cy.get('.header-search-scope-menu').contains('Quantum Workspace'); + + // switch to Everywhere -> global results with location labels + cy.get('.header-search-scope-menu .react-aria-MenuItem') + .contains('Everywhere') + .click(); + cy.get('.header-search-result-title').should('have.length.at.least', 3); + cy.get('.header-search-chip').contains('Workspace: Everywhere'); + cy.contains('.header-search-result', 'Vacation request form') + .find('.header-search-result-location') + .contains('Intranet Portal'); + cy.contains('.header-search-result', 'Vacation rules of the GreenCat team') + .find('.header-search-result-location') + .contains('GreenCat Workspace'); + }); + + it('scopes the livesearch to another workspace picked from the dropdown', () => { + cy.visit('/greencat'); + cy.get('.header-search-button').click(); + cy.get('.header-search-input-row input').type('vacation'); + cy.get('.header-search-result-title').should('have.length', 1); + cy.get('.header-search-chip').contains('Workspace:').click(); - cy.get('.header-search-chip-menu .react-aria-MenuItem') - .contains('Intranet Portal') + cy.get('.header-search-scope-menu .react-aria-MenuItem') + .contains('Quantum Workspace') .click(); - cy.get('.header-search-result-title').should('have.length.at.least', 2); + + cy.get('.header-search-result-title').should('have.length', 1); + cy.get('.header-search-result-title').contains( + 'Vacation policy of the Quantum group', + ); + cy.get('.header-search-chip.is-active').contains( + 'Workspace: Quantum Workspace', + ); + + // Enter goes to the picked workspace's scoped results page + cy.get('.header-search-input-row input').type('{enter}'); + cy.url().should('include', '/quantum/@@search'); + cy.url().should('include', 'local=true'); + cy.contains('Vacation policy of the Quantum group'); + cy.contains('Vacation rules of the GreenCat team').should('not.exist'); + }); + + it('offers "Search everywhere" when the scoped search is empty', () => { + cy.visit('/greencat'); + cy.get('.header-search-button').click(); + // "request" only matches the portal document, not the workspace + cy.get('.header-search-input-row input').type('request'); + + cy.get('.header-search-no-results').contains( + 'No results in “GreenCat Workspace”.', + ); + cy.get('.header-search-widen').contains('Search everywhere').click(); + cy.get('.header-search-result-title').contains('Vacation request form'); - cy.get('.header-search-chip').contains('Workspace: Intranet Portal'); + cy.get('.header-search-chip').contains('Workspace: Everywhere'); }); it('Enter opens the workspace-scoped results page without a local toggle', () => { @@ -81,13 +147,13 @@ context('Workspace search dialog (local/global scope)', () => { cy.get('.search-localized').should('not.exist'); }); - it('Enter searches globally when Intranet Portal is selected', () => { + it('Enter searches globally when Everywhere is selected', () => { cy.visit('/greencat'); cy.get('.header-search-button').click(); cy.get('.header-search-input-row input').type('vacation'); cy.get('.header-search-chip').contains('Workspace:').click(); - cy.get('.header-search-chip-menu .react-aria-MenuItem') - .contains('Intranet Portal') + cy.get('.header-search-scope-menu .react-aria-MenuItem') + .contains('Everywhere') .click(); cy.get('.header-search-input-row input').type('{enter}'); diff --git a/frontend/packages/kitconcept-intranet/locales/de/LC_MESSAGES/volto.po b/frontend/packages/kitconcept-intranet/locales/de/LC_MESSAGES/volto.po index 656bb099..c0ebe2d5 100644 --- a/frontend/packages/kitconcept-intranet/locales/de/LC_MESSAGES/volto.po +++ b/frontend/packages/kitconcept-intranet/locales/de/LC_MESSAGES/volto.po @@ -189,6 +189,11 @@ msgstr "Fehler" msgid "Event" msgstr "Termin" +#. Default: "Everywhere" +#: components/Header/HeaderSearch +msgid "Everywhere" +msgstr "Überall" + #. Default: "Expand sidebar" #: components/ContentReviewSidebar/ReviewSidebar msgid "Expand sidebar" @@ -354,6 +359,11 @@ msgstr "Keine Antwort — keine passenden Dokumente gefunden." msgid "No results for “{term}”." msgstr "Keine Ergebnisse für „{term}“." +#. Default: "No results in “{scope}”." +#: components/Header/HeaderSearch +msgid "No results in “{scope}”." +msgstr "Keine Ergebnisse in „{scope}“." + #. Default: "No selection" #: components/widgets/QuerystringWidget msgid "No selection" @@ -458,6 +468,16 @@ msgstr "Raum" msgid "Search" msgstr "" +#. Default: "Search everywhere" +#: components/Header/HeaderSearch +msgid "Search everywhere" +msgstr "Überall suchen" + +#. Default: "Search in all areas" +#: components/Header/HeaderSearch +msgid "Search in all areas" +msgstr "In allen Bereichen suchen" + #. Default: "Search titles only" #: components/Header/HeaderSearch msgid "Search titles only" diff --git a/frontend/packages/kitconcept-intranet/locales/en/LC_MESSAGES/volto.po b/frontend/packages/kitconcept-intranet/locales/en/LC_MESSAGES/volto.po index 7ea22302..0f9fbd12 100644 --- a/frontend/packages/kitconcept-intranet/locales/en/LC_MESSAGES/volto.po +++ b/frontend/packages/kitconcept-intranet/locales/en/LC_MESSAGES/volto.po @@ -189,6 +189,11 @@ msgstr "" msgid "Event" msgstr "" +#. Default: "Everywhere" +#: components/Header/HeaderSearch +msgid "Everywhere" +msgstr "" + #. Default: "Expand sidebar" #: components/ContentReviewSidebar/ReviewSidebar msgid "Expand sidebar" @@ -354,6 +359,11 @@ msgstr "" msgid "No results for “{term}”." msgstr "" +#. Default: "No results in “{scope}”." +#: components/Header/HeaderSearch +msgid "No results in “{scope}”." +msgstr "" + #. Default: "No selection" #: components/widgets/QuerystringWidget msgid "No selection" @@ -458,6 +468,16 @@ msgstr "" msgid "Search" msgstr "" +#. Default: "Search everywhere" +#: components/Header/HeaderSearch +msgid "Search everywhere" +msgstr "" + +#. Default: "Search in all areas" +#: components/Header/HeaderSearch +msgid "Search in all areas" +msgstr "" + #. Default: "Search titles only" #: components/Header/HeaderSearch msgid "Search titles only" diff --git a/frontend/packages/kitconcept-intranet/locales/es/LC_MESSAGES/volto.po b/frontend/packages/kitconcept-intranet/locales/es/LC_MESSAGES/volto.po index 2834ba1f..3c252415 100644 --- a/frontend/packages/kitconcept-intranet/locales/es/LC_MESSAGES/volto.po +++ b/frontend/packages/kitconcept-intranet/locales/es/LC_MESSAGES/volto.po @@ -196,6 +196,11 @@ msgstr "" msgid "Event" msgstr "" +#. Default: "Everywhere" +#: components/Header/HeaderSearch +msgid "Everywhere" +msgstr "" + #. Default: "Expand sidebar" #: components/ContentReviewSidebar/ReviewSidebar msgid "Expand sidebar" @@ -361,6 +366,11 @@ msgstr "" msgid "No results for “{term}”." msgstr "" +#. Default: "No results in “{scope}”." +#: components/Header/HeaderSearch +msgid "No results in “{scope}”." +msgstr "" + #. Default: "No selection" #: components/widgets/QuerystringWidget msgid "No selection" @@ -465,6 +475,16 @@ msgstr "" msgid "Search" msgstr "" +#. Default: "Search everywhere" +#: components/Header/HeaderSearch +msgid "Search everywhere" +msgstr "" + +#. Default: "Search in all areas" +#: components/Header/HeaderSearch +msgid "Search in all areas" +msgstr "" + #. Default: "Search titles only" #: components/Header/HeaderSearch msgid "Search titles only" diff --git a/frontend/packages/kitconcept-intranet/locales/pt_BR/LC_MESSAGES/volto.po b/frontend/packages/kitconcept-intranet/locales/pt_BR/LC_MESSAGES/volto.po index 0f98580d..41c0dbe3 100644 --- a/frontend/packages/kitconcept-intranet/locales/pt_BR/LC_MESSAGES/volto.po +++ b/frontend/packages/kitconcept-intranet/locales/pt_BR/LC_MESSAGES/volto.po @@ -194,6 +194,11 @@ msgstr "Erro" msgid "Event" msgstr "" +#. Default: "Everywhere" +#: components/Header/HeaderSearch +msgid "Everywhere" +msgstr "" + #. Default: "Expand sidebar" #: components/ContentReviewSidebar/ReviewSidebar msgid "Expand sidebar" @@ -359,6 +364,11 @@ msgstr "" msgid "No results for “{term}”." msgstr "" +#. Default: "No results in “{scope}”." +#: components/Header/HeaderSearch +msgid "No results in “{scope}”." +msgstr "" + #. Default: "No selection" #: components/widgets/QuerystringWidget msgid "No selection" @@ -463,6 +473,16 @@ msgstr "Sala" msgid "Search" msgstr "" +#. Default: "Search everywhere" +#: components/Header/HeaderSearch +msgid "Search everywhere" +msgstr "" + +#. Default: "Search in all areas" +#: components/Header/HeaderSearch +msgid "Search in all areas" +msgstr "" + #. Default: "Search titles only" #: components/Header/HeaderSearch msgid "Search titles only" diff --git a/frontend/packages/kitconcept-intranet/locales/volto.pot b/frontend/packages/kitconcept-intranet/locales/volto.pot index be9aa637..76005895 100644 --- a/frontend/packages/kitconcept-intranet/locales/volto.pot +++ b/frontend/packages/kitconcept-intranet/locales/volto.pot @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Plone\n" -"POT-Creation-Date: 2026-07-30T17:17:45.797Z\n" +"POT-Creation-Date: 2026-08-05T11:38:01.716Z\n" "Last-Translator: Plone i18n \n" "Language-Team: Plone i18n \n" "Content-Type: text/plain; charset=utf-8\n" @@ -191,6 +191,11 @@ msgstr "" msgid "Event" msgstr "" +#. Default: "Everywhere" +#: components/Header/HeaderSearch +msgid "Everywhere" +msgstr "" + #. Default: "Expand sidebar" #: components/ContentReviewSidebar/ReviewSidebar msgid "Expand sidebar" @@ -356,6 +361,11 @@ msgstr "" msgid "No results for “{term}”." msgstr "" +#. Default: "No results in “{scope}”." +#: components/Header/HeaderSearch +msgid "No results in “{scope}”." +msgstr "" + #. Default: "No selection" #: components/widgets/QuerystringWidget msgid "No selection" @@ -460,6 +470,16 @@ msgstr "" msgid "Search" msgstr "" +#. Default: "Search everywhere" +#: components/Header/HeaderSearch +msgid "Search everywhere" +msgstr "" + +#. Default: "Search in all areas" +#: components/Header/HeaderSearch +msgid "Search in all areas" +msgstr "" + #. Default: "Search titles only" #: components/Header/HeaderSearch msgid "Search titles only" diff --git a/frontend/packages/kitconcept-intranet/news/+search-scope-dropdown.feature b/frontend/packages/kitconcept-intranet/news/+search-scope-dropdown.feature new file mode 100644 index 00000000..5dfea80f --- /dev/null +++ b/frontend/packages/kitconcept-intranet/news/+search-scope-dropdown.feature @@ -0,0 +1 @@ +Workspace search dialog: the Workspace chip opens a scope dropdown - search everywhere or in any workspace you can access; result rows show their location and an empty scoped search offers "Search everywhere". @reebalazs diff --git a/frontend/packages/kitconcept-intranet/src/components/Header/HeaderSearch.tsx b/frontend/packages/kitconcept-intranet/src/components/Header/HeaderSearch.tsx index 52d3370a..48b06b38 100644 --- a/frontend/packages/kitconcept-intranet/src/components/Header/HeaderSearch.tsx +++ b/frontend/packages/kitconcept-intranet/src/components/Header/HeaderSearch.tsx @@ -16,8 +16,10 @@ import Icon from '@plone/volto/components/theme/Icon/Icon'; import { flattenToAppURL } from '@plone/volto/helpers/Url/Url'; import zoomSVG from '@plone/volto/icons/zoom.svg'; import calendarSVG from '@plone/volto/icons/calendar.svg'; +import checkSVG from '@plone/volto/icons/check.svg'; import fileSVG from '@plone/volto/icons/file.svg'; import folderSVG from '@plone/volto/icons/folder.svg'; +import globeSVG from '@plone/volto/icons/globe.svg'; import imageSVG from '@plone/volto/icons/image.svg'; import linkSVG from '@plone/volto/icons/link.svg'; import newsSVG from '@plone/volto/icons/news.svg'; @@ -28,6 +30,8 @@ import { resetRagSearch, solrSearchSuggestions, } from '@kitconcept/volto-solr/actions'; +import { useWorkspaceSwitcher } from '../NavigationTree/useWorkspaceSwitcher'; +import type { SearchItem } from '../NavigationTree/useNavigationTree'; const messages = defineMessages({ searchWorkspace: { @@ -50,6 +54,14 @@ const messages = defineMessages({ id: 'No results for “{term}”.', defaultMessage: 'No results for “{term}”.', }, + noResultsInScope: { + id: 'No results in “{scope}”.', + defaultMessage: 'No results in “{scope}”.', + }, + searchEverywhere: { + id: 'Search everywhere', + defaultMessage: 'Search everywhere', + }, askAI: { id: 'Ask AI', defaultMessage: 'Ask AI', @@ -110,6 +122,14 @@ const messages = defineMessages({ id: 'Intranet Portal', defaultMessage: 'Intranet Portal', }, + everywhere: { + id: 'Everywhere', + defaultMessage: 'Everywhere', + }, + everywhereHint: { + id: 'Search in all areas', + defaultMessage: 'Search in all areas', + }, allTypes: { id: 'All types', defaultMessage: 'All types', @@ -271,15 +291,150 @@ const FilterChip = ({ ); -type SearchScope = 'workspace' | 'global'; +// Search scope selection (ticket #570). 'current' is the workspace +// the dialog was opened in (resolved from content context, so it +// follows navigation); a picked entry carries its own path/title; +// 'everywhere' searches without a path filter. The "Intranet Portal" +// (portal-without-workspaces) option needs backend exclusion support +// and was descoped to a later ticket. +type SearchScope = + | { kind: 'everywhere' } + | { kind: 'current' } + | { kind: 'workspace'; path: string; title: string }; + +// Letter avatar for a workspace, tinted deterministically by title so +// a workspace keeps its color across renders and sessions (the design +// shows colored initials, there is no image avatar on Workspace). +const AVATAR_TONES = 6; + +const avatarTone = (title: string) => { + let hash = 0; + for (let i = 0; i < title.length; i += 1) { + hash = (hash * 31 + title.charCodeAt(i)) % AVATAR_TONES; + } + return hash; +}; + +const WorkspaceAvatar = ({ title }: { title: string }) => ( + +); + +// The Workspace chip: unlike the demo FilterChips this is a real +// control - it selects the search scope from "everywhere" plus the +// list of workspaces the user can access (same data as the navigation +// tree's workspace switcher, security-trimmed by the backend). +const ScopeChip = ({ + scope, + scopeTitle, + currentWorkspacePath, + workspaces, + onScopeChange, +}: { + scope: SearchScope; + scopeTitle: string; + currentWorkspacePath: string | null; + workspaces: SearchItem[]; + onScopeChange: (scope: SearchScope) => void; +}) => { + const intl = useIntl(); + const selectedPath = + scope.kind === 'current' + ? currentWorkspacePath + : scope.kind === 'workspace' + ? scope.path + : null; + return ( + + + { + if (key === 'everywhere') { + onScopeChange({ kind: 'everywhere' }); + return; + } + const path = String(key); + if (path === currentWorkspacePath) { + onScopeChange({ kind: 'current' }); + return; + } + const workspace = workspaces.find( + (item) => flattenToAppURL(item['@id']) === path, + ); + if (workspace) { + onScopeChange({ kind: 'workspace', path, title: workspace.title }); + } + }} + > + + + + {intl.formatMessage(messages.everywhere)} + + {intl.formatMessage(messages.everywhereHint)} + + + {scope.kind === 'everywhere' ? ( + + ) : null} + + {workspaces.map((item) => { + const path = flattenToAppURL(item['@id']); + return ( + + + {item.title} + {selectedPath === path ? ( + + ) : null} + + ); + })} + + + ); +}; const FilterChips = ({ - workspaceTitle, scope, + scopeTitle, + currentWorkspacePath, + workspaces, onScopeChange, }: { - workspaceTitle: string; scope: SearchScope; + scopeTitle: string; + currentWorkspacePath: string | null; + workspaces: SearchItem[]; onScopeChange: (scope: SearchScope) => void; }) => { const intl = useIntl(); @@ -288,11 +443,6 @@ const FilterChips = ({ label: string; options: string[]; }> = [ - { - id: 'workspace', - label: intl.formatMessage(messages.filterWorkspace), - options: [workspaceTitle, intl.formatMessage(messages.intranetPortal)], - }, { id: 'type', label: intl.formatMessage(messages.filterType), @@ -351,15 +501,17 @@ const FilterChips = ({ return (
+ {/* The Workspace chip is the real scope switch; the other + chips are inert demo data (facets deferred). */} + {filters.map((filter) => { - // The Workspace chip is the real scope switch (local/global); - // the other chips are inert demo data (facets deferred). - const isScopeChip = filter.id === 'workspace'; - const selected = isScopeChip - ? scope === 'workspace' - ? workspaceTitle - : intl.formatMessage(messages.intranetPortal) - : selection[filter.id] || filter.options[0]; + const selected = selection[filter.id] || filter.options[0]; return ( - isScopeChip - ? onScopeChange( - value === workspaceTitle ? 'workspace' : 'global', - ) - : setSelection((current) => ({ - ...current, - [filter.id]: value, - })) - } - isActive={ - isScopeChip - ? scope === 'workspace' - : selected !== filter.options[0] + setSelection((current) => ({ + ...current, + [filter.id]: value, + })) } + isActive={selected !== filter.options[0]} /> ); })} @@ -411,9 +555,11 @@ const FilterChips = ({ const SuggestionRow = ({ item, + location, onSelect, }: { item: SuggestionItem; + location: string | null; onSelect: (item: SuggestionItem) => void; }) => { const intl = useIntl(); @@ -436,6 +582,9 @@ const SuggestionRow = ({ {effectiveDate ? ( {effectiveDate} ) : null} + {location ? ( + {location} + ) : null} ); }; @@ -629,12 +778,26 @@ const HeaderSearch = () => { // change. (a: any, b: any) => a.title === b.title && a.path === b.path, ); - // The Workspace chip switches the scope: workspace (default, local - // to the current workspace) or global ("Intranet Portal"). All three - // searches - livesearch, Ask AI and the Enter results page - follow - // it (see ticket #426 / kitconcept.solr local scoping). - const [scope, setScope] = useState('workspace'); - const scopePath = scope === 'workspace' ? workspace.path : null; + // The Workspace chip switches the scope: the current workspace + // (default), any other workspace from the list, or everywhere. All + // three searches - livesearch, Ask AI and the Enter results page - + // follow it (see tickets #426/#570, kitconcept.solr local scoping). + const [scope, setScope] = useState({ kind: 'current' }); + // Workspace list for the scope dropdown; fetched once the dialog + // opens (the header itself is on every page, the list is not). + const { workspaces } = useWorkspaceSwitcher({ enabled: isSearchOpen }); + const scopePath = + scope.kind === 'current' + ? workspace.path + : scope.kind === 'workspace' + ? scope.path + : null; + const scopeTitle = + scope.kind === 'current' + ? workspace.title + : scope.kind === 'workspace' + ? scope.title + : intl.formatMessage(messages.everywhere); const term = searchText.trim(); const showResults = term.length >= 2; @@ -687,7 +850,7 @@ const HeaderSearch = () => { const closeSearch = useCallback(() => { setIsSearchOpen(false); setSearchText(''); - setScope('workspace'); + setScope({ kind: 'current' }); resetAi(); }, [resetAi]); @@ -719,6 +882,18 @@ const HeaderSearch = () => { history.push(url); }; + // Location label per result row (design #570): the workspace the + // item lives in, or "Intranet Portal" for anything outside the + // workspaces. Resolved against the same list the dropdown shows. + const locationForItem = (item: SuggestionItem): string => { + const path = flattenToAppURL(item['@id']); + const home = workspaces.find((candidate) => { + const workspacePath = flattenToAppURL(candidate['@id']); + return path === workspacePath || path.startsWith(`${workspacePath}/`); + }); + return home ? home.title : intl.formatMessage(messages.intranetPortal); + }; + const submitSearch = (event: FormEvent) => { event.preventDefault(); // Enter goes to the workspace-scoped search page: the nested @@ -806,8 +981,10 @@ const HeaderSearch = () => { @@ -821,11 +998,29 @@ const HeaderSearch = () => { navigateTo(flattenToAppURL(selected['@id'])) } /> )) + ) : scopePath ? ( + // Scoped search came up empty: name the scope and + // offer the one-click escape to search everywhere + // (the most common miss is content living outside + // the current workspace, design #570). +
+ {intl.formatMessage(messages.noResultsInScope, { + scope: scopeTitle, + })} + +
) : (
{intl.formatMessage(messages.noResultsFor, { term })} diff --git a/frontend/packages/kitconcept-intranet/src/components/NavigationTree/useWorkspaceSwitcher.ts b/frontend/packages/kitconcept-intranet/src/components/NavigationTree/useWorkspaceSwitcher.ts index 362d840c..0a85f7c2 100644 --- a/frontend/packages/kitconcept-intranet/src/components/NavigationTree/useWorkspaceSwitcher.ts +++ b/frontend/packages/kitconcept-intranet/src/components/NavigationTree/useWorkspaceSwitcher.ts @@ -5,10 +5,17 @@ import type { SearchItem } from './useNavigationTree'; const WORKSPACE_SWITCHER_SUBREQUEST = 'nav-tree-workspace-switcher'; -export function useWorkspaceSwitcher() { +export function useWorkspaceSwitcher(options?: { enabled?: boolean }) { const dispatch = useDispatch(); + // enabled=false defers the fetch (e.g. the search dialog only needs + // the list once it is opened); the redux subrequest is shared, so a + // list already fetched by another consumer is reused as-is. + const enabled = options?.enabled ?? true; useEffect(() => { + if (!enabled) { + return; + } dispatch( searchContent( '/', @@ -20,7 +27,7 @@ export function useWorkspaceSwitcher() { WORKSPACE_SWITCHER_SUBREQUEST, ), ); - }, [dispatch]); + }, [dispatch, enabled]); const subrequest = useSelector( (state: any) => state.search?.subrequests?.[WORKSPACE_SWITCHER_SUBREQUEST], diff --git a/frontend/packages/kitconcept-intranet/src/theme/header.scss b/frontend/packages/kitconcept-intranet/src/theme/header.scss index a63cf6f0..3e91c301 100644 --- a/frontend/packages/kitconcept-intranet/src/theme/header.scss +++ b/frontend/packages/kitconcept-intranet/src/theme/header.scss @@ -418,6 +418,93 @@ $search-text: #252525; } } +// Scope dropdown (ticket #570): "Everywhere" entry on top, then the +// workspace list with letter avatars; checkmark marks the selection. +// The base popover caps width below the menu's needs, so size the +// popover explicitly (same approach as the breadcrumb menu). +.react-aria-Popover:has(.header-search-scope-menu) { + width: min(320px, calc(100vw - 16px)); + max-width: none; +} + +.header-search-scope-menu { + min-width: 280px; + + .header-search-scope-item { + gap: 10px; + + &.is-everywhere { + padding-bottom: 10px; + border-radius: 6px 6px 0 0; + border-bottom: 1px solid #e1e6e9; + margin-bottom: 6px; + + .icon { + color: $search-grey; + } + } + } + + .header-search-scope-text { + display: flex; + flex: 1; + flex-direction: column; + line-height: 1.3; + } + + .header-search-scope-hint { + color: $search-grey; + font-size: 12px; + } + + .header-search-scope-check { + flex-shrink: 0; + color: $search-blue; + } +} + +.header-search-scope-avatar { + display: inline-flex; + width: 22px; + height: 22px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 5px; + font-size: 12px; + font-weight: 600; + + &.tone-0 { + background: #d9ecdb; + color: #256b32; + } + + &.tone-1 { + background: #dbe7f6; + color: #1f4f8f; + } + + &.tone-2 { + background: #fbe7d4; + color: #8a4b12; + } + + &.tone-3 { + background: #ecdff5; + color: #5f3689; + } + + &.tone-4 { + background: #fadbe2; + color: #8f2743; + } + + &.tone-5 { + background: #dff2f0; + color: #1d6a62; + } +} + .header-search-chip-toggles { display: flex; flex-wrap: wrap; @@ -510,6 +597,34 @@ $search-text: #252525; font-size: 13px; } +.header-search-result-location { + overflow: hidden; + max-width: 200px; + flex: 0 0 auto; + color: $search-grey; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.header-search-widen { + display: block; + padding: 0; + border: 0; + margin: 10px auto 0; + background: none; + color: $search-blue; + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 600; + + &:hover, + &:focus { + text-decoration: underline; + } +} + .header-search-no-results { padding: 40px 20px; color: $search-grey;