[MOB-68] Search (#2434)

* wip: search plugin query & save

* wip

* wip mob search

* mob search - explorer filtering & support name, extension search

* Mobile search

* cleanup

* code improvements & type to search all locations

* Update Search.tsx

* ts

* code doc and type tweak

* lint

* move onSuccess
This commit is contained in:
ameer2468
2024-05-02 00:52:14 +03:00
committed by GitHub
parent 2fcb287e4e
commit b29deff592
23 changed files with 524 additions and 131 deletions

View File

@@ -1,8 +1,8 @@
import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript/src/types';
import { useNavigation } from '@react-navigation/native';
import { Tag, useLibraryQuery } from '@sd/client';
import { useRef } from 'react';
import { ColorValue, Pressable, Text, View } from 'react-native';
import { Tag, useLibraryQuery } from '@sd/client';
import { ModalRef } from '~/components/layout/Modal';
import { tw, twStyle } from '~/lib/tailwind';
@@ -19,11 +19,9 @@ type DrawerTagItemProps = {
const DrawerTagItem: React.FC<DrawerTagItemProps> = (props) => {
const { tagName, tagColor, onPress } = props;
return (
<Pressable onPress={onPress} testID="drawer-tag">
<Pressable style={tw`flex-1`} onPress={onPress} testID="drawer-tag">
<View
style={twStyle(
'h-auto flex-row items-center gap-2 rounded-md border border-app-inputborder/50 bg-app-darkBox p-2'
)}
style={tw`flex-row items-center gap-2 rounded-md border border-app-inputborder/50 bg-app-darkBox p-2`}
>
<View style={twStyle('h-4 w-4 rounded-full', { backgroundColor: tagColor })} />
<Text style={twStyle('text-xs font-medium text-ink')} numberOfLines={1}>
@@ -50,7 +48,7 @@ const DrawerTags = () => {
>
<View style={tw`mt-2 flex-row justify-between gap-1`}>
<TagColumn tags={tagData} dataAmount={[0, 2]} />
<TagColumn tags={tagData} dataAmount={[2, 4]} />
{tagData?.length > 2 && <TagColumn tags={tagData} dataAmount={[2, 4]} />}
</View>
<View style={tw`mt-2 flex-row flex-wrap gap-1`}>
{/* Add Tag */}
@@ -92,8 +90,10 @@ interface TagColumnProps {
const TagColumn = ({ tags, dataAmount }: TagColumnProps) => {
const navigation = useNavigation<DrawerNavigationHelpers>();
return (
<View style={tw`w-[49%] flex-col gap-1`}>
{tags?.slice(dataAmount[0], dataAmount[1]).map((tag: any) => (
<View style={twStyle(`gap-1`,
tags && tags.length > 2 ? 'w-[49%] flex-col' : 'flex-1 flex-row'
)}>
{tags?.slice(dataAmount[0], dataAmount[1]).map((tag: Tag) => (
<DrawerTagItem
key={tag.id}
tagName={tag.name!}

View File

@@ -1,14 +1,14 @@
import { useNavigation } from '@react-navigation/native';
import { SearchData, isPath, type ExplorerItem } from '@sd/client';
import { FlashList } from '@shopify/flash-list';
import { UseInfiniteQueryResult } from '@tanstack/react-query';
import { ActivityIndicator, Pressable } from 'react-native';
import { isPath, SearchData, type ExplorerItem } from '@sd/client';
import Layout from '~/constants/Layout';
import { tw } from '~/lib/tailwind';
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
import { useExplorerStore } from '~/stores/explorerStore';
import { useActionsModalStore } from '~/stores/modalStore';
import { tw } from '~/lib/tailwind';
import ScreenContainer from '../layout/ScreenContainer';
import FileItem from './FileItem';
import FileRow from './FileRow';
@@ -21,21 +21,29 @@ type ExplorerProps = {
loadMore: () => void;
query: UseInfiniteQueryResult<SearchData<ExplorerItem>>;
count?: number;
};
empty?: never;
isEmpty?: never;
}
const Explorer = (props: ExplorerProps) => {
type Props = |
ExplorerProps
| ({
// isEmpty and empty are mutually exclusive
emptyComponent: React.ReactElement; // component to show when FlashList has no data
isEmpty: boolean; // if true - show empty component
} & Omit<ExplorerProps, 'empty' | 'isEmpty'>);
const Explorer = (props: Props) => {
const navigation = useNavigation<BrowseStackScreenProps<'Location'>['navigation']>();
const store = useExplorerStore();
const { modalRef, setData } = useActionsModalStore();
function handlePress(data: ExplorerItem) {
if (isPath(data) && data.item.is_dir && data.item.location_id !== null) {
navigation.push('Location', {
id: data.item.location_id,
path: `${data.item.materialized_path}${data.item.name}/`
});
navigation.push('Location', {
id: data.item.location_id,
path: `${data.item.materialized_path}${data.item.name}/`
});
} else {
setData(data);
modalRef.current?.present();
@@ -45,38 +53,44 @@ const Explorer = (props: ExplorerProps) => {
return (
<ScreenContainer tabHeight={props.tabHeight} scrollview={false} style={'gap-0 py-0'}>
<Menu />
{/* Flashlist not supporting empty centering: https://github.com/Shopify/flash-list/discussions/517
So needs to be done this way */}
{/* Items */}
<FlashList
key={store.layoutMode}
numColumns={store.layoutMode === 'grid' ? store.gridNumColumns : 1}
data={props.items ?? []}
keyExtractor={(item) =>
item.type === 'NonIndexedPath'
? item.item.path
: item.type === 'SpacedropPeer'
? item.item.name
: item.item.id.toString()
}
renderItem={({ item }) => (
<Pressable onPress={() => handlePress(item)}>
{store.layoutMode === 'grid' ? (
<FileItem data={item} />
) : (
<FileRow data={item} />
{props.isEmpty ? (
props.emptyComponent
) :
<FlashList
key={store.layoutMode}
numColumns={store.layoutMode === 'grid' ? store.gridNumColumns : 1}
data={props.items ?? []}
keyExtractor={(item) =>
item.type === 'NonIndexedPath'
? item.item.path
: item.type === 'SpacedropPeer'
? item.item.name
: item.item.id.toString()
}
renderItem={({ item }) => (
<Pressable onPress={() => handlePress(item)}>
{store.layoutMode === 'grid' ? (
<FileItem data={item} />
) : (
<FileRow data={item} />
)}
</Pressable>
)}
</Pressable>
)}
contentContainerStyle={tw`px-2 py-5`}
extraData={store.layoutMode}
estimatedItemSize={
store.layoutMode === 'grid'
? Layout.window.width / store.gridNumColumns
: store.listItemSize
}
onEndReached={() => props.loadMore?.()}
onEndReachedThreshold={0.6}
ListFooterComponent={props.query.isFetchingNextPage ? <ActivityIndicator /> : null}
/>
contentContainerStyle={tw`px-2 py-5`}
extraData={store.layoutMode}
estimatedItemSize={
store.layoutMode === 'grid'
? Layout.window.width / store.gridNumColumns
: store.listItemSize
}
onEndReached={() => props.loadMore?.()}
onEndReachedThreshold={0.6}
ListFooterComponent={props.query.isFetchingNextPage ? <ActivityIndicator /> : null}
/>
}
</ScreenContainer>
);
};

View File

@@ -1,9 +1,9 @@
import { useNavigation } from '@react-navigation/native';
import { Location, arraysEqual, byteSize, useOnlineLocations } from '@sd/client';
import { DotsThreeOutlineVertical } from 'phosphor-react-native';
import { useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import { arraysEqual, byteSize, Location, useOnlineLocations } from '@sd/client';
import { tw, twStyle } from '~/lib/tailwind';
import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack';
@@ -63,7 +63,7 @@ const ListLocation = ({ location }: ListLocationProps) => {
</View>
<View style={tw`flex-row items-center gap-3`}>
<View
style={tw`rounded-md border border-app-lightborder bg-app-highlight p-1.5`}
style={tw`rounded-md border border-app-box bg-app p-1.5`}
>
<Text
style={tw`text-left text-xs font-medium text-ink-dull`}

View File

@@ -0,0 +1,56 @@
import { useNavigation } from '@react-navigation/native';
import { useLibraryMutation } from '@sd/client';
import { forwardRef, useState } from 'react';
import { Text, View } from 'react-native';
import { Modal, ModalRef } from '~/components/layout/Modal';
import { Button } from '~/components/primitive/Button';
import { ModalInput } from '~/components/primitive/Input';
import { tw } from '~/lib/tailwind';
import { useSearchStore } from '~/stores/searchStore';
const SaveSearchModal = forwardRef<ModalRef>((_, ref) => {
const [searchName, setSearchName] = useState('');
const navigation = useNavigation();
const searchStore = useSearchStore();
const saveSearch = useLibraryMutation('search.saved.create', {
onSuccess: () => {
searchStore.applyFilters();
navigation.navigate('SearchStack', {
screen: 'Search'
});
}
});
return (
<Modal snapPoints={['22']} title="Save search" ref={ref}>
<View style={tw`p-4`}>
<ModalInput
autoFocus
value={searchName}
onChangeText={(text) => setSearchName(text)}
placeholder="Search Name..."
/>
<Button
disabled={searchName.length === 0}
style={tw`mt-2`}
variant="accent"
onPress={async () => {
await saveSearch.mutateAsync(
{
name: searchName,
filters: JSON.stringify(searchStore.mergedFilters),
description: null,
icon: null,
search: null
}
);
setSearchName('');
}}
>
<Text style={tw`font-medium text-ink`}>Save</Text>
</Button>
</View>
</Modal>
);
});
export default SaveSearchModal;

View File

@@ -20,20 +20,22 @@ import {
KindItem,
SearchFilters,
TagItem,
getSearchStore,
useSearchStore
} from '~/stores/searchStore';
const FiltersBar = () => {
const { filters, appliedFilters } = useSearchStore();
const searchStore = useSearchStore();
const navigation = useNavigation<SearchStackScreenProps<'Filters'>['navigation']>();
const flatListRef = useRef<FlatList>(null);
const appliedFiltersLength = Object.keys(searchStore.appliedFilters).length;
// Scroll to start when there are less than 2 filters.
useEffect(() => {
if (Object.entries(appliedFilters).length < 2) {
flatListRef.current?.scrollToOffset({ animated: true, offset: 0 });
// If there are applied filters, update the searchStore filters
if (appliedFiltersLength > 0) {
Object.assign(getSearchStore().filters, searchStore.appliedFilters);
}
}, [appliedFilters]);
}, [appliedFiltersLength, searchStore.appliedFilters]);
return (
<View
@@ -52,8 +54,13 @@ const FiltersBar = () => {
ref={flatListRef}
showsHorizontalScrollIndicator={false}
horizontal
data={Object.entries(appliedFilters)}
extraData={filters}
onContentSizeChange={() => {
if (flatListRef.current && appliedFiltersLength < 2) {
flatListRef.current.scrollToOffset({ animated: true, offset: 0 });
}
}}
data={Object.entries(searchStore.appliedFilters)}
extraData={searchStore.filters}
keyExtractor={(item) => item[0]}
renderItem={({ item }) => (
<FilterItem filter={item[0] as SearchFilters} value={item[1]} />
@@ -75,6 +82,10 @@ const FilterItem = ({ filter, value }: FilterItemProps) => {
const boxStyle = tw`w-auto flex-row items-center gap-1.5 border border-app-cardborder bg-app-card p-2`;
const filterCapital = filter.charAt(0).toUpperCase() + filter.slice(1);
const searchStore = useSearchStore();
// if the filter value is false or empty, return null i.e "Hidden"
if (!value) return null;
return (
<View style={tw`flex-row gap-0.5`}>
<View style={twStyle(boxStyle, 'rounded-bl-md rounded-tl-md')}>

View File

@@ -1,4 +1,4 @@
import { AnimatePresence } from 'moti';
import { AnimatePresence, MotiView } from 'moti';
import { MotiPressable } from 'moti/interactions';
import {
CircleDashed,
@@ -13,7 +13,7 @@ import { Text, View } from 'react-native';
import Card from '~/components/layout/Card';
import SectionTitle from '~/components/layout/SectionTitle';
import { tw, twStyle } from '~/lib/tailwind';
import { getSearchStore, SearchFilters, useSearchStore } from '~/stores/searchStore';
import { SearchFilters, getSearchStore, useSearchStore } from '~/stores/searchStore';
import Extension from './Extension';
import Kind from './Kind';
@@ -51,12 +51,15 @@ const FiltersList = () => {
const [selectedOptions, setSelectedOptions] = useState<SearchFilters[]>(
Object.keys(searchStore.appliedFilters) as SearchFilters[]
);
const appliedFiltersLength = Object.keys(searchStore.appliedFilters).length;
// If any filters are applied - we need to update the store
// so the UI can reflect the applied filters
useEffect(() => {
Object.assign(getSearchStore().filters, getSearchStore().appliedFilters);
}, []);
//if there are selected filters but not applied reset them
if (appliedFiltersLength === 0) {
getSearchStore().resetFilters();
}
}, [appliedFiltersLength]);
const selectedHandler = useCallback(
(option: Capitalize<SearchFilters>) => {
@@ -80,13 +83,16 @@ const FiltersList = () => {
searchStore.resetFilter(searchFiltersLowercase);
}
},
[selectedOptions, searchStore]
);
[selectedOptions, searchStore])
return (
<View style={tw`gap-10`}>
<SavedSearches />
<View>
<MotiView
from={{ opacity: 0, translateY: 20 }}
animate={{ opacity: 1, translateY: 0 }}
transition={{ type: 'timing', duration: 300 }}
>
<SectionTitle
style={tw`px-6 pb-3`}
title="What are you searching for?"
@@ -139,7 +145,7 @@ const FiltersList = () => {
))}
</View>
</View>
</View>
</MotiView>
{/* conditionally render the selected options - this approach makes sure the animation is right
by not relying on the index position of the object */}
<AnimatePresence>

View File

@@ -1,8 +1,8 @@
import { Location, useLibraryQuery } from '@sd/client';
import { MotiView } from 'moti';
import { memo, useCallback, useMemo } from 'react';
import { FlatList, Pressable, Text, View } from 'react-native';
import { LinearTransition } from 'react-native-reanimated';
import { Location, useLibraryQuery } from '@sd/client';
import { Icon } from '~/components/icons/Icon';
import Card from '~/components/layout/Card';
import Empty from '~/components/layout/Empty';
@@ -77,6 +77,7 @@ const LocationFilter = memo(({ data }: Props) => {
name: data.name as string
});
}, [data.id, data.name, searchStore]);
return (
<Pressable onPress={onPress}>
<Card

View File

@@ -1,7 +1,9 @@
import { useNavigation } from '@react-navigation/native';
import { Plus } from 'phosphor-react-native';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { Platform, Text, View } from 'react-native';
import { ModalRef } from '~/components/layout/Modal';
import SaveSearchModal from '~/components/modal/search/SaveSearchModal';
import { Button } from '~/components/primitive/Button';
import { tw, twStyle } from '~/lib/tailwind';
import { SearchStackScreenProps } from '~/navigation/SearchStack';
@@ -10,6 +12,8 @@ import { getSearchStore, useSearchStore } from '~/stores/searchStore';
const SaveAdd = () => {
const searchStore = useSearchStore();
const navigation = useNavigation<SearchStackScreenProps<'Search'>['navigation']>();
const modalRef = useRef<ModalRef>(null);
const filtersApplied = Object.keys(searchStore.appliedFilters).length > 0;
const buttonDisable = !filtersApplied && searchStore.disableActionButtons;
const isAndroid = Platform.OS === 'android';
@@ -38,6 +42,7 @@ const SaveAdd = () => {
opacity: buttonDisable ? 0.5 : 1
})}
variant="dashed"
onPress={() => modalRef.current?.present()}
>
<Plus weight="bold" size={12} color={tw.color('text-ink-dull')} />
<Text style={tw`font-medium text-ink-dull`}>Save search</Text>
@@ -58,6 +63,7 @@ const SaveAdd = () => {
{filtersApplied ? 'Update filters' : 'Add filters'}
</Text>
</Button>
<SaveSearchModal ref={modalRef} />
</View>
);
};

View File

@@ -1,15 +1,27 @@
import { useNavigation } from '@react-navigation/native';
import {
SavedSearch as ISavedSearch,
useLibraryMutation,
useLibraryQuery,
useRspcLibraryContext
} from '@sd/client';
import { MotiView } from 'moti';
import { MotiPressable } from 'moti/interactions';
import { FlatList, Text, View } from 'react-native';
import { X } from 'phosphor-react-native';
import { FlatList, Pressable, Text, View } from 'react-native';
import { Icon } from '~/components/icons/Icon';
import Card from '~/components/layout/Card';
import Empty from '~/components/layout/Empty';
import Fade from '~/components/layout/Fade';
import SectionTitle from '~/components/layout/SectionTitle';
import VirtualizedListWrapper from '~/components/layout/VirtualizedListWrapper';
import DottedDivider from '~/components/primitive/DottedDivider';
import { useSavedSearch } from '~/hooks/useSavedSearch';
import { tw } from '~/lib/tailwind';
import { getSearchStore } from '~/stores/searchStore';
const SavedSearches = () => {
const { data: savedSearches } = useLibraryQuery(['search.saved.list']);
return (
<Fade color="black" width={30} height="100%">
<MotiView
@@ -22,10 +34,16 @@ const SavedSearches = () => {
title="Saved searches"
sub="Tap a saved search for searching quickly"
/>
<VirtualizedListWrapper contentContainerStyle={tw`px-6`} horizontal>
<VirtualizedListWrapper contentContainerStyle={tw`w-full px-6`} horizontal>
<FlatList
data={Array.from({ length: 6 })}
renderItem={() => <SavedSearch />}
data={savedSearches}
ListEmptyComponent={() => {
return (
<Empty
icon="Folder" description="No saved searches" style={tw`w-full`} />
);
}}
renderItem={({ item }) => <SavedSearch search={item} />}
keyExtractor={(_, index) => index.toString()}
numColumns={Math.ceil(6 / 2)}
scrollEnabled={false}
@@ -41,16 +59,37 @@ const SavedSearches = () => {
);
};
const SavedSearch = () => {
interface Props {
search: ISavedSearch;
}
const SavedSearch = ({ search }: Props) => {
const navigation = useNavigation();
const dataForSearch = useSavedSearch(search);
const rspc = useRspcLibraryContext();
const deleteSearch = useLibraryMutation('search.saved.delete', {
onSuccess: () => rspc.queryClient.invalidateQueries(['search.saved.list'])
});
return (
<MotiPressable
from={{ opacity: 0, translateY: 20 }}
animate={{ opacity: 1, translateY: 0 }}
transition={{ type: 'timing', duration: 300 }}
onPress={() => {
getSearchStore().appliedFilters = dataForSearch;
navigation.navigate('SearchStack', {
screen: 'Search'
});
}}
>
<Card style={tw`mr-2 w-auto flex-row gap-2 p-2.5`}>
<Card style={tw`mr-2 w-auto flex-row items-center gap-2 p-2.5`}>
<Pressable
onPress={async () => await deleteSearch.mutateAsync(search.id)}
>
<X size={14} color={tw.color('text-ink-dull')} />
</Pressable>
<Icon name="Folder" size={20} />
<Text style={tw`text-sm font-medium text-ink`}>Saved search</Text>
<Text style={tw`text-sm font-medium text-ink`}>{search.name}</Text>
</Card>
</MotiPressable>
);

View File

@@ -1,9 +1,9 @@
import { Tag, useLibraryQuery } from '@sd/client';
import { MotiView } from 'moti';
import { memo, useCallback, useMemo } from 'react';
import { Pressable, Text, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { LinearTransition } from 'react-native-reanimated';
import { Tag, useLibraryQuery } from '@sd/client';
import Card from '~/components/layout/Card';
import Empty from '~/components/layout/Empty';
import Fade from '~/components/layout/Fade';
@@ -63,10 +63,7 @@ interface Props {
const TagFilter = memo(({ tag }: Props) => {
const searchStore = useSearchStore();
const isSelected = useMemo(
() =>
searchStore.filters.tags.some(
(filter) => filter.id === tag.id && filter.color === tag.color
),
() => searchStore.filters.tags.some((filter) => filter.id === tag.id),
[searchStore.filters.tags, tag]
);
const onPress = useCallback(() => {
@@ -74,7 +71,8 @@ const TagFilter = memo(({ tag }: Props) => {
id: tag.id,
color: tag.color!
});
}, [searchStore, tag.id, tag.color]);
}, [searchStore, tag]);
return (
<Pressable onPress={onPress}>
<Card

View File

@@ -0,0 +1,86 @@
import { SearchFilterArgs } from '@sd/client';
import { useEffect, useMemo } from 'react';
import { Filters, SearchFilters, getSearchStore, useSearchStore } from '~/stores/searchStore';
/**
* This hook merges the selected filters from Filters page in order
* to make query calls for saved searches and setups filters for the search
* the data structure has been designed to match the desktop app
*/
export function useFiltersSearch() {
const searchStore = useSearchStore();;
const filterFactory = (key: SearchFilters, value: Filters[keyof Filters]) => {
//hidden is the only boolean filter - so we can return it directly
//Rest of the filters are arrays, so we map them to the correct format
const filterValue = Array.isArray(value) ? value.map((v: any) => {
return v.id ? v.id : v;
}) : value;
//switch case for each filter
//This makes it easier to add new filters in the future and setup
//the correct object of each filter accordingly and easily
switch (key) {
case 'locations':
return { filePath: { locations: { in: filterValue } } };
case 'name':
return Array.isArray(filterValue) && filterValue.map((v: string) => {
return { filePath: { [key]: { contains: v } } };
})
case 'hidden':
return { filePath: { hidden: filterValue } };
case 'extension':
return Array.isArray(filterValue) && filterValue.map((v: string) => {
return { filePath: { [key]: { in: [v] } } };
})
case 'tags':
return { object: { tags: { in: filterValue } } };
case 'kind':
return { object: { kind: { in: filterValue } } };
default:
return {};
}
}
const mergedFilters = useMemo(() => {
const filters = [] as SearchFilterArgs[];
for (const key in searchStore.filters) {
const filterKey = key as SearchFilters;
//due to an issue with Valtio and Hermes Engine - need to do getSearchStore()
//https://github.com/pmndrs/valtio/issues/765
const filterValue = getSearchStore().filters[filterKey];
// no need to add empty filters
if (Array.isArray(filterValue)) {
const realValues = filterValue.filter((v) => v !== '');
if (realValues.length === 0) {
continue;
}
}
// create the filter object
const filter = filterFactory(filterKey, filterValue);
// add the filter to the mergedFilters
filters.push(filter as SearchFilterArgs);
}
// makes sure the array is not 2D
return filters.flat();
}, [searchStore.filters]);
useEffect(() => {
getSearchStore().mergedFilters = mergedFilters;
}, [searchStore.filters]);
};

View File

@@ -0,0 +1,134 @@
import { SavedSearch, SearchFilterArgs, useLibraryQuery } from '@sd/client';
import { useCallback, useMemo } from 'react';
import { kinds } from '~/components/search/filters/Kind';
import { Filters, SearchFilters } from '~/stores/searchStore';
/**
* This hook takes in the JSON of a Saved Search
* and returns the data of its filters for rendering in the UI
*/
export function useSavedSearch(search: SavedSearch) {
const parseFilters = JSON.parse(search.filters as string);
// returns an array of keys of the filters being used in the Saved Search
//i.e locations, tags, kind, etc...
const filterKeys: SearchFilters[] = parseFilters.reduce((acc: SearchFilters[], curr: keyof SearchFilterArgs) => {
const objectOrFilePath = Object.keys(curr)[0] as 'filePath' | 'object';
const key = Object.keys(curr[objectOrFilePath])[0] as SearchFilters;
if (!acc.includes(key)) {
acc.push(key as SearchFilters);
}
return acc;
}, []);
// this util function extracts the data of a filter from the Saved Search
const extractDataFromSavedSearch = (key: SearchFilters, filterTag: 'contains' | 'in', type: 'filePath' | 'object') => {
// Iterate through each item in the data array
for (const item of parseFilters) {
// Check if 'filePath' | 'object' exists and contains a the key
if (item[type] && key in item[type]) {
// Return the data of the filters
return item.filePath[key][filterTag];
}
}
return null;
}
const locations = useLibraryQuery(['locations.list'], {
keepPreviousData: true,
enabled: filterKeys.includes('locations'),
});
const tags = useLibraryQuery(['tags.list'], {
keepPreviousData: true,
enabled: filterKeys.includes('tags'),
});
// Filters like locations, tags, and kind require data to be rendered as a Filter
// We prepare the data in the same format as the "filters" object in the "SearchStore"
// it is then 'matched' with the data from the "Saved Search"
const prepFilters = useCallback(() => {
const data = {} as Record<SearchFilters, any>;
filterKeys.forEach((key: SearchFilters) => {
switch (key) {
case 'locations':
data.locations = locations.data?.map((location) => {
return {
id: location.id,
name: location.name
};
});
break;
case 'tags':
data.tags = tags.data?.map((tag) => {
return {
id: tag.id,
color: tag.color
};
});
break;
case 'kind':
data.kind = kinds.map((kind) => {
return {
name: kind.name,
id: kind.value,
icon: kind.icon
};
});
break;
case 'name':
data.name = extractDataFromSavedSearch(key, 'contains', 'filePath');
break;
case 'extension':
data.extension = extractDataFromSavedSearch(key, 'contains', 'filePath');
break;
}
});
return data;
}, [locations, tags]);
const filters: Partial<Filters> = useMemo(() => {
return parseFilters.reduce((acc: Record<SearchFilters, {}>, curr: keyof SearchFilterArgs) => {
const objectOrFilePath = Object.keys(curr)[0] as 'filePath' | 'object';
const key = Object.keys(curr[objectOrFilePath])[0] as SearchFilters; //locations, tags, kind, etc...
// this function extracts the data from the result of the "filters" object in the Saved Search
// and matches it with the values of the filters
const extractData = (key: SearchFilters) => {
const values: {
contains?: string;
in?: number[];
} = curr[objectOrFilePath][key];
const type = Object.keys(values)[0];
switch (type) {
case 'contains':
// some filters have a name property and some are just strings
return prepFilters()[key].filter((item: any) => {
return item.name ? item.name === values[type] :
item
});
case 'in':
return prepFilters()[key].filter((item: any) => values[type]?.includes(item.id));
default:
return values;
}
};
// the data being setup for the filters so it can be rendered
if (!acc[key]) {
acc[key] = extractData(key);
//don't include false values i.e if the "Hidden" filter is false
if (acc[key] === false) {
delete acc[key];
}
}
return acc;
}, {});
}, [parseFilters]);
return filters;
}

View File

@@ -1,6 +1,8 @@
import { createNativeStackNavigator, NativeStackScreenProps } from '@react-navigation/native-stack';
import React from 'react';
import DynamicHeader from '~/components/header/DynamicHeader';
import Header from '~/components/header/Header';
import LocationScreen from '~/screens/browse/Location';
import FiltersScreen from '~/screens/search/Filters';
import SearchScreen from '~/screens/search/Search';
@@ -25,6 +27,14 @@ export default function SearchStack() {
}
}}
/>
{/** This screen is already in BrowseStack - but added here as it offers the UX needed */}
<Stack.Screen
name="Location"
component={LocationScreen}
options={({route: optionsRoute}) => ({
header: (route) => <DynamicHeader optionsRoute={optionsRoute} headerRoute={route} kind="location" />
})}
/>
</Stack.Navigator>
);
}
@@ -32,6 +42,7 @@ export default function SearchStack() {
export type SearchStackParamList = {
Search: undefined;
Filters: undefined;
Location: { id: number; path: string };
};
export type SearchStackScreenProps<Screen extends keyof SearchStackParamList> =

View File

@@ -146,7 +146,7 @@ export default function TabNavigator() {
listeners={() => ({
focus: () => {
setActiveIndex(index);
}
},
})}
/>
))}

View File

@@ -16,7 +16,9 @@ const Stack = createNativeStackNavigator<BrowseStackParamList>();
export default function BrowseStack() {
return (
<Stack.Navigator initialRouteName="Browse">
<Stack.Navigator
initialRouteName="Browse"
>
<Stack.Screen
name="Browse"
component={BrowseScreen}

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useLibraryQuery, usePathsExplorerQuery } from '@sd/client';
import { useEffect } from 'react';
import Explorer from '~/components/explorer/Explorer';
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
import { getExplorerStore } from '~/stores/explorerStore';

View File

@@ -1,9 +1,9 @@
import { useNavigation } from '@react-navigation/native';
import { useLibraryQuery } from '@sd/client';
import { Plus } from 'phosphor-react-native';
import { useMemo, useRef } from 'react';
import { FlatList, Pressable, View } from 'react-native';
import { useDebounce } from 'use-debounce';
import { useLibraryQuery } from '@sd/client';
import Empty from '~/components/layout/Empty';
import { ModalRef } from '~/components/layout/Modal';
import ScreenContainer from '~/components/layout/ScreenContainer';

View File

@@ -1,10 +1,10 @@
import { useNavigation } from '@react-navigation/native';
import { useLibraryQuery } from '@sd/client';
import { Plus } from 'phosphor-react-native';
import { useMemo, useRef } from 'react';
import { Pressable, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { useDebounce } from 'use-debounce';
import { useLibraryQuery } from '@sd/client';
import Empty from '~/components/layout/Empty';
import { ModalRef } from '~/components/layout/Modal';
import ScreenContainer from '~/components/layout/ScreenContainer';
@@ -24,15 +24,15 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) {
const { search } = useSearchStore();
const tags = useLibraryQuery(['tags.list']);
const tagData = tags.data || [];
const tagsData = tags.data;
const [debouncedSearch] = useDebounce(search, 200);
const filteredTags = useMemo(
() =>
tagData?.filter((location) =>
tagsData?.filter((location) =>
location.name?.toLowerCase().includes(debouncedSearch.toLowerCase())
) ?? [],
[debouncedSearch, tagData]
[debouncedSearch, tagsData]
);
return (
@@ -76,7 +76,7 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) {
ItemSeparatorComponent={() => <View style={tw`h-2`} />}
contentContainerStyle={twStyle(
`py-6`,
tagData.length === 0 && 'h-full items-center justify-center'
tagsData?.length === 0 && 'h-full items-center justify-center'
)}
/>
<CreateTagModal ref={modalRef} />

View File

@@ -1,10 +1,13 @@
import { useIsFocused } from '@react-navigation/native';
import { SearchFilterArgs, useLibraryQuery, usePathsExplorerQuery } from '@sd/client';
import { ArrowLeft, DotsThreeOutline, FunnelSimple, MagnifyingGlass } from 'phosphor-react-native';
import { Suspense, useDeferredValue, useMemo, useState } from 'react';
import { ActivityIndicator, Platform, Pressable, TextInput, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { SearchFilterArgs, useObjectsExplorerQuery } from '@sd/client';
import Explorer from '~/components/explorer/Explorer';
import Empty from '~/components/layout/Empty';
import FiltersBar from '~/components/search/filters/FiltersBar';
import { useFiltersSearch } from '~/hooks/useFiltersSearch';
import { tw, twStyle } from '~/lib/tailwind';
import { SearchStackScreenProps } from '~/navigation/SearchStack';
import { getExplorerStore, useExplorerStore } from '~/stores/explorerStore';
@@ -15,11 +18,12 @@ const SearchScreen = ({ navigation }: SearchStackScreenProps<'Search'>) => {
const [loading, setLoading] = useState(false);
const searchStore = useSearchStore();
const explorerStore = useExplorerStore();
const appliedFiltersLength = useMemo(
() => Object.keys(searchStore.appliedFilters).length,
[searchStore.appliedFilters]
);
const isFocused = useIsFocused();
const appliedFiltersLength = Object.keys(searchStore.appliedFilters).length;
const isAndroid = Platform.OS === 'android';
const locations = useLibraryQuery(['locations.list'], {
keepPreviousData: true,
});
const [search, setSearch] = useState('');
const deferredSearch = useDeferredValue(search);
@@ -32,20 +36,32 @@ const SearchScreen = ({ navigation }: SearchStackScreenProps<'Search'>) => {
if (name) filters.push({ filePath: { name: { contains: name } } });
if (ext) filters.push({ filePath: { extension: { in: [ext] } } });
return filters;
}, [deferredSearch]);
if (name || ext) {
// Add locations filter to search all locations
if (locations.data && locations.data.length > 0) filters.push({ filePath: { locations: { in:
locations.data?.map((location) => location.id) } } });
}
const objects = useObjectsExplorerQuery({
return searchStore.mergedFilters.concat(filters);
}, [deferredSearch, searchStore.mergedFilters, locations.data]);
const objects = usePathsExplorerQuery({
arg: {
take: 30,
filters
},
order: null,
enabled: isFocused && filters.length > 1, // only fetch when screen is focused & filters are applied
suspense: true,
enabled: !!deferredSearch,
order: null,
onSuccess: () => getExplorerStore().resetNewThumbnails()
});
// Check if there are no objects or no search
const noObjects = objects.items?.length === 0 || !objects.items;
const noSearch = deferredSearch.length === 0 && appliedFiltersLength === 0;
useFiltersSearch();
return (
<View
style={twStyle('flex-1 bg-app-header', {
@@ -119,12 +135,26 @@ const SearchScreen = ({ navigation }: SearchStackScreenProps<'Search'>) => {
/>
</Pressable>
</View>
{appliedFiltersLength > 0 && <FiltersBar />}
{appliedFiltersLength > 0 && <FiltersBar/>}
</View>
{/* Content */}
<View style={tw`flex-1`}>
<Suspense fallback={<ActivityIndicator />}>
<Explorer {...objects} tabHeight={false} />
<Explorer
{...objects}
isEmpty={noObjects}
emptyComponent={
<Empty
icon={noSearch ? 'Search' : 'FolderNoSpace'}
style={twStyle('flex-1 items-center justify-center border-0', {
marginBottom: headerHeight
})}
textSize="text-md"
iconSize={100}
description={noSearch ? 'Add filters or type to search for files' : 'No files found'}
/>
}
tabHeight={false} />
</Suspense>
</View>
</View>

View File

@@ -1,3 +1,4 @@
import { SearchFilterArgs } from '@sd/client';
import { proxy, useSnapshot } from 'valtio';
import { IconName } from '~/components/icons/Icon';
@@ -19,29 +20,20 @@ export interface KindItem {
icon: IconName;
}
export interface Filters {
locations: FilterItem[];
tags: TagItem[];
name: string[];
extension: string[];
hidden: boolean;
kind: KindItem[];
}
interface State {
search: string;
filters: {
locations: FilterItem[];
tags: TagItem[];
name: string[];
extension: string[];
hidden: boolean;
kind: KindItem[];
};
appliedFilters: Partial<
Record<
SearchFilters,
{
locations: FilterItem[];
tags: TagItem[];
name: string[];
extension: string[];
hidden: boolean;
kind: KindItem[];
}
>
>;
filters: Filters;
appliedFilters: Partial<Filters>;
mergedFilters: SearchFilterArgs[],
disableActionButtons: boolean;
}
@@ -56,6 +48,7 @@ const initialState: State = {
kind: []
},
appliedFilters: {},
mergedFilters: [],
disableActionButtons: true
};
@@ -88,6 +81,7 @@ const searchStore = proxy<
applyFilters: () => void;
setSearch: (search: string) => void;
resetFilter: <K extends keyof State['filters']>(filter: K, apply?: boolean) => void;
resetFilters: () => void;
setInput: (index: number, value: string, key: 'name' | 'extension') => void;
addInput: (key: 'name' | 'extension') => void;
removeInput: (index: number, key: 'name' | 'extension') => void;
@@ -120,8 +114,9 @@ const searchStore = proxy<
searchStore.appliedFilters = Object.entries(searchStore.filters).reduce(
(acc, [key, value]) => {
if (Array.isArray(value)) {
if (value.length > 0 && value[0] !== '') {
acc[key as SearchFilters] = value.filter((v) => v !== ''); // Remove empty values i.e empty inputs
const realValues = value.filter((v) => v !== '');
if (realValues.length > 0) {
acc[key as SearchFilters] = realValues;
}
} else if (typeof value === 'boolean') {
// Only apply the hidden filter if it's true
@@ -144,7 +139,9 @@ const searchStore = proxy<
//instead of a useEffect or subscription - we can call applyFilters directly
if (apply) searchStore.applyFilters();
},
resetFilters: () => {
searchStore.filters = { ...initialState.filters };
},
setInput: (index, value, key) => {
const newValues = [...searchStore.filters[key]];
newValues[index] = value;

View File

@@ -1,7 +1,5 @@
import { FunnelSimple, Icon, Plus } from '@phosphor-icons/react';
import { IconTypes } from '@sd/assets/util';
import clsx from 'clsx';
import { memo, PropsWithChildren, useDeferredValue, useMemo, useState } from 'react';
import { useFeatureFlag, useLibraryMutation } from '@sd/client';
import {
Button,
@@ -13,9 +11,11 @@ import {
tw,
usePopover
} from '@sd/ui';
import clsx from 'clsx';
import { memo, PropsWithChildren, useDeferredValue, useMemo, useState } from 'react';
import { useIsDark, useKeybind } from '~/hooks';
import { AppliedFilters, FilterContainer, InteractiveSection } from './AppliedFilters';
import { AppliedFilters, InteractiveSection } from './AppliedFilters';
import { useSearchContext } from './context';
import { filterRegistry, SearchFilterCRUD, useToggleOptionSelected } from './Filters';
import {

View File

@@ -6,6 +6,8 @@ import { usePathsOffsetInfiniteQuery } from './usePathsOffsetInfiniteQuery';
export function usePathsExplorerQuery(props: {
arg: FilePathSearchArgs;
order: FilePathOrder | null;
enabled?: boolean;
suspense?: boolean;
/** This callback will fire any time the query successfully fetches new data. (NOTE: This will be removed on the next major version (react-query)) */
onSuccess?: () => void;
}) {

View File

@@ -1,5 +1,5 @@
import { AlphaClient } from '@oscartbeaumont-sd/rspc-client/v2';
import { createContext, PropsWithChildren, useContext, useEffect, useMemo } from 'react';
import { createContext, PropsWithChildren, useContext, useMemo } from 'react';
import { LibraryConfigWrapped, Procedures } from '../core';
import { valtioPersist } from '../lib';