v4: a profile's lists of dumps can be narrowed to a category — /@name/dumps and /@name/upvoted showed every dump, with no way to see only those filed under one category, as the index can. The title row of each list now has the index's category menu, and the category picked goes in the URL as ?category=<slug>, which the other list keeps and every other tab drops (a path segment would collide with /@name/dumps/<slug>, a dump's own page). GET /api/users/:key/dumps and /upvoted take ?category= as the followed feed does, an unknown slug answering an empty list; resolveCategoryFilter moves to category-service.ts for both. useUserDumpFeed passes everything the socket changes through the category, so a dump edited out of it leaves and an upvote outside it doesn't come in. CategorySwitcher now reports the category picked to its owner (onSelect) instead of navigating to the index's path itself, and the divider after it in the index's tab row is drawn by CSS, so the profile gets none. useTabParam's setter takes the query string to go to, the current one by default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
18
src/App.css
18
src/App.css
@@ -2283,6 +2283,14 @@ body.has-player .chat-fab {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Several actions in the title row, as a list's category menu and its
|
||||
"New dump" button. */
|
||||
.profile-section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Profile (own) page ── */
|
||||
.profile-username-row {
|
||||
display: flex;
|
||||
@@ -7385,9 +7393,10 @@ body.has-player .chat-fab {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Scope switcher — the leading item of the feed-sort tab row. The trigger
|
||||
reuses .feed-sort-btn so it shares the tabs' exact pill metrics; a thin
|
||||
divider sets it apart from the sort tabs that follow. */
|
||||
/* Scope switcher — the leading item of the feed-sort tab row, and in the
|
||||
title row of a profile's lists. The trigger reuses .feed-sort-btn so it
|
||||
shares the tabs' exact pill metrics; in the tab row, a thin divider sets it
|
||||
apart from the sort tabs that follow. */
|
||||
.category-switcher {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -7430,7 +7439,8 @@ body.has-player .chat-fab {
|
||||
}
|
||||
|
||||
/* Vertical rule separating the scope selector from the sort tabs. */
|
||||
.feed-sort-sep {
|
||||
.feed-sort .category-switcher::after {
|
||||
content: "";
|
||||
width: 2px;
|
||||
height: 1.05rem;
|
||||
background: var(--color-border);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Renders the menu that switches the index page's feed between all dumps and a
|
||||
* category.
|
||||
* Renders the menu that narrows a list of dumps to a category, on the index
|
||||
* page and on a profile's lists.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -13,32 +13,29 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useCategories } from "../../hooks/category/useCategories.ts";
|
||||
import { useViewerDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
|
||||
import { feedPath, type FeedTab } from "../../../shared/paths.ts";
|
||||
|
||||
/** Passed to {@link CategorySwitcher} by `FeedTabBar`. */
|
||||
/**
|
||||
* Passed to {@link CategorySwitcher} by `FeedTabBar` and `ProfileDumpsTab`,
|
||||
* which keep the category in their URL.
|
||||
*/
|
||||
export interface CategorySwitcherProps {
|
||||
/** The category shown, or none for every dump. */
|
||||
categorySlug?: string;
|
||||
/** The tab shown, which picking a category keeps. */
|
||||
tab: FeedTab;
|
||||
/** Called with the slug of the category picked, or undefined for "All". */
|
||||
onSelect: (slug: string | undefined) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the category menu at the head of `FeedTabBar`. Picking a category
|
||||
* keeps the feed tab, from `/music/new` to `/cinema/new`, and "All" leads to
|
||||
* `/new`; the viewer's default tab stays the bare path. It renders nothing
|
||||
* while the site has no categories.
|
||||
* Renders a button naming the category shown, "All" without one, that opens
|
||||
* the menu of every category. It renders nothing while the site has no
|
||||
* categories.
|
||||
*/
|
||||
export function CategorySwitcher(
|
||||
{ categorySlug, tab }: CategorySwitcherProps,
|
||||
{ categorySlug, onSelect }: CategorySwitcherProps,
|
||||
): React.JSX.Element | null {
|
||||
const { categories } = useCategories();
|
||||
const navigate = useNavigate();
|
||||
const defaultTab = useViewerDefaultFeedTab();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
|
||||
@@ -71,12 +68,19 @@ export function CategorySwitcher(
|
||||
}, [open]);
|
||||
|
||||
// The menu is fixed and portaled to <body>, so the tab row's
|
||||
// `overflow-x: auto` can't clip it. It must follow the button on scroll.
|
||||
// `overflow-x: auto` can't clip it. It must follow the button on scroll,
|
||||
// and stays within the viewport when the button sits at its right edge.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const update = () => {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (r) setMenuStyle({ top: r.bottom + 6, left: r.left });
|
||||
if (!r) return;
|
||||
const width = menuRef.current?.offsetWidth ?? 0;
|
||||
const left = Math.max(
|
||||
8,
|
||||
Math.min(r.left, globalThis.innerWidth - width - 8),
|
||||
);
|
||||
setMenuStyle({ top: r.bottom + 6, left });
|
||||
};
|
||||
update();
|
||||
globalThis.addEventListener("scroll", update, true);
|
||||
@@ -102,7 +106,7 @@ export function CategorySwitcher(
|
||||
|
||||
function select(slug: string) {
|
||||
setOpen(false);
|
||||
navigate(feedPath(slug || undefined, tab === defaultTab ? undefined : tab));
|
||||
onSelect(slug || undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -147,8 +151,6 @@ export function CategorySwitcher(
|
||||
</ul>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<span className="feed-sort-sep" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface FeedTabBarProps {
|
||||
* Renders the "hot", "new" and "journal" tabs, and "followed" for a signed-in
|
||||
* user. A tab leads to the same scope: `/new` for every dump, `/music/new` for
|
||||
* a category, and the viewer's default tab to the bare `/` or `/music`.
|
||||
* Picking a category keeps the tab, from `/music/new` to `/cinema/new`.
|
||||
* `Index` renders it twice, in the header and below it, and CSS shows one by
|
||||
* screen width.
|
||||
*/
|
||||
@@ -50,7 +51,13 @@ export function FeedTabBar(
|
||||
activeTab={tab}
|
||||
onChange={(t) =>
|
||||
navigate(feedPath(categorySlug, t === defaultTab ? undefined : t))}
|
||||
leading={<CategorySwitcher categorySlug={categorySlug} tab={tab} />}
|
||||
leading={
|
||||
<CategorySwitcher
|
||||
categorySlug={categorySlug}
|
||||
onSelect={(slug) =>
|
||||
navigate(feedPath(slug, tab === defaultTab ? undefined : tab))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Renders a profile's "dumps" or "upvoted" tab, the whole list of the user's
|
||||
* dumps or of those they upvoted.
|
||||
* dumps or of those they upvoted, which a category may narrow.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
|
||||
import { DumpCardList } from "../dump/DumpCardList.tsx";
|
||||
import { NewDumpButton } from "../dump/NewDumpButton.tsx";
|
||||
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
|
||||
import { FeedFooter } from "../feed/FeedFooter.tsx";
|
||||
import { ListState } from "../shared/ListState.tsx";
|
||||
import { ProfileSection } from "./ProfileSection.tsx";
|
||||
@@ -28,43 +30,83 @@ export interface ProfileDumpsTabProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list from `useUserDumpFeed`, its next page loading as its end
|
||||
* scrolls into view. The owner's own dumps get a "New dump" button.
|
||||
* Renders the list, with the category menu in its title row; the category
|
||||
* picked is the URL's `?category=`, which switching to the other list keeps
|
||||
* and to any other tab drops.
|
||||
* The owner's own dumps get a "New dump" button.
|
||||
*/
|
||||
export function ProfileDumpsTab(
|
||||
{ user, list, isOwnProfile }: ProfileDumpsTabProps,
|
||||
): React.JSX.Element {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const categorySlug = params.get("category") || undefined;
|
||||
|
||||
return (
|
||||
<ProfileSection
|
||||
title={list === "dumps" ? t`Dumps` : t`Upvoted`}
|
||||
actions={
|
||||
<div className="profile-section-actions">
|
||||
<CategorySwitcher
|
||||
categorySlug={categorySlug}
|
||||
onSelect={(slug) =>
|
||||
setParams((p) => {
|
||||
if (slug) p.set("category", slug);
|
||||
else p.delete("category");
|
||||
return p;
|
||||
})}
|
||||
/>
|
||||
{list === "dumps" && isOwnProfile && <NewDumpButton />}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DumpList
|
||||
key={categorySlug ?? ""}
|
||||
user={user}
|
||||
list={list}
|
||||
isOwnProfile={isOwnProfile}
|
||||
categorySlug={categorySlug}
|
||||
/>
|
||||
</ProfileSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list from `useUserDumpFeed`, its next page loading as its end
|
||||
* scrolls into view. Each category's list is another, which the tab mounts
|
||||
* afresh.
|
||||
*/
|
||||
function DumpList(
|
||||
{ user, list, isOwnProfile, categorySlug }: ProfileDumpsTabProps & {
|
||||
categorySlug?: string;
|
||||
},
|
||||
): React.JSX.Element {
|
||||
const { state, loadMore, cardClassName } = useUserDumpFeed(user.id, list, {
|
||||
isOwner: isOwnProfile,
|
||||
categorySlug,
|
||||
});
|
||||
const loaded = state.status === "loaded" ? state.data : null;
|
||||
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
|
||||
|
||||
return (
|
||||
<ProfileSection
|
||||
title={list === "dumps" ? t`Dumps` : t`Upvoted`}
|
||||
actions={list === "dumps" && isOwnProfile && <NewDumpButton />}
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ data }) => (
|
||||
<>
|
||||
<DumpCardList dumps={data.items} cardClassName={cardClassName} />
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
</ProfileSection>
|
||||
{({ data }) => (
|
||||
<>
|
||||
<DumpCardList dumps={data.items} cardClassName={cardClassName} />
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
hydrateDump,
|
||||
} from "../../utils/shared/deserialize.ts";
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
import { useCategories } from "../category/useCategories.ts";
|
||||
import { useWS } from "../ws/useWS.ts";
|
||||
import { useFeedCache } from "../feed/useFeedCache.ts";
|
||||
import { usePagedList, type UsePagedListResult } from "../feed/usePagedList.ts";
|
||||
@@ -35,28 +36,43 @@ export interface UseUserDumpFeedResult extends UsePagedListResult<Dump> {
|
||||
cardClassName?: (dump: Dump) => string | undefined;
|
||||
}
|
||||
|
||||
/** Passed to {@link useUserDumpFeed} by the profile's overview and tabs. */
|
||||
export interface UseUserDumpFeedOptions {
|
||||
/** Keeps the user's own private dumps in their list as they edit them. */
|
||||
isOwner: boolean;
|
||||
/** Dumps at hand that a vote of the user's own puts first in their upvoted. */
|
||||
source?: Dump[];
|
||||
/** Narrows the list to the category at this slug. */
|
||||
categorySlug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `list` of user `userId`, from `GET /api/users/:userId/<list>`, fetched
|
||||
* signed in so users see their own private dumps, with the call that loads its
|
||||
* next page. The socket keeps it live: a dump that goes private and public
|
||||
* again comes back at its place, and the upvoted list follows the user's
|
||||
* votes, `source` holding dumps at hand that a vote of theirs puts first at
|
||||
* once. The list and the scroll position are saved as the page is left, under
|
||||
* a key the overview and the tab share, and going back restores them.
|
||||
* next page; with `categorySlug`, only the dumps in that category. The socket
|
||||
* keeps it live: a dump that goes private and public again comes back at its
|
||||
* place, one edited out of the category leaves, and the upvoted list follows
|
||||
* the user's votes, `source` holding dumps at hand that a vote of theirs puts
|
||||
* first at once. The list and the scroll position are saved as the page is
|
||||
* left, under a key the overview and the unfiltered tab share, and going back
|
||||
* restores them.
|
||||
*/
|
||||
export function useUserDumpFeed(
|
||||
userId: string,
|
||||
list: UserDumpList,
|
||||
{ isOwner, source }: { isOwner: boolean; source?: Dump[] },
|
||||
{ isOwner, source, categorySlug }: UseUserDumpFeedOptions,
|
||||
): UseUserDumpFeedResult {
|
||||
const { user, authFetch } = useAuth();
|
||||
const { lastDumpEvent } = useWS();
|
||||
const { categories } = useCategories();
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:profile-${list}:${userId}`,
|
||||
`feed:profile-${list}:${userId}${categorySlug ? `:${categorySlug}` : ""}`,
|
||||
hydrateDump,
|
||||
);
|
||||
|
||||
const url = `${API_URL}/api/users/${userId}/${list}`;
|
||||
const url = `${API_URL}/api/users/${userId}/${list}${
|
||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
||||
}`;
|
||||
const paged = usePagedList<Dump>(
|
||||
`${url}:${user?.id ?? "guest"}`,
|
||||
useCallback(
|
||||
@@ -67,9 +83,25 @@ export function useUserDumpFeed(
|
||||
),
|
||||
cached,
|
||||
);
|
||||
const { state, setItems } = paged;
|
||||
const { state, setItems: setAllItems } = paged;
|
||||
const items = state.status === "loaded" ? state.data.items : [];
|
||||
|
||||
// Whatever the socket puts in or changes in a category's list passes
|
||||
// through its filter, so that no dump outside the category shows. A slug
|
||||
// the categories don't know yet lets everything through.
|
||||
const categoryId = categorySlug &&
|
||||
categories.find((c) => c.slug === categorySlug)?.id;
|
||||
const setItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setAllItems((prev) => {
|
||||
const next = fn(prev);
|
||||
return categoryId
|
||||
? next.filter((d) => d.categoryIds?.includes(categoryId))
|
||||
: next;
|
||||
}),
|
||||
[setAllItems, categoryId],
|
||||
);
|
||||
|
||||
const upvoted = useUpvotedList({
|
||||
profileUserId: list === "upvoted" ? userId : null,
|
||||
setItems,
|
||||
@@ -95,6 +127,7 @@ export function useUserDumpFeed(
|
||||
|
||||
return {
|
||||
...paged,
|
||||
setItems,
|
||||
cardClassName: list === "upvoted" ? upvoted.cardClassName : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ import { useLocation, useNavigate } from "react-router";
|
||||
* Returns the tab the path segment after `base` names, `defaultTab` on the
|
||||
* bare `base`, or null when the segment names none of `validTabs`, for the
|
||||
* page to answer as not found; and a setter that navigates to a tab in place
|
||||
* of the current history entry, the default tab to the bare `base`. A segment
|
||||
* after the tab's, as the profile's `/follow/playlists`, is the tab's own
|
||||
* business.
|
||||
* of the current history entry, the default tab to the bare `base`, with the
|
||||
* query string `search`, the current one by default. A segment after the
|
||||
* tab's, as the profile's `/follow/playlists`, is the tab's own business.
|
||||
*/
|
||||
export function useTabParam<T extends string>(
|
||||
base: string,
|
||||
validTabs: Iterable<T>,
|
||||
defaultTab: T,
|
||||
): [T | null, (tab: T) => void] {
|
||||
): [T | null, (tab: T, search?: string) => void] {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -33,11 +33,11 @@ export function useTabParam<T extends string>(
|
||||
: null;
|
||||
|
||||
const setTab = useCallback(
|
||||
(t: T) => {
|
||||
(t: T, search = location.search) => {
|
||||
// Keeps the query string (the search page's `?q=`) and the entry's
|
||||
// state, since this is the same entry and `AppHeader`'s search reads
|
||||
// where to go back to from it.
|
||||
navigate(`${base}${t === defaultTab ? "" : `/${t}`}${location.search}`, {
|
||||
navigate(`${base}${t === defaultTab ? "" : `/${t}`}${search}`, {
|
||||
replace: true,
|
||||
state: location.state,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,8 @@ import { NotFound } from "./NotFound.tsx";
|
||||
const OVERVIEW = "overview";
|
||||
type Tab = ProfileTab | typeof OVERVIEW;
|
||||
const TABS: readonly Tab[] = [OVERVIEW, ...PROFILE_TABS];
|
||||
/** The lists of dumps, which `?category=` narrows; the other tabs drop it. */
|
||||
const DUMP_LISTS: readonly Tab[] = ["dumps", "upvoted"];
|
||||
|
||||
/**
|
||||
* Renders `/@:username` and its tabs, `/@:username/:tab`, and the follow
|
||||
@@ -165,7 +167,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
: []),
|
||||
]}
|
||||
activeTab={tab}
|
||||
onChange={(key) => setTab(key)}
|
||||
onChange={(key) =>
|
||||
setTab(key, DUMP_LISTS.includes(key) ? undefined : "")}
|
||||
className="profile-tabs-scroller"
|
||||
innerClassName="profile-tabs"
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { CategorySwitcher } from "../../../components/category/CategorySwitcher.tsx";
|
||||
import type { Category } from "../../../../shared/category.ts";
|
||||
import { makeCategory } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const categories: Category[] = [
|
||||
makeCategory({ id: "c1", slug: "music", name: "Music" }),
|
||||
@@ -13,57 +13,43 @@ const categories: Category[] = [
|
||||
|
||||
describe("CategorySwitcher", () => {
|
||||
test("hidden with no categories", () => {
|
||||
renderWithContexts(<CategorySwitcher tab="hot" />, { route: "/hot" });
|
||||
renderWithContexts(<CategorySwitcher onSelect={() => {}} />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches scope, keeping the tab", async () => {
|
||||
test("picks a category, and closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher categorySlug="music" tab="new" />, {
|
||||
categories,
|
||||
route: "/music/new",
|
||||
});
|
||||
const trigger = screen.getByRole("button", { name: "Music" });
|
||||
await user.click(trigger);
|
||||
const onSelect = vi.fn();
|
||||
renderWithContexts(
|
||||
<CategorySwitcher categorySlug="music" onSelect={onSelect} />,
|
||||
{ categories },
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema/new");
|
||||
expect(onSelect).toHaveBeenCalledWith("cinema");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("All is the root; the default tab stays a bare path; an unknown slug shows as itself", async () => {
|
||||
test("All is no category; an unknown slug shows as itself", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher categorySlug="gone" tab="hot" />, {
|
||||
categories,
|
||||
route: "/gone/hot",
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
renderWithContexts(
|
||||
<CategorySwitcher categorySlug="gone" onSelect={onSelect} />,
|
||||
{ categories },
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "gone" }));
|
||||
await user.click(screen.getByRole("option", { name: "All" }));
|
||||
expect(currentPath()).toBe("/hot");
|
||||
await user.click(screen.getByRole("button", { name: "gone" }));
|
||||
await user.click(screen.getByRole("option", { name: "Music" }));
|
||||
expect(currentPath()).toBe("/music/hot");
|
||||
});
|
||||
|
||||
test("on the default tab, a category is its bare path", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher tab="journal" />, {
|
||||
categories,
|
||||
route: "/",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema");
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
test("Escape closes the menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher tab="hot" />, {
|
||||
renderWithContexts(<CategorySwitcher onSelect={() => {}} />, {
|
||||
categories,
|
||||
route: "/hot",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
@@ -28,6 +28,31 @@ describe("FeedTabBar", () => {
|
||||
expect(currentPath()).toBe("/music");
|
||||
});
|
||||
|
||||
test("picking a category keeps the tab, the default one bare", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FeedTabBar categorySlug="music" tab="new" />, {
|
||||
route: "/music/new",
|
||||
categories,
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema/new");
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
await user.click(screen.getByRole("option", { name: "All" }));
|
||||
expect(currentPath()).toBe("/new");
|
||||
});
|
||||
|
||||
test("on the default tab, a category is its bare path", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FeedTabBar tab="journal" />, {
|
||||
route: "/",
|
||||
categories,
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema");
|
||||
});
|
||||
|
||||
test("signed in, Followed is there", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FeedTabBar tab="follow" />, {
|
||||
|
||||
@@ -175,6 +175,48 @@ describe("UserPublicProfile", () => {
|
||||
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
|
||||
});
|
||||
|
||||
test("the dumps tab narrows to a category, in its URL, and drops a dump edited out of it; only the dump lists keep it", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
||||
const tagged = makeDump({
|
||||
id: "m",
|
||||
title: "Tagged",
|
||||
userId: "ann",
|
||||
categoryIds: ["c1"],
|
||||
});
|
||||
const { user, socket } = open({ url: "/@ann/dumps", categories: [music] });
|
||||
route("GET /api/users/:u/dumps", (req) =>
|
||||
page(
|
||||
req.url.searchParams.get("category") === "music"
|
||||
? [tagged]
|
||||
: [tagged, makeDump({ id: "o", title: "Other", userId: "ann" })],
|
||||
));
|
||||
await screen.findByText("Other");
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.click(screen.getByRole("option", { name: "Music" }));
|
||||
expect(currentPath()).toBe("/@ann/dumps?category=music");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Other")).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByText("Tagged")).toBeInTheDocument();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...tagged, categoryIds: [] }),
|
||||
});
|
||||
expect(screen.queryByText("Tagged")).not.toBeInTheDocument();
|
||||
// Switching to the upvoted list keeps the category.
|
||||
await user.click(screen.getByRole("button", { name: "Upvoted" }));
|
||||
expect(currentPath()).toBe("/@ann/upvoted?category=music");
|
||||
await waitFor(() =>
|
||||
expect(calls("GET /api/users/:u/upvoted").at(-1)!.url.searchParams.get(
|
||||
"category",
|
||||
)).toBe("music")
|
||||
);
|
||||
// Any other tab drops it.
|
||||
await user.click(screen.getByRole("button", { name: "Playlists" }));
|
||||
expect(currentPath()).toBe("/@ann/playlists");
|
||||
});
|
||||
|
||||
test("the dumps tab loads more as its end scrolls into view", async () => {
|
||||
open({ url: "/@ann/dumps" });
|
||||
// After open's, so that it wins.
|
||||
|
||||
Reference in New Issue
Block a user