mirror of
https://github.com/usememos/memos.git
synced 2025-04-08 23:01:07 +02:00
feat: persist memo filters with url query (#4239)
* feat: persist memo filters with url query - Implemented synchronization of memo filters with URL search parameters. - Enhanced memo store to manage current request state for fetching memos, allowing for request cancellation, avoiding the race conditions. * refactor: update import
This commit is contained in:
parent
0e1ac30462
commit
e3b378b03b
@ -1,10 +1,47 @@
|
|||||||
import { isEqual } from "lodash-es";
|
import { isEqual } from "lodash-es";
|
||||||
import { CalendarIcon, CheckCircleIcon, CodeIcon, EyeIcon, FilterIcon, LinkIcon, SearchIcon, TagIcon, XIcon } from "lucide-react";
|
import { CalendarIcon, CheckCircleIcon, CodeIcon, EyeIcon, FilterIcon, LinkIcon, SearchIcon, TagIcon, XIcon } from "lucide-react";
|
||||||
import { FilterFactor, getMemoFilterKey, MemoFilter, useMemoFilterStore } from "@/store/v1";
|
import { useEffect } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import usePrevious from "react-use/lib/usePrevious";
|
||||||
|
import { FilterFactor, getMemoFilterKey, MemoFilter, parseFilterQuery, stringifyFilters, useMemoFilterStore } from "@/store/v1";
|
||||||
|
|
||||||
const MemoFilters = () => {
|
const MemoFilters = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const memoFilterStore = useMemoFilterStore();
|
const memoFilterStore = useMemoFilterStore();
|
||||||
const filters = memoFilterStore.filters;
|
const filters = memoFilterStore.filters;
|
||||||
|
const prevFilters = usePrevious(filters);
|
||||||
|
const orderByTimeAsc = memoFilterStore.orderByTimeAsc;
|
||||||
|
const prevOrderByTimeAsc = usePrevious(orderByTimeAsc);
|
||||||
|
|
||||||
|
// Sync the filters and orderByTimeAsc to the search params.
|
||||||
|
useEffect(() => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
if (prevOrderByTimeAsc !== orderByTimeAsc) {
|
||||||
|
if (orderByTimeAsc) {
|
||||||
|
newSearchParams.set("orderBy", "asc");
|
||||||
|
} else {
|
||||||
|
newSearchParams.delete("orderBy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevFilters && stringifyFilters(prevFilters) !== stringifyFilters(filters)) {
|
||||||
|
if (filters.length > 0) {
|
||||||
|
newSearchParams.set("filter", stringifyFilters(filters));
|
||||||
|
} else {
|
||||||
|
newSearchParams.delete("filter");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams(newSearchParams);
|
||||||
|
}, [prevOrderByTimeAsc, orderByTimeAsc, prevFilters, filters, searchParams]);
|
||||||
|
|
||||||
|
// Sync the search params to the filters and orderByTimeAsc when the component is mounted.
|
||||||
|
useEffect(() => {
|
||||||
|
const newFilters = parseFilterQuery(searchParams.get("filter"));
|
||||||
|
const newOrderByTimeAsc = searchParams.get("orderBy") === "asc";
|
||||||
|
memoFilterStore.setState({ filters: newFilters, orderByTimeAsc: newOrderByTimeAsc });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getFilterDisplayText = (filter: MemoFilter) => {
|
const getFilterDisplayText = (filter: MemoFilter) => {
|
||||||
if (filter.value) {
|
if (filter.value) {
|
||||||
|
@ -41,7 +41,7 @@ const PagedMemoList = (props: Props) => {
|
|||||||
});
|
});
|
||||||
setState(() => ({
|
setState(() => ({
|
||||||
isRequesting: false,
|
isRequesting: false,
|
||||||
nextPageToken: response.nextPageToken,
|
nextPageToken: response?.nextPageToken || "",
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2,9 +2,10 @@ import { Tooltip } from "@mui/joy";
|
|||||||
import { Button } from "@usememos/mui";
|
import { Button } from "@usememos/mui";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { Outlet, useLocation } from "react-router-dom";
|
import { Outlet, useLocation, useSearchParams } from "react-router-dom";
|
||||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||||
|
import usePrevious from "react-use/lib/usePrevious";
|
||||||
import Navigation from "@/components/Navigation";
|
import Navigation from "@/components/Navigation";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||||
@ -16,11 +17,14 @@ import { useTranslate } from "@/utils/i18n";
|
|||||||
const RootLayout = () => {
|
const RootLayout = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { sm } = useResponsiveWidth();
|
const { sm } = useResponsiveWidth();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const memoFilterStore = useMemoFilterStore();
|
const memoFilterStore = useMemoFilterStore();
|
||||||
const [collapsed, setCollapsed] = useLocalStorage<boolean>("navigation-collapsed", false);
|
const [collapsed, setCollapsed] = useLocalStorage<boolean>("navigation-collapsed", false);
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const pathname = useMemo(() => location.pathname, [location.pathname]);
|
||||||
|
const prevPathname = usePrevious(pathname);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
@ -33,9 +37,11 @@ const RootLayout = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// When the route changes, remove all filters.
|
// When the route changes and there is no filter in the search params, remove all filters.
|
||||||
|
if (prevPathname !== pathname && !searchParams.has("filter")) {
|
||||||
memoFilterStore.removeFilter(() => true);
|
memoFilterStore.removeFilter(() => true);
|
||||||
}, [location.pathname]);
|
}
|
||||||
|
}, [prevPathname, pathname, searchParams]);
|
||||||
|
|
||||||
return !initialized ? (
|
return !initialized ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
|
@ -9,28 +9,56 @@ interface State {
|
|||||||
// It should be update when any state change.
|
// It should be update when any state change.
|
||||||
stateId: string;
|
stateId: string;
|
||||||
memoMapByName: Record<string, Memo>;
|
memoMapByName: Record<string, Memo>;
|
||||||
|
currentRequest: AbortController | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultState = (): State => ({
|
const getDefaultState = (): State => ({
|
||||||
stateId: uniqueId(),
|
stateId: uniqueId(),
|
||||||
memoMapByName: {},
|
memoMapByName: {},
|
||||||
|
currentRequest: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useMemoStore = create(
|
export const useMemoStore = create(
|
||||||
combine(getDefaultState(), (set, get) => ({
|
combine(getDefaultState(), (set, get) => ({
|
||||||
setState: (state: State) => set(state),
|
setState: (state: State) => set(state),
|
||||||
getState: () => get(),
|
getState: () => get(),
|
||||||
|
updateStateId: () => set({ stateId: uniqueId() }),
|
||||||
fetchMemos: async (request: Partial<ListMemosRequest>) => {
|
fetchMemos: async (request: Partial<ListMemosRequest>) => {
|
||||||
const { memos, nextPageToken } = await memoServiceClient.listMemos({
|
const currentRequest = get().currentRequest;
|
||||||
|
if (currentRequest) {
|
||||||
|
currentRequest.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
set({ currentRequest: controller });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { memos, nextPageToken } = await memoServiceClient.listMemos(
|
||||||
|
{
|
||||||
...request,
|
...request,
|
||||||
view: MemoView.MEMO_VIEW_FULL,
|
view: MemoView.MEMO_VIEW_FULL,
|
||||||
});
|
},
|
||||||
const memoMap = { ...get().memoMapByName };
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
const memoMap = request.pageToken ? { ...get().memoMapByName } : {};
|
||||||
for (const memo of memos) {
|
for (const memo of memos) {
|
||||||
memoMap[memo.name] = memo;
|
memoMap[memo.name] = memo;
|
||||||
}
|
}
|
||||||
set({ stateId: uniqueId(), memoMapByName: memoMap });
|
set({ stateId: uniqueId(), memoMapByName: memoMap });
|
||||||
return { memos, nextPageToken };
|
return { memos, nextPageToken };
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (get().currentRequest === controller) {
|
||||||
|
set({ currentRequest: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
getOrFetchMemoByName: async (name: string, options?: { skipCache?: boolean; skipStore?: boolean }) => {
|
getOrFetchMemoByName: async (name: string, options?: { skipCache?: boolean; skipStore?: boolean }) => {
|
||||||
const memoMap = get().memoMapByName;
|
const memoMap = get().memoMapByName;
|
||||||
@ -99,7 +127,7 @@ export const useMemoList = () => {
|
|||||||
const memos = Object.values(memoStore.getState().memoMapByName);
|
const memos = Object.values(memoStore.getState().memoMapByName);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
memoStore.setState({ stateId: uniqueId(), memoMapByName: {} });
|
memoStore.updateStateId();
|
||||||
};
|
};
|
||||||
|
|
||||||
const size = () => {
|
const size = () => {
|
||||||
|
@ -18,13 +18,41 @@ export interface MemoFilter {
|
|||||||
|
|
||||||
export const getMemoFilterKey = (filter: MemoFilter) => `${filter.factor}:${filter.value}`;
|
export const getMemoFilterKey = (filter: MemoFilter) => `${filter.factor}:${filter.value}`;
|
||||||
|
|
||||||
|
export const parseFilterQuery = (query: string | null): MemoFilter[] => {
|
||||||
|
if (!query) return [];
|
||||||
|
try {
|
||||||
|
return query.split(",").map((filterStr) => {
|
||||||
|
const [factor, value] = filterStr.split(":");
|
||||||
|
return {
|
||||||
|
factor: factor as FilterFactor,
|
||||||
|
value: decodeURIComponent(value),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse filter query:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stringifyFilters = (filters: MemoFilter[]): string => {
|
||||||
|
return filters.map((filter) => `${filter.factor}:${encodeURIComponent(filter.value)}`).join(",");
|
||||||
|
};
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
filters: MemoFilter[];
|
filters: MemoFilter[];
|
||||||
orderByTimeAsc: boolean;
|
orderByTimeAsc: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getInitialState = (): State => {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
return {
|
||||||
|
filters: parseFilterQuery(searchParams.get("filter")),
|
||||||
|
orderByTimeAsc: searchParams.get("orderBy") === "asc",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const useMemoFilterStore = create(
|
export const useMemoFilterStore = create(
|
||||||
combine(((): State => ({ filters: [], orderByTimeAsc: false }))(), (set, get) => ({
|
combine(getInitialState(), (set, get) => ({
|
||||||
setState: (state: State) => set(state),
|
setState: (state: State) => set(state),
|
||||||
getState: () => get(),
|
getState: () => get(),
|
||||||
getFiltersByFactor: (factor: FilterFactor) => get().filters.filter((f) => f.factor === factor),
|
getFiltersByFactor: (factor: FilterFactor) => get().filters.filter((f) => f.factor === factor),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user