Skip to content

Commit efcb6af

Browse files
zachdunnclaude
andauthored
fix(web): collapse org release filter tabs to All/Web/GitHub (#798)
* fix(web): collapse org release filter tabs to All/Web/GitHub The per-type filter strip (All/Scrape/Feed/GitHub) was both more granular than users care about and prone to a layout glitch where the inner flex-wrap on the chip group would split chips across two rows while the checkbox stayed vertically centered, looking like overlap. - Collapse feed/scrape/agent into a single "Web" group; GitHub stays distinct. Show the strip only when the org actually has both sides. - Stop the chip group from shrinking (shrink-0 + whitespace-nowrap) so it never wraps internally; let the checkbox wrap to its own row via ml-auto when the parent is narrow. - Replace -mt-2 with mt-3 so the active pill no longer collides with the tab bar's border-b. The API contract is unchanged — "Web" sends source_type=feed,scrape,agent through the existing tolerant parser. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Reset filterGroup to "all" if its source type disappears Defensive guard for the (currently theoretical) case where availableSourceTypes changes such that the active group is no longer represented — without this, buildQuery would keep sending the stale source_type and the user would see empty results with no visible tab to clear it. CodeRabbit catch on #798. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add aria-pressed to filter buttons CodeRabbit nit on #798 — the active state was visual-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Abort in-flight pagination when filters change Without this, clicking "Load more" then flipping a filter could race — the old page's response would land after the filter-change refetch and append stale rows to the new filtered list. The filter-change effect now aborts any in-flight loadMore alongside its own previous request. CodeRabbit catch on #798. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Surface loadMore failures via fetchError, don't rethrow Re-throwing inside loadMore (invoked from onClick) caused unhandled promise rejections on transient failures like a malformed JSON response. Mirror the refetch effect — set fetchError so the existing banner surfaces the failure. CodeRabbit catch on #798. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 41412ed commit efcb6af

1 file changed

Lines changed: 60 additions & 24 deletions

File tree

web/src/components/org-release-list.tsx

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useCallback, useEffect, useMemo } from "react";
3+
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
44
import { ReleaseListItem } from "./release-item";
55
import type { OrgReleaseItem } from "@/lib/api";
66
import type { SourceType } from "@buildinternet/releases-core/source-enums";
@@ -14,12 +14,17 @@ interface OrgReleaseListProps {
1414
availableSourceTypes: SourceType[];
1515
}
1616

17-
const SOURCE_TYPE_LABELS: Record<SourceType, string> = {
18-
github: "GitHub",
19-
feed: "Feed",
20-
scrape: "Scrape",
21-
agent: "Agent",
22-
};
17+
// Source types collapse into two filter groups for the user-facing tabs.
18+
// GitHub is a distinct, well-known surface; everything else (feed / scrape /
19+
// agent) is plumbing the user shouldn't have to reason about, so we present
20+
// it as a single "Web" group.
21+
const WEB_SOURCE_TYPES: readonly SourceType[] = ["feed", "scrape", "agent"];
22+
const FILTER_GROUPS = {
23+
all: { label: "All", types: [] as SourceType[] },
24+
web: { label: "Web", types: [...WEB_SOURCE_TYPES] as SourceType[] },
25+
github: { label: "GitHub", types: ["github"] as SourceType[] },
26+
} as const;
27+
type FilterGroup = keyof typeof FILTER_GROUPS;
2328

