Skip to content

Commit c866be6

Browse files
SuchitraSwainlidel
andauthored
feat: Add search/filter functionality to Files UI (#2451)
* feat: Add search/filter functionality to Files UI - Add SearchFilter component with real-time filtering - Support searching by file name, CID, and file type - Integrate search with both list and grid view modes - Add proper keyboard navigation support for filtered results - Include internationalization support for search UI - Maintain all existing functionality while adding search capability Resolves #2447 * fix: Remove unused modalOpen parameter from FilesGrid component - Remove modalOpen from FilesGridPropsConnected interface - Remove modalOpen parameter from FilesGrid component function - Fixes TypeScript error TS6133 about unused variable * feat(files): add toggle for search filter visibility search filter bar is now hidden by default, with a toolbar button to show/hide it. preference persists to localStorage. also fixes "no matches" message not showing due to <Trans> wrapper swallowing the filter conditional, and improves clear button alignment. * fix(files): improve aria attributes on search toggle button add aria-pressed to convey toggle state, use static aria-label, and hide decorative SVG icon from screen readers. * test(files): add e2e tests for search filter cover search toggle visibility, filtering by name and CID in both list and grid views, hiding search to clear filter, and no-matches message. * fix(files): preserve search filter across view switches, fix input bug use a ref in FilesPage to share filter text across list/grid views without passing it through connect()/withTranslation() HOC layers, which caused render delays that broke the controlled input (only the first character was registered). each child now owns local filter state for fast updates and syncs to the shared ref. SearchFilter accepts initialValue prop so it starts with the correct text on remount. also restores the isRoot guard in emptyRowsRenderer, fixes stale keyHandler deps in files-grid, and adds e2e test for filter persistence across view switches. * fix(files): grid select-all should only select filtered files move the grid "Select all entries" checkbox from FilesPage.js into files-grid.tsx where filteredFiles is computed, matching how FilesList already handles its own select-all. the old code in FilesPage selected all files regardless of the active search filter. * fix(a11y): toggle aria-label on search filter button to match state aria-label was static, always saying "show" even when filter was visible --------- Co-authored-by: Suchitra Swain <suchitraswain.2012gmail.com> Co-authored-by: Marcin Rataj <lidel@lidel.org>
1 parent 17f675e commit c866be6

File tree

8 files changed

+459
-89
lines changed

8 files changed

+459
-89
lines changed

public/locales/en/files.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@
192192
"noPinsInProgress": "All done, no remote pins in progress.",
193193
"remotePinningInProgress": "Remote pinning in progress:",
194194
"selectAllEntries": "Select all entries",
195+
"searchFiles": "Filter by name or CID…",
196+
"clearSearch": "Clear search",
197+
"noFilesMatchFilter": "No files match your search",
195198
"previewNotFound": {
196199
"title": "Unable to load this path",
197200
"errorPrefix": "Error:",
@@ -202,5 +205,7 @@
202205
"helpListItemForums": "Visit the <1>Discussion Forums</1> to ask for help.",
203206
"backButton": "Go to Files"
204207
},
205-
"inspectResolveFailed": "Failed to resolve path: {{path}}"
208+
"inspectResolveFailed": "Failed to resolve path: {{path}}",
209+
"showSearch": "Click to show search filter",
210+
"hideSearch": "Click to hide search filter"
206211
}

src/files/FilesPage.js

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import FilePreview from './file-preview/FilePreview.js'
1515
import FilesList from './files-list/FilesList.js'
1616
import FilesGrid from './files-grid/files-grid.js'
1717
import { ViewList, ViewModule } from '../icons/stroke-icons.js'
18+
import GlyphSearch from '../icons/GlyphSearch.tsx'
1819
import FileNotFound from './file-not-found/index.tsx'
1920
import { getJoyrideLocales } from '../helpers/i8n.js'
2021
import SortDropdown from './sort-dropdown/SortDropdown.js'
@@ -26,7 +27,6 @@ import Header from './header/Header.js'
2627
import FileImportStatus from './file-import-status/FileImportStatus.js'
2728
import { useExplore } from 'ipld-explorer-components/providers'
2829
import SelectedActions from './selected-actions/SelectedActions.js'
29-
import Checkbox from '../components/checkbox/Checkbox.js'
3030

