Skip to content
This repository has been archived by the owner on Jan 23, 2025. It is now read-only.

feat: add global search #1294

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@emotion/styled": "11.11.5",
"@graasp/chatbox": "3.1.0",
"@graasp/map": "1.15.0",
"@graasp/query-client": "3.11.0",
"@graasp/query-client": "github:graasp/graasp-query-client#global-search",
"@graasp/sdk": "4.12.1",
"@graasp/translations": "1.28.0",
"@graasp/ui": "4.19.2",
Expand Down
7 changes: 7 additions & 0 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
HOME_PATH,
ITEMS_PATH,
ITEM_PUBLISH_PATH,
ITEM_SEARCH_PATH,
ITEM_SETTINGS_PATH,
ITEM_SHARE_PATH,
MAP_ITEMS_PATH,
Expand All @@ -33,6 +34,7 @@ import ItemScreenLayout from './pages/item/ItemScreenLayout';
import ItemSettingsPage from './pages/item/ItemSettingsPage';
import ItemSharingPage from './pages/item/ItemSharingPage';
import LibrarySettingsPage from './pages/item/LibrarySettingsPage';
import ItemSearchPage from './search/SearchPage';

const { useItemFeedbackUpdates } = hooks;

Expand Down Expand Up @@ -74,6 +76,10 @@ const App = (): JSX.Element => {
RecycledItemsScreen,
withAuthorizationProps,
);
const SearchWithAuthorization = withAuthorization(
ItemSearchPage,
withAuthorizationProps,
);

