From 6bfe59f1058f844261cea942230ca16cdc51f6b8 Mon Sep 17 00:00:00 2001 From: Arjita Mitra Date: Wed, 10 Jun 2026 09:18:57 +0200 Subject: [PATCH 1/5] fix: User can't search by folder name(WPB-25672) --- .../useConversationSearch/useConversationSearchFiles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts index 9558e273f49..c8a2f789730 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts @@ -104,7 +104,6 @@ export const useConversationSearchFiles = ({ path: conversationPath, sortBy: shouldSort ? 'mtime' : undefined, sortDirection: shouldSort ? 'desc' : undefined, - type: 'file', deleted: getCellsFilesPath() === RECYCLE_BIN_PATH, ...searchParams, }); From f50150aaa9efbc0bf443b5cbd1d72cb25c0e65e4 Mon Sep 17 00:00:00 2001 From: Arjita Mitra Date: Wed, 10 Jun 2026 11:02:28 +0200 Subject: [PATCH 2/5] fix: remove useMemo that froze conversationPath to the conversation root --- .../useGetAllCellsNodes/useGetAllCellsNodes.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useGetAllCellsNodes/useGetAllCellsNodes.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useGetAllCellsNodes/useGetAllCellsNodes.ts index d6a8fb45c3a..f84e6f37707 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/useGetAllCellsNodes/useGetAllCellsNodes.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useGetAllCellsNodes/useGetAllCellsNodes.ts @@ -17,7 +17,7 @@ * */ -import {useEffect, useCallback, useMemo, useState} from 'react'; +import {useEffect, useCallback, useState} from 'react'; import {QualifiedId} from '@wireapp/api-client/lib/user/'; @@ -53,15 +53,16 @@ export const useGetAllCellsNodes = ({ const [offset, setOffset] = useState(0); const {domain, id} = conversationQualifiedId; - const conversationPath = useMemo(() => getCellsApiPath({conversationQualifiedId: {domain, id}}), [domain, id]); const fetchNodes = useCallback(async () => { try { setError(null); setStatus('loading'); + // Resolve the path fresh on every fetch (not memoized) so folder navigation works. + // Memoizing on [domain, id] froze it to the conversation root and broke navigation. const result = await cellsRepository.getAllNodes({ - path: conversationPath, + path: getCellsApiPath({conversationQualifiedId: {domain, id}}), limit: pageSize, offset, deleted: getCellsFilesPath() === RECYCLE_BIN_PATH, @@ -94,7 +95,7 @@ export const useGetAllCellsNodes = ({ } // cellsRepository and userRepository are not dependencies because they're singletons // eslint-disable-next-line react-hooks/exhaustive-deps - }, [conversationPath, id, offset, pageSize, setError, setNodes, setPagination, setStatus]); + }, [domain, id, offset, pageSize, setError, setNodes, setPagination, setStatus]); const handleHashChange = useCallback((): void => { if (enabled !== true) { From c45910cc9f0d8469630744be20522176804594bc Mon Sep 17 00:00:00 2001 From: Arjita Mitra Date: Thu, 11 Jun 2026 12:31:54 +0200 Subject: [PATCH 3/5] feat: recursive search for drive folders --- .../components/Conversation/Conversation.tsx | 1 + .../CellsTable/CellsTable.tsx | 3 + .../CellsTableColumns/CellsTableColumns.tsx | 5 +- .../CellsTableNameColumn.tsx | 20 +- .../CellsTableRowOptions.tsx | 6 +- .../ConversationCells/ConversationCells.tsx | 11 +- .../common/openFolder/openFolder.test.ts | 63 +++++ .../common/openFolder/openFolder.ts | 34 +-- .../useConversationSearchFiles.test.ts | 224 ++++++++++++++++++ .../useConversationSearchFiles.ts | 92 ++++--- .../repositories/cells/cellsRepository.ts | 3 + .../api-client/src/cells/cellsApi.test.ts | 20 +- libraries/api-client/src/cells/cellsApi.ts | 11 +- 13 files changed, 426 insertions(+), 67 deletions(-) create mode 100644 apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.test.ts create mode 100644 apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts diff --git a/apps/webapp/src/script/components/Conversation/Conversation.tsx b/apps/webapp/src/script/components/Conversation/Conversation.tsx index 13831a14f7c..1e1a2724596 100644 --- a/apps/webapp/src/script/components/Conversation/Conversation.tsx +++ b/apps/webapp/src/script/components/Conversation/Conversation.tsx @@ -633,6 +633,7 @@ export const Conversation = ({ setIsSharedDriveSearchViewOpen(true); } }} + onCloseSearchView={() => setIsSharedDriveSearchViewOpen(false)} /> )} diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx index a841b5c60ba..f94ecb4df7f 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTable.tsx @@ -41,6 +41,7 @@ interface CellsTableProps { conversationQualifiedId: QualifiedId; conversationName: string; onRefresh: () => void; + onCloseSearchView?: () => void; } export const CellsTable = ({ @@ -49,6 +50,7 @@ export const CellsTable = ({ conversationQualifiedId, conversationName, onRefresh, + onCloseSearchView, }: CellsTableProps) => { const table = useReactTable({ data: nodes, @@ -57,6 +59,7 @@ export const CellsTable = ({ conversationQualifiedId, conversationName, onRefresh, + onCloseSearchView, }), getCoreRowModel: getCoreRowModel(), }); diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableColumns.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableColumns.tsx index 61dec5e468f..a41b6e2d453 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableColumns.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableColumns.tsx @@ -38,15 +38,17 @@ export const getCellsTableColumns = ({ conversationQualifiedId, conversationName, onRefresh, + onCloseSearchView, }: { cellsRepository: CellsRepository; conversationQualifiedId: QualifiedId; conversationName: string; onRefresh: () => void; + onCloseSearchView?: () => void; }) => [ columnHelper.accessor('name', { header: t('cells.tableRow.name'), - cell: info => , + cell: info => , }), columnHelper.accessor('owner', { header: t('cells.tableRow.owner'), @@ -84,6 +86,7 @@ export const getCellsTableColumns = ({ conversationQualifiedId={conversationQualifiedId} conversationName={conversationName} onRefresh={onRefresh} + onCloseSearchView={onCloseSearchView} /> ); }, diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableNameColumn/CellsTableNameColumn.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableNameColumn/CellsTableNameColumn.tsx index 6c00fc7bd88..c8e92605424 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableNameColumn/CellsTableNameColumn.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableNameColumn/CellsTableNameColumn.tsx @@ -17,8 +17,6 @@ * */ -import {QualifiedId} from '@wireapp/api-client/lib/user/'; - import {FolderIcon, PlayIcon} from '@wireapp/react-ui-kit'; import {FileTypeIcon} from 'Components/Conversation/common/FileTypeIcon/FileTypeIcon'; @@ -39,10 +37,10 @@ import {useCellsFilePreviewModal} from '../../common/CellsFilePreviewModalContex interface CellsTableNameColumnProps { node: CellNode; - conversationQualifiedId: QualifiedId; + onCloseSearchView?: () => void; } -export const CellsTableNameColumn = ({node, conversationQualifiedId}: CellsTableNameColumnProps) => { +export const CellsTableNameColumn = ({node, onCloseSearchView}: CellsTableNameColumnProps) => { return ( <> {node.name} @@ -50,7 +48,7 @@ export const CellsTableNameColumn = ({node, conversationQualifiedId}: CellsTable {node.type === CellNodeType.FILE ? ( ) : ( - + )} @@ -91,14 +89,22 @@ const FileNameColumn = ({file}: {file: CellFile}) => { ); }; -const FolderNameColumn = ({name, conversationQualifiedId}: {name: string; conversationQualifiedId: QualifiedId}) => { +const FolderNameColumn = ({ + name, + path, + onCloseSearchView, +}: { + name: string; + path: string; + onCloseSearchView?: () => void; +}) => { return ( <> diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx index 77607004f87..b2b9edbaf22 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/CellsTable/CellsTableColumns/CellsTableRowOptions/CellsTableRowOptions.tsx @@ -58,6 +58,7 @@ interface CellsTableRowOptionsProps { conversationQualifiedId: QualifiedId; conversationName: string; onRefresh: () => void; + onCloseSearchView?: () => void; } export const CellsTableRowOptions = ({ @@ -66,6 +67,7 @@ export const CellsTableRowOptions = ({ conversationQualifiedId, conversationName, onRefresh, + onCloseSearchView, }: CellsTableRowOptionsProps) => { return ( @@ -81,6 +83,7 @@ export const CellsTableRowOptions = ({ conversationQualifiedId={conversationQualifiedId} conversationName={conversationName} onRefresh={onRefresh} + onCloseSearchView={onCloseSearchView} /> ); @@ -92,6 +95,7 @@ const CellsTableRowOptionsContent = ({ conversationQualifiedId, conversationName, onRefresh, + onCloseSearchView, }: CellsTableRowOptionsProps) => { const {fireAndForgetInvoker} = useApplicationContext(); const {handleOpenFile} = useCellsFilePreviewModal(); @@ -175,7 +179,7 @@ const CellsTableRowOptionsContent = ({ node.type === CellNodeType.FOLDER - ? openFolder({conversationQualifiedId, name: node.name}) + ? openFolder({path: node.path, onBeforeNavigate: onCloseSearchView}) : handleOpenFile(node) } > diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx index c514d182431..68abf517005 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx @@ -60,6 +60,7 @@ interface ConversationCellsProps { conversationRepository: ConversationRepository; isSearchViewOpen: boolean; onOpenSearchView: () => void; + onCloseSearchView: () => void; } export const ConversationCells = memo( @@ -70,6 +71,7 @@ export const ConversationCells = memo( conversationRepository, isSearchViewOpen, onOpenSearchView, + onCloseSearchView, }: ConversationCellsProps) => { const {fireAndForgetInvoker} = useApplicationContext(); const {cellsState: initialCellState, name} = useKoSubscribableChildren(activeConversation, ['cellsState', 'name']); @@ -92,7 +94,9 @@ export const ConversationCells = memo( const {refresh, setOffset} = useGetAllCellsNodes({ cellsRepository, conversationQualifiedId, - enabled: isCellsStateReady, + //Without this, the browse hook's hashchange handler would compete with + // (and flap against) search results. + enabled: isCellsStateReady && !isSearchViewOpen, fireAndForgetInvoker, userRepository, }); @@ -164,6 +168,8 @@ export const ConversationCells = memo( }); }, [loadMoreOffset, loadMoreSearchResults]); + const handleSearchViewClosure = isSearchViewOpen ? onCloseSearchView : undefined; + useOnPresignedUrlExpired({conversationId, refreshCallback: handleRefresh}); const isLoading = nodesStatus === 'loading'; @@ -208,6 +214,9 @@ export const ConversationCells = memo( conversationQualifiedId={conversationQualifiedId} conversationName={name} onRefresh={handleRefresh} + // opening a folder must close search view and open the browse view + // with that folder (and breadcrumbs) + onCloseSearchView={handleSearchViewClosure} /> )} {isCellsStatePending && !isRefreshing && ( diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.test.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.test.ts new file mode 100644 index 00000000000..0fda3c90aa0 --- /dev/null +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.test.ts @@ -0,0 +1,63 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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, see http://www.gnu.org/licenses/. + * + */ + +import {openFolder} from './openFolder'; + +const CONV_ID = 'abc-123'; +const DOMAIN = 'staging.zinfra.io'; + +function currentHash(): string { + return window.location.hash.replace('#', ''); +} + +describe('openFolder', () => { + beforeEach(() => { + window.location.hash = ''; + }); + + describe('absolute path (id@domain)', () => { + it('navigates to the folder when path has one level', () => { + openFolder({path: `${CONV_ID}@${DOMAIN}/Test`}); + + expect(currentHash()).toBe(`/conversation/${CONV_ID}/${DOMAIN}/files/Test`); + }); + + it('navigates to the correct nested folder', () => { + openFolder({path: `${CONV_ID}@${DOMAIN}/Test/2025/deep`}); + + expect(currentHash()).toBe(`/conversation/${CONV_ID}/${DOMAIN}/files/Test/2025/deep`); + }); + + it('encodes path segments with special characters', () => { + openFolder({path: `${CONV_ID}@${DOMAIN}/My Folder/Sub Folder`}); + + expect(currentHash()).toBe(`/conversation/${CONV_ID}/${DOMAIN}/files/My%20Folder/Sub%20Folder`); + }); + + it('calls onBeforeNavigate before changing the URL', () => { + const hashBeforeNavigate: string[] = []; + const onBeforeNavigate = () => hashBeforeNavigate.push(currentHash()); + + openFolder({path: `${CONV_ID}@${DOMAIN}/Test`, onBeforeNavigate}); + + expect(hashBeforeNavigate).toEqual(['']); + expect(currentHash()).toContain('/files/Test'); + }); + }); +}); diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.ts index 82ab04ae4f3..b207229646f 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/common/openFolder/openFolder.ts @@ -20,29 +20,29 @@ import {MouseEvent as ReactMouseEvent} from 'react'; import {QualifiedId} from '@wireapp/api-client/lib/user/'; +import {parseQualifiedId} from '@wireapp/core/lib/util/qualifiedIdUtil'; import {generateConversationUrl} from 'src/script/router/routeGenerator'; import {createNavigate} from 'src/script/router/routerBindings'; -import {getCellsFilesPath} from '../getCellsFilesPath/getCellsFilesPath'; - interface OpenFolderParams { - conversationQualifiedId: QualifiedId; - name: string; + path: string; event?: ReactMouseEvent; + // Called before navigating so the search view can close before the browse view re-enables. + onBeforeNavigate?: () => void; + conversationQualifiedId?: QualifiedId; } -export const openFolder = ({conversationQualifiedId, name, event}: OpenFolderParams) => { - const currentPath = getCellsFilesPath(); - const pathSegments = currentPath ? currentPath.split('/') : []; - const encodedSegments = [...pathSegments, name].map(segment => encodeURIComponent(segment)); - const newPath = encodedSegments.join('/'); - - createNavigate( - generateConversationUrl({ - id: conversationQualifiedId.id, - domain: conversationQualifiedId.domain, - filePath: `files/${newPath}`, - }), - )(event); +export const openFolder = ({path, event, onBeforeNavigate, conversationQualifiedId}: OpenFolderParams) => { + const stripped = path.startsWith('/') ? path.slice(1) : path; + const [firstSegment, ...rest] = stripped.split('/'); + + const isAbsolute = firstSegment.includes('@'); + const {id, domain} = isAbsolute ? parseQualifiedId(firstSegment) : (conversationQualifiedId ?? {id: '', domain: ''}); + const filePathParts = isAbsolute ? rest : stripped.split('/').filter(Boolean); + + const filePath = `files/${filePathParts.map(encodeURIComponent).join('/')}`; + + onBeforeNavigate?.(); + createNavigate(generateConversationUrl({id, domain, filePath}))(event); }; diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts new file mode 100644 index 00000000000..dbcfb83f837 --- /dev/null +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts @@ -0,0 +1,224 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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, see http://www.gnu.org/licenses/. + * + */ + +import {act, renderHook} from '@testing-library/react'; +import {RestNodeCollection} from 'cells-sdk-ts'; + +import {CellsRepository} from 'Repositories/cells/cellsRepository'; +import {UserRepository} from 'Repositories/user/userRepository'; +import {createExecutingFireAndForgetInvokerForTest} from 'src/script/page/testSupport/rootContextTestSupport'; +import {CellNode, CellNodeType} from 'src/script/types/cellNode'; + +import {useConversationSearchFiles} from './useConversationSearchFiles'; + +import {useCellsStore} from '../common/useCellsStore/useCellsStore'; + +const CONV_ID = 'conv-abc'; +const DOMAIN = 'staging.zinfra.io'; +const QUALIFIED_ID = {id: CONV_ID, domain: DOMAIN}; + +const emptyFilters = { + selectedTagIds: [], + selectedFileTypeIds: [], + selectedCreatorIds: [], + isSharedViaLink: false, +}; + +const staleFolderNode: CellNode = { + id: 'folder-id', + name: 'Arjita', + path: `${CONV_ID}@${DOMAIN}/Arjita`, + type: CellNodeType.FOLDER, + extension: '', + sizeMb: '-', + uploadedAtTimestamp: 0, + owner: '', + conversationName: '', + tags: [], + presignedUrlExpiresAt: null, + user: null, +}; + +type FakeCellsRepository = jest.Mocked>; +type FakeUserRepository = jest.Mocked>; + +function createFakeCellsRepository(searchResult: Partial = {}): FakeCellsRepository { + return {searchNodes: jest.fn().mockResolvedValue({Nodes: [], ...searchResult})}; +} + +function createFakeUserRepository(): FakeUserRepository { + return {getUsersById: jest.fn().mockResolvedValue([])}; +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise(resolvePromise => { + resolve = resolvePromise; + }); + return {promise, resolve}; +} + +function renderSearchHook({ + cellsRepository = createFakeCellsRepository(), + userRepository = createFakeUserRepository(), + enabled = true, + onClear = jest.fn(), + fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(), +}: { + cellsRepository?: FakeCellsRepository; + userRepository?: FakeUserRepository; + enabled?: boolean; + onClear?: () => void; + fireAndForgetInvoker?: ReturnType; +} = {}) { + return { + fireAndForgetInvoker, + onClear, + ...renderHook(() => + useConversationSearchFiles({ + cellsRepository: cellsRepository as unknown as CellsRepository, + userRepository: userRepository as unknown as UserRepository, + conversationQualifiedId: QUALIFIED_ID, + enabled, + fireAndForgetInvoker, + filters: emptyFilters, + onClear, + }), + ), + }; +} + +describe('useConversationSearchFiles', () => { + beforeEach(() => { + useCellsStore.getState().clearAll({conversationId: CONV_ID}); + window.location.hash = `/conversation/${CONV_ID}/${DOMAIN}/files`; + }); + + it('fires an initial fetch when enabled', async () => { + const {fireAndForgetInvoker} = renderSearchHook(); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + expect(useCellsStore.getState().status).toBe('success'); + }); + + it('does not fetch when disabled', async () => { + const cellsRepository = createFakeCellsRepository(); + const {fireAndForgetInvoker} = renderSearchHook({cellsRepository, enabled: false}); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + expect(cellsRepository.searchNodes).not.toHaveBeenCalled(); + }); + + it('loads the conversation root when opening search from inside a folder', async () => { + window.location.hash = `#/conversation/${CONV_ID}/${DOMAIN}/files/MyFolder`; + const cellsRepository = createFakeCellsRepository(); + const {fireAndForgetInvoker} = renderSearchHook({cellsRepository}); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + expect(cellsRepository.searchNodes).toHaveBeenCalledWith( + expect.objectContaining({ + path: `${CONV_ID}@${DOMAIN}`, + recursive: false, + deleted: false, + sortBy: undefined, + sortDirection: undefined, + }), + ); + }); + + it('loads recycle-bin contents when opening search from the recycle bin', async () => { + window.location.hash = `#/conversation/${CONV_ID}/${DOMAIN}/files/recycle_bin`; + const cellsRepository = createFakeCellsRepository(); + const {fireAndForgetInvoker} = renderSearchHook({cellsRepository}); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + expect(cellsRepository.searchNodes).toHaveBeenCalledWith( + expect.objectContaining({ + path: `${CONV_ID}@${DOMAIN}`, + recursive: false, + deleted: true, + }), + ); + }); + + it('clears the previous browse rows as soon as search opens', async () => { + const search = createDeferred(); + const cellsRepository = {searchNodes: jest.fn().mockReturnValue(search.promise)} as FakeCellsRepository; + const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(); + + useCellsStore.getState().setNodes({ + conversationId: CONV_ID, + nodes: [staleFolderNode], + }); + useCellsStore.getState().setStatus('success'); + + renderSearchHook({cellsRepository, fireAndForgetInvoker}); + + expect(useCellsStore.getState().getNodes({conversationId: CONV_ID})).toEqual([]); + expect(useCellsStore.getState().status).toBe('loading'); + + act(() => { + search.resolve({Nodes: []}); + }); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + }); + + it('does not write to the store when disabled mid-flight', async () => { + const search = createDeferred(); + const cellsRepository = {searchNodes: jest.fn().mockReturnValue(search.promise)} as FakeCellsRepository; + const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(); + + const {rerender} = renderHook( + ({enabled}: {enabled: boolean}) => + useConversationSearchFiles({ + cellsRepository: cellsRepository as unknown as CellsRepository, + userRepository: createFakeUserRepository() as unknown as UserRepository, + conversationQualifiedId: QUALIFIED_ID, + enabled, + fireAndForgetInvoker, + filters: emptyFilters, + onClear: jest.fn(), + }), + {initialProps: {enabled: true}}, + ); + + act(() => rerender({enabled: false})); + + act(() => { + search.resolve({Nodes: [{Path: `${CONV_ID}@${DOMAIN}/stale-file.txt`, Type: 'LEAF', Uuid: 'uuid-1'}]}); + }); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + expect(useCellsStore.getState().getNodes({conversationId: CONV_ID})).toHaveLength(0); + }); + + it('calls onClear when the search input is emptied', async () => { + const onClear = jest.fn(); + const {result, fireAndForgetInvoker} = renderSearchHook({onClear}); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + act(() => result.current.handleSearch('test')); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + act(() => result.current.handleSearch('')); + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + expect(onClear).toHaveBeenCalled(); + }); +}); diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts index c8a2f789730..fd42c45c78b 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts @@ -17,7 +17,7 @@ * */ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import is from '@sindresorhus/is'; import {QualifiedId} from '@wireapp/api-client/lib/user/'; @@ -33,7 +33,6 @@ import { hasActiveSearchParams, toConversationDriveSearchParams, } from '../common/driveFilters/driveFilters'; -import {getCellsApiPath} from '../common/getCellsApiPath/getCellsApiPath'; import {getCellsFilesPath} from '../common/getCellsFilesPath/getCellsFilesPath'; import {LOAD_MORE_INCREMENT, LOAD_MORE_INITIAL_SIZE} from '../common/loadMorePagination/loadMorePagination'; import {RECYCLE_BIN_PATH} from '../common/recycleBin/recycleBin'; @@ -52,7 +51,8 @@ interface UseConversationSearchFilesProps { } const DEBOUNCE_TIME = 300; -const FETCH_ALL_QUERY = '*'; + +const normalizeSearchQuery = (query: string): string => (is.nonEmptyStringAndNotWhitespace(query) ? query.trim() : ''); export const useConversationSearchFiles = ({ cellsRepository, @@ -67,16 +67,18 @@ export const useConversationSearchFiles = ({ const [searchValue, setSearchValue] = useState(''); const [searchQuery, setSearchQuery] = useState(''); - const isInitialLoad = useRef(true); const shouldPerformSearch = useRef(false); const hasFiredInitialFetchRef = useRef(false); + const wasEnabledRef = useRef(false); + // Prevents stale in-flight responses from overwriting the store after the search view closes. + const enabledRef = useRef(enabled); + enabledRef.current = enabled; const searchParams = useMemo(() => toConversationDriveSearchParams(filters), [filters]); const hasActiveParams = hasActiveSearchParams(searchParams); const hadActiveSearchParamsRef = useRef(hasActiveParams); - const {id} = conversationQualifiedId; - const conversationPath = getCellsApiPath({conversationQualifiedId}); + const {id, domain} = conversationQualifiedId; const searchNodes = useCallback( async ({ @@ -94,20 +96,35 @@ export const useConversationSearchFiles = ({ setError(null); setStatus(append ? 'fetchingMore' : 'loading'); - const shouldSort = query.length === 0 || query === FETCH_ALL_QUERY; const searchParams = toConversationDriveSearchParams(filtersParam); + // Recurse only when actually searching so the empty search view + // matches the browse view: folders first, then files. + const hasQuery = query.length > 0; + const hasFilters = hasActiveSearchParams(searchParams); + const isRecycleBin = getCellsFilesPath() === RECYCLE_BIN_PATH; + const isSearchingOrFiltering = hasQuery || hasFilters; + + // recency sort for searches inside the recycle bin only + const forceRecencySort = isRecycleBin && hasQuery; + const result = await cellsRepository.searchNodes({ query, + recursive: isSearchingOrFiltering, limit: append ? LOAD_MORE_INCREMENT : LOAD_MORE_INITIAL_SIZE, offset, - path: conversationPath, - sortBy: shouldSort ? 'mtime' : undefined, - sortDirection: shouldSort ? 'desc' : undefined, - deleted: getCellsFilesPath() === RECYCLE_BIN_PATH, + path: `${id}@${domain}`, + sortBy: forceRecencySort ? 'mtime' : undefined, + sortDirection: forceRecencySort ? 'desc' : undefined, + deleted: isRecycleBin, ...searchParams, }); + // Search may have closed while the lookup request was in flight. + if (!enabledRef.current) { + return; + } + if (result.Nodes === undefined || result.Nodes.length === 0) { if (!append) { setNodes({conversationId: id, nodes: []}); @@ -119,13 +136,14 @@ export const useConversationSearchFiles = ({ const users = await getUsersFromNodes({nodes: result.Nodes, userRepository}); + // Search may also close while resolving node owners. + if (!enabledRef.current) { + return; + } + // filter out draft nodes from results const filteredNodes = result.Nodes.filter(node => node.IsDraft !== true); - - const transformedNodes = transformDataToCellsNodes({ - nodes: filteredNodes, - users, - }); + const transformedNodes = transformDataToCellsNodes({nodes: filteredNodes, users}); if (append) { appendNodes({conversationId: id, nodes: transformedNodes}); @@ -135,11 +153,6 @@ export const useConversationSearchFiles = ({ const pagination = result.Pagination !== undefined ? transformToCellPagination(result.Pagination) : null; setPagination({conversationId: id, pagination}); - - if (isInitialLoad.current) { - isInitialLoad.current = false; - } - setStatus('success'); } catch (error) { const wrappedError = error instanceof Error ? error : new Error('Failed to load files', {cause: error}); @@ -158,9 +171,26 @@ export const useConversationSearchFiles = ({ }, // cellsRepository and userRepository are not dependencies because they're singletons // eslint-disable-next-line react-hooks/exhaustive-deps - [appendNodes, setNodes, setPagination, setStatus, setError, id, conversationPath], + [appendNodes, setNodes, setPagination, setStatus, setError, id, domain], ); + useLayoutEffect(() => { + if (!enabled) { + wasEnabledRef.current = false; + return; + } + + if (wasEnabledRef.current) { + return; + } + + wasEnabledRef.current = true; + + // Clear stale browse rows before paint when the search view takes ownership of the shared store. + clearAll({conversationId: id}); + setStatus('loading'); + }, [clearAll, enabled, id, setStatus]); + const searchNodesDebounced = useDebouncedCallback(async (value: string) => { shouldPerformSearch.current = false; setSearchQuery(value); @@ -201,7 +231,7 @@ export const useConversationSearchFiles = ({ if (preserveFilters && hasActiveParams) { fireAndForgetInvoker.fireAndForget(async (): Promise => { - await searchNodes({query: FETCH_ALL_QUERY, filters}); + await searchNodes({query: '', filters}); }); return; } @@ -212,7 +242,7 @@ export const useConversationSearchFiles = ({ const handleReload = useCallback(async (): Promise => { setStatus('loading'); clearAll({conversationId: id}); - await searchNodes({query: searchQuery.trim().length > 0 ? searchQuery : FETCH_ALL_QUERY, filters}); + await searchNodes({query: normalizeSearchQuery(searchQuery), filters}); }, [clearAll, filters, id, searchNodes, searchQuery, setStatus]); useEffect(() => { @@ -228,7 +258,7 @@ export const useConversationSearchFiles = ({ } fireAndForgetInvoker.fireAndForget(async (): Promise => { - await searchNodes({query: searchQuery.trim().length > 0 ? searchQuery : FETCH_ALL_QUERY, filters}); + await searchNodes({query: normalizeSearchQuery(searchQuery), filters}); }); }, [searchNodes, searchQuery, enabled, filters, hasActiveParams, fireAndForgetInvoker]); @@ -245,10 +275,7 @@ export const useConversationSearchFiles = ({ } hasFiredInitialFetchRef.current = true; fireAndForgetInvoker.fireAndForget(async (): Promise => { - await searchNodes({ - query: searchQuery.trim().length > 0 ? searchQuery : FETCH_ALL_QUERY, - filters, - }); + await searchNodes({query: normalizeSearchQuery(searchQuery), filters}); }); }, [enabled, searchNodes, searchQuery, filters, fireAndForgetInvoker]); @@ -263,12 +290,7 @@ export const useConversationSearchFiles = ({ const loadMore = useCallback( async (offset: number): Promise => { - await searchNodes({ - query: searchQuery.trim().length > 0 ? searchQuery : FETCH_ALL_QUERY, - filters, - offset, - append: true, - }); + await searchNodes({query: normalizeSearchQuery(searchQuery), filters, offset, append: true}); }, [filters, searchNodes, searchQuery], ); diff --git a/apps/webapp/src/script/repositories/cells/cellsRepository.ts b/apps/webapp/src/script/repositories/cells/cellsRepository.ts index 416743b8a74..2050f598f3d 100644 --- a/apps/webapp/src/script/repositories/cells/cellsRepository.ts +++ b/apps/webapp/src/script/repositories/cells/cellsRepository.ts @@ -239,6 +239,7 @@ export class CellsRepository { async searchNodes({ query, + recursive, limit = DEFAULT_MAX_FILES_LIMIT, offset = 0, tags, @@ -252,6 +253,7 @@ export class CellsRepository { deleted = false, }: { query: string; + recursive?: boolean; limit?: number; offset?: number; tags?: string[]; @@ -266,6 +268,7 @@ export class CellsRepository { }) { return this.apiClient.api.cells.searchNodes({ phrase: query, + recursive, limit, offset, sortBy, diff --git a/libraries/api-client/src/cells/cellsApi.test.ts b/libraries/api-client/src/cells/cellsApi.test.ts index 4c363d96a7c..ad4123656b8 100644 --- a/libraries/api-client/src/cells/cellsApi.test.ts +++ b/libraries/api-client/src/cells/cellsApi.test.ts @@ -1873,7 +1873,7 @@ describe('CellsAPI', () => { expect(result).toEqual(mockResponse); }); - it('handles empty search phrase', async () => { + it('omits the Text filter and lists non-recursively for an empty search phrase', async () => { const searchPhrase = ''; const mockResponse: RestNodeCollection = { Nodes: [], @@ -1884,9 +1884,8 @@ describe('CellsAPI', () => { const result = await cellsAPI.searchNodes({phrase: searchPhrase}); expect(mockNodeServiceApi.lookup).toHaveBeenCalledWith({ - Scope: {Root: {Path: '/'}, Recursive: true}, + Scope: {Root: {Path: '/'}, Recursive: false}, Filters: { - Text: {SearchIn: 'BaseName', Term: searchPhrase}, Type: 'UNKNOWN', Status: { Deleted: 'Not', @@ -1896,10 +1895,25 @@ describe('CellsAPI', () => { Flags: ['WithPreSignedURLs'], Limit: '10', Offset: '0', + SortField: undefined, + SortDirDesc: undefined, }); expect(result).toEqual(mockResponse); }); + it('recurses for an empty phrase when recursive is explicitly requested', async () => { + const mockResponse: RestNodeCollection = {Nodes: []} as RestNodeCollection; + mockNodeServiceApi.lookup.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await cellsAPI.searchNodes({phrase: '', recursive: true}); + + expect(mockNodeServiceApi.lookup).toHaveBeenCalledWith( + expect.objectContaining({ + Scope: {Root: {Path: '/'}, Recursive: true}, + }), + ); + }); + it('filters by tags when provided', async () => { const searchPhrase = 'test'; const tags = ['tag1', 'tag2']; diff --git a/libraries/api-client/src/cells/cellsApi.ts b/libraries/api-client/src/cells/cellsApi.ts index 2e3749f08cf..8ddf2b02d89 100644 --- a/libraries/api-client/src/cells/cellsApi.ts +++ b/libraries/api-client/src/cells/cellsApi.ts @@ -419,6 +419,7 @@ export class CellsAPI { async searchNodes({ phrase, path = '/', + recursive, limit = DEFAULT_LIMIT, offset = DEFAULT_OFFSET, sortBy, @@ -432,6 +433,7 @@ export class CellsAPI { }: { phrase: string; path?: string; + recursive?: boolean; limit?: number; offset?: number; sortBy?: string; @@ -450,10 +452,15 @@ export class CellsAPI { const mimeOp: 'Should' | 'Must' = mimeTypes !== undefined && mimeTypes.length > 1 ? 'Should' : 'Must'; const creatorOp: 'Should' | 'Must' = creatorIds !== undefined && creatorIds.length > 1 ? 'Should' : 'Must'; + // `searchTerm == nil` drops the Text filter and sets `recursive = false`, + // making the empty search view behave exactly like the browse listing (folders-first natural order). + const hasPhrase = phrase.length > 0; + const isRecursive = recursive ?? hasPhrase; + const request: RestLookupRequest = { - Scope: {Root: {Path: path}, Recursive: true}, + Scope: {Root: {Path: path}, Recursive: isRecursive}, Filters: { - Text: {SearchIn: 'BaseName', Term: phrase}, + ...(hasPhrase ? {Text: {SearchIn: 'BaseName', Term: phrase}} : {}), Type: type || 'UNKNOWN', Status: { Deleted: deleted ? 'Only' : 'Not', From 2c92d0f2a057bda299fa043cfed1bbab6f48162e Mon Sep 17 00:00:00 2001 From: Arjita Mitra Date: Thu, 11 Jun 2026 21:34:41 +0200 Subject: [PATCH 4/5] feat: make search/filtering recursive and scoped to current folder --- .../useConversationSearchFiles.test.ts | 56 +++++++++++++++++-- .../useConversationSearchFiles.ts | 6 +- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts index dbcfb83f837..a916489a730 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts @@ -17,7 +17,7 @@ * */ -import {act, renderHook} from '@testing-library/react'; +import {act, renderHook, waitFor} from '@testing-library/react'; import {RestNodeCollection} from 'cells-sdk-ts'; import {CellsRepository} from 'Repositories/cells/cellsRepository'; @@ -27,13 +27,14 @@ import {CellNode, CellNodeType} from 'src/script/types/cellNode'; import {useConversationSearchFiles} from './useConversationSearchFiles'; +import type {ConversationDriveFiltersState} from '../common/driveFilters/driveFilters'; import {useCellsStore} from '../common/useCellsStore/useCellsStore'; const CONV_ID = 'conv-abc'; const DOMAIN = 'staging.zinfra.io'; const QUALIFIED_ID = {id: CONV_ID, domain: DOMAIN}; -const emptyFilters = { +const emptyFilters: ConversationDriveFiltersState = { selectedTagIds: [], selectedFileTypeIds: [], selectedCreatorIds: [], @@ -80,12 +81,14 @@ function renderSearchHook({ enabled = true, onClear = jest.fn(), fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(), + filters = emptyFilters, }: { cellsRepository?: FakeCellsRepository; userRepository?: FakeUserRepository; enabled?: boolean; onClear?: () => void; fireAndForgetInvoker?: ReturnType; + filters?: ConversationDriveFiltersState; } = {}) { return { fireAndForgetInvoker, @@ -97,7 +100,7 @@ function renderSearchHook({ conversationQualifiedId: QUALIFIED_ID, enabled, fireAndForgetInvoker, - filters: emptyFilters, + filters, onClear, }), ), @@ -125,7 +128,7 @@ describe('useConversationSearchFiles', () => { expect(cellsRepository.searchNodes).not.toHaveBeenCalled(); }); - it('loads the conversation root when opening search from inside a folder', async () => { + it('uses the current folder as the search root when opening search from inside a folder', async () => { window.location.hash = `#/conversation/${CONV_ID}/${DOMAIN}/files/MyFolder`; const cellsRepository = createFakeCellsRepository(); const {fireAndForgetInvoker} = renderSearchHook({cellsRepository}); @@ -133,7 +136,7 @@ describe('useConversationSearchFiles', () => { expect(cellsRepository.searchNodes).toHaveBeenCalledWith( expect.objectContaining({ - path: `${CONV_ID}@${DOMAIN}`, + path: `${CONV_ID}@${DOMAIN}/MyFolder`, recursive: false, deleted: false, sortBy: undefined, @@ -208,6 +211,49 @@ describe('useConversationSearchFiles', () => { expect(useCellsStore.getState().getNodes({conversationId: CONV_ID})).toHaveLength(0); }); + it('searches recursively within the current folder when the user types a query', async () => { + window.location.hash = `#/conversation/${CONV_ID}/${DOMAIN}/files/Arjita`; + const cellsRepository = createFakeCellsRepository(); + const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(); + const {result} = renderSearchHook({cellsRepository, fireAndForgetInvoker}); + + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + act(() => result.current.handleSearch('doc')); + + await waitFor(() => + expect(cellsRepository.searchNodes).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'doc', + recursive: true, + path: `${CONV_ID}@${DOMAIN}/Arjita`, + }), + ), + ); + }); + + it('searches recursively when an active filter is applied without a text query', async () => { + const cellsRepository = createFakeCellsRepository(); + const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(); + + renderSearchHook({ + cellsRepository, + fireAndForgetInvoker, + filters: { + ...emptyFilters, + selectedFileTypeIds: ['pictures'], + }, + }); + + await act(() => fireAndForgetInvoker.waitUntilAllSettled()); + + expect(cellsRepository.searchNodes).toHaveBeenCalledWith( + expect.objectContaining({ + recursive: true, + }), + ); + }); + it('calls onClear when the search input is emptied', async () => { const onClear = jest.fn(); const {result, fireAndForgetInvoker} = renderSearchHook({onClear}); diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts index fd42c45c78b..b3dcb936c1d 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts @@ -33,6 +33,7 @@ import { hasActiveSearchParams, toConversationDriveSearchParams, } from '../common/driveFilters/driveFilters'; +import {getCellsApiPath} from '../common/getCellsApiPath/getCellsApiPath'; import {getCellsFilesPath} from '../common/getCellsFilesPath/getCellsFilesPath'; import {LOAD_MORE_INCREMENT, LOAD_MORE_INITIAL_SIZE} from '../common/loadMorePagination/loadMorePagination'; import {RECYCLE_BIN_PATH} from '../common/recycleBin/recycleBin'; @@ -108,12 +109,15 @@ export const useConversationSearchFiles = ({ // recency sort for searches inside the recycle bin only const forceRecencySort = isRecycleBin && hasQuery; + // search scopes to the folder the user is browsing + const searchRootPath = getCellsApiPath({conversationQualifiedId: {id, domain}}); + const result = await cellsRepository.searchNodes({ query, recursive: isSearchingOrFiltering, limit: append ? LOAD_MORE_INCREMENT : LOAD_MORE_INITIAL_SIZE, offset, - path: `${id}@${domain}`, + path: searchRootPath, sortBy: forceRecencySort ? 'mtime' : undefined, sortDirection: forceRecencySort ? 'desc' : undefined, deleted: isRecycleBin, From ac68cf667ae752c519aa72787cff3486c4dd3b58 Mon Sep 17 00:00:00 2001 From: Arjita Mitra Date: Fri, 12 Jun 2026 12:42:29 +0200 Subject: [PATCH 5/5] fix: allow legacy search when new shared drive search is disabled --- .../components/Conversation/Conversation.tsx | 1 + .../ConversationCells/ConversationCells.tsx | 3 +++ .../useConversationSearchFiles.test.ts | 21 +++++++++++++++++++ .../useConversationSearchFiles.ts | 8 +++++-- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/webapp/src/script/components/Conversation/Conversation.tsx b/apps/webapp/src/script/components/Conversation/Conversation.tsx index 1e1a2724596..f96f044519b 100644 --- a/apps/webapp/src/script/components/Conversation/Conversation.tsx +++ b/apps/webapp/src/script/components/Conversation/Conversation.tsx @@ -627,6 +627,7 @@ export const Conversation = ({ userRepository={repositories.user} cellsRepository={repositories.cells} conversationRepository={conversationRepository} + isSharedDriveSearchAndFiltersEnabled={isSharedDriveSearchAndFiltersEnabled} isSearchViewOpen={isSharedDriveSearchAndFiltersEnabled && isSharedDriveSearchViewOpen} onOpenSearchView={() => { if (isSharedDriveSearchAndFiltersEnabled) { diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx b/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx index 68abf517005..34a6bd3d769 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/ConversationCells.tsx @@ -58,6 +58,7 @@ interface ConversationCellsProps { userRepository: UserRepository; activeConversation: Conversation; conversationRepository: ConversationRepository; + isSharedDriveSearchAndFiltersEnabled: boolean; isSearchViewOpen: boolean; onOpenSearchView: () => void; onCloseSearchView: () => void; @@ -69,6 +70,7 @@ export const ConversationCells = memo( userRepository, activeConversation, conversationRepository, + isSharedDriveSearchAndFiltersEnabled, isSearchViewOpen, onOpenSearchView, onCloseSearchView, @@ -116,6 +118,7 @@ export const ConversationCells = memo( cellsRepository, conversationQualifiedId, enabled: isCellsStateReady && isSearchViewOpen, + allowSearchWhenDisabled: !isSharedDriveSearchAndFiltersEnabled, fireAndForgetInvoker, userRepository, filters: filterState, diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts index a916489a730..da8cfeabc6a 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.test.ts @@ -79,6 +79,7 @@ function renderSearchHook({ cellsRepository = createFakeCellsRepository(), userRepository = createFakeUserRepository(), enabled = true, + allowSearchWhenDisabled = false, onClear = jest.fn(), fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(), filters = emptyFilters, @@ -86,6 +87,7 @@ function renderSearchHook({ cellsRepository?: FakeCellsRepository; userRepository?: FakeUserRepository; enabled?: boolean; + allowSearchWhenDisabled?: boolean; onClear?: () => void; fireAndForgetInvoker?: ReturnType; filters?: ConversationDriveFiltersState; @@ -99,6 +101,7 @@ function renderSearchHook({ userRepository: userRepository as unknown as UserRepository, conversationQualifiedId: QUALIFIED_ID, enabled, + allowSearchWhenDisabled, fireAndForgetInvoker, filters, onClear, @@ -128,6 +131,24 @@ describe('useConversationSearchFiles', () => { expect(cellsRepository.searchNodes).not.toHaveBeenCalled(); }); + it('supports legacy inline searches while the dedicated search view is disabled', async () => { + const cellsRepository = createFakeCellsRepository({ + Nodes: [{Path: `${CONV_ID}@${DOMAIN}/doc.pdf`, Type: 'LEAF', Uuid: 'doc.pdf'}], + }); + const fireAndForgetInvoker = createExecutingFireAndForgetInvokerForTest(); + const {result} = renderSearchHook({ + cellsRepository, + enabled: false, + allowSearchWhenDisabled: true, + fireAndForgetInvoker, + }); + + act(() => result.current.handleSearch('doc')); + + await waitFor(() => expect(useCellsStore.getState().getNodes({conversationId: CONV_ID})).toHaveLength(1)); + expect(useCellsStore.getState().status).toBe('success'); + }); + it('uses the current folder as the search root when opening search from inside a folder', async () => { window.location.hash = `#/conversation/${CONV_ID}/${DOMAIN}/files/MyFolder`; const cellsRepository = createFakeCellsRepository(); diff --git a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts index b3dcb936c1d..3773c3d2ca3 100644 --- a/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts +++ b/apps/webapp/src/script/components/Conversation/ConversationCells/useConversationSearch/useConversationSearchFiles.ts @@ -46,6 +46,7 @@ interface UseConversationSearchFilesProps { userRepository: UserRepository; conversationQualifiedId: QualifiedId; enabled: boolean; + allowSearchWhenDisabled?: boolean; fireAndForgetInvoker: FireAndForgetInvoker; filters: ConversationDriveFiltersState; onClear?: () => void; @@ -60,6 +61,7 @@ export const useConversationSearchFiles = ({ userRepository, conversationQualifiedId, enabled, + allowSearchWhenDisabled = false, fireAndForgetInvoker, filters, onClear, @@ -74,6 +76,8 @@ export const useConversationSearchFiles = ({ // Prevents stale in-flight responses from overwriting the store after the search view closes. const enabledRef = useRef(enabled); enabledRef.current = enabled; + const allowSearchWhenDisabledRef = useRef(allowSearchWhenDisabled); + allowSearchWhenDisabledRef.current = allowSearchWhenDisabled; const searchParams = useMemo(() => toConversationDriveSearchParams(filters), [filters]); const hasActiveParams = hasActiveSearchParams(searchParams); @@ -125,7 +129,7 @@ export const useConversationSearchFiles = ({ }); // Search may have closed while the lookup request was in flight. - if (!enabledRef.current) { + if (!enabledRef.current && !allowSearchWhenDisabledRef.current) { return; } @@ -141,7 +145,7 @@ export const useConversationSearchFiles = ({ const users = await getUsersFromNodes({nodes: result.Nodes, userRepository}); // Search may also close while resolving node owners. - if (!enabledRef.current) { + if (!enabledRef.current && !allowSearchWhenDisabledRef.current) { return; }