3131
const FilesPage = ({
3232
doFetchPinningServices, doFilesFetch, doPinsFetch, doFilesSizeGet, doFilesDownloadLink, doFilesDownloadCarLink, doFilesWrite, doAddCarFile, doFilesBulkCidImport, doFilesAddPath, doUpdateHash,
@@ -44,13 +44,20 @@ const FilesPage = ({
4444
file: null
4545
})
4646
const [viewMode, setViewMode] = useState(() => readSetting('files.viewMode') || 'list')
47+
const [showSearch, setShowSearch] = useState(() => readSetting('files.showSearch') || false)
48+
const filterRef = useRef('')
4749
const [selected, setSelected] = useState([])
4850

4951
const toggleViewMode = () => {
5052
const newMode = viewMode === 'list' ? 'grid' : 'list'
5153
setViewMode(newMode)
5254
}
5355

56+
const toggleSearch = () => setShowSearch(prev => {
57+
if (prev) filterRef.current = ''
58+
return !prev
59+
})
60+
5461
useEffect(() => {
5562
doFetchPinningServices()
5663
doFilesFetch()
@@ -85,6 +92,11 @@ const FilesPage = ({
8592
writeSetting('files.viewMode', viewMode)
8693
}, [viewMode])
8794

95+
// Persist search visibility to localStorage
96+
useEffect(() => {
97+
writeSetting('files.showSearch', showSearch)
98+
}, [showSearch])
99+
88100
/* TODO: uncomment below if we ever want automatic remote pin check
89101
* (it was disabled for now due to https://github.com/ipfs/ipfs-desktop/issues/1954)
90102
useEffect(() => {
@@ -263,8 +275,8 @@ const FilesPage = ({
263275

264276
return <>
265277
{viewMode === 'list'
266-
? <FilesList {...commonProps} />
267-
: <FilesGrid {...commonProps} />}
278+
? <FilesList {...commonProps} showSearch={showSearch} filterRef={filterRef} />
279+
: <FilesGrid {...commonProps} showSearch={showSearch} filterRef={filterRef} />}
268280

269281
{selectedFiles.length !== 0 && <SelectedActions
270282
className={'fixed bottom-0 right-0'}
@@ -364,31 +376,19 @@ const FilesPage = ({
364376
>
365377
{viewMode === 'list' ? <ViewList width="24" height="24" /> : <ViewModule width="24" height="24" />}
366378
</button>
379+
<button
380+
className="pointer selected-item ml2"
381+
onClick={toggleSearch}
382+
title={showSearch ? t('hideSearch') : t('showSearch')}
383+
aria-label={showSearch ? t('hideSearch') : t('showSearch')}
384+
aria-pressed={showSearch}
385+
style={{ height: '24px' }}
386+
>
387+
<GlyphSearch width="24" height="24" fill="currentColor" aria-hidden="true" />
388+
</button>
367389
</div>
368390
</Header>
369391

370-
{(files && files.type !== 'file') && <div className="flex items-center justify-between">
371-
<div>
372-
{viewMode === 'grid' && files?.content?.length > 0
373-
? (
374-
<Checkbox
375-
className='pv3 pl3 pr1 bg-white flex-none'
376-
onChange={(checked) => {
377-
if (checked) {
378-
setSelected(files.content.map(f => f.name))
379-
} else {
380-
setSelected([])
381-
}
382-
}}
383-
checked={files?.content?.length > 0 && selected.length === files.content.length}
384-
label={<span className='fw5 f6'>{t('selectAllEntries')}</span>}
385-
/>
386-
)
387-
: null
388-
}
389-
</div>
390-
</div>}
391-
392392
<MainView t={t} files={files} remotePins={remotePins} pendingPins={pendingPins} failedPins={failedPins} doExploreUserProvidedPath={doExploreUserProvidedPath}/>
393393

394394
<Preview files={files} onDownload={() => onDownload([files])} onClose={onClosePreview} />

src/files/files-grid/files-grid.tsx

Lines changed: 107 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
1-
import React, { useRef, useState, useEffect, useCallback, type FC, type MouseEvent } from 'react'
1+
import React, { useRef, useState, useEffect, useCallback, useMemo, type FC, type MouseEvent } from 'react'
22
import { Trans, withTranslation } from 'react-i18next'
33
import { useDrop } from 'react-dnd'
44
import { NativeTypes } from 'react-dnd-html5-backend'
55
import { normalizeFiles } from '../../lib/files.js'
66
import GridFile from './grid-file.jsx'
7+
import Checkbox from '../../components/checkbox/Checkbox.js'
78
import { connect } from 'redux-bundler-react'
89
import './files-grid.css'
910
import { TFunction } from 'i18next'
1011
import type { ContextMenuFile, ExtendedFile, FileStream } from '../types'
1112
import type { CID } from 'multiformats/cid'
13+
import SearchFilter from '../search-filter/SearchFilter'
1214

1315
export interface FilesGridProps {
1416
files: ContextMenuFile[]
1517
pins: string[]
1618
remotePins: string[]
1719
pendingPins: string[]
1820
failedPins: string[]
21+
showSearch?: boolean
22+
filterRef?: React.MutableRefObject<string>
1923
}
2024

2125
type SetPinningProps = { cid: CID, pinned: boolean }
@@ -34,14 +38,14 @@ interface FilesGridPropsConnected extends FilesGridProps {
3438
onSelect: (fileName: string | string[], isSelected: boolean) => void
3539
filesIsFetching: boolean
3640
selected: string[]
37-
modalOpen: boolean
3841
}
3942

4043
const FilesGrid = ({
4144
files, pins = [], remotePins = [], pendingPins = [], failedPins = [], filesPathInfo, t, onRemove, onRename, onNavigate, onAddFiles,
42-
onMove, handleContextMenuClick, filesIsFetching, onSetPinning, onDismissFailedPin, selected = [], onSelect, modalOpen = false
45+
onMove, handleContextMenuClick, filesIsFetching, onSetPinning, onDismissFailedPin, selected = [], onSelect, showSearch, filterRef
4346
}: FilesGridPropsConnected) => {
4447
const [focused, setFocused] = useState<string | null>(null)
48+
const [filter, setFilter] = useState(() => filterRef?.current || '')
4549
const filesRefs = useRef<Record<string, HTMLDivElement>>({})
4650
const gridRef = useRef<HTMLDivElement | null>(null)
4751

@@ -63,16 +67,55 @@ const FilesGrid = ({
6367
onAddFiles(normalizeFiles(files))
6468
}
6569

70+
const filteredFiles = useMemo(() => {
71+
if (!filter) return files
72+
73+
const filterLower = filter.toLowerCase()
74+
return files.filter(file => {
75+
// Search by name
76+
if (file.name && file.name.toLowerCase().includes(filterLower)) {
77+
return true
78+
}
79+
// Search by CID
80+
if (file.cid && file.cid.toString().toLowerCase().includes(filterLower)) {
81+
return true
82+
}
83+
// Search by type
84+
if (file.type && file.type.toLowerCase().includes(filterLower)) {
85+
return true
86+
}
87+
return false
88+
})
89+
}, [files, filter])
90+
6691
const handleSelect = useCallback((fileName: string, isSelected: boolean) => {
6792
onSelect(fileName, isSelected)
6893
}, [onSelect])
6994

70-
const keyHandler = useCallback((e: KeyboardEvent) => {
71-
// Don't handle keyboard events when a modal is open
72-
if (modalOpen) {
73-
return
95+
const handleFilterChange = useCallback((newFilter: string) => {
96+
setFilter(newFilter)
97+
if (filterRef) filterRef.current = newFilter
98+
setFocused(null)
99+
}, [filterRef])
100+
101+
const allSelected = selected.length !== 0 && selected.length === filteredFiles.length
102+
103+
const toggleAll = useCallback((checked: boolean) => {
104+
if (checked) {
105+
onSelect(filteredFiles.map(file => file.name), true)
106+
} else {
107+
onSelect([], false)
74108
}
109+
}, [filteredFiles, onSelect])
75110

111+
useEffect(() => {
112+
if (!showSearch) {
113+
setFilter('')
114+
if (filterRef) filterRef.current = ''
115+
}
116+
}, [showSearch, filterRef])
117+
118+
const keyHandler = useCallback((e: KeyboardEvent) => {
76119
// Don't capture keyboard events when user is typing in a text input or textarea
77120
const target = e.target as HTMLElement
78121
if (target.tagName === 'TEXTAREA') {
@@ -86,7 +129,7 @@ const FilesGrid = ({
86129
}
87130
}
88131

89-
const focusedFile = focused == null ? null : files.find(el => el.name === focused)
132+
const focusedFile = focused == null ? null : filteredFiles.find(el => el.name === focused)
90133

91134
gridRef.current?.focus?.()
92135

@@ -101,7 +144,7 @@ const FilesGrid = ({
101144
}
102145

103146
if ((e.key === 'Delete' || e.key === 'Backspace') && selected.length > 0) {
104-
const selectedFiles = files.filter(f => selected.includes(f.name))
147+
const selectedFiles = filteredFiles.filter(f => selected.includes(f.name))
105148
return onRemove(selectedFiles)
106149
}
107150

@@ -120,13 +163,13 @@ const FilesGrid = ({
120163
if (isArrowKey) {
121164
e.preventDefault()
122165
const columns = Math.floor((gridRef.current?.clientWidth || window.innerWidth) / 220)
123-
const currentIndex = files.findIndex(el => el.name === focusedFile?.name)
166+
const currentIndex = filteredFiles.findIndex(el => el.name === focusedFile?.name)
124167
let newIndex = currentIndex
125168

126169
switch (e.key) {
127170
case 'ArrowDown':
128171
if (currentIndex === -1) {
129-
newIndex = files.length - 1 // if no focused file, set to last file
172+
newIndex = filteredFiles.length - 1 // if no focused file, set to last file
130173
} else {
131174
newIndex = currentIndex + columns
132175
}
@@ -139,15 +182,15 @@ const FilesGrid = ({
139182
}
140183
break
141184
case 'ArrowRight':
142-
if (currentIndex === -1 || currentIndex === files.length - 1) {
185+
if (currentIndex === -1 || currentIndex === filteredFiles.length - 1) {
143186
newIndex = 0 // if no focused file, set to last file
144187
} else {
145188
newIndex = currentIndex + 1
146189
}
147190
break
148191
case 'ArrowLeft':
149192
if (currentIndex === -1 || currentIndex === 0) {
150-
newIndex = files.length - 1 // if no focused file, set to last file
193+
newIndex = filteredFiles.length - 1 // if no focused file, set to last file
151194
} else {
152195
newIndex = currentIndex - 1
153196
}
@@ -156,8 +199,8 @@ const FilesGrid = ({
156199
break
157200
}
158201

159-
if (newIndex >= 0 && newIndex < files.length) {
160-
const name = files[newIndex].name
202+
if (newIndex >= 0 && newIndex < filteredFiles.length) {
203+
const name = filteredFiles[newIndex].name
161204
setFocused(name)
162205
const element = filesRefs.current[name]
163206
if (element && element.scrollIntoView) {
@@ -167,7 +210,7 @@ const FilesGrid = ({
167210
}
168211
}
169212
}
170-
}, [files, focused, selected, onSelect, onRename, onRemove, onNavigate, handleSelect, modalOpen])
213+
}, [filteredFiles, focused, selected, onSelect, onRename, onRemove, onNavigate, handleSelect])
171214

172215
useEffect(() => {
173216
if (filesIsFetching) return
@@ -180,38 +223,56 @@ const FilesGrid = ({
180223
const gridClassName = `files-grid${isOver && canDrop ? ' files-grid--drop-target' : ''}`
181224

182225
return (
183-
<div ref={(el) => {
184-
drop(el)
185-
gridRef.current = el
186-
}} className={gridClassName} tabIndex={0} role="grid" aria-label={t('filesGridLabel')} data-testid="files-grid">
187-
{files.map(file => (
188-
<GridFile
189-
key={file.name}
190-
{...file}
191-
refSetter={(r: HTMLDivElement | null) => { filesRefs.current[file.name] = r as HTMLDivElement }}
192-
selected={selected.includes(file.name)}
193-
focused={focused === file.name}
194-
pinned={pins?.includes(file.cid?.toString())}
195-
isRemotePin={remotePins?.includes(file.cid?.toString())}
196-
isPendingPin={pendingPins?.includes(file.cid?.toString())}
197-
isFailedPin={failedPins?.some(p => p?.includes(file.cid?.toString()))}
198-
isMfs={filesPathInfo?.isMfs}
199-
onNavigate={() => onNavigate({ path: file.path, cid: file.cid })}
200-
onAddFiles={onAddFiles}
201-
onMove={onMove}
202-
onSetPinning={onSetPinning}
203-
onDismissFailedPin={onDismissFailedPin}
204-
handleContextMenuClick={handleContextMenuClick}
205-
onSelect={handleSelect}
226+
<div className="flex flex-column">
227+
{showSearch && <SearchFilter
228+
initialValue={filter}
229+
onFilterChange={handleFilterChange}
230+
filteredCount={filteredFiles.length}
231+
totalCount={files.length}
232+
/>}
233+
{filteredFiles.length > 0 && (
234+
<Checkbox
235+
className='pv3 pl3 pr1 flex-none'
236+
onChange={toggleAll}
237+
checked={allSelected}
238+
label={<span className='fw5 f6'>{t('selectAllEntries')}</span>}
239+
aria-label={t('selectAllEntries')}
206240
/>
207-
))}
208-
{files.length === 0 && !filesPathInfo?.isRoot && (
209-
<Trans i18nKey='filesList.noFiles' t={t}>
210-
<div className='pv3 b--light-gray files-grid-empty bt tc charcoal-muted f6 noselect'>
211-
There are no available files. Add some!
212-
</div>
213-
</Trans>
214241
)}
242+
<div ref={(el) => {
243+
drop(el)
244+
gridRef.current = el
245+
}} className={gridClassName} tabIndex={0} role="grid" aria-label={t('filesGridLabel')} data-testid="files-grid">
246+
{filteredFiles.map(file => (
247+
<GridFile
248+
key={file.name}
249+
{...file}
250+
refSetter={(r: HTMLDivElement | null) => { filesRefs.current[file.name] = r as HTMLDivElement }}
251+
selected={selected.includes(file.name)}
252+
focused={focused === file.name}
253+
pinned={pins?.includes(file.cid?.toString())}
254+
isRemotePin={remotePins?.includes(file.cid?.toString())}
255+
isPendingPin={pendingPins?.includes(file.cid?.toString())}
256+
isFailedPin={failedPins?.some(p => p?.includes(file.cid?.toString()))}
257+
isMfs={filesPathInfo?.isMfs}
258+
onNavigate={() => onNavigate({ path: file.path, cid: file.cid })}
259+
onAddFiles={onAddFiles}
260+
onMove={onMove}
261+
onSetPinning={onSetPinning}
262+
onDismissFailedPin={onDismissFailedPin}
263+
handleContextMenuClick={handleContextMenuClick}
264+
onSelect={handleSelect}
265+
/>
266+
))}
267+
{filteredFiles.length === 0 && filter && (
268+
<div className='pv3 b--light-gray files-grid-empty bt tc charcoal-muted f6 noselect'>{t('noFilesMatchFilter')}</div>
269+
)}
270+
{filteredFiles.length === 0 && !filter && !filesPathInfo?.isRoot && (
271+
<Trans i18nKey='filesList.noFiles' t={t}>
272+
<div className='pv3 b--light-gray files-grid-empty bt tc charcoal-muted f6 noselect'>No files in this directory. Click the "Import" button to add some.</div>
273+
</Trans>
274+
)}
275+
</div>
215276
</div>
216277
)
217278
}

0 commit comments

Comments
 (0)