From 6fbc81337f845850f400fd7ea3590c4f40f4b82f Mon Sep 17 00:00:00 2001 From: johnnyjoygh Date: Wed, 5 Aug 2026 23:12:49 +0800 Subject: [PATCH] chore(sidebar): improve view and tag controls - expose display settings from the Views header and refine layout controls - add explicit tag list/tree navigation with accessible expansion behavior - cover view persistence, menu behavior, and tag tree construction --- web/src/components/AppSidebar/AppSidebar.tsx | 22 +- web/src/components/AppSidebar/SidebarRow.tsx | 2 +- web/src/components/AppSidebar/TagsSection.tsx | 164 ++++++++-- .../MemoDetailSidebar/MemoOutline.tsx | 2 +- web/src/components/MemoDisplaySettingMenu.tsx | 287 +++++++++-------- web/src/components/TagTree.tsx | 300 +++++++++--------- web/src/contexts/ViewContext.tsx | 6 +- web/src/locales/en.json | 2 + web/src/locales/zh-Hans.json | 2 + web/tests/app-sidebar-logo.test.tsx | 7 + web/tests/filtered-memo-stats.test.ts | 10 +- web/tests/memo-display-setting-menu.test.tsx | 56 ++++ web/tests/tag-tree.test.ts | 32 ++ web/tests/view-context-columns.test.tsx | 9 + 14 files changed, 569 insertions(+), 332 deletions(-) create mode 100644 web/tests/memo-display-setting-menu.test.tsx create mode 100644 web/tests/tag-tree.test.ts diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx index 9b3863d6..3dd4a841 100644 --- a/web/src/components/AppSidebar/AppSidebar.tsx +++ b/web/src/components/AppSidebar/AppSidebar.tsx @@ -28,6 +28,7 @@ import toast from "react-hot-toast"; import { Link, matchPath, useLocation, useNavigate, useSearchParams } from "react-router-dom"; import ConfirmDialog from "@/components/ConfirmDialog"; import { MemoDetailSidebar } from "@/components/MemoDetailSidebar"; +import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu"; import { SETTINGS_SECTIONS } from "@/components/Settings/settingSections"; import StatisticsView from "@/components/StatisticsView"; import UserMenu from "@/components/UserMenu"; @@ -101,15 +102,18 @@ const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => { - - +
+ + +
) } > diff --git a/web/src/components/AppSidebar/SidebarRow.tsx b/web/src/components/AppSidebar/SidebarRow.tsx index 247aa6e1..286e5204 100644 --- a/web/src/components/AppSidebar/SidebarRow.tsx +++ b/web/src/components/AppSidebar/SidebarRow.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; export const SIDEBAR_ROW_CLASSES = - "group flex h-[30px] w-full min-w-0 items-center gap-2 rounded-md px-2 text-[13px] leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"; + "group flex h-[30px] w-full min-w-0 items-center gap-2 rounded-md px-2 text-[13px] leading-[18px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"; export const SIDEBAR_ROW_ICON_CLASSES = "size-[15px] shrink-0 opacity-75"; export const SIDEBAR_ROW_COUNT_CLASSES = "text-[11px] tabular-nums text-muted-foreground/60"; diff --git a/web/src/components/AppSidebar/TagsSection.tsx b/web/src/components/AppSidebar/TagsSection.tsx index b8f3085e..49cc8238 100644 --- a/web/src/components/AppSidebar/TagsSection.tsx +++ b/web/src/components/AppSidebar/TagsSection.tsx @@ -1,12 +1,10 @@ -import { HashIcon, MoreHorizontalIcon } from "lucide-react"; -import { useMemo } from "react"; -import { Switch } from "@/components/ui/switch"; +import { ListIcon, ListTreeIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { useLocalStorage } from "@/hooks"; +import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import TagTree from "../TagTree"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import SidebarRow from "./SidebarRow"; import SidebarSectionHeader from "./SidebarSectionHeader"; interface Props { @@ -14,13 +12,66 @@ interface Props { onSelect?: () => void; } +const TagPath = ({ tag }: { tag: string }) => { + const segments = tag.split("/"); + + return ( + + {segments.map((segment, index) => ( + + {index > 0 && /} + {segment} + + ))} + + ); +}; + +const getExpandableTagPaths = (tags: [tag: string, amount: number][]) => { + const paths = new Set(); + + for (const [tag] of tags) { + const segments = tag.split("/"); + for (let index = 1; index < segments.length; index++) { + paths.add(segments.slice(0, index).join("/")); + } + } + + return paths; +}; + +const getParentTagPaths = (tag: string) => { + const segments = tag.split("/"); + return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join("/")); +}; + const TagsSection = ({ tagCount, onSelect }: Props) => { const t = useTranslate(); const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext(); const [treeMode, setTreeMode] = useLocalStorage("tag-view-as-tree", false); - const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage("tag-tree-auto-expand", false); + const [expandedTagPaths, setExpandedTagPaths] = useState>(() => new Set()); const activeTags = new Set(getFiltersByFactor("tagSearch").map((filter) => filter.value)); + const activeTag = activeTags.values().next().value as string | undefined; const tags = useMemo(() => Object.entries(tagCount).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])), [tagCount]); + const expandableTagPaths = useMemo(() => getExpandableTagPaths(tags), [tags]); + + useEffect(() => { + setExpandedTagPaths((current) => { + const next = new Set(Array.from(current).filter((path) => expandableTagPaths.has(path))); + return next.size === current.size ? current : next; + }); + }, [expandableTagPaths]); + + useEffect(() => { + if (!treeMode || !activeTag) { + return; + } + + setExpandedTagPaths((current) => { + const next = new Set([...current, ...getParentTagPaths(activeTag)]); + return next.size === current.size ? current : next; + }); + }, [activeTag, treeMode]); if (tags.length === 0) { return null; @@ -37,44 +88,93 @@ const TagsSection = ({ tagCount, onSelect }: Props) => { onSelect?.(); }; + const handleToggleBranch = (tag: string) => { + setExpandedTagPaths((current) => { + const next = new Set(current); + if (next.has(tag)) { + next.delete(tag); + } else { + next.add(tag); + } + return next; + }); + }; + return (
- - } +
+ + +
} > - {t("common.tags")} + + {t("common.tags")} + {tags.length} +
{treeMode ? ( - + ) : ( -
+
{tags.map(([tag, amount]) => { const active = activeTags.has(tag); - return handleTagClick(tag)} />; + return ( + + ); })}
)} diff --git a/web/src/components/MemoDetailSidebar/MemoOutline.tsx b/web/src/components/MemoDetailSidebar/MemoOutline.tsx index 052b7f0d..7cacf174 100644 --- a/web/src/components/MemoDetailSidebar/MemoOutline.tsx +++ b/web/src/components/MemoDetailSidebar/MemoOutline.tsx @@ -67,7 +67,7 @@ const MemoOutline = ({ headings }: MemoOutlineProps) => { onClick={(e) => handleClick(e, heading.slug)} aria-current={active ? "location" : undefined} className={cn( - "relative flex h-[30px] min-w-0 items-center rounded-md pr-2 text-[13px] leading-none transition-colors", + "relative flex h-[30px] min-w-0 items-center rounded-md pr-2 text-[13px] leading-[18px] transition-colors", heading.level === minLevel && "font-medium", active ? "text-foreground" : "text-muted-foreground/70 hover:bg-sidebar-accent/65 hover:text-foreground", )} diff --git a/web/src/components/MemoDisplaySettingMenu.tsx b/web/src/components/MemoDisplaySettingMenu.tsx index a01f47de..3623c50d 100644 --- a/web/src/components/MemoDisplaySettingMenu.tsx +++ b/web/src/components/MemoDisplaySettingMenu.tsx @@ -1,5 +1,5 @@ -import { Columns2Icon, Columns3Icon, InfinityIcon, type LucideIcon, Rows3Icon, Settings2Icon } from "lucide-react"; -import { useMemo } from "react"; +import { Columns2Icon, Columns3Icon, InfinityIcon, type LucideIcon, Rows3Icon, SlidersHorizontalIcon } from "lucide-react"; +import type { ReactNode } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -12,6 +12,12 @@ interface Props { className?: string; } +interface SettingRowProps { + label: string; + description?: string; + children: ReactNode; +} + // Keyed by the context's canonical value list, so adding a column option forces an icon and // wording here at compile time. The i18n param is deliberately named `n`, not `count` — // i18next would route `count` through plural-form lookup. @@ -22,7 +28,17 @@ const LAYOUT_OPTIONS: Record ( +
+
+

{label}

+ {description &&

{description}

} +
+ {children} +
+); + +function MemoDisplaySettingsContent() { const t = useTranslate(); const { orderByTimeAsc, @@ -31,7 +47,7 @@ function MemoDisplaySettingMenu({ className }: Props) { linkPreview, maxColumns, setTimeBasis, - toggleSortOrder, + setOrderByTimeAsc, setCompactMode, setLinkPreview, setMaxColumns, @@ -40,140 +56,145 @@ function MemoDisplaySettingMenu({ className }: Props) { // there; it only becomes a real choice at a single column. const compactLocked = maxColumns !== 1; - const timeBasisOptions = useMemo( - () => [ - { value: "create_time", label: t("common.created-at") }, - { value: "update_time", label: t("common.last-updated-at") }, - ], - [t], - ); - const sortOrderOptions = useMemo( - () => [ - { value: "false", label: t("memo.newest-first") }, - { value: "true", label: t("memo.oldest-first") }, - ], - [t], + const timeBasisOptions = [ + { value: "create_time", label: t("common.created-at") }, + { value: "update_time", label: t("common.last-updated-at") }, + ]; + const sortOrderOptions = [ + { value: "desc", label: t("memo.newest-first") }, + { value: "asc", label: t("memo.oldest-first") }, + ]; + + return ( +
+
+

{t("memo.layout")}

+
{ + const delta = + event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; + if (delta === 0) return; + event.preventDefault(); + const index = MAX_COLUMNS_VALUES.indexOf(maxColumns); + const next = MAX_COLUMNS_VALUES[(index + delta + MAX_COLUMNS_VALUES.length) % MAX_COLUMNS_VALUES.length]; + setMaxColumns(next); + event.currentTarget.querySelector(`[data-value="${next}"]`)?.focus(); + }} + > + {MAX_COLUMNS_VALUES.map((value) => { + const { icon: Icon, key } = LAYOUT_OPTIONS[value]; + const label = t(`memo.${key}`, { n: value }); + const description = t(`memo.${key}-description`, { n: value }); + const shortLabel = value > 1 ? value.toString() : label; + const active = maxColumns === value; + return ( + + ); + })} +
+
+ +
+

{t("memo.order")}

+ + + + + + +
+ +
+ + + + + + +
+
); +} + +function MemoDisplaySettingMenu({ className }: Props) { + const t = useTranslate(); return ( - - - - -
-
- {t("memo.shown-time")} - -
-
- {t("memo.order")} - -
-
- - {t("memo.compact-mode")} - - -
-
- {t("memo.link-preview")} - -
-
- {t("memo.layout")} - {/* A quiet muted track (28px tall, borderless); only the active option carries the accent - fill. A radiogroup with roving tabindex, since the options are mutually exclusive. */} -
{ - const delta = - event.key === "ArrowRight" || event.key === "ArrowDown" - ? 1 - : event.key === "ArrowLeft" || event.key === "ArrowUp" - ? -1 - : 0; - if (delta === 0) return; - event.preventDefault(); - const index = MAX_COLUMNS_VALUES.indexOf(maxColumns); - const next = MAX_COLUMNS_VALUES[(index + delta + MAX_COLUMNS_VALUES.length) % MAX_COLUMNS_VALUES.length]; - setMaxColumns(next); - event.currentTarget.querySelector(`[data-value="${next}"]`)?.focus(); - }} - > - {MAX_COLUMNS_VALUES.map((value) => { - const { icon: Icon, key } = LAYOUT_OPTIONS[value]; - const label = t(`memo.${key}`, { n: value }); - const active = maxColumns === value; - return ( - - setMaxColumns(value)} - className={cn( - "grid h-6 w-7 place-items-center rounded-md transition-colors", - active - ? "bg-accent text-accent-foreground" - : "text-muted-foreground/70 hover:bg-accent/50 hover:text-foreground", - )} - /> - } - > - - - -

{label}

-

{t(`memo.${key}-description`, { n: value })}

-
-
- ); - })} -
-
+ + }> + + + + + {t("memo.view-options")} + + +
+

