From 0fc1863315a5a66058510e6785a18e1e641895a3 Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Fri, 24 Jul 2026 17:51:41 +0200 Subject: [PATCH 01/12] feat(search): add size filter to global search --- src/app/i18n/locales/de.json | 17 ++ src/app/i18n/locales/en.json | 17 ++ src/app/i18n/locales/es.json | 17 ++ src/app/i18n/locales/fr.json | 17 ++ src/app/i18n/locales/it.json | 17 ++ src/app/i18n/locales/ru.json | 17 ++ src/app/i18n/locales/tw.json | 17 ++ src/app/i18n/locales/zh.json | 17 ++ .../Home/components/NavbarGlobalSearch.tsx | 32 +++ .../Home/components/SearchFilterRadioList.tsx | 31 +++ .../Home/components/SearchSizeFilter.tsx | 196 ++++++++++++++++++ src/views/Home/utils/sizeFilterUtils.test.ts | 64 ++++++ src/views/Home/utils/sizeFilterUtils.ts | 68 ++++++ 13 files changed, 527 insertions(+) create mode 100644 src/views/Home/components/SearchFilterRadioList.tsx create mode 100644 src/views/Home/components/SearchSizeFilter.tsx create mode 100644 src/views/Home/utils/sizeFilterUtils.test.ts create mode 100644 src/views/Home/utils/sizeFilterUtils.ts diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 8f105d16c..3ba50f3a6 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -301,6 +301,23 @@ "after": "Nach", "before": "Vor", "selectToday": "Heute auswählen" + }, + "size": { + "size": "Größe", + "anySize": "Beliebige Größe", + "less5mb": "Weniger als 5 MB", + "less100mb": "Weniger als 100 MB", + "less1gb": "Weniger als 1 GB", + "more1gb": "Mehr als 1 GB", + "customRange": "Benutzerdefinierter Größenbereich", + "biggerThan": "Größer als", + "smallerThan": "Kleiner als", + "unit": "Einheit", + "bytes": "Bytes", + "kilobytes": "Kilobytes", + "megabytes": "Megabytes", + "gigabytes": "Gigabytes", + "terabytes": "Terabytes" } }, "emptyState": { diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index cce1a5d2e..6d3ef1456 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -322,6 +322,23 @@ "after": "After", "before": "Before", "selectToday": "Select today" + }, + "size": { + "size": "Size", + "anySize": "Any size", + "less5mb": "Less than 5 MB", + "less100mb": "Less than 100 MB", + "less1gb": "Less than 1 GB", + "more1gb": "More than 1 GB", + "customRange": "Custom size range", + "biggerThan": "Bigger than", + "smallerThan": "Smaller than", + "unit": "Unit", + "bytes": "Bytes", + "kilobytes": "Kilobytes", + "megabytes": "Megabytes", + "gigabytes": "Gigabytes", + "terabytes": "Terabytes" } }, "emptyState": { diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 9f4b1a628..0c824ec18 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -301,6 +301,23 @@ "after": "Después de", "before": "Antes de", "selectToday": "Seleccionar hoy" + }, + "size": { + "size": "Tamaño", + "anySize": "Cualquier tamaño", + "less5mb": "Menos de 5 MB", + "less100mb": "Menos de 100 MB", + "less1gb": "Menos de 1 GB", + "more1gb": "Más de 1 GB", + "customRange": "Rango de tamaño personalizado", + "biggerThan": "Mayor que", + "smallerThan": "Menor que", + "unit": "Unidad", + "bytes": "Bytes", + "kilobytes": "Kilobytes", + "megabytes": "Megabytes", + "gigabytes": "Gigabytes", + "terabytes": "Terabytes" } }, "emptyState": { diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 18165ed0f..46e0f4267 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -301,6 +301,23 @@ "after": "Après", "before": "Avant", "selectToday": "Sélectionner aujourd'hui" + }, + "size": { + "size": "Taille", + "anySize": "Toute taille", + "less5mb": "Moins de 5 MB", + "less100mb": "Moins de 100 MB", + "less1gb": "Moins de 1 GB", + "more1gb": "Plus de 1 GB", + "customRange": "Plage de taille personnalisée", + "biggerThan": "Supérieur à", + "smallerThan": "Inférieur à", + "unit": "Unité", + "bytes": "Octets", + "kilobytes": "Kilo-octets", + "megabytes": "Méga-octets", + "gigabytes": "Giga-octets", + "terabytes": "Téra-octets" } }, "emptyState": { diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 8f404358f..6aa163e58 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -322,6 +322,23 @@ "after": "Dopo", "before": "Prima", "selectToday": "Seleziona oggi" + }, + "size": { + "size": "Dimensione", + "anySize": "Qualsiasi dimensione", + "less5mb": "Meno di 5 MB", + "less100mb": "Meno di 100 MB", + "less1gb": "Meno di 1 GB", + "more1gb": "Più di 1 GB", + "customRange": "Intervallo di dimensioni personalizzato", + "biggerThan": "Maggiore di", + "smallerThan": "Minore di", + "unit": "Unità", + "bytes": "Byte", + "kilobytes": "Kilobyte", + "megabytes": "Megabyte", + "gigabytes": "Gigabyte", + "terabytes": "Terabyte" } }, "emptyState": { diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 530ccac01..b789f7618 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -301,6 +301,23 @@ "after": "После", "before": "До", "selectToday": "Выбрать сегодня" + }, + "size": { + "size": "Размер", + "anySize": "Любой размер", + "less5mb": "Менее 5 MB", + "less100mb": "Менее 100 MB", + "less1gb": "Менее 1 GB", + "more1gb": "Более 1 GB", + "customRange": "Пользовательский диапазон размера", + "biggerThan": "Больше чем", + "smallerThan": "Меньше чем", + "unit": "Единица", + "bytes": "Байты", + "kilobytes": "Килобайты", + "megabytes": "Мегабайты", + "gigabytes": "Гигабайты", + "terabytes": "Терабайты" } }, "emptyState": { diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 9ccbe51c2..30476f45a 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -322,6 +322,23 @@ "after": "之後", "before": "之前", "selectToday": "選擇今天" + }, + "size": { + "size": "大小", + "anySize": "任何大小", + "less5mb": "小於 5 MB", + "less100mb": "小於 100 MB", + "less1gb": "小於 1 GB", + "more1gb": "大於 1 GB", + "customRange": "自訂大小範圍", + "biggerThan": "大於", + "smallerThan": "小於", + "unit": "單位", + "bytes": "位元組", + "kilobytes": "KB", + "megabytes": "MB", + "gigabytes": "GB", + "terabytes": "TB" } }, "emptyState": { diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 019cb9448..a69916a59 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -322,6 +322,23 @@ "after": "之后", "before": "之前", "selectToday": "选择今天" + }, + "size": { + "size": "大小", + "anySize": "任何大小", + "less5mb": "小于 5 MB", + "less100mb": "小于 100 MB", + "less1gb": "小于 1 GB", + "more1gb": "大于 1 GB", + "customRange": "自定义大小范围", + "biggerThan": "大于", + "smallerThan": "小于", + "unit": "单位", + "bytes": "字节", + "kilobytes": "KB", + "megabytes": "MB", + "gigabytes": "GB", + "terabytes": "TB" } }, "emptyState": { diff --git a/src/views/Home/components/NavbarGlobalSearch.tsx b/src/views/Home/components/NavbarGlobalSearch.tsx index 4fd8ccdf3..b283d4880 100644 --- a/src/views/Home/components/NavbarGlobalSearch.tsx +++ b/src/views/Home/components/NavbarGlobalSearch.tsx @@ -21,8 +21,16 @@ import NotFoundState from './NotFoundState'; import EmptyState from './EmptyState'; import { toggleTypeCategory } from '../utils/typeFilterUtils'; import { changeSpecificDate, datePresetToRange, SearchDatePreset, SpecificDateRange } from '../utils/dateFilterUtils'; +import { + changeCustomSize, + CustomSizeRange, + emptyCustomSizeRange, + SearchSizePreset, + sizePresetToRange, +} from '../utils/sizeFilterUtils'; import SearchTypeFilter from './SearchTypeFilter'; import SearchDateFilter from './SearchDateFilter'; +import SearchSizeFilter from './SearchSizeFilter'; import { Dayjs } from 'dayjs'; import { getItemPlainName } from 'app/crypto/services/utils'; import navigationService from 'services/navigation.service'; @@ -85,6 +93,8 @@ const Navbar = (props: NavbarProps) => { const [filters, setFilters] = useState(emptySearchFilters); const [datePreset, setDatePreset] = useState('any'); const [specificDates, setSpecificDates] = useState({}); + const [sizePreset, setSizePreset] = useState('any'); + const [customSize, setCustomSize] = useState(emptyCustomSizeRange); const [query, setQuery] = useState(''); const [searchResult, setSearchResult] = useState([]); @@ -146,6 +156,21 @@ const Navbar = (props: NavbarProps) => { const changeDateFilterDate = (field: 'after' | 'before', date?: Dayjs) => applyDateFilter('specific', changeSpecificDate(specificDates, field, date)); + const applySizeFilter = (preset: SearchSizePreset, custom: CustomSizeRange) => { + setSizePreset(preset); + setCustomSize(custom); + setFilters((current) => ({ ...current, ...sizePresetToRange(preset, custom) })); + }; + + const selectSizePreset = (preset: SearchSizePreset) => { + if (preset !== sizePreset) applySizeFilter(preset, emptyCustomSizeRange); + }; + + const changeCustomSizeFilter = (changes: Partial) => { + const nextCustomSize = changeCustomSize(customSize, changes); + if (nextCustomSize !== customSize) applySizeFilter('custom', nextCustomSize); + }; + const openSearchBoxRef = useRef(openSearchBox); openSearchBoxRef.current = openSearchBox; @@ -337,6 +362,13 @@ const Navbar = (props: NavbarProps) => { onChangeDate={changeDateFilterDate} onClose={refocusSearchInput} /> + {shouldShowResults() ? ( diff --git a/src/views/Home/components/SearchFilterRadioList.tsx b/src/views/Home/components/SearchFilterRadioList.tsx new file mode 100644 index 000000000..fc5a2bfc8 --- /dev/null +++ b/src/views/Home/components/SearchFilterRadioList.tsx @@ -0,0 +1,31 @@ +import { RadioButton } from '@internxt/ui'; + +interface SearchFilterRadioListProps { + anyLabel: string; + items: { id: T; label: string }[]; + selected: T | 'any'; + onSelect: (id: T | 'any') => void; +} + +const SearchFilterRadioList = ({ + anyLabel, + items, + selected, + onSelect, +}: SearchFilterRadioListProps): JSX.Element => ( +
event.preventDefault()} onClick={(event) => event.preventDefault()}> +
+ onSelect('any')} /> +

{anyLabel}

+
+
+ {items.map(({ id, label }) => ( +
+ onSelect(id)} /> +

{label}

+
+ ))} +
+); + +export default SearchFilterRadioList; diff --git a/src/views/Home/components/SearchSizeFilter.tsx b/src/views/Home/components/SearchSizeFilter.tsx new file mode 100644 index 000000000..5149e8800 --- /dev/null +++ b/src/views/Home/components/SearchSizeFilter.tsx @@ -0,0 +1,196 @@ +import { Menu, MenuButton, MenuItem, MenuItems, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; +import { CaretDownIcon, CheckIcon, XIcon } from '@phosphor-icons/react'; +import { useEffect, useState } from 'react'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { CustomSizeRange, SearchSizePreset, SIZE_PRESET_ITEMS, SIZE_UNIT_ITEMS, SizeUnit } from '../utils/sizeFilterUtils'; +import DropdownCloseObserver from './DropdownCloseObserver'; +import SearchFilterRadioList from './SearchFilterRadioList'; + +interface SizeUnitSelectProps { + unit: SizeUnit; + onChange: (unit: SizeUnit) => void; +} + +const SizeUnitSelect = ({ unit, onChange }: SizeUnitSelectProps): JSX.Element => { + const { translate } = useTranslationContext(); + + return ( + + + {unit} + + + + {SIZE_UNIT_ITEMS.map(({ id, labelKey }) => ( + + + + ))} + + + ); +}; + +interface SearchSizeInputProps { + label: string; + value?: number; + unit: SizeUnit; + onValueChange: (value?: number) => void; + onUnitChange: (unit: SizeUnit) => void; + onEnter: () => void; +} + +const SearchSizeInput = ({ + label, + value, + unit, + onValueChange, + onUnitChange, + onEnter, +}: SearchSizeInputProps): JSX.Element => { + const { translate } = useTranslationContext(); + const [text, setText] = useState(''); + + useEffect(() => { + setText(value !== undefined ? String(value) : ''); + }, [value]); + + const handleTextChange = (nextText: string) => { + const digits = nextText.replace(/\D/g, ''); + const nextValue = digits === '' ? undefined : Number(digits); + setText(digits); + if (nextValue !== value) onValueChange(nextValue); + }; + + return ( +
+
+

{label}

+
+ ); +}; + +interface SearchSizeFilterProps { + preset: SearchSizePreset; + custom: CustomSizeRange; + onSelectPreset: (preset: SearchSizePreset) => void; + onChangeCustom: (changes: Partial) => void; + onClose: () => void; +} + +const SearchSizeFilter = ({ + preset, + custom, + onSelectPreset, + onChangeCustom, + onClose, +}: SearchSizeFilterProps): JSX.Element => { + const { translate } = useTranslationContext(); + const isAnySize = preset === 'any'; + + return ( + + {({ open, close }) => ( + <> + + + {translate('general.searchBar.filters.size.size')} + + + + + ({ + id, + label: translate(`general.searchBar.filters.size.${labelKey}`), + }))} + selected={preset} + onSelect={onSelectPreset} + /> + {preset === 'custom' && ( + <> +
+
+ onChangeCustom({ biggerThan: value })} + onUnitChange={(unit) => onChangeCustom({ biggerThanUnit: unit })} + onEnter={close} + /> + onChangeCustom({ smallerThan: value })} + onUnitChange={(unit) => onChangeCustom({ smallerThanUnit: unit })} + onEnter={close} + /> +
+ + )} + + + )} + + ); +}; + +export default SearchSizeFilter; diff --git a/src/views/Home/utils/sizeFilterUtils.test.ts b/src/views/Home/utils/sizeFilterUtils.test.ts new file mode 100644 index 000000000..f34f18dc0 --- /dev/null +++ b/src/views/Home/utils/sizeFilterUtils.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'vitest'; +import { changeCustomSize, CustomSizeRange, emptyCustomSizeRange, sizePresetToRange } from './sizeFilterUtils'; + +describe('sizePresetToRange', () => { + test('When the preset is any, then no bounds are returned', () => { + expect(sizePresetToRange('any', emptyCustomSizeRange)).toEqual({ minSize: undefined, maxSize: undefined }); + }); + + test('When a less-than preset is selected, then only the upper bound is returned in bytes', () => { + expect(sizePresetToRange('less5mb', emptyCustomSizeRange)).toEqual({ minSize: undefined, maxSize: 5242880 }); + expect(sizePresetToRange('less100mb', emptyCustomSizeRange)).toEqual({ minSize: undefined, maxSize: 104857600 }); + expect(sizePresetToRange('less1gb', emptyCustomSizeRange)).toEqual({ minSize: undefined, maxSize: 1073741824 }); + }); + + test('When the more-than preset is selected, then only the lower bound is returned in bytes', () => { + expect(sizePresetToRange('more1gb', emptyCustomSizeRange)).toEqual({ minSize: 1073741824, maxSize: undefined }); + }); + + test('When the preset is custom, then each bound is converted with its own unit', () => { + const custom: CustomSizeRange = { biggerThan: 10, biggerThanUnit: 'KB', smallerThan: 5, smallerThanUnit: 'GB' }; + + expect(sizePresetToRange('custom', custom)).toEqual({ minSize: 10240, maxSize: 5368709120 }); + }); + + test('When a custom bound is missing, then an open range is returned', () => { + const custom: CustomSizeRange = { ...emptyCustomSizeRange, smallerThan: 2, smallerThanUnit: 'MB' }; + + expect(sizePresetToRange('custom', custom)).toEqual({ minSize: undefined, maxSize: 2097152 }); + }); +}); + +describe('changeCustomSize', () => { + test('When the change keeps the range valid, then it is applied', () => { + const current: CustomSizeRange = { ...emptyCustomSizeRange, biggerThan: 10 }; + + expect(changeCustomSize(current, { smallerThan: 5 }).smallerThan).toBe(5); + }); + + test('When a unit changes with a value set, then the value is preserved', () => { + const current: CustomSizeRange = { ...emptyCustomSizeRange, biggerThan: 10 }; + + const next = changeCustomSize(current, { biggerThanUnit: 'MB' }); + + expect(next).toEqual({ ...current, biggerThanUnit: 'MB' }); + }); + + test('When the change would invert the range in bytes, then the current range is returned unchanged', () => { + const current: CustomSizeRange = { biggerThan: 10, biggerThanUnit: 'KB', smallerThan: 5, smallerThanUnit: 'GB' }; + + expect(changeCustomSize(current, { smallerThanUnit: 'B' })).toBe(current); + }); + + test('When both bounds are equal in bytes, then the change is accepted', () => { + const current: CustomSizeRange = { biggerThan: 1024, biggerThanUnit: 'B', smallerThanUnit: 'KB' }; + + expect(changeCustomSize(current, { smallerThan: 1 }).smallerThan).toBe(1); + }); + + test('When a bound is cleared, then the range is always accepted', () => { + const current: CustomSizeRange = { biggerThan: 10, biggerThanUnit: 'KB', smallerThan: 5, smallerThanUnit: 'GB' }; + + expect(changeCustomSize(current, { biggerThan: undefined }).biggerThan).toBeUndefined(); + }); +}); diff --git a/src/views/Home/utils/sizeFilterUtils.ts b/src/views/Home/utils/sizeFilterUtils.ts new file mode 100644 index 000000000..9f8894497 --- /dev/null +++ b/src/views/Home/utils/sizeFilterUtils.ts @@ -0,0 +1,68 @@ +import { SearchFilters } from '../services'; + +export type SearchSizePreset = 'any' | 'less5mb' | 'less100mb' | 'less1gb' | 'more1gb' | 'custom'; + +export type SizeUnit = 'B' | 'KB' | 'MB' | 'GB' | 'TB'; + +export interface CustomSizeRange { + biggerThan?: number; + biggerThanUnit: SizeUnit; + smallerThan?: number; + smallerThanUnit: SizeUnit; +} + +export const emptyCustomSizeRange: CustomSizeRange = { biggerThanUnit: 'KB', smallerThanUnit: 'GB' }; + +export const UNIT_BYTES: Record = { + B: 1, + KB: 1024, + MB: 1024 ** 2, + GB: 1024 ** 3, + TB: 1024 ** 4, +}; + +export const SIZE_UNIT_ITEMS: { id: SizeUnit; labelKey: string }[] = [ + { id: 'B', labelKey: 'bytes' }, + { id: 'KB', labelKey: 'kilobytes' }, + { id: 'MB', labelKey: 'megabytes' }, + { id: 'GB', labelKey: 'gigabytes' }, + { id: 'TB', labelKey: 'terabytes' }, +]; + +export const SIZE_PRESET_ITEMS: { id: Exclude; labelKey: string }[] = [ + { id: 'less5mb', labelKey: 'less5mb' }, + { id: 'less100mb', labelKey: 'less100mb' }, + { id: 'less1gb', labelKey: 'less1gb' }, + { id: 'more1gb', labelKey: 'more1gb' }, + { id: 'custom', labelKey: 'customRange' }, +]; + +export const sizePresetToRange = ( + preset: SearchSizePreset, + custom: CustomSizeRange, +): Pick => { + switch (preset) { + case 'less5mb': + return { minSize: undefined, maxSize: 5 * UNIT_BYTES.MB }; + case 'less100mb': + return { minSize: undefined, maxSize: 100 * UNIT_BYTES.MB }; + case 'less1gb': + return { minSize: undefined, maxSize: UNIT_BYTES.GB }; + case 'more1gb': + return { minSize: UNIT_BYTES.GB, maxSize: undefined }; + case 'custom': + return { + minSize: custom.biggerThan !== undefined ? custom.biggerThan * UNIT_BYTES[custom.biggerThanUnit] : undefined, + maxSize: custom.smallerThan !== undefined ? custom.smallerThan * UNIT_BYTES[custom.smallerThanUnit] : undefined, + }; + default: + return { minSize: undefined, maxSize: undefined }; + } +}; + +export const changeCustomSize = (current: CustomSizeRange, changes: Partial): CustomSizeRange => { + const next = { ...current, ...changes }; + const { minSize, maxSize } = sizePresetToRange('custom', next); + if (minSize !== undefined && maxSize !== undefined && maxSize < minSize) return current; + return next; +}; From 99a1a51ab136afa5da09704a9eedc399a239a475 Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Fri, 24 Jul 2026 17:54:10 +0200 Subject: [PATCH 02/12] refactor(search): share filter radio list and close panels on enter --- .../Home/components/NavbarGlobalSearch.tsx | 6 ++- .../Home/components/SearchDateFilter.tsx | 43 +++++++++---------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/views/Home/components/NavbarGlobalSearch.tsx b/src/views/Home/components/NavbarGlobalSearch.tsx index b283d4880..b462652a7 100644 --- a/src/views/Home/components/NavbarGlobalSearch.tsx +++ b/src/views/Home/components/NavbarGlobalSearch.tsx @@ -153,8 +153,10 @@ const Navbar = (props: NavbarProps) => { if (preset !== datePreset) applyDateFilter(preset, {}); }; - const changeDateFilterDate = (field: 'after' | 'before', date?: Dayjs) => - applyDateFilter('specific', changeSpecificDate(specificDates, field, date)); + const changeDateFilterDate = (field: 'after' | 'before', date?: Dayjs) => { + const nextSpecificDates = changeSpecificDate(specificDates, field, date); + if (nextSpecificDates !== specificDates) applyDateFilter('specific', nextSpecificDates); + }; const applySizeFilter = (preset: SearchSizePreset, custom: CustomSizeRange) => { setSizePreset(preset); diff --git a/src/views/Home/components/SearchDateFilter.tsx b/src/views/Home/components/SearchDateFilter.tsx index dd378717e..d02a5262c 100644 --- a/src/views/Home/components/SearchDateFilter.tsx +++ b/src/views/Home/components/SearchDateFilter.tsx @@ -1,5 +1,4 @@ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; -import { RadioButton } from '@internxt/ui'; import { CaretDownIcon, XIcon } from '@phosphor-icons/react'; import dayjs, { Dayjs } from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; @@ -8,6 +7,7 @@ import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { DATE_PRESET_ITEMS, SearchDatePreset, SpecificDateRange } from '../utils/dateFilterUtils'; import DateCalendar from './DateCalendar'; import DropdownCloseObserver from './DropdownCloseObserver'; +import SearchFilterRadioList from './SearchFilterRadioList'; dayjs.extend(customParseFormat); @@ -27,9 +27,10 @@ interface SearchDateInputProps { minDate?: Dayjs; maxDate?: Dayjs; onChange: (date?: Dayjs) => void; + onEnter: () => void; } -const SearchDateInput = ({ label, value, minDate, maxDate, onChange }: SearchDateInputProps): JSX.Element => { +const SearchDateInput = ({ label, value, minDate, maxDate, onChange, onEnter }: SearchDateInputProps): JSX.Element => { const [text, setText] = useState(''); const [showCalendar, setShowCalendar] = useState(false); @@ -58,6 +59,12 @@ const SearchDateInput = ({ label, value, minDate, maxDate, onChange }: SearchDat spellCheck="false" autoComplete="off" onChange={(event) => handleTextChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + onEnter(); + } + }} onFocus={() => setShowCalendar(true)} onBlur={() => { setShowCalendar(false); @@ -101,7 +108,7 @@ const SearchDateFilter = ({ return ( - {({ open }) => ( + {({ open, close }) => ( <> -
event.preventDefault()} - onClick={(event) => event.preventDefault()} - > -
- onSelectPreset('any')} /> -

{translate('general.searchBar.filters.date.anyDate')}

-
-
- {DATE_PRESET_ITEMS.map(({ id, labelKey }) => ( -
- onSelectPreset(id)} /> -

- {translate(`general.searchBar.filters.date.${labelKey}`, { year: presetYears[id] })} -

-
- ))} -
+ ({ + id, + label: translate(`general.searchBar.filters.date.${labelKey}`, { year: presetYears[id] }), + }))} + selected={preset} + onSelect={onSelectPreset} + /> {preset === 'specific' && ( <>
@@ -147,12 +144,14 @@ const SearchDateFilter = ({ value={specific.after} maxDate={specific.before} onChange={(date) => onChangeDate('after', date)} + onEnter={close} /> onChangeDate('before', date)} + onEnter={close} />
From c420428f5b7a4a24a090325c8f32f9852f1cca17 Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Wed, 29 Jul 2026 15:34:06 +0200 Subject: [PATCH 03/12] [PB-6258] fix: allow conflicting custom size range so the search shows the empty state --- src/views/Home/utils/sizeFilterUtils.test.ts | 4 ++-- src/views/Home/utils/sizeFilterUtils.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/views/Home/utils/sizeFilterUtils.test.ts b/src/views/Home/utils/sizeFilterUtils.test.ts index f34f18dc0..dfeb357d4 100644 --- a/src/views/Home/utils/sizeFilterUtils.test.ts +++ b/src/views/Home/utils/sizeFilterUtils.test.ts @@ -44,10 +44,10 @@ describe('changeCustomSize', () => { expect(next).toEqual({ ...current, biggerThanUnit: 'MB' }); }); - test('When the change would invert the range in bytes, then the current range is returned unchanged', () => { + test('When the change inverts the range in bytes, then it is still applied so the search returns an empty result', () => { const current: CustomSizeRange = { biggerThan: 10, biggerThanUnit: 'KB', smallerThan: 5, smallerThanUnit: 'GB' }; - expect(changeCustomSize(current, { smallerThanUnit: 'B' })).toBe(current); + expect(changeCustomSize(current, { smallerThanUnit: 'B' }).smallerThanUnit).toBe('B'); }); test('When both bounds are equal in bytes, then the change is accepted', () => { diff --git a/src/views/Home/utils/sizeFilterUtils.ts b/src/views/Home/utils/sizeFilterUtils.ts index 9f8894497..28b3a0050 100644 --- a/src/views/Home/utils/sizeFilterUtils.ts +++ b/src/views/Home/utils/sizeFilterUtils.ts @@ -61,8 +61,5 @@ export const sizePresetToRange = ( }; export const changeCustomSize = (current: CustomSizeRange, changes: Partial): CustomSizeRange => { - const next = { ...current, ...changes }; - const { minSize, maxSize } = sizePresetToRange('custom', next); - if (minSize !== undefined && maxSize !== undefined && maxSize < minSize) return current; - return next; + return { ...current, ...changes }; }; From 65c0d5a65b40648aa888acc687806c412bdababf Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Wed, 29 Jul 2026 15:42:09 +0200 Subject: [PATCH 04/12] Revert "[PB-6258] fix: allow conflicting custom size range so the search shows the empty state" This reverts commit c420428f5b7a4a24a090325c8f32f9852f1cca17. --- src/views/Home/utils/sizeFilterUtils.test.ts | 4 ++-- src/views/Home/utils/sizeFilterUtils.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/views/Home/utils/sizeFilterUtils.test.ts b/src/views/Home/utils/sizeFilterUtils.test.ts index dfeb357d4..f34f18dc0 100644 --- a/src/views/Home/utils/sizeFilterUtils.test.ts +++ b/src/views/Home/utils/sizeFilterUtils.test.ts @@ -44,10 +44,10 @@ describe('changeCustomSize', () => { expect(next).toEqual({ ...current, biggerThanUnit: 'MB' }); }); - test('When the change inverts the range in bytes, then it is still applied so the search returns an empty result', () => { + test('When the change would invert the range in bytes, then the current range is returned unchanged', () => { const current: CustomSizeRange = { biggerThan: 10, biggerThanUnit: 'KB', smallerThan: 5, smallerThanUnit: 'GB' }; - expect(changeCustomSize(current, { smallerThanUnit: 'B' }).smallerThanUnit).toBe('B'); + expect(changeCustomSize(current, { smallerThanUnit: 'B' })).toBe(current); }); test('When both bounds are equal in bytes, then the change is accepted', () => { diff --git a/src/views/Home/utils/sizeFilterUtils.ts b/src/views/Home/utils/sizeFilterUtils.ts index 28b3a0050..9f8894497 100644 --- a/src/views/Home/utils/sizeFilterUtils.ts +++ b/src/views/Home/utils/sizeFilterUtils.ts @@ -61,5 +61,8 @@ export const sizePresetToRange = ( }; export const changeCustomSize = (current: CustomSizeRange, changes: Partial): CustomSizeRange => { - return { ...current, ...changes }; + const next = { ...current, ...changes }; + const { minSize, maxSize } = sizePresetToRange('custom', next); + if (minSize !== undefined && maxSize !== undefined && maxSize < minSize) return current; + return next; }; From 27113b6c0dd11331427524edc27b36fcddb9017c Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Fri, 31 Jul 2026 13:42:27 +0200 Subject: [PATCH 05/12] [PB-6258] fix: make size and date radio rows fully clickable via shared SearchFilterRow --- src/views/Home/components/SearchFilterRadioList.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/views/Home/components/SearchFilterRadioList.tsx b/src/views/Home/components/SearchFilterRadioList.tsx index fc5a2bfc8..833c269d7 100644 --- a/src/views/Home/components/SearchFilterRadioList.tsx +++ b/src/views/Home/components/SearchFilterRadioList.tsx @@ -1,4 +1,5 @@ import { RadioButton } from '@internxt/ui'; +import SearchFilterRow from './SearchFilterRow'; interface SearchFilterRadioListProps { anyLabel: string; @@ -14,16 +15,16 @@ const SearchFilterRadioList = ({ onSelect, }: SearchFilterRadioListProps): JSX.Element => (
event.preventDefault()} onClick={(event) => event.preventDefault()}> -
+ onSelect('any')}> onSelect('any')} />

{anyLabel}

-
+
{items.map(({ id, label }) => ( -
+ onSelect(id)} key={id}> onSelect(id)} />

{label}

-
+ ))}
); From 0440644b6f037de3cc957bcc855cca3e58490c9d Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Mon, 3 Aug 2026 15:34:32 +0200 Subject: [PATCH 06/12] [PB-6258] fix: make the size filter clear control a real button Same fix as the date filter: the X that clears a custom size input was an icon with a mouse handler, unreachable by keyboard. It is now a button that prevents the input from blurring on mouse down and clears on click. --- .../Home/components/SearchSizeFilter.tsx | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/views/Home/components/SearchSizeFilter.tsx b/src/views/Home/components/SearchSizeFilter.tsx index 5149e8800..c54345c2f 100644 --- a/src/views/Home/components/SearchSizeFilter.tsx +++ b/src/views/Home/components/SearchSizeFilter.tsx @@ -2,7 +2,13 @@ import { Menu, MenuButton, MenuItem, MenuItems, Popover, PopoverButton, PopoverP import { CaretDownIcon, CheckIcon, XIcon } from '@phosphor-icons/react'; import { useEffect, useState } from 'react'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { CustomSizeRange, SearchSizePreset, SIZE_PRESET_ITEMS, SIZE_UNIT_ITEMS, SizeUnit } from '../utils/sizeFilterUtils'; +import { + CustomSizeRange, + SearchSizePreset, + SIZE_PRESET_ITEMS, + SIZE_UNIT_ITEMS, + SizeUnit, +} from '../utils/sizeFilterUtils'; import DropdownCloseObserver from './DropdownCloseObserver'; import SearchFilterRadioList from './SearchFilterRadioList'; @@ -97,14 +103,15 @@ const SearchSizeInput = ({ {unit}
{text.length > 0 && ( - { - event.preventDefault(); - handleTextChange(''); - }} - /> + )}
From 7208f3f0563061bda93f1f9ec26422786206119d Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Tue, 4 Aug 2026 11:39:17 +0200 Subject: [PATCH 07/12] [PB-6258] fix: keep the search filter pills inside the results panel --- .../Home/components/NavbarGlobalSearch.tsx | 2 +- .../Home/components/SearchDateFilter.tsx | 18 +++++-------- .../Home/components/SearchFilterPill.tsx | 27 +++++++++++++++++++ .../Home/components/SearchSizeFilter.tsx | 16 +++++------ .../Home/components/SearchTypeFilter.tsx | 17 +++++------- 5 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 src/views/Home/components/SearchFilterPill.tsx diff --git a/src/views/Home/components/NavbarGlobalSearch.tsx b/src/views/Home/components/NavbarGlobalSearch.tsx index 8e32c6c12..ae2ea02dc 100644 --- a/src/views/Home/components/NavbarGlobalSearch.tsx +++ b/src/views/Home/components/NavbarGlobalSearch.tsx @@ -351,7 +351,7 @@ const Navbar = (props: NavbarProps) => { onMouseEnter={() => setPreventBlur(true)} onMouseLeave={() => setPreventBlur(false)} > -
+
+ {({ open, close }) => ( <> - {translate('general.searchBar.filters.date.dateModified')} - - + as={SearchFilterPill} + label={translate('general.searchBar.filters.date.dateModified')} + active={!isAnyDate} + /> { + label: string; + active: boolean; +} + +const SearchFilterPill = forwardRef(({ label, active, ...props }, ref) => ( + +)); + +SearchFilterPill.displayName = 'SearchFilterPill'; + +export default SearchFilterPill; diff --git a/src/views/Home/components/SearchSizeFilter.tsx b/src/views/Home/components/SearchSizeFilter.tsx index c54345c2f..2007a1518 100644 --- a/src/views/Home/components/SearchSizeFilter.tsx +++ b/src/views/Home/components/SearchSizeFilter.tsx @@ -10,6 +10,7 @@ import { SizeUnit, } from '../utils/sizeFilterUtils'; import DropdownCloseObserver from './DropdownCloseObserver'; +import SearchFilterPill from './SearchFilterPill'; import SearchFilterRadioList from './SearchFilterRadioList'; interface SizeUnitSelectProps { @@ -142,20 +143,15 @@ const SearchSizeFilter = ({ const isAnySize = preset === 'any'; return ( - + {({ open, close }) => ( <> - {translate('general.searchBar.filters.size.size')} - - + as={SearchFilterPill} + label={translate('general.searchBar.filters.size.size')} + active={!isAnySize} + /> + {({ open }) => ( <> - {translate('general.searchBar.filters.attachments')} - - + as={SearchFilterPill} + label={translate('general.searchBar.filters.attachments')} + active={isFiltering} + /> Date: Tue, 4 Aug 2026 12:03:46 +0200 Subject: [PATCH 08/12] [PB-6258] feat: show a spinner while the global search is loading --- src/views/Home/components/NavbarGlobalSearch.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/views/Home/components/NavbarGlobalSearch.tsx b/src/views/Home/components/NavbarGlobalSearch.tsx index ae2ea02dc..260598886 100644 --- a/src/views/Home/components/NavbarGlobalSearch.tsx +++ b/src/views/Home/components/NavbarGlobalSearch.tsx @@ -6,6 +6,7 @@ import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; import { SearchResult } from '@internxt/sdk/dist/drive/storage/types'; import { defaultSearchFilters, searchItems, SearchFileCategory, SearchFilters } from '../services'; import { ArrowSquareOutIcon, GearIcon, GiftIcon, MagnifyingGlassIcon, XIcon } from '@phosphor-icons/react'; +import { Loader } from '@internxt/ui'; import AccountPopover from './AccountPopover'; import referralService from 'services/referral.service'; import i18next from 'i18next'; @@ -64,6 +65,13 @@ const getClearButtonClassName = (query: string, openSearchBox: boolean) => { return `${baseClass} ${visibilityClass}`; }; +const getSearchLoaderClassName = (loadingSearch: boolean, openSearchBox: boolean) => { + const baseClass = 'absolute right-9 top-1/2 z-1 -translate-y-1/2 transition-all duration-100 ease-out'; + const isHidden = !loadingSearch || !openSearchBox; + const visibilityClass = isHidden ? 'opacity-0' : ''; + return `${baseClass} ${visibilityClass}`; +}; + const getSearchResultsClassName = (openSearchBox: boolean) => { const baseClass = 'absolute top-12 z-10 flex h-80 w-full max-w-screen-sm origin-top flex-col rounded-xl bg-surface text-gray-100 shadow-subtle-hard ring-1 ring-gray-10 transition-all duration-150 ease-out dark:bg-gray-5'; @@ -309,7 +317,7 @@ const Navbar = (props: NavbarProps) => { spellCheck="false" type="text" value={query} - className="inxt-input left-icon h-10 w-full appearance-none rounded-lg border border-transparent bg-gray-5 px-9 text-lg text-gray-100 placeholder-gray-60 outline-none ring-1 ring-gray-10 transition-all duration-150 ease-out hover:shadow-sm hover:ring-gray-20 focus:border-primary focus:bg-surface focus:placeholder-gray-80 focus:shadow-none focus:ring-3 focus:ring-primary/10 dark:focus:bg-gray-1 dark:focus:ring-primary/20" + className="inxt-input left-icon h-10 w-full appearance-none rounded-lg border border-transparent bg-gray-5 pl-9 pr-16 text-lg text-gray-100 placeholder-gray-60 outline-none ring-1 ring-gray-10 transition-all duration-150 ease-out hover:shadow-sm hover:ring-gray-20 focus:border-primary focus:bg-surface focus:placeholder-gray-80 focus:shadow-none focus:ring-3 focus:ring-primary/10 dark:focus:bg-gray-1 dark:focus:ring-primary/20" onChange={(e) => { setQuery(e.target.value); handleSearch(); @@ -334,6 +342,11 @@ const Navbar = (props: NavbarProps) => { placeholder={translate('general.searchBar.placeholder')} />
{isMacOs ? '⌘F' : 'Ctrl F'}
+ { From 6f96c31c753f8a83777da0fd20e7cd657594544f Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Tue, 4 Aug 2026 12:30:38 +0200 Subject: [PATCH 09/12] [PB-6258] fix: only mark the date and size filters as applied when they have a value --- .../Home/components/NavbarGlobalSearch.tsx | 13 +++++++-- .../Home/components/SearchDateFilter.tsx | 6 ++-- .../Home/components/SearchSizeFilter.tsx | 5 ++-- src/views/Home/utils/dateFilterUtils.test.ts | 22 +++++++++++++- src/views/Home/utils/dateFilterUtils.ts | 5 ++++ src/views/Home/utils/sizeFilterUtils.test.ts | 29 ++++++++++++++++++- src/views/Home/utils/sizeFilterUtils.ts | 5 ++++ 7 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/views/Home/components/NavbarGlobalSearch.tsx b/src/views/Home/components/NavbarGlobalSearch.tsx index 260598886..9933edf05 100644 --- a/src/views/Home/components/NavbarGlobalSearch.tsx +++ b/src/views/Home/components/NavbarGlobalSearch.tsx @@ -155,7 +155,12 @@ const Navbar = (props: NavbarProps) => { const applyDateFilter = (preset: SearchDatePreset, specific: SpecificDateRange) => { setDatePreset(preset); setSpecificDates(specific); - setFilters((current) => ({ ...current, ...datePresetToRange(preset, specific) })); + setFilters((current) => { + const range = datePresetToRange(preset, specific); + const unchanged = + range.modifiedAfter === current.modifiedAfter && range.modifiedBefore === current.modifiedBefore; + return unchanged ? current : { ...current, ...range }; + }); }; const selectDatePreset = (preset: SearchDatePreset) => { @@ -170,7 +175,11 @@ const Navbar = (props: NavbarProps) => { const applySizeFilter = (preset: SearchSizePreset, custom: CustomSizeRange) => { setSizePreset(preset); setCustomSize(custom); - setFilters((current) => ({ ...current, ...sizePresetToRange(preset, custom) })); + setFilters((current) => { + const range = sizePresetToRange(preset, custom); + const unchanged = range.minSize === current.minSize && range.maxSize === current.maxSize; + return unchanged ? current : { ...current, ...range }; + }); }; const selectSizePreset = (preset: SearchSizePreset) => { diff --git a/src/views/Home/components/SearchDateFilter.tsx b/src/views/Home/components/SearchDateFilter.tsx index e4885cdf4..e45f29792 100644 --- a/src/views/Home/components/SearchDateFilter.tsx +++ b/src/views/Home/components/SearchDateFilter.tsx @@ -4,7 +4,7 @@ import dayjs, { Dayjs } from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import { useEffect, useState } from 'react'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { DATE_PRESET_ITEMS, SearchDatePreset, SpecificDateRange } from '../utils/dateFilterUtils'; +import { DATE_PRESET_ITEMS, isDateFilterActive, SearchDatePreset, SpecificDateRange } from '../utils/dateFilterUtils'; import DateCalendar from './DateCalendar'; import DropdownCloseObserver from './DropdownCloseObserver'; import SearchFilterPill from './SearchFilterPill'; @@ -106,7 +106,7 @@ const SearchDateFilter = ({ onClose, }: SearchDateFilterProps): JSX.Element => { const { translate } = useTranslationContext(); - const isAnyDate = preset === 'any'; + const isFiltering = isDateFilterActive(preset, specific); const presetYears: Partial> = { thisYear: dayjs().year(), lastYear: dayjs().year() - 1, @@ -120,7 +120,7 @@ const SearchDateFilter = ({ { const { translate } = useTranslationContext(); - const isAnySize = preset === 'any'; + const isFiltering = isSizeFilterActive(preset, custom); return ( @@ -150,7 +151,7 @@ const SearchSizeFilter = ({ { beforeEach(() => { @@ -98,3 +98,23 @@ describe('changeSpecificDate', () => { expect(next.before?.isSame(dayjs('2026-07-10'), 'day')).toBe(true); }); }); + +describe('isDateFilterActive', () => { + test('When the preset is any, then the filter is not active', () => { + expect(isDateFilterActive('any', {})).toBe(false); + }); + + test('When a bounded preset is selected, then the filter is active', () => { + expect(isDateFilterActive('today', {})).toBe(true); + expect(isDateFilterActive('lastYear', {})).toBe(true); + }); + + test('When the specific preset is selected with no dates, then the filter is not active', () => { + expect(isDateFilterActive('specific', {})).toBe(false); + }); + + test('When the specific preset is selected with one date, then the filter is active', () => { + expect(isDateFilterActive('specific', { after: dayjs('2026-07-10') })).toBe(true); + expect(isDateFilterActive('specific', { before: dayjs('2026-07-10') })).toBe(true); + }); +}); diff --git a/src/views/Home/utils/dateFilterUtils.ts b/src/views/Home/utils/dateFilterUtils.ts index b084b761d..04988dfa6 100644 --- a/src/views/Home/utils/dateFilterUtils.ts +++ b/src/views/Home/utils/dateFilterUtils.ts @@ -47,6 +47,11 @@ export const datePresetToRange = ( } }; +export const isDateFilterActive = (preset: SearchDatePreset, specific: SpecificDateRange): boolean => { + const { modifiedAfter, modifiedBefore } = datePresetToRange(preset, specific); + return modifiedAfter !== undefined || modifiedBefore !== undefined; +}; + export const changeSpecificDate = ( current: SpecificDateRange, field: 'after' | 'before', diff --git a/src/views/Home/utils/sizeFilterUtils.test.ts b/src/views/Home/utils/sizeFilterUtils.test.ts index f34f18dc0..449aee5fe 100644 --- a/src/views/Home/utils/sizeFilterUtils.test.ts +++ b/src/views/Home/utils/sizeFilterUtils.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'vitest'; -import { changeCustomSize, CustomSizeRange, emptyCustomSizeRange, sizePresetToRange } from './sizeFilterUtils'; +import { + changeCustomSize, + CustomSizeRange, + emptyCustomSizeRange, + isSizeFilterActive, + sizePresetToRange, +} from './sizeFilterUtils'; describe('sizePresetToRange', () => { test('When the preset is any, then no bounds are returned', () => { @@ -62,3 +68,24 @@ describe('changeCustomSize', () => { expect(changeCustomSize(current, { biggerThan: undefined }).biggerThan).toBeUndefined(); }); }); + +describe('isSizeFilterActive', () => { + test('When the preset is any, then the filter is not active', () => { + expect(isSizeFilterActive('any', emptyCustomSizeRange)).toBe(false); + }); + + test('When a bounded preset is selected, then the filter is active', () => { + expect(isSizeFilterActive('less5mb', emptyCustomSizeRange)).toBe(true); + expect(isSizeFilterActive('more1gb', emptyCustomSizeRange)).toBe(true); + }); + + test('When the custom preset is selected with no bounds, then the filter is not active', () => { + expect(isSizeFilterActive('custom', emptyCustomSizeRange)).toBe(false); + }); + + test('When the custom preset is selected with one bound, then the filter is active', () => { + const custom: CustomSizeRange = { biggerThan: 10, biggerThanUnit: 'KB', smallerThanUnit: 'GB' }; + + expect(isSizeFilterActive('custom', custom)).toBe(true); + }); +}); diff --git a/src/views/Home/utils/sizeFilterUtils.ts b/src/views/Home/utils/sizeFilterUtils.ts index 9f8894497..b21ccb623 100644 --- a/src/views/Home/utils/sizeFilterUtils.ts +++ b/src/views/Home/utils/sizeFilterUtils.ts @@ -60,6 +60,11 @@ export const sizePresetToRange = ( } }; +export const isSizeFilterActive = (preset: SearchSizePreset, custom: CustomSizeRange): boolean => { + const { minSize, maxSize } = sizePresetToRange(preset, custom); + return minSize !== undefined || maxSize !== undefined; +}; + export const changeCustomSize = (current: CustomSizeRange, changes: Partial): CustomSizeRange => { const next = { ...current, ...changes }; const { minSize, maxSize } = sizePresetToRange('custom', next); From 4c29b887ec0c03c40b1b9ffcd5bc474f3caa66e7 Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Tue, 4 Aug 2026 12:34:22 +0200 Subject: [PATCH 10/12] [PB-6258] fix: accept only digits in the specific date inputs --- .../Home/components/SearchDateFilter.tsx | 16 +++++++--- src/views/Home/utils/dateFilterUtils.test.ts | 30 ++++++++++++++++++- src/views/Home/utils/dateFilterUtils.ts | 5 ++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/views/Home/components/SearchDateFilter.tsx b/src/views/Home/components/SearchDateFilter.tsx index e45f29792..151d52318 100644 --- a/src/views/Home/components/SearchDateFilter.tsx +++ b/src/views/Home/components/SearchDateFilter.tsx @@ -4,7 +4,13 @@ import dayjs, { Dayjs } from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import { useEffect, useState } from 'react'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { DATE_PRESET_ITEMS, isDateFilterActive, SearchDatePreset, SpecificDateRange } from '../utils/dateFilterUtils'; +import { + DATE_PRESET_ITEMS, + formatDateInput, + isDateFilterActive, + SearchDatePreset, + SpecificDateRange, +} from '../utils/dateFilterUtils'; import DateCalendar from './DateCalendar'; import DropdownCloseObserver from './DropdownCloseObserver'; import SearchFilterPill from './SearchFilterPill'; @@ -40,12 +46,13 @@ const SearchDateInput = ({ label, value, minDate, maxDate, onChange, onEnter }: }, [value?.valueOf()]); const handleTextChange = (nextText: string) => { - setText(nextText); - if (nextText === '') { + const nextDate = formatDateInput(nextText); + setText(nextDate); + if (nextDate === '') { onChange(undefined); return; } - const parsed = dayjs(nextText, DATE_INPUT_FORMAT, true); + const parsed = dayjs(nextDate, DATE_INPUT_FORMAT, true); if (parsed.isValid() && !parsed.isAfter(dayjs(), 'day')) onChange(parsed); }; @@ -55,6 +62,7 @@ const SearchDateInput = ({ label, value, minDate, maxDate, onChange, onEnter }:
{ beforeEach(() => { @@ -118,3 +124,25 @@ describe('isDateFilterActive', () => { expect(isDateFilterActive('specific', { before: dayjs('2026-07-10') })).toBe(true); }); }); + +describe('formatDateInput', () => { + test('When only digits are typed, then the separators are added as the date is completed', () => { + expect(formatDateInput('0')).toBe('0'); + expect(formatDateInput('010')).toBe('01/0'); + expect(formatDateInput('01072026')).toBe('01/07/2026'); + }); + + test('When characters that are not digits are typed, then they are dropped', () => { + expect(formatDateInput('abc')).toBe(''); + expect(formatDateInput('01-07-2026')).toBe('01/07/2026'); + expect(formatDateInput('1a/b7')).toBe('17'); + }); + + test('When more than eight digits are typed, then the extra ones are dropped', () => { + expect(formatDateInput('010720261234')).toBe('01/07/2026'); + }); + + test('When the text is emptied, then an empty string is returned', () => { + expect(formatDateInput('')).toBe(''); + }); +}); diff --git a/src/views/Home/utils/dateFilterUtils.ts b/src/views/Home/utils/dateFilterUtils.ts index 04988dfa6..60989d2fb 100644 --- a/src/views/Home/utils/dateFilterUtils.ts +++ b/src/views/Home/utils/dateFilterUtils.ts @@ -47,6 +47,11 @@ export const datePresetToRange = ( } }; +export const formatDateInput = (text: string): string => { + const digits = text.replace(/\D/g, '').slice(0, 8); + return [digits.slice(0, 2), digits.slice(2, 4), digits.slice(4, 8)].filter((part) => part.length > 0).join('/'); +}; + export const isDateFilterActive = (preset: SearchDatePreset, specific: SpecificDateRange): boolean => { const { modifiedAfter, modifiedBefore } = datePresetToRange(preset, specific); return modifiedAfter !== undefined || modifiedBefore !== undefined; From 04388a695921fa0650ba46461afe690608f60a41 Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Tue, 4 Aug 2026 13:04:04 +0200 Subject: [PATCH 11/12] [PB-6258] fix: switch between search filter dropdowns with a single click --- .../Home/components/DropdownCloseObserver.tsx | 17 ----------------- .../Home/components/DropdownOpenObserver.tsx | 17 +++++++++++++++++ .../Home/components/NavbarGlobalSearch.tsx | 19 ++++++++++++++----- .../Home/components/SearchDateFilter.tsx | 8 ++++---- .../Home/components/SearchSizeFilter.tsx | 8 ++++---- .../Home/components/SearchTypeFilter.tsx | 9 +++++---- 6 files changed, 44 insertions(+), 34 deletions(-) delete mode 100644 src/views/Home/components/DropdownCloseObserver.tsx create mode 100644 src/views/Home/components/DropdownOpenObserver.tsx diff --git a/src/views/Home/components/DropdownCloseObserver.tsx b/src/views/Home/components/DropdownCloseObserver.tsx deleted file mode 100644 index 1e9775dc7..000000000 --- a/src/views/Home/components/DropdownCloseObserver.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect, useRef } from 'react'; - -interface DropdownCloseObserverProps { - open: boolean; - onClose: () => void; -} - -const DropdownCloseObserver = ({ open, onClose }: DropdownCloseObserverProps): null => { - const wasOpen = useRef(false); - useEffect(() => { - if (wasOpen.current && !open) onClose(); - wasOpen.current = open; - }, [open]); - return null; -}; - -export default DropdownCloseObserver; diff --git a/src/views/Home/components/DropdownOpenObserver.tsx b/src/views/Home/components/DropdownOpenObserver.tsx new file mode 100644 index 000000000..ede2d9f62 --- /dev/null +++ b/src/views/Home/components/DropdownOpenObserver.tsx @@ -0,0 +1,17 @@ +import { useEffect, useRef } from 'react'; + +interface DropdownOpenObserverProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const DropdownOpenObserver = ({ open, onOpenChange }: DropdownOpenObserverProps): null => { + const wasOpen = useRef(false); + useEffect(() => { + if (wasOpen.current !== open) onOpenChange(open); + wasOpen.current = open; + }, [open]); + return null; +}; + +export default DropdownOpenObserver; diff --git a/src/views/Home/components/NavbarGlobalSearch.tsx b/src/views/Home/components/NavbarGlobalSearch.tsx index 9933edf05..0b1a8e88b 100644 --- a/src/views/Home/components/NavbarGlobalSearch.tsx +++ b/src/views/Home/components/NavbarGlobalSearch.tsx @@ -44,6 +44,8 @@ interface NavbarProps { plan: PlanState; } +type SearchFilterName = 'type' | 'date' | 'size'; + const getSearchBoxClassName = (openSearchBox: boolean) => { const baseClass = 'relative flex w-full items-center rounded-lg transition-all duration-150 ease-out'; const widthClass = openSearchBox ? 'max-w-screen-sm' : 'max-w-sm'; @@ -194,9 +196,16 @@ const Navbar = (props: NavbarProps) => { const openSearchBoxRef = useRef(openSearchBox); openSearchBoxRef.current = openSearchBox; - const refocusSearchInput = () => { + const openDropdown = useRef(null); + + const handleDropdownOpenChange = (name: SearchFilterName) => (open: boolean) => { + if (open) { + openDropdown.current = name; + return; + } + if (openDropdown.current === name) openDropdown.current = null; setTimeout(() => { - if (openSearchBoxRef.current) searchInput.current?.focus(); + if (openSearchBoxRef.current && openDropdown.current === null) searchInput.current?.focus(); }, 0); }; @@ -378,21 +387,21 @@ const Navbar = (props: NavbarProps) => { selected={filters.type} onToggle={toggleTypeFilter} onToggleAll={toggleAllTypeFilters} - onClose={refocusSearchInput} + onOpenChange={handleDropdownOpenChange('type')} />
diff --git a/src/views/Home/components/SearchDateFilter.tsx b/src/views/Home/components/SearchDateFilter.tsx index 151d52318..dc64b16f2 100644 --- a/src/views/Home/components/SearchDateFilter.tsx +++ b/src/views/Home/components/SearchDateFilter.tsx @@ -12,7 +12,7 @@ import { SpecificDateRange, } from '../utils/dateFilterUtils'; import DateCalendar from './DateCalendar'; -import DropdownCloseObserver from './DropdownCloseObserver'; +import DropdownOpenObserver from './DropdownOpenObserver'; import SearchFilterPill from './SearchFilterPill'; import SearchFilterRadioList from './SearchFilterRadioList'; @@ -25,7 +25,7 @@ interface SearchDateFilterProps { specific: SpecificDateRange; onSelectPreset: (preset: SearchDatePreset) => void; onChangeDate: (field: 'after' | 'before', date?: Dayjs) => void; - onClose: () => void; + onOpenChange: (open: boolean) => void; } interface SearchDateInputProps { @@ -111,7 +111,7 @@ const SearchDateFilter = ({ specific, onSelectPreset, onChangeDate, - onClose, + onOpenChange, }: SearchDateFilterProps): JSX.Element => { const { translate } = useTranslationContext(); const isFiltering = isDateFilterActive(preset, specific); @@ -124,7 +124,7 @@ const SearchDateFilter = ({ {({ open, close }) => ( <> - + void; onChangeCustom: (changes: Partial) => void; - onClose: () => void; + onOpenChange: (open: boolean) => void; } const SearchSizeFilter = ({ @@ -138,7 +138,7 @@ const SearchSizeFilter = ({ custom, onSelectPreset, onChangeCustom, - onClose, + onOpenChange, }: SearchSizeFilterProps): JSX.Element => { const { translate } = useTranslationContext(); const isFiltering = isSizeFilterActive(preset, custom); @@ -147,7 +147,7 @@ const SearchSizeFilter = ({ {({ open, close }) => ( <> - + void; onToggleAll: () => void; - onClose: () => void; + onOpenChange: (open: boolean) => void; } -const SearchTypeFilter = ({ selected, onToggle, onToggleAll, onClose }: SearchTypeFilterProps): JSX.Element => { +const SearchTypeFilter = ({ selected, onToggle, onToggleAll, onOpenChange }: SearchTypeFilterProps): JSX.Element => { const { translate } = useTranslationContext(); const allSelected = areAllTypeCategoriesSelected(selected); const isFiltering = isTypeFilterActive(selected); @@ -24,7 +24,7 @@ const SearchTypeFilter = ({ selected, onToggle, onToggleAll, onClose }: SearchTy {({ open }) => ( <> - + From a4fde627527e42279f2e7a23aa1b8d61dad1908b Mon Sep 17 00:00:00 2001 From: victor-ferro Date: Tue, 4 Aug 2026 14:51:20 +0200 Subject: [PATCH 12/12] [PB-6258] fix: keep custom size values within the exact integer range --- .../Home/components/SearchSizeFilter.tsx | 2 + src/views/Home/utils/sizeFilterUtils.test.ts | 46 +++++++++++++++++++ src/views/Home/utils/sizeFilterUtils.ts | 14 +++++- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/views/Home/components/SearchSizeFilter.tsx b/src/views/Home/components/SearchSizeFilter.tsx index c692210af..18f0e1f05 100644 --- a/src/views/Home/components/SearchSizeFilter.tsx +++ b/src/views/Home/components/SearchSizeFilter.tsx @@ -5,6 +5,7 @@ import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { CustomSizeRange, isSizeFilterActive, + isSizeWithinLimit, SearchSizePreset, SIZE_PRESET_ITEMS, SIZE_UNIT_ITEMS, @@ -76,6 +77,7 @@ const SearchSizeInput = ({ const handleTextChange = (nextText: string) => { const digits = nextText.replace(/\D/g, ''); const nextValue = digits === '' ? undefined : Number(digits); + if (nextValue !== undefined && !isSizeWithinLimit(nextValue, unit)) return; setText(digits); if (nextValue !== value) onValueChange(nextValue); }; diff --git a/src/views/Home/utils/sizeFilterUtils.test.ts b/src/views/Home/utils/sizeFilterUtils.test.ts index 449aee5fe..4ae6f0ded 100644 --- a/src/views/Home/utils/sizeFilterUtils.test.ts +++ b/src/views/Home/utils/sizeFilterUtils.test.ts @@ -4,6 +4,8 @@ import { CustomSizeRange, emptyCustomSizeRange, isSizeFilterActive, + isSizeWithinLimit, + maxSizeForUnit, sizePresetToRange, } from './sizeFilterUtils'; @@ -67,6 +69,30 @@ describe('changeCustomSize', () => { expect(changeCustomSize(current, { biggerThan: undefined }).biggerThan).toBeUndefined(); }); + + test('When the unit change leaves the bound past the limit, then the bound is clamped to the maximum for that unit', () => { + const current: CustomSizeRange = { + biggerThan: Number.MAX_SAFE_INTEGER, + biggerThanUnit: 'B', + smallerThanUnit: 'GB', + }; + + const next = changeCustomSize(current, { biggerThanUnit: 'KB' }); + + expect(next.biggerThanUnit).toBe('KB'); + expect(next.biggerThan).toBe(maxSizeForUnit('KB')); + }); + + test('When the bound already fits the new unit, then it is kept as it is', () => { + const current: CustomSizeRange = { biggerThan: 10, biggerThanUnit: 'B', smallerThanUnit: 'GB' }; + + expect(changeCustomSize(current, { biggerThanUnit: 'TB' })).toEqual({ + biggerThan: 10, + biggerThanUnit: 'TB', + smallerThan: undefined, + smallerThanUnit: 'GB', + }); + }); }); describe('isSizeFilterActive', () => { @@ -89,3 +115,23 @@ describe('isSizeFilterActive', () => { expect(isSizeFilterActive('custom', custom)).toBe(true); }); }); + +describe('maxSizeForUnit', () => { + test('When a unit is given, then the maximum is what still fits the exact integer range', () => { + expect(maxSizeForUnit('B')).toBe(Number.MAX_SAFE_INTEGER); + expect(maxSizeForUnit('KB')).toBe(8796093022207); + expect(maxSizeForUnit('TB')).toBe(8191); + }); +}); + +describe('isSizeWithinLimit', () => { + test('When the value in bytes stays exact, then it is within the limit', () => { + expect(isSizeWithinLimit(500, 'MB')).toBe(true); + expect(isSizeWithinLimit(8191, 'TB')).toBe(true); + }); + + test('When the value in bytes exceeds the exact integer range, then it is not within the limit', () => { + expect(isSizeWithinLimit(8192, 'TB')).toBe(false); + expect(isSizeWithinLimit(Number('1231423523523465612345'), 'B')).toBe(false); + }); +}); diff --git a/src/views/Home/utils/sizeFilterUtils.ts b/src/views/Home/utils/sizeFilterUtils.ts index b21ccb623..2aafcc42e 100644 --- a/src/views/Home/utils/sizeFilterUtils.ts +++ b/src/views/Home/utils/sizeFilterUtils.ts @@ -65,8 +65,20 @@ export const isSizeFilterActive = (preset: SearchSizePreset, custom: CustomSizeR return minSize !== undefined || maxSize !== undefined; }; +export const maxSizeForUnit = (unit: SizeUnit): number => Math.floor(Number.MAX_SAFE_INTEGER / UNIT_BYTES[unit]); + +export const isSizeWithinLimit = (value: number, unit: SizeUnit): boolean => value <= maxSizeForUnit(unit); + +const clampToUnit = (value: number | undefined, unit: SizeUnit): number | undefined => + value === undefined ? value : Math.min(value, maxSizeForUnit(unit)); + export const changeCustomSize = (current: CustomSizeRange, changes: Partial): CustomSizeRange => { - const next = { ...current, ...changes }; + const merged = { ...current, ...changes }; + const next = { + ...merged, + biggerThan: clampToUnit(merged.biggerThan, merged.biggerThanUnit), + smallerThan: clampToUnit(merged.smallerThan, merged.smallerThanUnit), + }; const { minSize, maxSize } = sizePresetToRange('custom', next); if (minSize !== undefined && maxSize !== undefined && maxSize < minSize) return current; return next;