return (
<Routes>
Expand Down Expand Up @@ -105,6 +111,7 @@ const App = (): JSX.Element => {
<Route path={RECYCLE_BIN_PATH} element={<RecycleWithAuthorization />} />
<Route path={buildItemPath()} element={<ItemScreen />} />
<Route path={ITEMS_PATH} element={<HomeWithAuthorization />} />
<Route path={ITEM_SEARCH_PATH} element={<SearchWithAuthorization />} />
<Route path={REDIRECT_PATH} element={<Redirect />} />
<Route path="*" element={<Redirect />} />
</Route>
Expand Down
283 changes: 283 additions & 0 deletions src/components/search/SearchPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import { useRef, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useSearchParams } from 'react-router-dom';

import { LoadingButton } from '@mui/lab';
import {
Alert,
Checkbox,
Chip,
Container,
FormControlLabel,
FormGroup,
IconButton,
InputAdornment,
MenuItem,
Select,
Typography,
} from '@mui/material';
import TextField from '@mui/material/TextField';

import {
ItemType,
ItemTypeUnion,
PermissionLevel,
formatDate,
} from '@graasp/sdk';

import { ArrowDownAZ, ArrowDownZA, Search } from 'lucide-react';

import { Ordering } from '@/enums';

import { useBuilderTranslation } from '../../config/i18n';
import { hooks } from '../../config/queryClient';

const pageSize = 10;

type SearchItemSortableColumn =
| 'rank'
| 'item.name'
| 'item.creator.name'
| 'item.type'
| 'item.created_at'
| 'item.updated_at';

const SearchPage = (): JSX.Element | null => {
const [searchParams] = useSearchParams();
const { t: translateBuilder, i18n } = useBuilderTranslation();
const [enabled, setEnabled] = useState(false);
const [sortBy, setSortBy] = useState<SearchItemSortableColumn>('rank');
const [ordering, setOrdering] = useState<'asc' | 'desc'>('asc');
const [types, setTypes] = useState<ItemTypeUnion[]>([]);
const [permissions, setPermissions] = useState<PermissionLevel[]>([]);
const parentId = searchParams.get('parentId') ?? undefined;
const { data: parentItem, isError } = hooks.useItem(parentId);
const [shouldSearchWithinItem, setShouldSearchWithinItem] = useState<boolean>(
Boolean(parentId),
);

const [keywords, setKeywords] = useState<string[]>();

let finalOrdering = ordering;
if (sortBy === 'rank' && !keywords) {
finalOrdering = 'desc';
}

let finalSortBy = sortBy;
if (sortBy === 'rank' && !keywords) {
finalSortBy = 'item.updated_at';
}

const { data, isFetching, isFetchingNextPage, fetchNextPage } =
hooks.useSearchItems(
{
parentId: shouldSearchWithinItem ? parentId : undefined,
permissions,
keywords,
types,
ordering: finalOrdering,
sortBy: finalSortBy,
},
{},
{ enabled },
);

const inputRef = useRef();

const currentTotalCount = (data?.pages?.length ?? 0) * (pageSize ?? 10);
const totalCount = data?.pages?.[0]?.totalCount ?? 0;

const search = async () => {
setEnabled(true);
if (inputRef.current?.value) {
setKeywords(inputRef.current.value.split(' '));
}
};

const toggleType = (type: ItemTypeUnion) => {
setEnabled(true);
if (types.includes(type)) {
setTypes(types.filter((a) => a !== type));
} else {
setTypes([...types, type]);
}
};

const togglePermission = (p: PermissionLevel) => {
setEnabled(true);
if (permissions.includes(p)) {
setPermissions(permissions.filter((a) => a !== p));
} else {
setPermissions([...permissions, p]);
}
};

const sortByOrderingOptions: {
sortBy: SearchItemSortableColumn;
ordering: Ordering;
text: string;
}[] = [
{ sortBy: 'rank', ordering: Ordering.DESC, text: 'pertinence' },
{ sortBy: 'item.name', ordering: Ordering.ASC, text: 'name (asc)' },
{
sortBy: 'item.name',
ordering: Ordering.DESC,
text: 'name (desc)',
},
{
sortBy: 'item.creator.name',
ordering: Ordering.ASC,
text: 'creator (asc)',
},
{
sortBy: 'item.creator.name',
ordering: Ordering.DESC,
text: 'creator (desc)',
},
{
sortBy: 'item.updated_at',
ordering: Ordering.ASC,
text: 'dernière modification (asc)',
},
{
sortBy: 'item.updated_at',
ordering: Ordering.DESC,
text: 'dernière modification (desc)',
},
];

if (isError) {
return <Alert severity="error">no corresponding item</Alert>;
}

return (
<>
<Helmet>
<title>{translateBuilder('Search')}</title>
</Helmet>
<Container>
<Typography variant="h1" textAlign="center" noWrap>
Search in {shouldSearchWithinItem ? parentItem?.name : 'Graasp'}
</Typography>
<TextField
sx={{ mt: 2 }}
variant="outlined"
placeholder="search..."
fullWidth
inputRef={inputRef}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>

{parentId && (
<FormGroup>
<FormControlLabel
control={
<Checkbox
onChange={() =>
setShouldSearchWithinItem(!shouldSearchWithinItem)
}
checked={shouldSearchWithinItem}
/>
}
label={`Search within ${parentItem.name}`}
/>
</FormGroup>
)}

{Object.values(ItemType).map((t) => (
<Chip
color={types.includes(t) ? 'success' : undefined}
onClick={() => toggleType(t)}
label={t}
key={t}
sx={{ mx: 1 }}
/>
))}
<br />

{Object.values(PermissionLevel).map((p) => (
<Chip
color={permissions.includes(p) ? 'success' : undefined}
onClick={() => togglePermission(p)}
label={p}
key={p}
sx={{ mx: 1 }}
/>
))}
<br />
<Select
defaultValue={0}
onChange={(i) => {
const idx = i.target.value as number;
if (idx >= 0) {
setSortBy(sortByOrderingOptions[idx].sortBy);
setOrdering(sortByOrderingOptions[idx].ordering);
}
}}
>
{sortByOrderingOptions.map((d, idx) => (
<MenuItem value={idx} key={d.text}>
{d.text}
</MenuItem>
))}
</Select>

<IconButton
onClick={() =>
ordering === 'asc' ? setOrdering('desc') : setOrdering('asc')
}
>
{ordering === 'asc' ? <ArrowDownZA /> : <ArrowDownAZ />}
</IconButton>
{/* TODO: disabled if params did not change */}
<LoadingButton
variant="contained"
loading={isFetching}
onClick={() => search()}
fullWidth
>
Search
</LoadingButton>
{totalCount ? (
<Typography variant="caption">
found {totalCount} results for {keywords}
</Typography>
) : null}
{data?.pages?.map((p) =>
p.data
// eslint-disable-next-line arrow-body-style
?.map((i) => {
return (
<Typography key={i.id}>
{i.id} {i.name} -
{formatDate(i.createdAt, { locale: i18n.language })}
</Typography>
);
}),
)}
{Boolean(enabled && !isFetching && totalCount === 0) && (
<Typography variant="h4" textAlign="center">
No result found for {keywords}
</Typography>
)}
{totalCount > currentTotalCount && (
<LoadingButton
loading={isFetchingNextPage}
onClick={() => fetchNextPage()}
variant="contained"
>
show next page
</LoadingButton>
)}
</Container>
</>
);
};

export default SearchPage;
1 change: 1 addition & 0 deletions src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const RECYCLE_BIN_PATH = '/recycle-bin';
export const ITEM_ID_PARAMS = 'itemId';
export const ITEM_SHARE_PATH = 'share';
export const ITEM_PUBLISH_PATH = 'publish';
export const ITEM_SEARCH_PATH = 'search';
export const ITEM_SETTINGS_PATH = 'settings';
export const MAP_ITEMS_PATH = 'map';
export const buildItemSettingsPath = (id = ':itemId'): string =>
Expand Down
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1751,9 +1751,9 @@ __metadata:
languageName: node
linkType: hard

"@graasp/query-client@npm:3.11.0":
version: 3.11.0
resolution: "@graasp/query-client@npm:3.11.0"
"@graasp/query-client@github:graasp/graasp-query-client#global-search":
version: 3.13.0
resolution: "@graasp/query-client@https://github.com/graasp/graasp-query-client.git#commit=377a20ecb0ffcc15005408c5c0bfe827eb556c7a"
dependencies:
"@tanstack/react-query": "npm:4.36.1"
"@tanstack/react-query-devtools": "npm:4.36.1"
Expand All @@ -1764,7 +1764,7 @@ __metadata:
"@graasp/sdk": ^4.0.0
"@graasp/translations": ^1.23.0
react: ^18.0.0
checksum: 10/08e2b56136e2e1ae48dc2c619a0eff9d5594eb277572c8a3ae85953932fec94be5b97bec78817402a5c833dbd2d709cce556549af0e47b13a360ebdbeb85e0ec
checksum: 10/e7cac3fc2db74e7db4a7f4bd690c16c025d1efc5bfe88278fe48db602fb249bbea3b5ec4dcea44192aed0453fad11904d7821ee5f06875b7464d8bf38c6e1356
languageName: node
linkType: hard

Expand Down Expand Up @@ -8084,7 +8084,7 @@ __metadata:
"@emotion/styled": "npm:11.11.5"
"@graasp/chatbox": "npm:3.1.0"
"@graasp/map": "npm:1.15.0"
"@graasp/query-client": "npm:3.11.0"
"@graasp/query-client": "github:graasp/graasp-query-client#global-search"
"@graasp/sdk": "npm:4.12.1"
"@graasp/translations": "npm:1.28.0"
"@graasp/ui": "npm:4.19.2"
Expand Down