web: add support for sorting in search

This commit is contained in:
Abdullah Atta
2025-05-17 11:03:54 +05:00
parent 22b037ea1d
commit 1ca30b8a5b
12 changed files with 2780 additions and 2710 deletions

View File

@@ -28,8 +28,7 @@ import {
SortDesc, SortDesc,
DetailedView, DetailedView,
CompactView, CompactView,
Icon, Icon
Loading
} from "../icons"; } from "../icons";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button, Flex, Text } from "@theme-ui/components"; import { Button, Flex, Text } from "@theme-ui/components";
@@ -45,6 +44,7 @@ import {
GroupingKey GroupingKey
} from "@notesnook/core"; } from "@notesnook/core";
import { strings } from "@notesnook/intl"; import { strings } from "@notesnook/intl";
import { useStore as useSearchStore } from "../../stores/search-store";
const groupByToTitleMap = { const groupByToTitleMap = {
none: "None", none: "None",
@@ -60,12 +60,13 @@ type GroupingMenuOptions = {
parentKey: keyof GroupOptions; parentKey: keyof GroupOptions;
groupingKey: GroupingKey; groupingKey: GroupingKey;
refresh: () => void; refresh: () => void;
isSearching?: boolean;
}; };
const groupByMenu: (options: GroupingMenuOptions) => MenuItem | null = ( const groupByMenu: (options: GroupingMenuOptions) => MenuItem | null = (
options options
) => ) =>
options.groupingKey === "reminders" options.groupingKey === "reminders" || options.isSearching
? null ? null
: { : {
type: "button", type: "button",
@@ -105,6 +106,8 @@ const orderByMenu: (options: GroupingMenuOptions) => MenuItem = (options) => ({
? strings.aToZ() ? strings.aToZ()
: options.groupOptions.sortBy === "dueDate" : options.groupOptions.sortBy === "dueDate"
? strings.earliestFirst() ? strings.earliestFirst()
: options.groupOptions.sortBy === "relevance"
? strings.leastRelevantFirst()
: strings.oldestToNewest() : strings.oldestToNewest()
}, },
{ {
@@ -114,6 +117,8 @@ const orderByMenu: (options: GroupingMenuOptions) => MenuItem = (options) => ({
? strings.zToA() ? strings.zToA()
: options.groupOptions.sortBy === "dueDate" : options.groupOptions.sortBy === "dueDate"
? strings.latestFirst() ? strings.latestFirst()
: options.groupOptions.sortBy === "relevance"
? strings.mostRelevantFirst()
: strings.newestToOldest() : strings.newestToOldest()
} }
]) ])
@@ -156,6 +161,11 @@ const sortByMenu: (options: GroupingMenuOptions) => MenuItem = (options) => ({
{ {
key: "title", key: "title",
title: strings.sortByStrings.title() title: strings.sortByStrings.title()
},
{
key: "relevance",
title: strings.sortByStrings.relevance(),
isHidden: options.groupingKey !== "search"
} }
]) ])
} }
@@ -198,6 +208,8 @@ async function changeGroupOptions(
: groupOptions.sortBy; : groupOptions.sortBy;
} }
await db.settings.setGroupOptions(options.groupingKey, groupOptions); await db.settings.setGroupOptions(options.groupingKey, groupOptions);
if (options.groupingKey === "search")
useSearchStore.setState({ sortOptions: groupOptions });
options.refresh(); options.refresh();
} }
@@ -209,7 +221,7 @@ function map(
item.isChecked = options.groupOptions[options.parentKey] === item.key; item.isChecked = options.groupOptions[options.parentKey] === item.key;
item.onClick = () => changeGroupOptions(options, item); item.onClick = () => changeGroupOptions(options, item);
return { ...item, type: "button" }; return { ...item, type: "button" };
}, []); });
} }
type GroupHeaderProps = { type GroupHeaderProps = {
@@ -222,6 +234,7 @@ type GroupHeaderProps = {
refresh: () => void; refresh: () => void;
onSelectGroup: () => void; onSelectGroup: () => void;
isFocused: boolean; isFocused: boolean;
isSearching?: boolean;
}; };
function GroupHeader(props: GroupHeaderProps) { function GroupHeader(props: GroupHeaderProps) {
const { const {
@@ -232,10 +245,11 @@ function GroupHeader(props: GroupHeaderProps) {
groupingKey, groupingKey,
refresh, refresh,
onSelectGroup, onSelectGroup,
isFocused isFocused,
isSearching
} = props; } = props;
const [groupOptions, setGroupOptions] = useState( const [groupOptions, setGroupOptions] = useState(
db.settings.getGroupOptions(groupingKey) db.settings.getGroupOptions(isSearching ? "search" : groupingKey)
); );
const groupHeaderRef = useRef<HTMLDivElement>(null); const groupHeaderRef = useRef<HTMLDivElement>(null);
const { openMenu, target } = useMenuTrigger(); const { openMenu, target } = useMenuTrigger();
@@ -270,21 +284,15 @@ function GroupHeader(props: GroupHeaderProps) {
return ( return (
<Flex <Flex
ref={groupHeaderRef} ref={groupHeaderRef}
onClick={(e) => { onClick={async (e) => {
if (e.ctrlKey) { if (e.ctrlKey) {
onSelectGroup(); onSelectGroup();
return; return;
} }
e.stopPropagation(); e.stopPropagation();
const items: MenuItem[] = [ const groupItems = await groups();
{ const items: MenuItem[] = groupItems.map(({ group, index }) => {
key: "groups",
type: "lazy-loader",
loader: <Loading sx={{ my: 2 }} />,
async items() {
const items = await groups();
return items.map(({ group, index }) => {
const groupTitle = group.title.toString(); const groupTitle = group.title.toString();
return { return {
type: "button", type: "button",
@@ -294,9 +302,7 @@ function GroupHeader(props: GroupHeaderProps) {
checked: group.title === title checked: group.title === title
} as MenuItem; } as MenuItem;
}); });
} if (!items.length) return;
}
];
openMenu(items, { openMenu(items, {
title: strings.jumpToGroup(), title: strings.jumpToGroup(),
@@ -352,13 +358,16 @@ function GroupHeader(props: GroupHeaderProps) {
groupByToTitleMap[groupOptions.groupBy || "default"] groupByToTitleMap[groupOptions.groupBy || "default"]
}`} }`}
onClick={() => { onClick={() => {
const groupOptions = db.settings!.getGroupOptions(groupingKey); const groupOptions = db.settings.getGroupOptions(
isSearching ? "search" : groupingKey
);
setGroupOptions(groupOptions); setGroupOptions(groupOptions);
const menuOptions: Omit<GroupingMenuOptions, "parentKey"> = { const menuOptions: Omit<GroupingMenuOptions, "parentKey"> = {
groupingKey, groupingKey: isSearching ? "search" : groupingKey,
groupOptions, groupOptions,
refresh refresh,
isSearching
}; };
const groupBy = groupByMenu({ const groupBy = groupByMenu({
...menuOptions, ...menuOptions,

View File

@@ -74,6 +74,7 @@ type ListContainerProps = {
header?: JSX.Element; header?: JSX.Element;
placeholder: JSX.Element; placeholder: JSX.Element;
isLoading?: boolean; isLoading?: boolean;
isSearching?: boolean;
onDrop?: (e: React.DragEvent<HTMLDivElement>) => void; onDrop?: (e: React.DragEvent<HTMLDivElement>) => void;
button?: { button?: {
onClick: () => void; onClick: () => void;
@@ -91,7 +92,8 @@ function ListContainer(props: ListContainerProps) {
button, button,
compact, compact,
sx, sx,
Scroller Scroller,
isSearching
} = props; } = props;
const [focusedGroupIndex, setFocusedGroupIndex] = useState(-1); const [focusedGroupIndex, setFocusedGroupIndex] = useState(-1);
@@ -243,11 +245,14 @@ function ListContainer(props: ListContainerProps) {
focusGroup: setFocusedGroupIndex, focusGroup: setFocusedGroupIndex,
context, context,
compact, compact,
onMouseUp onMouseUp,
isSearching
}} }}
itemContent={(index, _data, context) => ( itemContent={(index, _data, context) =>
context ? (
<ItemRenderer context={context} index={index} /> <ItemRenderer context={context} index={index} />
)} ) : null
}
/> />
</Flex> </Flex>
</> </>
@@ -293,6 +298,7 @@ type ListContext = {
focusGroup: (index: number) => void; focusGroup: (index: number) => void;
context?: Context; context?: Context;
compact?: boolean; compact?: boolean;
isSearching?: boolean;
onMouseUp: (e: MouseEvent, itemIndex: number) => void; onMouseUp: (e: MouseEvent, itemIndex: number) => void;
}; };
@@ -312,8 +318,10 @@ function ItemRenderer({
selectItems, selectItems,
scrollToIndex, scrollToIndex,
context: itemContext, context: itemContext,
compact compact,
isSearching
} = context; } = context;
const resolvedItem = useResolvedItem({ index, items }); const resolvedItem = useResolvedItem({ index, items });
if (!resolvedItem || !resolvedItem.item) { if (!resolvedItem || !resolvedItem.item) {
const placeholderData = getListItemPlaceholderData(group, compact); const placeholderData = getListItemPlaceholderData(group, compact);
@@ -351,6 +359,7 @@ function ItemRenderer({
{resolvedItem.group && group ? ( {resolvedItem.group && group ? (
<GroupHeader <GroupHeader
groupingKey={group} groupingKey={group}
isSearching={isSearching}
refresh={refresh} refresh={refresh}
title={resolvedItem.group.title} title={resolvedItem.group.title}
isFocused={index === focusedGroupIndex} isFocused={index === focusedGroupIndex}

View File

@@ -18,25 +18,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { DependencyList, useEffect, useState } from "react"; import { DependencyList, useEffect, useState } from "react";
import { useStore as useSearchStore } from "../stores/search-store"; import { useStore as useSearchStore } from "../stores/search-store";
import { VirtualizedGrouping } from "@notesnook/core"; import { SortOptions, VirtualizedGrouping } from "@notesnook/core";
import { db } from "../common/db";
export function useSearch<T>( export function useSearch<T>(
type: "notes" | "notebooks" | "notebook" | "reminders" | "trash" | "tags", type: "notes" | "notebooks" | "notebook" | "reminders" | "trash" | "tags",
lookup: (query: string) => Promise<VirtualizedGrouping<T>> | undefined, lookup: (
query: string,
sortOptions?: SortOptions
) => Promise<VirtualizedGrouping<T> | undefined>,
deps: DependencyList = [] deps: DependencyList = []
) { ) {
const isSearching = useSearchStore((store) => store.isSearching); const isSearching = useSearchStore((store) => store.isSearching);
const query = useSearchStore((store) => store.query); const query = useSearchStore((store) => store.query);
const searchType = useSearchStore((store) => store.searchType); const searchType = useSearchStore((store) => store.searchType);
const sortOptions = useSearchStore((store) => store.sortOptions);
const [filteredItems, setFilteredItems] = useState<VirtualizedGrouping<T>>(); const [filteredItems, setFilteredItems] = useState<VirtualizedGrouping<T>>();
useEffect(() => { useEffect(() => {
(async function () {
if (!query || !isSearching) return setFilteredItems(undefined); if (!query || !isSearching) return setFilteredItems(undefined);
if (searchType !== type) return; if (searchType !== type) return;
setFilteredItems(await lookup(query));
})(); lookup(query, sortOptions || db.settings.getGroupOptions("search")).then(
}, [isSearching, query, searchType, type, ...deps]); (items) => setFilteredItems(items)
);
}, [isSearching, query, searchType, type, sortOptions, ...deps]);
return filteredItems; return filteredItems;
} }

View File

@@ -109,6 +109,10 @@ export const useTip = (
}; };
const tips: Tip[] = [ const tips: Tip[] = [
{
text: `Wrap a query in double quotes to search for an exact match.`,
contexts: ["search"]
},
{ {
text: "Hold Ctrl/Cmd & click on multiple items to select them.", text: "Hold Ctrl/Cmd & click on multiple items to select them.",
contexts: ["notes", "notebooks", "tags"] contexts: ["notes", "notebooks", "tags"]

View File

@@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { SortOptions } from "@notesnook/core";
import createStore from "../common/store"; import createStore from "../common/store";
import BaseStore from "./index"; import BaseStore from "./index";
@@ -24,9 +25,15 @@ class SearchStore extends BaseStore<SearchStore> {
isSearching = false; isSearching = false;
query?: string; query?: string;
searchType?: string; searchType?: string;
sortOptions?: SortOptions;
resetSearch = () => { resetSearch = () => {
this.set({ isSearching: false, query: undefined, searchType: undefined }); this.set({
isSearching: false,
query: undefined,
searchType: undefined,
sortOptions: undefined
});
}; };
} }

View File

@@ -34,9 +34,9 @@ function Home() {
const setContext = useStore((store) => store.setContext); const setContext = useStore((store) => store.setContext);
const filteredItems = useSearch( const filteredItems = useSearch(
"notes", "notes",
(query) => { async (query, sortOptions) => {
if (useStore.getState().context) return; if (useStore.getState().context) return;
return db.lookup.notes(query).sorted(); return await db.lookup.notes(query).sorted(sortOptions);
}, },
[notes] [notes]
); );
@@ -72,7 +72,8 @@ function Home() {
compact={isCompact} compact={isCompact}
refresh={refresh} refresh={refresh}
items={filteredItems || notes} items={filteredItems || notes}
placeholder={<Placeholder context="notes" />} isSearching={!!filteredItems}
placeholder={<Placeholder context={filteredItems ? "search" : "notes"} />}
button={{ button={{
onClick: () => useEditorStore.getState().newSession() onClick: () => useEditorStore.getState().newSession()
}} }}

View File

@@ -44,10 +44,10 @@ function Notes(props: NotesProps) {
const isCompact = useNotesStore((store) => store.viewMode === "compact"); const isCompact = useNotesStore((store) => store.viewMode === "compact");
const filteredItems = useSearch( const filteredItems = useSearch(
context?.type === "notebook" ? "notebook" : "notes", context?.type === "notebook" ? "notebook" : "notes",
(query) => { async (query, sortOptions) => {
if (!context || !contextNotes) return; if (!context || !contextNotes) return;
const notes = notesFromContext(context); const notes = notesFromContext(context);
return db.lookup.notes(query, notes).sorted(); return await db.lookup.notes(query, notes).sorted(sortOptions);
}, },
[context, contextNotes] [context, contextNotes]
); );
@@ -58,6 +58,7 @@ function Notes(props: NotesProps) {
type={type} type={type}
group={type} group={type}
refresh={refreshContext} refresh={refreshContext}
isSearching={!!filteredItems}
compact={isCompact} compact={isCompact}
context={context} context={context}
items={filteredItems || contextNotes} items={filteredItems || contextNotes}
@@ -65,7 +66,9 @@ function Notes(props: NotesProps) {
placeholder={ placeholder={
<Placeholder <Placeholder
context={ context={
context.type === "favorite" filteredItems
? "search"
: context.type === "favorite"
? "favorites" ? "favorites"
: context.type === "archive" : context.type === "archive"
? "archive" ? "archive"

View File

@@ -30,8 +30,8 @@ function Reminders() {
useNavigate("reminders", () => store.refresh()); useNavigate("reminders", () => store.refresh());
const reminders = useStore((state) => state.reminders); const reminders = useStore((state) => state.reminders);
const refresh = useStore((state) => state.refresh); const refresh = useStore((state) => state.refresh);
const filteredItems = useSearch("reminders", (query) => const filteredItems = useSearch("reminders", (query, sortOptions) =>
db.lookup.reminders(query).sorted() db.lookup.reminders(query).sorted(sortOptions)
); );
if (!reminders) return <ListLoader />; if (!reminders) return <ListLoader />;
@@ -42,7 +42,10 @@ function Reminders() {
group="reminders" group="reminders"
refresh={refresh} refresh={refresh}
items={filteredItems || reminders} items={filteredItems || reminders}
placeholder={<Placeholder context="reminders" />} isSearching={!!filteredItems}
placeholder={
<Placeholder context={filteredItems ? "search" : "reminders"} />
}
button={{ button={{
onClick: () => hashNavigate("/reminders/create") onClick: () => hashNavigate("/reminders/create")
}} }}

View File

@@ -33,8 +33,8 @@ function Trash() {
const items = useStore((store) => store.trash); const items = useStore((store) => store.trash);
const refresh = useStore((store) => store.refresh); const refresh = useStore((store) => store.refresh);
const clearTrash = useStore((store) => store.clear); const clearTrash = useStore((store) => store.clear);
const filteredItems = useSearch("trash", (query) => const filteredItems = useSearch("trash", (query, sortOptions) =>
db.lookup.trash(query).sorted() db.lookup.trash(query).sorted(sortOptions)
); );
if (!items) return <ListLoader />; if (!items) return <ListLoader />;
@@ -43,7 +43,8 @@ function Trash() {
type="trash" type="trash"
group="trash" group="trash"
refresh={refresh} refresh={refresh}
placeholder={<Placeholder context="trash" />} isSearching={!!filteredItems}
placeholder={<Placeholder context={filteredItems ? "search" : "trash"} />}
items={filteredItems || items} items={filteredItems || items}
button={{ button={{
onClick: function () { onClick: function () {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -636,6 +636,8 @@ $headline$: Use starting line of the note as title.`,
newOld: () => t`New - old`, newOld: () => t`New - old`,
latestFirst: () => t`Latest first`, latestFirst: () => t`Latest first`,
earliestFirst: () => t`Earliest first`, earliestFirst: () => t`Earliest first`,
mostRelevantFirst: () => t`Most relevant first`,
leastRelevantFirst: () => t`Least relevant first`,
aToZ: () => t`A to Z`, aToZ: () => t`A to Z`,
zToA: () => t`Z to A`, zToA: () => t`Z to A`,
title: () => t`Title`, title: () => t`Title`,
@@ -645,7 +647,8 @@ $headline$: Use starting line of the note as title.`,
dateCreated: () => t`Date created`, dateCreated: () => t`Date created`,
title: () => t`Title`, title: () => t`Title`,
dueDate: () => t`Due date`, dueDate: () => t`Due date`,
dateDeleted: () => t`Date deleted` dateDeleted: () => t`Date deleted`,
relevance: () => t`Relevance`
}, },
groupByStrings: { groupByStrings: {
default: () => t`Default`, default: () => t`Default`,