{t("memo.view-options")}

+
); diff --git a/web/src/components/TagTree.tsx b/web/src/components/TagTree.tsx index 4157637f..a0dff7d3 100644 --- a/web/src/components/TagTree.tsx +++ b/web/src/components/TagTree.tsx @@ -1,172 +1,176 @@ -import { ChevronRightIcon, HashIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_CLASSES, SIDEBAR_ROW_ICON_CLASSES } from "@/components/AppSidebar/SidebarRow"; -import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext"; +import { ChevronRightIcon } from "lucide-react"; +import { useMemo } from "react"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; -interface Tag { +interface TagTreeNode { key: string; text: string; - amount: number; - subTags: Tag[]; + amount?: number; + subTags: TagTreeNode[]; } interface Props { tagAmounts: [tag: string, amount: number][]; - expandSubTags: boolean; + activeTag?: string; + expandedTagPaths: ReadonlySet; + onTagClick: (tag: string) => void; + onToggleBranch: (tag: string) => void; } -const TagTree = ({ tagAmounts: rawTagAmounts, expandSubTags }: Props) => { - const [tags, setTags] = useState([]); +export const buildTagTree = (tagAmounts: [tag: string, amount: number][]) => { + const root: TagTreeNode = { + key: "", + text: "", + subTags: [], + }; - useEffect(() => { - const sortedTagAmounts = Array.from(rawTagAmounts).sort(); - const root: Tag = { - key: "", - text: "", - amount: 0, - subTags: [], - }; + for (const [tag, amount] of [...tagAmounts].sort(([left], [right]) => left.localeCompare(right))) { + const segments = tag.split("/"); + let parent = root; + let path = ""; - for (const tagAmount of sortedTagAmounts) { - const subtags = tagAmount[0].split("/"); - let tempObj = root; - let tagText = ""; + segments.forEach((segment, index) => { + path = path ? `${path}/${segment}` : segment; + let node = parent.subTags.find((item) => item.key === segment); - for (let i = 0; i < subtags.length; i++) { - const key = subtags[i]; - let amount: number = 0; - - if (i === 0) { - tagText += key; - } else { - tagText += "/" + key; - } - if (sortedTagAmounts.some(([tag, amount]) => tag === tagText && amount > 1)) { - amount = tagAmount[1]; - } - - let obj = null; - - for (const t of tempObj.subTags) { - if (t.text === tagText) { - obj = t; - break; - } - } - - if (!obj) { - obj = { - key, - text: tagText, - amount: amount, - subTags: [], - }; - tempObj.subTags.push(obj); - } - - tempObj = obj; + if (!node) { + node = { + key: segment, + text: path, + subTags: [], + }; + parent.subTags.push(node); } - } - setTags(root.subTags as Tag[]); - }, [rawTagAmounts]); + if (index === segments.length - 1) { + node.amount = amount; + } + parent = node; + }); + } + + return root.subTags; +}; + +const TagMark = () => ( + +); + +interface TagItemProps { + tag: TagTreeNode; + depth: number; + activeTag?: string; + expandedTagPaths: ReadonlySet; + onTagClick: (tag: string) => void; + onToggleBranch: (tag: string) => void; +} + +const TagItem = ({ tag, depth, activeTag, expandedTagPaths, onTagClick, onToggleBranch }: TagItemProps) => { + const t = useTranslate(); + const isTag = tag.amount !== undefined; + const isActive = activeTag === tag.text; + const isAncestorOfActiveTag = activeTag?.startsWith(`${tag.text}/`) ?? false; + const hasSubTags = tag.subTags.length > 0; + const showSubTags = expandedTagPaths.has(tag.text); return ( -
- {tags.map((t, idx) => ( - +
+
+ {hasSubTags ? ( + + ) : ( + + )} + + {isTag ? ( + + ) : ( + + {tag.key} + + )} + + {isTag && ( + + {tag.amount} + + )} +
+ + {hasSubTags && showSubTags && ( +
+ {tag.subTags.map((subTag) => ( + + ))} +
+ )} +
+ ); +}; + +const TagTree = ({ tagAmounts, activeTag, expandedTagPaths, onTagClick, onToggleBranch }: Props) => { + const t = useTranslate(); + const tags = useMemo(() => buildTagTree(tagAmounts), [tagAmounts]); + + return ( +
+ {tags.map((tag) => ( + ))}
); }; -interface TagItemContainerProps { - tag: Tag; - expandSubTags: boolean; -} - -const TagItemContainer = (props: TagItemContainerProps) => { - const { tag, expandSubTags } = props; - const t = useTranslate(); - const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext(); - const tagFilters = getFiltersByFactor("tagSearch"); - const isActive = tagFilters.some((f: MemoFilter) => f.value === tag.text); - const hasSubTags = tag.subTags.length > 0; - const [showSubTags, setShowSubTags] = useState(false); - - useEffect(() => { - setShowSubTags(expandSubTags); - }, [expandSubTags]); - - const handleTagClick = () => { - if (isActive) { - removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag.text); - } else { - // Remove all existing tag filters first, then add the new one - removeFilter((f: MemoFilter) => f.factor === "tagSearch"); - addFilter({ - factor: "tagSearch", - value: tag.text, - }); - } - }; - - const handleToggleBtnClick = useCallback((event: React.MouseEvent) => { - event.stopPropagation(); - setShowSubTags((current) => !current); - }, []); - - return ( - <> -
- - {tag.amount > 1 && {tag.amount}} - {hasSubTags && ( - - )} -
- {hasSubTags ? ( -
- {tag.subTags.map((st, idx) => ( - - ))} -
- ) : null} - - ); -}; - export default TagTree; diff --git a/web/src/contexts/ViewContext.tsx b/web/src/contexts/ViewContext.tsx index 99f811e0..662c4715 100644 --- a/web/src/contexts/ViewContext.tsx +++ b/web/src/contexts/ViewContext.tsx @@ -21,7 +21,7 @@ interface ViewContextValue { compactMode: boolean; linkPreview: boolean; maxColumns: MemoMaxColumns; - toggleSortOrder: () => void; + setOrderByTimeAsc: (value: boolean) => void; setTimeBasis: (field: MemoTimeBasis) => void; setCompactMode: (value: boolean) => void; setLinkPreview: (value: boolean) => void; @@ -78,7 +78,7 @@ export function ViewProvider({ children }: { children: ReactNode }) { }); }; - const toggleSortOrder = () => updateState((prev) => ({ orderByTimeAsc: !prev.orderByTimeAsc })); + const setOrderByTimeAsc = (value: boolean) => updateState({ orderByTimeAsc: value }); const setTimeBasis = (field: MemoTimeBasis) => updateState({ timeBasis: field }); const setCompactMode = (value: boolean) => updateState({ compactMode: value }); const setLinkPreview = (value: boolean) => updateState({ linkPreview: value }); @@ -92,7 +92,7 @@ export function ViewProvider({ children }: { children: ReactNode }) { compactMode: viewState.compactMode, linkPreview: viewState.linkPreview, maxColumns: viewState.maxColumns, - toggleSortOrder, + setOrderByTimeAsc, setTimeBasis, setCompactMode, setLinkPreview, diff --git a/web/src/locales/en.json b/web/src/locales/en.json index bef1e0aa..5dde3ebd 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -309,6 +309,7 @@ "label": "Filters" }, "compact-mode": "Compact mode", + "grid-compact-hint": "Grid layouts always use compact cards.", "layout": "Layout", "layout-auto": "Auto", "layout-auto-description": "As many columns as fit the screen", @@ -373,6 +374,7 @@ "updated": "Tasks updated" }, "view-detail": "View Detail", + "view-options": "View options", "visibility": { "disabled": "Public memos are disabled", "private": "Private", diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index 85ca0b66..d93559ff 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -268,6 +268,7 @@ "label": "过滤器" }, "compact-mode": "紧凑模式", + "grid-compact-hint": "网格布局始终使用紧凑卡片。", "layout": "布局", "layout-auto": "自动", "layout-auto-description": "根据屏幕宽度显示尽可能多的列", @@ -291,6 +292,7 @@ "show-more": "查看更多", "to-do": "待办", "view-detail": "查看详情", + "view-options": "视图选项", "visibility": { "disabled": "已禁用公开备忘录", "private": "私有", diff --git a/web/tests/app-sidebar-logo.test.tsx b/web/tests/app-sidebar-logo.test.tsx index b1181a55..c8ccdaa2 100644 --- a/web/tests/app-sidebar-logo.test.tsx +++ b/web/tests/app-sidebar-logo.test.tsx @@ -12,6 +12,10 @@ vi.mock("@/components/MemosLogo", () => ({ default: () => Memos logo, })); +vi.mock("@/components/MemoDisplaySettingMenu", () => ({ + default: () => , +})); + vi.mock("@/components/UserMenu", () => ({ default: () =>
User menu
, })); @@ -172,6 +176,9 @@ describe("App sidebar logo", () => { const calendar = screen.getByText("Calendar"); const views = screen.getByText("common.views"); expect(calendar.compareDocumentPosition(views) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + const viewOptions = screen.getByRole("button", { name: "memo.view-options" }); + const createView = screen.getByRole("button", { name: "common.create" }); + expect(viewOptions.compareDocumentPosition(createView) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.getByRole("button", { name: "common.tasks" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "common.all" })).not.toBeInTheDocument(); diff --git a/web/tests/filtered-memo-stats.test.ts b/web/tests/filtered-memo-stats.test.ts index a23ef66d..4c17188f 100644 --- a/web/tests/filtered-memo-stats.test.ts +++ b/web/tests/filtered-memo-stats.test.ts @@ -23,8 +23,8 @@ vi.mock("@/contexts/ViewContext", async () => { }; }); -import { useAllUserStats, useUserStats } from "@/hooks/useUserQueries"; import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats"; +import { useAllUserStats, useUserStats } from "@/hooks/useUserQueries"; const wrapper = ({ children }: { children: ReactNode }) => children as never; @@ -57,7 +57,7 @@ describe("useFilteredMemoStats", () => { mockUseView.mockReturnValue({ timeBasis: "create_time", orderByTimeAsc: false, - toggleSortOrder: vi.fn(), + setOrderByTimeAsc: vi.fn(), setTimeBasis: vi.fn(), }); @@ -71,7 +71,7 @@ describe("useFilteredMemoStats", () => { mockUseView.mockReturnValue({ timeBasis: "update_time", orderByTimeAsc: false, - toggleSortOrder: vi.fn(), + setOrderByTimeAsc: vi.fn(), setTimeBasis: vi.fn(), }); @@ -95,7 +95,7 @@ describe("useFilteredMemoStats", () => { mockUseView.mockReturnValue({ timeBasis: "update_time", orderByTimeAsc: false, - toggleSortOrder: vi.fn(), + setOrderByTimeAsc: vi.fn(), setTimeBasis: vi.fn(), }); @@ -111,7 +111,7 @@ describe("useFilteredMemoStats", () => { mockUseView.mockReturnValue({ timeBasis: "create_time", orderByTimeAsc: false, - toggleSortOrder: vi.fn(), + setOrderByTimeAsc: vi.fn(), setTimeBasis: vi.fn(), }); diff --git a/web/tests/memo-display-setting-menu.test.tsx b/web/tests/memo-display-setting-menu.test.tsx new file mode 100644 index 00000000..f5daf317 --- /dev/null +++ b/web/tests/memo-display-setting-menu.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu"; +import { ViewProvider } from "@/contexts/ViewContext"; + +vi.mock("@/utils/i18n", () => ({ + useTranslate: () => (key: string, params?: Record) => { + const labels: Record = { + "common.created-at": "Created", + "common.last-updated-at": "Last updated", + "memo.compact-mode": "Compact mode", + "memo.direction": "Direction", + "memo.grid-compact-hint": "Grid layouts always use compact cards.", + "memo.layout": "Layout", + "memo.layout-auto": "Auto", + "memo.layout-auto-description": "As many columns as fit", + "memo.layout-columns-description": `Up to ${params?.n ?? ""} columns`, + "memo.layout-list": "List", + "memo.layout-list-description": "A single column", + "memo.link-preview": "Link preview", + "memo.newest-first": "Newest first", + "memo.oldest-first": "Oldest first", + "memo.order": "Order", + "memo.order-by": "Order by", + "memo.view-options": "View options", + }; + if (key === "memo.layout-columns") return `${params?.n ?? ""} columns`; + return labels[key] ?? key; + }, +})); + +describe("MemoDisplaySettingMenu", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("opens from an accessible trigger and explains the compact grid constraint", () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "View options" })); + + const compactMode = screen.getByRole("switch", { name: "Compact mode" }); + expect(compactMode).not.toBeChecked(); + expect(compactMode).toBeEnabled(); + + fireEvent.click(screen.getByRole("radio", { name: "2 columns" })); + + expect(compactMode).toBeChecked(); + expect(compactMode).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText("Grid layouts always use compact cards.")).toBeInTheDocument(); + }); +}); diff --git a/web/tests/tag-tree.test.ts b/web/tests/tag-tree.test.ts new file mode 100644 index 00000000..2ecbe5f2 --- /dev/null +++ b/web/tests/tag-tree.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { buildTagTree } from "@/components/TagTree"; + +describe("tag tree", () => { + it("preserves exact counts, including one", () => { + const tree = buildTagTree([ + ["a", 2], + ["a/b", 1], + ]); + + expect(tree).toMatchObject([ + { + key: "a", + text: "a", + amount: 2, + subTags: [{ key: "b", text: "a/b", amount: 1, subTags: [] }], + }, + ]); + }); + + it("keeps generated parent paths structural", () => { + const tree = buildTagTree([["personal/travel/singapore", 2]]); + + expect(tree[0].amount).toBeUndefined(); + expect(tree[0].subTags[0].amount).toBeUndefined(); + expect(tree[0].subTags[0].subTags[0]).toMatchObject({ + key: "singapore", + text: "personal/travel/singapore", + amount: 2, + }); + }); +}); diff --git a/web/tests/view-context-columns.test.tsx b/web/tests/view-context-columns.test.tsx index f8f74a9f..4b8d2bf6 100644 --- a/web/tests/view-context-columns.test.tsx +++ b/web/tests/view-context-columns.test.tsx @@ -28,6 +28,15 @@ describe("ViewContext maxColumns setting", () => { expect(persisted().maxColumns).toBe(0); }); + it("sets and persists the sort direction explicitly", () => { + const { result } = renderHook(() => useView(), { wrapper }); + + act(() => result.current.setOrderByTimeAsc(true)); + + expect(result.current.orderByTimeAsc).toBe(true); + expect(persisted().orderByTimeAsc).toBe(true); + }); + it("restores a persisted column count on init", () => { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify({ maxColumns: 2 }));