From 66653a14cdbf7a135a858f109fc7fa6c9d9d794e Mon Sep 17 00:00:00 2001 From: Beatrice Guerra Date: Wed, 23 Aug 2023 14:02:23 +0200 Subject: [PATCH] fix(search): translate module inside search bar placeholder refs: SHELL-87 (#312) --- src/boot/bootstrapper.tsx | 25 +---- src/boot/context-bridge.tsx | 23 +++++ src/search/search-bar.test.tsx | 163 +++++++++++++++++++++++++++++++++ src/search/search-bar.tsx | 74 ++++++++------- src/shell/shell-view.test.tsx | 13 +-- src/test/constants.ts | 1 + src/test/test-app-utils.tsx | 42 ++++++++- src/test/utils.tsx | 24 +++-- 8 files changed, 284 insertions(+), 81 deletions(-) create mode 100644 src/boot/context-bridge.tsx create mode 100644 src/search/search-bar.test.tsx diff --git a/src/boot/bootstrapper.tsx b/src/boot/bootstrapper.tsx index 11957822..f6cd436c 100644 --- a/src/boot/bootstrapper.tsx +++ b/src/boot/bootstrapper.tsx @@ -6,17 +6,13 @@ import React, { FC, useEffect } from 'react'; -import { - ModalManager, - SnackbarManager, - useModal, - useSnackbar -} from '@zextras/carbonio-design-system'; +import { ModalManager, SnackbarManager } from '@zextras/carbonio-design-system'; import { useTranslation } from 'react-i18next'; -import { BrowserRouter, Route, Switch, useHistory, useParams } from 'react-router-dom'; +import { BrowserRouter, Route, Switch, useParams } from 'react-router-dom'; import AppLoaderMounter from './app/app-loader-mounter'; import { registerDefaultViews } from './app/default-views'; +import { ContextBridge } from './context-bridge'; import { Loader } from './loader'; import ShellI18nextProvider from './shell-i18n-provider'; import { ThemeProvider } from './theme-provider'; @@ -24,21 +20,6 @@ import { BASENAME, IS_STANDALONE } from '../constants'; import { NotificationPermissionChecker } from '../notification/NotificationPermissionChecker'; import ShellView from '../shell/shell-view'; import { useAppStore } from '../store/app'; -import { useBridge } from '../store/context-bridge'; - -const ContextBridge = (): null => { - const history = useHistory(); - const createSnackbar = useSnackbar(); - const createModal = useModal(); - useBridge({ - functions: { - getHistory: () => history, - createSnackbar, - createModal - } - }); - return null; -}; const StandaloneListener = (): null => { const { route } = useParams<{ route?: string }>(); diff --git a/src/boot/context-bridge.tsx b/src/boot/context-bridge.tsx new file mode 100644 index 00000000..abed2c9e --- /dev/null +++ b/src/boot/context-bridge.tsx @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2023 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useModal, useSnackbar } from '@zextras/carbonio-design-system'; +import { useHistory } from 'react-router-dom'; + +import { useBridge } from '../store/context-bridge'; + +export const ContextBridge = (): null => { + const history = useHistory(); + const createSnackbar = useSnackbar(); + const createModal = useModal(); + useBridge({ + functions: { + getHistory: () => history, + createSnackbar, + createModal + } + }); + return null; +}; diff --git a/src/search/search-bar.test.tsx b/src/search/search-bar.test.tsx new file mode 100644 index 00000000..c2b871f3 --- /dev/null +++ b/src/search/search-bar.test.tsx @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: 2023 Zextras + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import React from 'react'; + +import { act } from '@testing-library/react'; +import { useLocation } from 'react-router-dom'; + +import { SearchBar } from './search-bar'; +import { useSearchStore } from './search-store'; +import { ContextBridge } from '../boot/context-bridge'; +import { SEARCH_APP_ID } from '../constants'; +import { useAppStore } from '../store/app'; +import { ICONS } from '../test/constants'; +import { + generateCarbonioModule, + generateModuleRouteDescriptor, + setupAppStore +} from '../test/test-app-utils'; +import { setup, screen, within } from '../test/utils'; + +describe('Search bar', () => { + const LocationDisplayer = (): JSX.Element => { + const location = useLocation(); + + return ( +
+ {location.pathname} + {location.search} +
+ ); + }; + + test('should render the module selector and the input of the search bar', async () => { + const app = generateCarbonioModule(); + const route = 'appRoute'; + + useSearchStore.setState({ + module: 'route' + }); + useAppStore.getState().setters.addSearchView({ + app: app.name, + icon: app.icon, + route, + label: app.display, + position: app.priority, + id: app.name, + component: () =>
{app.name}
+ }); + setup(, { initialRouterEntries: [`/search/${route}`] }); + expect(screen.getByText(app.display)).toBeVisible(); + expect(screen.getByRole('textbox', { name: `Search in ${app.display}` })).toBeVisible(); + }); + + test('should navigate to the search of the module when selected', async () => { + const app1 = generateCarbonioModule({ priority: 1 }); + const route1 = generateModuleRouteDescriptor({ id: app1.name, position: 1 }); + const app2 = generateCarbonioModule({ priority: 2 }); + const route2 = generateModuleRouteDescriptor({ id: app2.name, position: 2 }); + const searchApp = generateCarbonioModule({ priority: 3, name: SEARCH_APP_ID }); + const searchRoute = generateModuleRouteDescriptor({ + label: 'search', + position: 3, + id: SEARCH_APP_ID + }); + useSearchStore.setState({ + module: route1.route + }); + setupAppStore([app1, app2, searchApp], [route1, route2, searchRoute]); + useAppStore.getState().setters.addSearchView({ + app: app1.name, + icon: app1.icon, + route: route1.route, + label: app1.display, + position: app1.priority, + id: app1.name, + component: () =>
{app1.name}
+ }); + useAppStore.getState().setters.addSearchView({ + app: app2.name, + icon: app2.icon, + route: route2.route, + label: app2.display, + position: app2.priority, + id: app2.name, + component: () =>
{app2.name}
+ }); + + const { user } = setup( + <> + + + + , + { initialRouterEntries: [`/search/${route1.route}`] } + ); + expect( + within(screen.getByTestId('location-display')).getByText(`/search/${route1.route}`) + ).toBeVisible(); + await user.click(screen.getByText(app1.display)); + expect(screen.getByText(app2.display)).toBeVisible(); + await user.click(screen.getByText(app2.display)); + expect( + within(screen.getByTestId('location-display')).getByText(`/search/${route2.route}`) + ).toBeVisible(); + }); + + test('should navigate to the search of the module when search is run', async () => { + const app1 = generateCarbonioModule({ priority: 1 }); + const route1 = generateModuleRouteDescriptor({ id: app1.name, position: 1 }); + const app2 = generateCarbonioModule({ priority: 2 }); + const route2 = generateModuleRouteDescriptor({ id: app2.name, position: 2 }); + const searchApp = generateCarbonioModule({ priority: 3, name: SEARCH_APP_ID }); + const searchRoute = generateModuleRouteDescriptor({ + label: 'search', + position: 3, + id: SEARCH_APP_ID + }); + useSearchStore.setState({ + module: route1.route + }); + setupAppStore([app1, app2, searchApp], [route1, route2, searchRoute]); + useAppStore.getState().setters.addSearchView({ + app: app1.name, + icon: app1.icon, + route: route1.route, + label: app1.display, + position: app1.priority, + id: app1.name, + component: () =>
{app1.name}
+ }); + useAppStore.getState().setters.addSearchView({ + app: app2.name, + icon: app2.icon, + route: route2.route, + label: app2.display, + position: app2.priority, + id: app2.name, + component: () =>
{app2.name}
+ }); + + const { user } = setup( + <> + + + + , + { initialRouterEntries: [`/search/${route1.route}`] } + ); + expect( + within(screen.getByTestId('location-display')).getByText(`/search/${route1.route}`) + ).toBeVisible(); + await act(async () => { + await user.type(screen.getByRole('textbox'), 'key1'); + }); + await user.click(screen.getByRoleWithIcon('button', { icon: ICONS.search })); + expect( + within(screen.getByTestId('location-display')).getByText(`/search/${route1.route}`) + ).toBeVisible(); + }); +}); diff --git a/src/search/search-bar.tsx b/src/search/search-bar.tsx index d346a59d..811d7eb4 100644 --- a/src/search/search-bar.tsx +++ b/src/search/search-bar.tsx @@ -23,6 +23,7 @@ import { useSearchStore } from './search-store'; import { QueryChip, QueryItem } from '../../types'; import { LOCAL_STORAGE_SEARCH_KEY, SEARCH_APP_ID } from '../constants'; import { useLocalStorage } from '../shell/hooks/useLocalStorage'; +import { useAppStore } from '../store/app'; import { getT } from '../store/i18n'; const OutlinedIconButton = styled(IconButton)` @@ -63,8 +64,21 @@ export const SearchBar = (): React.JSX.Element => { ); const [inputTyped, setInputTyped] = useState(''); const history = useHistory(); - const { updateQuery, module, query, searchDisabled, setSearchDisabled, tooltip } = - useSearchStore(); + const { + updateQuery, + module: currentSearchModuleRoute, + query, + searchDisabled, + setSearchDisabled, + tooltip + } = useSearchStore(); + const modules = useAppStore((s) => s.views.search); + const moduleLabel = useMemo( + () => + modules.find(({ route }) => route === currentSearchModuleRoute)?.label || + currentSearchModuleRoute, + [currentSearchModuleRoute, modules] + ); const [isTyping, setIsTyping] = useState(false); @@ -159,12 +173,12 @@ export const SearchBar = (): React.JSX.Element => { ); }); // TODO: perform a navigation only when coming from a different module (not the search one) - history.push(`/${SEARCH_APP_ID}/${module}`); - }, [updateQuery, history, module, inputTyped, searchInputValue]); + history.push(`/${SEARCH_APP_ID}/${currentSearchModuleRoute}`); + }, [updateQuery, history, currentSearchModuleRoute, inputTyped, searchInputValue]); const appSuggestions = useMemo( () => - filter(storedSuggestions, (v) => v.app === module) + filter(storedSuggestions, (v) => v.app === currentSearchModuleRoute) .reverse() .map( (item): SearchOption => ({ @@ -176,7 +190,7 @@ export const SearchBar = (): React.JSX.Element => { } }) ), - [storedSuggestions, module, searchDisabled] + [storedSuggestions, currentSearchModuleRoute, searchDisabled] ); const updateOptions = useCallback( @@ -208,7 +222,7 @@ export const SearchBar = (): React.JSX.Element => { if ( lastChipLabel && typeof lastChipLabel === 'string' && - module && + currentSearchModuleRoute && !find(appSuggestions, (suggestion) => suggestion.label === lastChipLabel) ) { setStoredSuggestions((prevState) => { @@ -216,7 +230,7 @@ export const SearchBar = (): React.JSX.Element => { value: lastChipLabel, label: lastChipLabel, icon: 'ClockOutline', - app: module, + app: currentSearchModuleRoute, id: lastChipLabel }; return [...prevState, newSuggestion]; @@ -226,7 +240,7 @@ export const SearchBar = (): React.JSX.Element => { // FIXME: remove the cast (by making ChipItem support generics?) setSearchInputValue(newQuery as QueryChip[]); }, - [appSuggestions, module, setStoredSuggestions] + [appSuggestions, currentSearchModuleRoute, setStoredSuggestions] ); const onInputType = useCallback>( @@ -243,36 +257,20 @@ export const SearchBar = (): React.JSX.Element => { ); useEffect(() => { - if (module) { - const suggestions = filter(appSuggestions, (suggestion) => suggestion.app === module).slice( - 0, - 5 - ); + if (currentSearchModuleRoute) { + const suggestions = filter( + appSuggestions, + (suggestion) => suggestion.app === currentSearchModuleRoute + ).slice(0, 5); setOptions(suggestions); } - }, [appSuggestions, module]); + }, [appSuggestions, currentSearchModuleRoute]); const containerRef = useRef(null); const addFocus = useCallback(() => setInputHasFocus(true), []); const removeFocus = useCallback(() => setInputHasFocus(false), []); - // disabled for now, awaiting refactor of the search bar - // useEffect(() => { - // const handler = (event: KeyboardEvent): unknown => - // handleKeyboardShortcuts({ - // event, - // inputRef, - // primaryAction, - // secondaryActions, - // currentApp - // }); - // document.addEventListener('keydown', handler); - // return (): void => { - // document.removeEventListener('keydown', handler); - // }; - // }, [currentApp, inputRef, primaryAction, secondaryActions]); - useEffect(() => { const ref = inputRef.current; const runSearchOnKeyUp = (ev: KeyboardEvent): void => { @@ -292,16 +290,16 @@ export const SearchBar = (): React.JSX.Element => { }; }, [onSearch, removeFocus]); - const disableOptions = useMemo(() => !(options.length > 0) || isTyping, [options, isTyping]); + const disableOptions = useMemo(() => options.length <= 0 || isTyping, [options, isTyping]); const placeholder = useMemo( () => - inputHasFocus && module + inputHasFocus && currentSearchModuleRoute ? t('search.active_input_label', 'Separate your keywords by a comma or pressing TAB') : t('search.idle_input_label', 'Search in {{module}}', { - module + module: moduleLabel }), - [inputHasFocus, module, t] + [currentSearchModuleRoute, inputHasFocus, moduleLabel, t] ); const clearButtonPlaceholder = useMemo( @@ -337,10 +335,10 @@ export const SearchBar = (): React.JSX.Element => { (newChip) => { setIsTyping(false); setInputTyped(''); - if (module) { + if (currentSearchModuleRoute) { const suggestions = filter( appSuggestions, - (suggestion) => suggestion?.app === module + (suggestion) => suggestion?.app === currentSearchModuleRoute ).slice(0, 5); setOptions(suggestions); @@ -351,7 +349,7 @@ export const SearchBar = (): React.JSX.Element => { hasAvatar: false }; }, - [appSuggestions, module] + [appSuggestions, currentSearchModuleRoute] ); useEffect(() => { diff --git a/src/shell/shell-view.test.tsx b/src/shell/shell-view.test.tsx index 35312964..73dd6f22 100644 --- a/src/shell/shell-view.test.tsx +++ b/src/shell/shell-view.test.tsx @@ -7,14 +7,13 @@ import React, { FC } from 'react'; import { act, screen, waitFor } from '@testing-library/react'; import 'jest-styled-components'; -import { useHistory } from 'react-router-dom'; import { BOARD_DEFAULT_POSITION } from './boards/board-container'; import { Border } from './hooks/useResize'; import ShellView from './shell-view'; import { Board } from '../../types'; +import { ContextBridge } from '../boot/context-bridge'; import { LOCAL_STORAGE_BOARD_SIZE } from '../constants'; -import { useBridge } from '../store/context-bridge'; import { ICONS, TESTID_SELECTORS } from '../test/constants'; import { mockedApps, setupAppStore } from '../test/test-app-utils'; import { @@ -28,16 +27,6 @@ import { import { setup } from '../test/utils'; import { SizeAndPosition } from '../utils/utils'; -const ContextBridge: FC = () => { - const history = useHistory(); - useBridge({ - functions: { - getHistory: () => history - } - }); - return null; -}; - const Dummy: FC = () => null; jest.mock('../utility-bar/bar', () => ({ diff --git a/src/test/constants.ts b/src/test/constants.ts index de04d78c..326f13c6 100644 --- a/src/test/constants.ts +++ b/src/test/constants.ts @@ -138,6 +138,7 @@ export const ICONS = { reduceBoard: 'CollapseOutline', resetBoardSize: 'DiagonalArrowLeftDown', unCollapseBoard: 'BoardOpen', + search: 'Search', settings: 'SettingsModOutline' }; diff --git a/src/test/test-app-utils.tsx b/src/test/test-app-utils.tsx index 49b60a44..8a72c833 100644 --- a/src/test/test-app-utils.tsx +++ b/src/test/test-app-utils.tsx @@ -5,6 +5,8 @@ */ import React from 'react'; +import { faker } from '@faker-js/faker'; + import { AppRouteDescriptor, CarbonioModule } from '../../types'; import { useAppStore } from '../store/app'; @@ -34,7 +36,43 @@ export const mockedRoutes: AppRouteDescriptor = { app: 'carbonio-mails-ui' }; -export function setupAppStore(apps = mockedApps, routes = mockedRoutes): void { +export function setupAppStore(apps = mockedApps, routes = [mockedRoutes]): void { useAppStore.getState().setters.setApps(apps); - useAppStore.getState().setters.addRoute(routes); + routes.forEach((route) => { + useAppStore.getState().setters.addRoute(route); + }); +} + +export function generateCarbonioModule(data?: Partial): CarbonioModule { + return { + commit: '', + description: faker.commerce.productDescription(), + display: faker.commerce.productName(), + icon: 'PeopleOutline', + js_entrypoint: '', + name: `carbonio-${faker.word.sample()}-ui`, + priority: 1, + type: 'carbonio', + version: '0.0.1', + ...data + }; +} + +export function generateModuleRouteDescriptor( + data?: Partial +): AppRouteDescriptor { + const id = data?.id || faker.string.sample(); + const route = id.replace('carbonio-', '').replace('-ui', ''); + return { + id, + route, + app: id, + position: 1, + visible: true, + label: faker.commerce.productName(), + primaryBar: 'PeopleOutline', + appView: () =>
{id}
, + badge: { show: false }, + ...data + }; } diff --git a/src/test/utils.tsx b/src/test/utils.tsx index 5ca3e8f2..13a59622 100644 --- a/src/test/utils.tsx +++ b/src/test/utils.tsx @@ -13,10 +13,10 @@ import { queries, queryHelpers, render, + screen as rtlScreen, type RenderOptions, type RenderResult, - screen, - within + within as rtlWithin } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ModalManager, SnackbarManager } from '@zextras/carbonio-design-system'; @@ -34,6 +34,8 @@ export type UserEvent = ReturnType<(typeof userEvent)['setup']> & { type ByRoleWithIconOptions = ByRoleOptions & { icon: string | RegExp; }; + +type ExtendedQueries = typeof queries & typeof customQueries; /** * Matcher function to search an icon button through the icon data-testid */ @@ -43,8 +45,9 @@ const queryAllByRoleWithIcon: GetAllBy<[ByRoleMatcher, ByRoleWithIconOptions]> = { icon, ...options } ) => filter( - screen.queryAllByRole('button', options), - (element) => within(element).queryByTestId(`icon: ${icon}`) !== null + // eslint-disable-next-line testing-library/prefer-screen-queries + rtlScreen.queryAllByRole('button', options), + (element) => rtlWithin(element).queryByTestId(`icon: ${icon}`) !== null ); const getByRoleWithIconMultipleError = ( container: Element | null, @@ -77,6 +80,13 @@ const customQueries = { findAllByRoleWithIcon, findByRoleWithIcon }; +const extendedQueries: ExtendedQueries = { ...queries, ...customQueries }; + +export const within = ( + element: Parameters>[0] +): ReturnType> => rtlWithin(element, extendedQueries); + +export const screen = within(document.body); const getAppI18n = (): i18n => { const newI18n = i18next.createInstance(); @@ -97,7 +107,7 @@ const getAppI18n = (): i18n => { }; interface WrapperProps { - children?: React.ReactNode | undefined; + children?: React.ReactNode; initialRouterEntries?: string[]; } @@ -134,12 +144,12 @@ function customRender( }: WrapperProps & { options?: Omit; } = {} -): RenderResult { +): RenderResult { return render(ui, { wrapper: ({ children }: Pick) => ( {children} ), - queries: { ...queries, ...customQueries }, + queries: extendedQueries, ...options }); }