feat: open notes from tags
This commit is contained in:
1 parent
9156aed2d6
commit
51267d357a
17 files changed
+159
-25
No files matched your search
@@ -83,6 +83,22 @@ describe("memo list service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("filters by one exact tag in page and count queries", async () => {
|
||||
const database = createDatabase();
|
||||
await listMemos(database, {
|
||||
workspaceId: "ws_1",
|
||||
tag: "Demo",
|
||||
});
|
||||
|
||||
expect(database.calls).toHaveLength(2);
|
||||
for (const call of database.calls) {
|
||||
expect(call.sql).toContain("json_each(m.tags_json)");
|
||||
expect(call.sql).toContain("LOWER(CAST(memo_tag.value AS TEXT)) = LOWER(?)");
|
||||
expect(call.parameters).toContain("Demo");
|
||||
expect(call.sql).not.toContain("m.tags_json LIKE");
|
||||
}
|
||||
});
|
||||
|
||||
test("combines FTS and escaped LIKE search for normal text", async () => {
|
||||
const database = createDatabase();
|
||||
await listMemos(database, {
|
||||
|
||||
@@ -43,6 +43,7 @@ export type ListMemosInput = {
|
||||
notebookId?: string;
|
||||
includeNotebookDescendants?: boolean;
|
||||
query?: string;
|
||||
tag?: string;
|
||||
includeTrash?: boolean;
|
||||
sort?: string;
|
||||
filter?: string;
|
||||
@@ -150,6 +151,7 @@ export const listMemos = async (
|
||||
): Promise<ListMemosResult> => {
|
||||
const notebookId = input.notebookId;
|
||||
const query = input.query?.trim();
|
||||
const tag = input.tag?.trim();
|
||||
const includeTrash = input.includeTrash === true;
|
||||
const sort = normalizeMemoListSort(input.sort);
|
||||
const filter = normalizeMemoListFilter(input.filter);
|
||||
@@ -186,6 +188,13 @@ export const listMemos = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
baseConditions.push(
|
||||
"EXISTS (SELECT 1 FROM json_each(m.tags_json) AS memo_tag WHERE LOWER(CAST(memo_tag.value AS TEXT)) = LOWER(?))",
|
||||
);
|
||||
baseBinds.push(tag);
|
||||
}
|
||||
|
||||
if (filter === "tagged") baseConditions.push("m.tags_json <> '[]'");
|
||||
else if (filter === "untagged") baseConditions.push("m.tags_json = '[]'");
|
||||
else if (filter === "pinned") baseConditions.push("m.is_pinned = 1");
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("memo route contracts", () => {
|
||||
},
|
||||
});
|
||||
const response = await createApp(dependencies).request(
|
||||
"/api/v1/memos?notebookId=nb_1&includeDescendants=1&q=hello&trash=1&sort=title-asc&filter=pinned&limit=25&cursor=opaque",
|
||||
"/api/v1/memos?notebookId=nb_1&includeDescendants=1&q=hello&tag=Demo&trash=1&sort=title-asc&filter=pinned&limit=25&cursor=opaque",
|
||||
{},
|
||||
environment,
|
||||
);
|
||||
@@ -70,6 +70,7 @@ describe("memo route contracts", () => {
|
||||
notebookId: "nb_1",
|
||||
includeNotebookDescendants: true,
|
||||
query: "hello",
|
||||
tag: "Demo",
|
||||
includeTrash: true,
|
||||
sort: "title-asc",
|
||||
filter: "pinned",
|
||||
|
||||
@@ -126,6 +126,7 @@ export const registerMemoRoutes = (
|
||||
notebookId: context.req.query("notebookId"),
|
||||
includeNotebookDescendants: context.req.query("includeDescendants") === "1",
|
||||
query: context.req.query("q"),
|
||||
tag: context.req.query("tag"),
|
||||
includeTrash: context.req.query("trash") === "1",
|
||||
sort: context.req.query("sort"),
|
||||
filter: context.req.query("filter"),
|
||||
|
||||
@@ -331,6 +331,7 @@ const CheckCircleCheck = ({ className }: { className?: string }) => (
|
||||
export const MemoListPane = ({
|
||||
notebooks,
|
||||
notebook,
|
||||
selectedTag,
|
||||
memos,
|
||||
totalMemoCount,
|
||||
hasMoreMemos,
|
||||
@@ -375,6 +376,7 @@ export const MemoListPane = ({
|
||||
onOpenAssets,
|
||||
onOpenTrash,
|
||||
onBackFromTrash,
|
||||
onClearTag,
|
||||
onOpenSettings,
|
||||
onSyncMemos,
|
||||
onCreateMemo,
|
||||
@@ -404,6 +406,7 @@ export const MemoListPane = ({
|
||||
multiSelectKeyDown: boolean;
|
||||
notebooks: Notebook[];
|
||||
notebook: Notebook | null;
|
||||
selectedTag: string | null;
|
||||
memos: MemoSummary[];
|
||||
totalMemoCount: number;
|
||||
hasMoreMemos: boolean;
|
||||
@@ -448,6 +451,7 @@ export const MemoListPane = ({
|
||||
onOpenAssets: () => void;
|
||||
onOpenTrash: () => void;
|
||||
onBackFromTrash: () => void;
|
||||
onClearTag: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onSyncMemos: () => void;
|
||||
onCreateMemo: () => void;
|
||||
@@ -496,8 +500,8 @@ export const MemoListPane = ({
|
||||
const canToggleVisibleMemoSelection = visibleMemoIds.length > 0;
|
||||
const visibleSelectionToggleLabel = allVisibleMemosSelected ? t("memoList.selectedListNone") : t("memoList.selectedListAll");
|
||||
|
||||
const listTitle = view === "trash" ? t("memoList.trash") : notebook?.name ?? t("memoList.allMemos");
|
||||
const listContextLabel = view === "trash" ? t("memoList.deletedMemos") : notebook ? t("memoList.currentNotebook") : t("memoList.allNotebooks");
|
||||
const listTitle = view === "trash" ? t("memoList.trash") : selectedTag ? `#${selectedTag}` : notebook?.name ?? t("memoList.allMemos");
|
||||
const listContextLabel = view === "trash" ? t("memoList.deletedMemos") : selectedTag ? t("memoList.tagFilter") : notebook ? t("memoList.currentNotebook") : t("memoList.allNotebooks");
|
||||
const visibleCount = `${memos.length}${memos.length !== totalMemoCount ? ` / ${totalMemoCount}` : ""}`;
|
||||
const listCountLabel = view === "trash"
|
||||
? t("memoList.deletedCount", { count: visibleCount })
|
||||
@@ -533,7 +537,7 @@ export const MemoListPane = ({
|
||||
const moveTargetTitle =
|
||||
view === "trash" ? t("workspace.selection.trashCannotMove") : notebooks.length === 0 ? t("workspace.selection.noMovableNotebook") : isMoving ? t("workspace.selection.moving") : t("memoList.moveToNotebook");
|
||||
const searchActive = Boolean(search.trim());
|
||||
const hasListConstraint = searchActive || filterMode !== "all";
|
||||
const hasListConstraint = searchActive || filterMode !== "all" || Boolean(selectedTag);
|
||||
const activeFilterLabel = filterOptions.find((option) => option.value === filterMode)?.label ?? t("options.memoFilter.all");
|
||||
const activeSortLabel = memoSortOptions.find((option) => option.value === sortMode)?.label ?? t("options.memoSort.updatedDesc");
|
||||
const syncMemosTitle = !canSyncMemos
|
||||
@@ -852,6 +856,7 @@ export const MemoListPane = ({
|
||||
onClearSelection();
|
||||
onFilterModeChange("all");
|
||||
onSearch("");
|
||||
if (selectedTag) onClearTag();
|
||||
focusSearchInput();
|
||||
};
|
||||
|
||||
@@ -1031,13 +1036,13 @@ export const MemoListPane = ({
|
||||
{!mobileSearchActive && (
|
||||
<div className="mb-3 flex items-center justify-between gap-3 lg:hidden">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{view === "trash" && (
|
||||
{(view === "trash" || selectedTag) && (
|
||||
<button
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-600 transition hover:bg-slate-100 hover:text-slate-900"
|
||||
type="button"
|
||||
title={t("notebookPane.backToList")}
|
||||
aria-label={t("notebookPane.backToList")}
|
||||
onClick={onBackFromTrash}
|
||||
onClick={view === "trash" ? onBackFromTrash : onClearTag}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -1073,14 +1078,14 @@ export const MemoListPane = ({
|
||||
)}
|
||||
|
||||
<div className="mb-3 hidden min-w-0 lg:flex items-start gap-1">
|
||||
{view === "trash" && (
|
||||
{(view === "trash" || selectedTag) && (
|
||||
<Button
|
||||
className="-ml-2 mt-0.5 shrink-0"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={t("notebookPane.backToList")}
|
||||
aria-label={t("notebookPane.backToList")}
|
||||
onClick={onBackFromTrash}
|
||||
onClick={view === "trash" ? onBackFromTrash : onClearTag}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -1344,7 +1349,7 @@ export const MemoListPane = ({
|
||||
{searchActive
|
||||
? t("memoList.searchResults", { count: totalMemoCount })
|
||||
: t("memoList.constrainedCount", {
|
||||
label: t("memoList.filterConstraint", { label: activeFilterLabel }),
|
||||
label: selectedTag ? `#${selectedTag}` : t("memoList.filterConstraint", { label: activeFilterLabel }),
|
||||
count: totalMemoCount,
|
||||
})}
|
||||
</span>
|
||||
|
||||
@@ -10,7 +10,15 @@ import type { TagSummary } from "@edgeever/shared";
|
||||
import { AppConfirmDialog } from "./dialogs/ConfirmDialogs";
|
||||
import type { EdgeEverRepository } from "@/lib/repository";
|
||||
|
||||
export const TagsPane = ({ onClose, repository }: { onClose: () => void; repository: EdgeEverRepository }) => {
|
||||
export const TagsPane = ({
|
||||
onClose,
|
||||
onSelectTag,
|
||||
repository,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSelectTag: (tag: string) => void;
|
||||
repository: EdgeEverRepository;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingTagName, setEditingTagName] = useState<string | null>(null);
|
||||
@@ -66,8 +74,8 @@ export const TagsPane = ({ onClose, repository }: { onClose: () => void; reposit
|
||||
const nextName = editingTagValue.trim();
|
||||
return (
|
||||
<div key={tag.name} className={cn("flex min-h-12 items-center gap-3 rounded-md border border-slate-200 bg-white px-3 py-2", isEditing && "border-emerald-200 bg-emerald-50/30")}>
|
||||
<span className="min-w-0 flex-1">
|
||||
{isEditing ? (
|
||||
{isEditing ? (
|
||||
<div className="min-w-0 flex-1">
|
||||
<form className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center" onSubmit={(event) => { event.preventDefault(); if (nextName && nextName !== tag.name && !renameMutation.isPending) renameMutation.mutate({ tag: tag.name, name: nextName }); }}>
|
||||
<label className="sr-only" htmlFor={`tag-rename-${tag.name}`}>{t("tagsDialog.nameLabel")}</label>
|
||||
<Input id={`tag-rename-${tag.name}`} className="h-9 min-w-0 flex-1 focus-visible:border-emerald-300 focus-visible:ring-emerald-500/20" value={editingTagValue} autoFocus disabled={renameMutation.isPending} maxLength={80} onChange={(event) => setEditingTagValue(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); cancelRename(); } }} />
|
||||
@@ -76,10 +84,16 @@ export const TagsPane = ({ onClose, repository }: { onClose: () => void; reposit
|
||||
<Button size="sm" type="button" variant="outline" onClick={cancelRename} disabled={renameMutation.isPending}>{t("common.cancel")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="min-w-0 flex-1 rounded-md px-1 py-1 text-left transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/30"
|
||||
type="button"
|
||||
onClick={() => onSelectTag(tag.name)}
|
||||
>
|
||||
<><span className="block truncate text-sm font-semibold text-slate-950">#{tag.name}</span><span className="mt-1 block text-xs text-slate-500">{t("tagsDialog.memoCount", { count: tag.memoCount })}{tag.updatedAt ? ` · ${formatDateTime(tag.updatedAt)}` : ""}</span></>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{!isEditing && <><Button size="icon" variant="ghost" title={t("tagsDialog.renameTitle")} aria-label={t("tagsDialog.renameAria", { name: tag.name })} onClick={() => { setEditingTagName(tag.name); setEditingTagValue(tag.name); }}><Pencil className="h-4 w-4" /></Button><Button size="icon" variant="danger" title={t("tagsDialog.deleteTitle")} aria-label={t("tagsDialog.deleteAria", { name: tag.name })} onClick={() => setTagDeleteConfirmation(tag)}><Trash2 className="h-4 w-4" /></Button></>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -700,6 +700,7 @@ export const WorkspaceApp = ({
|
||||
setSelectedNotebookId,
|
||||
setSelectionMoveTargetNotebookId,
|
||||
} = useWorkspaceSelection();
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const autoSelectedDemoNotebookRef = useRef(false);
|
||||
const [createdMemoEditId, setCreatedMemoEditId] = useState<string | null>(null);
|
||||
const pendingCreatedMemoIdRef = useRef<string | null>(null);
|
||||
@@ -921,11 +922,11 @@ export const WorkspaceApp = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoSelectedDemoNotebookRef.current && selectedNotebookId === null) {
|
||||
if (!autoSelectedDemoNotebookRef.current && selectedNotebookId === null && selectedTag === null) {
|
||||
autoSelectedDemoNotebookRef.current = true;
|
||||
setSelectedNotebookId(preferredNotebookId);
|
||||
}
|
||||
}, [i18n.resolvedLanguage, notebooks, selectedNotebookId]);
|
||||
}, [i18n.resolvedLanguage, notebooks, selectedNotebookId, selectedTag]);
|
||||
|
||||
const mobileEditorReturnMemoId = route.mobileEditorReturnMemoId;
|
||||
const visibleActivePane: Pane = mobileEditorReturnMemoId ? "memos" : activePane;
|
||||
@@ -1284,15 +1285,16 @@ export const WorkspaceApp = ({
|
||||
});
|
||||
|
||||
const selectedNotebookDescendantIds = useMemo(
|
||||
() => (selectedNotebookId ? getNotebookDescendantIds(notebooks, selectedNotebookId) : []),
|
||||
[notebooks, selectedNotebookId]
|
||||
() => (selectedNotebookId && !selectedTag ? getNotebookDescendantIds(notebooks, selectedNotebookId) : []),
|
||||
[notebooks, selectedNotebookId, selectedTag]
|
||||
);
|
||||
const memosQuery = useInfiniteQuery({
|
||||
queryKey: ["memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode, selectedNotebookDescendantIds],
|
||||
queryKey: ["memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode, selectedNotebookDescendantIds, selectedTag],
|
||||
queryFn: ({ pageParam }) => repository.listMemos({
|
||||
notebookId: memoView === "notebook" ? selectedNotebookId : null,
|
||||
notebookIds: memoView === "notebook" ? selectedNotebookDescendantIds : undefined,
|
||||
notebookId: memoView === "notebook" && !selectedTag ? selectedNotebookId : null,
|
||||
notebookIds: memoView === "notebook" && !selectedTag ? selectedNotebookDescendantIds : undefined,
|
||||
q: search,
|
||||
tag: memoView === "notebook" ? selectedTag ?? undefined : undefined,
|
||||
trash: memoView === "trash",
|
||||
filter: memoFilterMode,
|
||||
sort: memoSortMode,
|
||||
@@ -1402,6 +1404,7 @@ export const WorkspaceApp = ({
|
||||
onSuccess: async (data) => {
|
||||
await putLocalNotebook(localDataScope, data.notebook);
|
||||
await queryClient.invalidateQueries({ queryKey: ["notebooks"] });
|
||||
setSelectedTag(null);
|
||||
setSelectedNotebookId(data.notebook.id);
|
||||
setActivePane("memos");
|
||||
},
|
||||
@@ -1450,6 +1453,7 @@ export const WorkspaceApp = ({
|
||||
|
||||
setMemoView("notebook");
|
||||
setSearch("");
|
||||
setSelectedTag(null);
|
||||
// A newly created memo is not pinned or otherwise guaranteed to match
|
||||
// the active list filter. Leave filtered views so the selected memo
|
||||
// remains visible instead of the list effect falling back to its first
|
||||
@@ -1515,6 +1519,7 @@ export const WorkspaceApp = ({
|
||||
setTemplatesOpen(false);
|
||||
setMemoView("notebook");
|
||||
setSearch("");
|
||||
setSelectedTag(null);
|
||||
setSelectedNotebookId(targetNotebookId);
|
||||
cacheMemoDetail(queryClient, data.memo, "notebook");
|
||||
updateMemoSummaryInLists(queryClient, memoToSummary(data.memo));
|
||||
@@ -1792,6 +1797,7 @@ export const WorkspaceApp = ({
|
||||
mutationFn: repository.restoreMemo,
|
||||
onSuccess: (data) => {
|
||||
setMemoView("notebook");
|
||||
setSelectedTag(null);
|
||||
cacheMemoDetail(queryClient, data.memo, "notebook");
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["memos"] }),
|
||||
@@ -2150,6 +2156,7 @@ export const WorkspaceApp = ({
|
||||
const handleSelectNotebook = (notebookId: string) => {
|
||||
navigateWorkspaceHome();
|
||||
setMemoView("notebook");
|
||||
setSelectedTag(null);
|
||||
setSelectedNotebookId(notebookId);
|
||||
setMobileBottomNavActive("home");
|
||||
clearMemoSelection();
|
||||
@@ -2162,6 +2169,7 @@ export const WorkspaceApp = ({
|
||||
const handleSelectAllMemos = () => {
|
||||
navigateWorkspaceHome();
|
||||
setMemoView("notebook");
|
||||
setSelectedTag(null);
|
||||
setSelectedNotebookId(null);
|
||||
setRightView("editor");
|
||||
setMobileBottomNavActive("home");
|
||||
@@ -2178,6 +2186,7 @@ export const WorkspaceApp = ({
|
||||
setMemoView("notebook");
|
||||
}
|
||||
setMobileBottomNavActive("home");
|
||||
setSelectedTag(null);
|
||||
setSelectedNotebookId(null);
|
||||
setSearch("");
|
||||
clearMemoSelection();
|
||||
@@ -2186,6 +2195,20 @@ export const WorkspaceApp = ({
|
||||
setActivePane("memos");
|
||||
};
|
||||
|
||||
const handleSelectTag = (tag: string) => {
|
||||
navigateWorkspaceHome();
|
||||
setMemoView("notebook");
|
||||
setSelectedTag(tag);
|
||||
setSelectedNotebookId(null);
|
||||
setRightView("editor");
|
||||
setMobileBottomNavActive("home");
|
||||
clearMemoSelection();
|
||||
clearPendingCreatedMemo();
|
||||
setCreatedMemoEditId(null);
|
||||
setSelectedMemoId(null);
|
||||
setActivePane("memos");
|
||||
};
|
||||
|
||||
const handleMobileSearch = () => {
|
||||
setMobileBottomNavActive("search");
|
||||
setActivePane("memos");
|
||||
@@ -2734,6 +2757,7 @@ export const WorkspaceApp = ({
|
||||
onSelect={(notebookId) => {
|
||||
navigateWorkspaceHome();
|
||||
setMemoView("notebook");
|
||||
setSelectedTag(null);
|
||||
setSelectedNotebookId(notebookId);
|
||||
clearMemoSelection();
|
||||
setRightView("editor");
|
||||
@@ -2764,6 +2788,7 @@ export const WorkspaceApp = ({
|
||||
onOpenTrash={() => {
|
||||
navigateWorkspaceTrash();
|
||||
setMemoView("trash");
|
||||
setSelectedTag(null);
|
||||
setSelectedNotebookId(null);
|
||||
setMobileBottomNavActive("home");
|
||||
clearMemoSelection();
|
||||
@@ -2790,7 +2815,8 @@ export const WorkspaceApp = ({
|
||||
)}
|
||||
>
|
||||
<MemoListPane
|
||||
notebook={selectedNotebook}
|
||||
notebook={selectedTag ? null : selectedNotebook}
|
||||
selectedTag={selectedTag}
|
||||
notebooks={notebooks}
|
||||
view={memoView}
|
||||
memos={memos}
|
||||
@@ -2835,6 +2861,7 @@ export const WorkspaceApp = ({
|
||||
onOpenTrash={() => {
|
||||
navigateWorkspaceTrash();
|
||||
setMemoView("trash");
|
||||
setSelectedTag(null);
|
||||
setSelectedNotebookId(null);
|
||||
setMobileBottomNavActive("home");
|
||||
clearMemoSelection();
|
||||
@@ -2844,6 +2871,7 @@ export const WorkspaceApp = ({
|
||||
setActivePane("memos");
|
||||
}}
|
||||
onBackFromTrash={handleSelectAllMemos}
|
||||
onClearTag={handleSelectAllMemos}
|
||||
onOpenMemo={(memoId) => {
|
||||
if (shouldNavigateHomeWhenOpeningMemo(memoView)) {
|
||||
navigateWorkspaceHome();
|
||||
@@ -2954,7 +2982,7 @@ export const WorkspaceApp = ({
|
||||
) : rightView === "assets" ? (
|
||||
<AssetsPane onClose={handleCloseAssets} repository={repository} />
|
||||
) : rightView === "tags" ? (
|
||||
<TagsPane onClose={handleCloseAssets} repository={repository} />
|
||||
<TagsPane onClose={handleCloseAssets} onSelectTag={handleSelectTag} repository={repository} />
|
||||
) : rightView === "templates" ? (
|
||||
<TemplatesPane
|
||||
canCreateMemo={canCreateMemo}
|
||||
@@ -3040,6 +3068,7 @@ export const WorkspaceApp = ({
|
||||
memoFilterMode,
|
||||
memoSortMode,
|
||||
selectedNotebookDescendantIds,
|
||||
selectedTag,
|
||||
],
|
||||
exact: true,
|
||||
refetchType: "active",
|
||||
@@ -3149,7 +3178,7 @@ export const WorkspaceApp = ({
|
||||
)}
|
||||
{mobileNotebookPickerOpen && (
|
||||
<MobileNotebookPicker
|
||||
currentLabel={memoView === "trash" ? t("notebookPane.trash") : undefined}
|
||||
currentLabel={memoView === "trash" ? t("notebookPane.trash") : selectedTag ? `#${selectedTag}` : undefined}
|
||||
notebooks={notebooks}
|
||||
selectedNotebookId={selectedNotebookId}
|
||||
onClose={() => setMobileNotebookPickerOpen(false)}
|
||||
|
||||
@@ -760,6 +760,7 @@ export const api = {
|
||||
notebookId?: string | null;
|
||||
includeDescendants?: boolean;
|
||||
q?: string;
|
||||
tag?: string;
|
||||
trash?: boolean;
|
||||
sort?: MemoSortMode;
|
||||
filter?: MemoFilterMode;
|
||||
@@ -780,6 +781,10 @@ export const api = {
|
||||
search.set("q", params.q.trim());
|
||||
}
|
||||
|
||||
if (params.tag?.trim()) {
|
||||
search.set("tag", params.tag.trim());
|
||||
}
|
||||
|
||||
if (params.trash) {
|
||||
search.set("trash", "1");
|
||||
}
|
||||
|
||||
@@ -94,6 +94,18 @@ describe("local mirror", () => {
|
||||
expect(result.memos[0]?.excerpt).toContain("Hello local first");
|
||||
});
|
||||
|
||||
test("filters local memos by an exact tag across notebooks", async () => {
|
||||
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
|
||||
const expected = await createLocalMemo(scope, { notebookId: "nb-a", tags: ["Demo"] });
|
||||
await createLocalMemo(scope, { notebookId: "nb-b", tags: ["demo-extra"] });
|
||||
await createLocalMemo(scope, { notebookId: "nb-b", title: "Demo without tag" });
|
||||
|
||||
const result = await listLocalMemos(scope, { tag: "demo" });
|
||||
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(result.memos.map((memo) => memo.id)).toEqual([expected.id]);
|
||||
});
|
||||
|
||||
test("updates local content and preserves the memo identity", async () => {
|
||||
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
|
||||
const memo = await createLocalMemo(scope, { notebookId: "nb-inbox" });
|
||||
|
||||
@@ -10,6 +10,7 @@ export type LocalMemoListParams = {
|
||||
notebookId?: string | null;
|
||||
notebookIds?: string[];
|
||||
q?: string;
|
||||
tag?: string;
|
||||
trash?: boolean;
|
||||
sort?: MemoSortMode;
|
||||
filter?: MemoFilterMode;
|
||||
@@ -203,10 +204,12 @@ export const listLocalMemos = async (scope: string, params: LocalMemoListParams)
|
||||
let memos = await localDb.memos.where("scope").equals(scope).toArray();
|
||||
const notebookIds = params.notebookIds ?? (params.notebookId ? [params.notebookId] : null);
|
||||
const q = params.q?.trim().toLocaleLowerCase();
|
||||
const tag = params.tag?.trim().toLocaleLowerCase();
|
||||
|
||||
memos = memos.filter((memo) => {
|
||||
if (memo.isDeleted !== Boolean(params.trash)) return false;
|
||||
if (notebookIds?.length && !notebookIds.includes(memo.notebookId)) return false;
|
||||
if (tag && !memo.tags.some((memoTag) => memoTag.toLocaleLowerCase() === tag)) return false;
|
||||
if (params.filter === "tagged" && memo.tags.length === 0) return false;
|
||||
if (params.filter === "untagged" && memo.tags.length > 0) return false;
|
||||
if (params.filter === "pinned" && !memo.isPinned) return false;
|
||||
|
||||
@@ -29,6 +29,7 @@ const memoMatchesFilter = (memo: MemoSummary, filterMode: unknown) => {
|
||||
const memoMatchesStaticListConstraints = (memo: MemoSummary, queryKey: readonly unknown[]) => {
|
||||
const [, view, notebookId, , filterMode] = queryKey;
|
||||
const notebookIds = Array.isArray(queryKey[6]) ? queryKey[6] : [];
|
||||
const tag = typeof queryKey[7] === "string" ? queryKey[7].trim().toLocaleLowerCase() : "";
|
||||
const memoView = view === "trash" ? "trash" : "notebook";
|
||||
|
||||
if ((memoView === "trash") !== memo.isDeleted) {
|
||||
@@ -44,6 +45,10 @@ const memoMatchesStaticListConstraints = (memo: MemoSummary, queryKey: readonly
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tag && !memo.tags.some((memoTag) => memoTag.toLocaleLowerCase() === tag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return memoMatchesFilter(memo, filterMode);
|
||||
};
|
||||
|
||||
|
||||
@@ -388,6 +388,7 @@ export const createWebRepository = (scope: string): EdgeEverRepository => {
|
||||
notebookId: params.notebookId,
|
||||
includeDescendants: Boolean(params.notebookIds?.length),
|
||||
q: params.q,
|
||||
tag: params.tag,
|
||||
trash: params.trash,
|
||||
filter: params.filter,
|
||||
sort: params.sort,
|
||||
|
||||
@@ -79,6 +79,20 @@ describe("EdgeEver client HTTP contract", () => {
|
||||
expect(result.suggestions).toEqual([{ name: "existing", existing: true }]);
|
||||
});
|
||||
|
||||
test("sends an exact tag constraint when listing memos", async () => {
|
||||
let requestUrl;
|
||||
const client = createEdgeEverClient({
|
||||
fetch: async (input) => {
|
||||
requestUrl = String(input);
|
||||
return jsonResponse({ memos: [], totalCount: 0, nextCursor: null });
|
||||
},
|
||||
});
|
||||
|
||||
await client.listMemos({ tag: "产品 和 交互" });
|
||||
|
||||
expect(requestUrl).toBe("/api/v1/memos?tag=%E4%BA%A7%E5%93%81+%E5%92%8C+%E4%BA%A4%E4%BA%92");
|
||||
});
|
||||
|
||||
test("updates the workspace AI tag suggestion prompt", async () => {
|
||||
let call;
|
||||
const client = createEdgeEverClient({
|
||||
|
||||
@@ -471,6 +471,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
notebookId?: string | null;
|
||||
includeDescendants?: boolean;
|
||||
q?: string;
|
||||
tag?: string;
|
||||
trash?: boolean;
|
||||
sort?: MemoSortMode;
|
||||
filter?: MemoFilterMode;
|
||||
@@ -491,6 +492,10 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
search.set("q", params.q.trim());
|
||||
}
|
||||
|
||||
if (params.tag?.trim()) {
|
||||
search.set("tag", params.tag.trim());
|
||||
}
|
||||
|
||||
if (params.trash) {
|
||||
search.set("trash", "1");
|
||||
}
|
||||
|
||||
@@ -758,6 +758,7 @@ export const enUS = {
|
||||
currentNotebook: "Current notebook",
|
||||
allNotebooks: "All notebooks",
|
||||
deletedMemos: "Deleted notes",
|
||||
tagFilter: "Tag filter",
|
||||
allMemos: "All notes",
|
||||
memoCount: "{{count}} notes",
|
||||
deletedCount: "{{count}} deleted",
|
||||
|
||||
@@ -758,6 +758,7 @@ export const zhCN = {
|
||||
currentNotebook: "当前笔记本",
|
||||
allNotebooks: "所有笔记本",
|
||||
deletedMemos: "已删除笔记",
|
||||
tagFilter: "标签筛选",
|
||||
allMemos: "全部笔记",
|
||||
memoCount: "{{count}} 条笔记",
|
||||
deletedCount: "{{count}} 条已删除",
|
||||
|
||||
@@ -61,4 +61,16 @@ describe("memo list cache updates", () => {
|
||||
expect(flattened[0]?.revision).toBe(3);
|
||||
expect(cached?.pages[0]?.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
test("removes a note when an edit no longer matches the selected exact tag", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const queryKey = ["memos", "notebook", null, "", "all", "updated-desc", [], "demo"] as const;
|
||||
queryClient.setQueryData(queryKey, queryData([[memo({ tags: ["Demo"] })]], 1));
|
||||
|
||||
updateMemoSummaryInLists(queryClient, memo({ tags: ["demo-extra"], revision: 2 }));
|
||||
|
||||
const cached = queryClient.getQueryData<MemoListQueryData>(queryKey);
|
||||
expect(cached?.pages[0]?.memos).toEqual([]);
|
||||
expect(cached?.pages[0]?.totalCount).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user