2429
export function OrgReleaseList({
2530
orgSlug,
@@ -28,7 +33,7 @@ export function OrgReleaseList({
2833
multipleSourcesExist,
2934
availableSourceTypes,
3035
}: OrgReleaseListProps) {
31-
const [sourceType, setSourceType] = useState<string>("all");
36+
const [filterGroup, setFilterGroup] = useState<FilterGroup>("all");
3237
const [includePrereleases, setIncludePrereleases] = useState(false);
3338
const [releases, setReleases] = useState(initialReleases);
3439
const [cursor, setCursor] = useState(initialCursor);
@@ -38,32 +43,53 @@ export function OrgReleaseList({
3843
// true, we render the SSR-provided rows directly so filter tabs paint
3944
// instantly; flipping any filter triggers a fetch and replaces them.
4045
const [pristine, setPristine] = useState(true);
46+
// Tracks the in-flight pagination fetch so we can abort it when the user
47+
// flips a filter mid-page-load. Without this, an old page's response can
48+
// race the filter switch and append stale rows to the new filtered list.
49+
const loadMoreAbortRef = useRef<AbortController | null>(null);
50+
51+
const hasGithub = availableSourceTypes.includes("github");
52+
const hasWeb = availableSourceTypes.some((t) => WEB_SOURCE_TYPES.includes(t));
4153

4254
const filterTabs = useMemo(() => {
43-
// Hide the source-type tab strip when there's only one underlying type;
44-
// the filter would always be a single-button row.
45-
if (availableSourceTypes.length <= 1) return [];
55+
// The Web vs. GitHub split is only useful when the org actually has both
56+
// sides; a one-button row collapses to no filter.
57+
if (!hasGithub || !hasWeb) return [];
4658
return [
47-
{ value: "all", label: "All" },
48-
...availableSourceTypes.map((t) => ({ value: t, label: SOURCE_TYPE_LABELS[t] })),
59+
{ value: "all" as const, label: FILTER_GROUPS.all.label },
60+
{ value: "web" as const, label: FILTER_GROUPS.web.label },
61+
{ value: "github" as const, label: FILTER_GROUPS.github.label },
4962
];
50-
}, [availableSourceTypes]);
63+
}, [hasGithub, hasWeb]);
64+
65+
// Defensive: if the available source types change such that the active
66+
// group disappears, fall back to "all" so buildQuery never sends a
67+
// source_type that's no longer represented in the org's catalog.
68+
useEffect(() => {
69+
if ((filterGroup === "github" && !hasGithub) || (filterGroup === "web" && !hasWeb)) {
70+
setFilterGroup("all");
71+
}
72+
}, [filterGroup, hasGithub, hasWeb]);
5173

5274
const buildQuery = useCallback(
5375
(extra: Record<string, string> = {}) => {
5476
const params = new URLSearchParams();
55-
if (sourceType !== "all") params.set("source_type", sourceType);
77+
const types = FILTER_GROUPS[filterGroup].types;
78+
if (types.length > 0) params.set("source_type", types.join(","));
5679
if (includePrereleases) params.set("include_prereleases", "true");
5780
for (const [k, v] of Object.entries(extra)) params.set(k, v);
5881
return params.toString();
5982
},
60-
[sourceType, includePrereleases],
83+
[filterGroup, includePrereleases],
6184
);
6285

6386
// Refetch when filters change (skip the initial render — the SSR rows
6487
// already match the default filter state).
6588
useEffect(() => {
6689
if (pristine) return;
90+
// Cancel any in-flight pagination so its response can't append to the
91+
// newly-filtered list once it lands.
92+
loadMoreAbortRef.current?.abort();
6793
const controller = new AbortController();
6894
setLoading(true);
6995
setFetchError(null);
@@ -85,15 +111,24 @@ export function OrgReleaseList({
85111

86112
const loadMore = useCallback(async () => {
87113
if (!cursor) return;
114+
loadMoreAbortRef.current?.abort();
115+
const controller = new AbortController();
116+
loadMoreAbortRef.current = controller;
88117
setLoading(true);
89118
try {
90-
const res = await fetch(`/api/org-releases/${orgSlug}?${buildQuery({ cursor })}`);
119+
const res = await fetch(`/api/org-releases/${orgSlug}?${buildQuery({ cursor })}`, {
120+
signal: controller.signal,
121+
});
91122
if (!res.ok) return;
92123
const data = await res.json();
124+
if (controller.signal.aborted) return;
93125
setReleases((prev) => [...prev, ...data.releases]);
94126
setCursor(data.pagination.nextCursor);
127+
} catch (err) {
128+
if ((err as Error).name === "AbortError") return;
129+
setFetchError("Failed to load more releases.");
95130
} finally {
96-
setLoading(false);
131+
if (!controller.signal.aborted) setLoading(false);
97132
}
98133
}, [cursor, orgSlug, buildQuery]);
99134

@@ -108,21 +143,22 @@ export function OrgReleaseList({
108143
return (
109144
<div>
110145
{showFilterRow && (
111-
<div className="flex flex-wrap items-center justify-between gap-3 mb-2 -mt-2">
112-
<div className="flex flex-wrap items-center gap-1">
146+
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3 mb-3">
147+
<div className="flex items-center gap-1 shrink-0">
113148
{showSourceTypeTabs &&
114149
filterTabs.map((tab) => {
115-
const active = sourceType === tab.value;
150+
const active = filterGroup === tab.value;
116151
return (
117152
<button
118153
key={tab.value}
119154
type="button"
155+
aria-pressed={active}
120156
onClick={() => {
121157
setPristine(false);
122-
setSourceType(tab.value);
158+
setFilterGroup(tab.value);
123159
}}
124160
className={
125-
"text-[12px] px-2 py-1 rounded-md transition-colors " +
161+
"text-[12px] px-2 py-1 rounded-md transition-colors whitespace-nowrap " +
126162
(active
127163
? "bg-stone-100 dark:bg-stone-800 text-stone-900 dark:text-stone-100 font-medium"
128164
: "text-stone-500 dark:text-stone-400 hover:text-stone-700 dark:hover:text-stone-200")
@@ -133,7 +169,7 @@ export function OrgReleaseList({
133169
);
134170
})}
135171
</div>
136-
<label className="flex items-center gap-2 text-[12px] text-stone-500 dark:text-stone-400 cursor-pointer select-none">
172+
<label className="flex items-center gap-2 text-[12px] text-stone-500 dark:text-stone-400 cursor-pointer select-none ml-auto shrink-0">
137173
<input
138174
type="checkbox"
139175
checked={includePrereleases}

0 commit comments

Comments
 (0)