add search support

This commit is contained in:
Athou
2022-10-27 16:25:32 +02:00
parent 9252042c99
commit d187c23a77
10 changed files with 144 additions and 34 deletions

View File

@@ -32,7 +32,7 @@ describe("entries", () => {
} as AxiosResponse<Entries>) } as AxiosResponse<Entries>)
const store = configureStore({ reducer: reducers }) const store = configureStore({ reducer: reducers })
const promise = store.dispatch(loadEntries({ type: "feed", id: "feed-id" })) const promise = store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
expect(store.getState().entries.source.type).toBe("feed") expect(store.getState().entries.source.type).toBe("feed")
expect(store.getState().entries.source.id).toBe("feed-id") expect(store.getState().entries.source.id).toBe("feed-id")

View File

@@ -27,6 +27,7 @@ interface EntriesState {
timestamp?: number timestamp?: number
selectedEntryId?: string selectedEntryId?: string
hasMore: boolean hasMore: boolean
search?: string
scrollingToEntry: boolean scrollingToEntry: boolean
} }
@@ -44,38 +45,43 @@ const initialState: EntriesState = {
const getEndpoint = (sourceType: EntrySourceType) => const getEndpoint = (sourceType: EntrySourceType) =>
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
export const loadEntries = createAsyncThunk<Entries, EntrySource, { state: RootState }>("entries/load", async (source, thunkApi) => { export const loadEntries = createAsyncThunk<Entries, { source: EntrySource; clearSearch: boolean }, { state: RootState }>(
"entries/load",
async (arg, thunkApi) => {
if (arg.clearSearch) thunkApi.dispatch(setSearch(""))
const state = thunkApi.getState() const state = thunkApi.getState()
const endpoint = getEndpoint(source.type) const endpoint = getEndpoint(arg.source.type)
const result = await endpoint({ const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0))
id: source.type === "tag" ? Constants.categories.all.id : source.id,
order: state.user.settings?.readingOrder,
readType: state.user.settings?.readingMode,
offset: 0,
limit: 50,
tag: source.type === "tag" ? source.id : undefined,
})
return result.data return result.data
}) }
)
export const loadMoreEntries = createAsyncThunk<Entries, void, { state: RootState }>("entries/loadMore", async (_, thunkApi) => { export const loadMoreEntries = createAsyncThunk<Entries, void, { state: RootState }>("entries/loadMore", async (_, thunkApi) => {
const state = thunkApi.getState() const state = thunkApi.getState()
const { source } = state.entries const { source } = state.entries
const offset = const offset =
state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length
const endpoint = getEndpoint(state.entries.source.type) const endpoint = getEndpoint(state.entries.source.type)
const result = await endpoint({ const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
return result.data
})
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
id: source.type === "tag" ? Constants.categories.all.id : source.id, id: source.type === "tag" ? Constants.categories.all.id : source.id,
readType: state.user.settings?.readingMode,
order: state.user.settings?.readingOrder, order: state.user.settings?.readingOrder,
readType: state.user.settings?.readingMode,
offset, offset,
limit: 50, limit: 50,
tag: source.type === "tag" ? source.id : undefined, tag: source.type === "tag" ? source.id : undefined,
keywords: state.entries.search,
}) })
return result.data export const reloadEntries = createAsyncThunk<void, void, { state: RootState }>("entries/reload", async (arg, thunkApi) => {
})
export const reloadEntries = createAsyncThunk<void, void, { state: RootState }>("entries/reload", async (_, thunkApi) => {
const state = thunkApi.getState() const state = thunkApi.getState()
thunkApi.dispatch(loadEntries(state.entries.source)) thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
})
export const search = createAsyncThunk<void, string, { state: RootState }>("entries/search", async (arg, thunkApi) => {
const state = thunkApi.getState()
thunkApi.dispatch(setSearch(arg))
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
}) })
export const markEntry = createAsyncThunk( export const markEntry = createAsyncThunk(
"entries/entry/mark", "entries/entry/mark",
@@ -263,6 +269,9 @@ export const entriesSlice = createSlice({
setScrollingToEntry: (state, action: PayloadAction<boolean>) => { setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
state.scrollingToEntry = action.payload state.scrollingToEntry = action.payload
}, },
setSearch: (state, action: PayloadAction<string>) => {
state.search = action.payload
},
}, },
extraReducers: builder => { extraReducers: builder => {
builder.addCase(markEntry.pending, (state, action) => { builder.addCase(markEntry.pending, (state, action) => {
@@ -294,7 +303,7 @@ export const entriesSlice = createSlice({
}) })
}) })
builder.addCase(loadEntries.pending, (state, action) => { builder.addCase(loadEntries.pending, (state, action) => {
state.source = action.meta.arg state.source = action.meta.arg.source
state.entries = [] state.entries = []
state.timestamp = undefined state.timestamp = undefined
state.sourceLabel = "" state.sourceLabel = ""
@@ -325,4 +334,5 @@ export const entriesSlice = createSlice({
}, },
}) })
export const { setSearch } = entriesSlice.actions
export default entriesSlice.reducer export default entriesSlice.reducer

View File

@@ -1,8 +1,9 @@
import { Box, createStyles, TypographyStylesProvider } from "@mantine/core" import { Box, createStyles, Mark, TypographyStylesProvider } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { useAppSelector } from "app/store"
import { calculatePlaceholderSize } from "app/utils" import { calculatePlaceholderSize } from "app/utils"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading" import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { Interweave, TransformCallback } from "interweave" import { ChildrenNode, Interweave, Matcher, MatchResponse, Node, TransformCallback } from "interweave"
export interface ContentProps { export interface ContentProps {
content: string content: string
@@ -54,13 +55,39 @@ const transform: TransformCallback = node => {
return undefined return undefined
} }
class HighlightMatcher extends Matcher {
private search: string
constructor(search: string) {
super("highlight")
this.search = search
}
match(string: string): MatchResponse<unknown> | null {
const pattern = this.search.split(" ").join("|")
return this.doMatch(string, new RegExp(pattern, "i"), () => ({}))
}
// eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars
replaceWith(children: ChildrenNode, props: unknown): Node {
return <Mark>{children}</Mark>
}
// eslint-disable-next-line class-methods-use-this
asTag(): string {
return "span"
}
}
export function Content(props: ContentProps) { export function Content(props: ContentProps) {
const { classes } = useStyles() const { classes } = useStyles()
const search = useAppSelector(state => state.entries.search)
const matchers = search ? [new HighlightMatcher(search)] : []
return ( return (
<TypographyStylesProvider> <TypographyStylesProvider>
<Box className={classes.content}> <Box className={classes.content}>
<Interweave content={props.content} transform={transform} /> <Interweave content={props.content} transform={transform} matchers={matchers} />
</Box> </Box>
</TypographyStylesProvider> </TypographyStylesProvider>
) )

View File

@@ -2,6 +2,7 @@ import { Box, createStyles, Image, Text } from "@mantine/core"
import { Entry } from "app/types" import { Entry } from "app/types"
import { RelativeDate } from "components/RelativeDate" import { RelativeDate } from "components/RelativeDate"
import { OnDesktop } from "components/responsive/OnDesktop" import { OnDesktop } from "components/responsive/OnDesktop"
import { FeedEntryTitle } from "./FeedEntryTitle"
export interface FeedEntryHeaderProps { export interface FeedEntryHeaderProps {
entry: Entry entry: Entry
@@ -43,7 +44,9 @@ export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
{props.entry.feedName} {props.entry.feedName}
</Text> </Text>
</OnDesktop> </OnDesktop>
<Box className={classes.title}>{props.entry.title}</Box> <Box className={classes.title}>
<FeedEntryTitle entry={props.entry} />
</Box>
<OnDesktop> <OnDesktop>
<Text color="dimmed" className={classes.date}> <Text color="dimmed" className={classes.date}>
<RelativeDate date={props.entry.date} /> <RelativeDate date={props.entry.date} />

View File

@@ -1,6 +1,7 @@
import { Box, createStyles, Image, Text } from "@mantine/core" import { Box, createStyles, Image, Text } from "@mantine/core"
import { Entry } from "app/types" import { Entry } from "app/types"
import { RelativeDate } from "components/RelativeDate" import { RelativeDate } from "components/RelativeDate"
import { FeedEntryTitle } from "./FeedEntryTitle"
export interface FeedEntryHeaderProps { export interface FeedEntryHeaderProps {
entry: Entry entry: Entry
@@ -27,7 +28,9 @@ export function FeedEntryHeader(props: FeedEntryHeaderProps) {
const { classes } = useStyles(props) const { classes } = useStyles(props)
return ( return (
<Box> <Box>
<Box className={classes.headerText}>{props.entry.title}</Box> <Box className={classes.headerText}>
<FeedEntryTitle entry={props.entry} />
</Box>
<Box className={classes.headerSubtext}> <Box className={classes.headerSubtext}>
<Box mr={6}> <Box mr={6}>
<Image withPlaceholder src={props.entry.iconUrl} alt="feed icon" width={18} height={18} /> <Image withPlaceholder src={props.entry.iconUrl} alt="feed icon" width={18} height={18} />

View File

@@ -0,0 +1,13 @@
import { Highlight } from "@mantine/core"
import { useAppSelector } from "app/store"
import { Entry } from "app/types"
export interface FeedEntryTitleProps {
entry: Entry
}
export function FeedEntryTitle(props: FeedEntryTitleProps) {
const search = useAppSelector(state => state.entries.search)
const keywords = search?.split(" ")
return <Highlight highlight={keywords ?? ""}>{props.entry.title}</Highlight>
}

View File

@@ -1,11 +1,13 @@
import { t } from "@lingui/macro" import { t } from "@lingui/macro"
import { Center, Divider, Group } from "@mantine/core" import { Center, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
import { reloadEntries } from "app/slices/entries" import { useForm } from "@mantine/form"
import { reloadEntries, search } from "app/slices/entries"
import { changeReadingMode, changeReadingOrder } from "app/slices/user" import { changeReadingMode, changeReadingOrder } from "app/slices/user"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { ActionButton } from "components/ActionButtton" import { ActionButton } from "components/ActionButtton"
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { TbArrowDown, TbArrowUp, TbEye, TbEyeOff, TbRefresh, TbUser } from "react-icons/tb" import { useEffect } from "react"
import { TbArrowDown, TbArrowUp, TbEye, TbEyeOff, TbRefresh, TbSearch, TbUser } from "react-icons/tb"
import { MarkAllAsReadButton } from "./MarkAllAsReadButton" import { MarkAllAsReadButton } from "./MarkAllAsReadButton"
import { ProfileMenu } from "./ProfileMenu" import { ProfileMenu } from "./ProfileMenu"
@@ -17,8 +19,22 @@ const iconSize = 18
export function Header() { export function Header() {
const settings = useAppSelector(state => state.user.settings) const settings = useAppSelector(state => state.user.settings)
const profile = useAppSelector(state => state.user.profile) const profile = useAppSelector(state => state.user.profile)
const searchFromStore = useAppSelector(state => state.entries.search)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const searchForm = useForm<{ search: string }>({
validate: {
search: value => (value.length > 0 && value.length < 3 ? t`Search requires at least 3 characters` : null),
},
})
const { setValues } = searchForm
useEffect(() => {
setValues({
search: searchFromStore,
})
}, [setValues, searchFromStore])
if (!settings) return <Loader /> if (!settings) return <Loader />
return ( return (
<Center> <Center>
@@ -39,6 +55,24 @@ export function Header() {
onClick={() => dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))} onClick={() => dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
/> />
<Popover>
<Popover.Target>
<Indicator disabled={!searchFromStore}>
<ActionButton icon={<TbSearch size={iconSize} />} label={t`Search`} />
</Indicator>
</Popover.Target>
<Popover.Dropdown>
<form onSubmit={searchForm.onSubmit(values => dispatch(search(values.search)))}>
<TextInput
placeholder={t`Search`}
{...searchForm.getInputProps("search")}
icon={<TbSearch size={iconSize} />}
autoFocus
/>
</form>
</Popover.Dropdown>
</Popover>
<HeaderDivider /> <HeaderDivider />
<ProfileMenu control={<ActionButton icon={<TbUser size={iconSize} />} label={profile?.name} />} /> <ProfileMenu control={<ActionButton icon={<TbUser size={iconSize} />} label={profile?.name} />} />

View File

@@ -588,11 +588,17 @@ msgstr "Save"
msgid "Scroll smoothly when navigating between entries" msgid "Scroll smoothly when navigating between entries"
msgstr "Scroll smoothly when navigating between entries" msgstr "Scroll smoothly when navigating between entries"
#: src/components/header/Header.tsx
#: src/components/header/Header.tsx
#: src/components/sidebar/TreeSearch.tsx #: src/components/sidebar/TreeSearch.tsx
#: src/components/sidebar/TreeSearch.tsx #: src/components/sidebar/TreeSearch.tsx
msgid "Search" msgid "Search"
msgstr "Search" msgstr "Search"
#: src/components/header/Header.tsx
msgid "Search requires at least 3 characters"
msgstr "Search requires at least 3 characters"
#: src/components/KeyboardShortcutsHelp.tsx #: src/components/KeyboardShortcutsHelp.tsx
msgid "Set focus on next entry without opening it" msgid "Set focus on next entry without opening it"
msgstr "Set focus on next entry without opening it" msgstr "Set focus on next entry without opening it"

View File

@@ -588,11 +588,17 @@ msgstr "Enregistrer"
msgid "Scroll smoothly when navigating between entries" msgid "Scroll smoothly when navigating between entries"
msgstr "Défilement animé lors de la navigation entre les entrées" msgstr "Défilement animé lors de la navigation entre les entrées"
#: src/components/header/Header.tsx
#: src/components/header/Header.tsx
#: src/components/sidebar/TreeSearch.tsx #: src/components/sidebar/TreeSearch.tsx
#: src/components/sidebar/TreeSearch.tsx #: src/components/sidebar/TreeSearch.tsx
msgid "Search" msgid "Search"
msgstr "Rechercher" msgstr "Rechercher"
#: src/components/header/Header.tsx
msgid "Search requires at least 3 characters"
msgstr "La recherche requiert au moins 3 caractères"
#: src/components/KeyboardShortcutsHelp.tsx #: src/components/KeyboardShortcutsHelp.tsx
msgid "Set focus on next entry without opening it" msgid "Set focus on next entry without opening it"
msgstr "Sélectionner l'article suivant sans l'ouvrir" msgstr "Sélectionner l'article suivant sans l'ouvrir"

View File

@@ -45,7 +45,15 @@ export function FeedEntriesPage(props: FeedEntriesPageProps) {
} }
useEffect(() => { useEffect(() => {
dispatch(loadEntries({ type: props.sourceType, id })) dispatch(
loadEntries({
source: {
type: props.sourceType,
id,
},
clearSearch: true,
})
)
}, [dispatch, props.sourceType, id, location.state]) }, [dispatch, props.sourceType, id, location.state])
const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0) const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0)