diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index dd70915a9..c44a14872 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -308,6 +308,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 7da88b0fc..618ba28b2 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -329,6 +329,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 1ee0a0ae4..cc64d395f 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -308,6 +308,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 7de4ba7aa..ebcfd170a 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -308,6 +308,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 470712d46..7f6957753 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -329,6 +329,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 eeca63b57..70f80142c 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -308,6 +308,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 f25d1c55e..67be42fc3 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -329,6 +329,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 9cc8d410b..e30f9db53 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -329,6 +329,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/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 1ed34911d..0b1a8e88b 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'; @@ -21,8 +22,16 @@ import NotFoundState from './NotFoundState'; import EmptyState from './EmptyState'; import { toggleAllTypeCategories, 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'; @@ -35,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'; @@ -56,6 +67,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'; @@ -85,6 +103,8 @@ const Navbar = (props: NavbarProps) => { const [filters, setFilters] = useState(defaultSearchFilters); 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([]); @@ -137,22 +157,55 @@ 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) => { 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); + setCustomSize(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) => { + 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; - 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); }; @@ -282,7 +335,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(); @@ -307,6 +360,11 @@ const Navbar = (props: NavbarProps) => { placeholder={translate('general.searchBar.placeholder')} />
{isMacOs ? '⌘F' : 'Ctrl F'}
+ { @@ -324,19 +382,26 @@ const Navbar = (props: NavbarProps) => { onMouseEnter={() => setPreventBlur(true)} onMouseLeave={() => setPreventBlur(false)} > -
+
+
diff --git a/src/views/Home/components/SearchDateFilter.tsx b/src/views/Home/components/SearchDateFilter.tsx index 88acee423..dc64b16f2 100644 --- a/src/views/Home/components/SearchDateFilter.tsx +++ b/src/views/Home/components/SearchDateFilter.tsx @@ -1,14 +1,20 @@ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; -import { RadioButton } from '@internxt/ui'; -import { CaretDownIcon, XIcon } from '@phosphor-icons/react'; +import { XIcon } from '@phosphor-icons/react'; 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, + formatDateInput, + isDateFilterActive, + SearchDatePreset, + SpecificDateRange, +} from '../utils/dateFilterUtils'; import DateCalendar from './DateCalendar'; -import DropdownCloseObserver from './DropdownCloseObserver'; -import SearchFilterRow from './SearchFilterRow'; +import DropdownOpenObserver from './DropdownOpenObserver'; +import SearchFilterPill from './SearchFilterPill'; +import SearchFilterRadioList from './SearchFilterRadioList'; dayjs.extend(customParseFormat); @@ -19,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 { @@ -28,9 +34,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); @@ -39,12 +46,13 @@ const SearchDateInput = ({ label, value, minDate, maxDate, onChange }: SearchDat }, [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); }; @@ -54,11 +62,18 @@ const SearchDateInput = ({ label, value, minDate, maxDate, onChange }: SearchDat
handleTextChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + onEnter(); + } + }} onFocus={() => setShowCalendar(true)} onBlur={() => { setShowCalendar(false); @@ -96,54 +111,39 @@ const SearchDateFilter = ({ specific, onSelectPreset, onChangeDate, - onClose, + onOpenChange, }: 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, }; return ( - - {({ open }) => ( + + {({ open, close }) => ( <> - + - {translate('general.searchBar.filters.date.dateModified')} - - + as={SearchFilterPill} + label={translate('general.searchBar.filters.date.dateModified')} + active={isFiltering} + /> -
event.preventDefault()} - onClick={(event) => event.preventDefault()} - > - onSelectPreset('any')}> - onSelectPreset('any')} /> -

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

-
-
- {DATE_PRESET_ITEMS.map(({ id, labelKey }) => ( - onSelectPreset(id)} key={id}> - 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' && ( <>
@@ -153,12 +153,14 @@ const SearchDateFilter = ({ value={specific.after} maxDate={specific.before} onChange={(date) => onChangeDate('after', date)} + onEnter={close} /> onChangeDate('before', date)} + onEnter={close} />
diff --git a/src/views/Home/components/SearchFilterPill.tsx b/src/views/Home/components/SearchFilterPill.tsx new file mode 100644 index 000000000..2fb0c7f2c --- /dev/null +++ b/src/views/Home/components/SearchFilterPill.tsx @@ -0,0 +1,27 @@ +import { CaretDownIcon } from '@phosphor-icons/react'; +import { ButtonHTMLAttributes, forwardRef } from 'react'; + +interface SearchFilterPillProps extends ButtonHTMLAttributes { + label: string; + active: boolean; +} + +const SearchFilterPill = forwardRef(({ label, active, ...props }, ref) => ( + +)); + +SearchFilterPill.displayName = 'SearchFilterPill'; + +export default SearchFilterPill; diff --git a/src/views/Home/components/SearchFilterRadioList.tsx b/src/views/Home/components/SearchFilterRadioList.tsx new file mode 100644 index 000000000..833c269d7 --- /dev/null +++ b/src/views/Home/components/SearchFilterRadioList.tsx @@ -0,0 +1,32 @@ +import { RadioButton } from '@internxt/ui'; +import SearchFilterRow from './SearchFilterRow'; + +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')}> + onSelect('any')} /> +

{anyLabel}

+
+
+ {items.map(({ id, label }) => ( + onSelect(id)} key={id}> + 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..18f0e1f05 --- /dev/null +++ b/src/views/Home/components/SearchSizeFilter.tsx @@ -0,0 +1,202 @@ +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, + isSizeFilterActive, + isSizeWithinLimit, + SearchSizePreset, + SIZE_PRESET_ITEMS, + SIZE_UNIT_ITEMS, + SizeUnit, +} from '../utils/sizeFilterUtils'; +import DropdownOpenObserver from './DropdownOpenObserver'; +import SearchFilterPill from './SearchFilterPill'; +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); + if (nextValue !== undefined && !isSizeWithinLimit(nextValue, unit)) return; + setText(digits); + if (nextValue !== value) onValueChange(nextValue); + }; + + return ( +
+
+

{label}

+
+ ); +}; + +interface SearchSizeFilterProps { + preset: SearchSizePreset; + custom: CustomSizeRange; + onSelectPreset: (preset: SearchSizePreset) => void; + onChangeCustom: (changes: Partial) => void; + onOpenChange: (open: boolean) => void; +} + +const SearchSizeFilter = ({ + preset, + custom, + onSelectPreset, + onChangeCustom, + onOpenChange, +}: SearchSizeFilterProps): JSX.Element => { + const { translate } = useTranslationContext(); + const isFiltering = isSizeFilterActive(preset, custom); + + return ( + + {({ open, close }) => ( + <> + + + + + ({ + 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/components/SearchTypeFilter.tsx b/src/views/Home/components/SearchTypeFilter.tsx index 7a4107833..6b818f3e8 100644 --- a/src/views/Home/components/SearchTypeFilter.tsx +++ b/src/views/Home/components/SearchTypeFilter.tsx @@ -1,43 +1,39 @@ import { Menu, MenuButton, MenuItems } from '@headlessui/react'; import { SearchFileCategory } from '../services'; import { Checkbox } from '@internxt/ui'; -import { CaretDownIcon } from '@phosphor-icons/react'; import iconService from 'app/drive/services/icon.service'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { areAllTypeCategoriesSelected, isTypeFilterActive, TYPE_FILTER_ITEMS } from '../utils/typeFilterUtils'; -import DropdownCloseObserver from './DropdownCloseObserver'; +import DropdownOpenObserver from './DropdownOpenObserver'; +import SearchFilterPill from './SearchFilterPill'; import SearchFilterRow from './SearchFilterRow'; interface SearchTypeFilterProps { selected: SearchFileCategory[]; onToggle: (category: SearchFileCategory) => 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); return ( - + {({ open }) => ( <> - + - {translate('general.searchBar.filters.attachments')} - - + as={SearchFilterPill} + label={translate('general.searchBar.filters.attachments')} + active={isFiltering} + /> diff --git a/src/views/Home/utils/dateFilterUtils.test.ts b/src/views/Home/utils/dateFilterUtils.test.ts index caee042c3..d60918d69 100644 --- a/src/views/Home/utils/dateFilterUtils.test.ts +++ b/src/views/Home/utils/dateFilterUtils.test.ts @@ -1,6 +1,12 @@ import dayjs from 'dayjs'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { changeSpecificDate, datePresetToRange, SpecificDateRange } from './dateFilterUtils'; +import { + changeSpecificDate, + datePresetToRange, + formatDateInput, + isDateFilterActive, + SpecificDateRange, +} from './dateFilterUtils'; describe('datePresetToRange', () => { beforeEach(() => { @@ -98,3 +104,45 @@ 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); + }); +}); + +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 b084b761d..60989d2fb 100644 --- a/src/views/Home/utils/dateFilterUtils.ts +++ b/src/views/Home/utils/dateFilterUtils.ts @@ -47,6 +47,16 @@ 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; +}; + 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 new file mode 100644 index 000000000..4ae6f0ded --- /dev/null +++ b/src/views/Home/utils/sizeFilterUtils.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'vitest'; +import { + changeCustomSize, + CustomSizeRange, + emptyCustomSizeRange, + isSizeFilterActive, + isSizeWithinLimit, + maxSizeForUnit, + 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(); + }); + + 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', () => { + 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); + }); +}); + +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 new file mode 100644 index 000000000..2aafcc42e --- /dev/null +++ b/src/views/Home/utils/sizeFilterUtils.ts @@ -0,0 +1,85 @@ +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 isSizeFilterActive = (preset: SearchSizePreset, custom: CustomSizeRange): boolean => { + const { minSize, maxSize } = sizePresetToRange(preset, custom); + 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 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; +};