mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
## Summary Follow-up batch on the Projects overview (continues merged #1677): - **Repository access restrictions** — repositories the viewer can't reach are surfaced with a reason instead of failing silently. Channel-ACL denials (which arrive as the same 404 as a missing repo, for anti-enumeration) are re-classified using the repository's channel binding and the viewer's memberships (`useRepositoryAccess.ts`, `projectRepoAvailability.ts`). - **Projects loads in seconds instead of minutes** — enumeration no longer crawls every kind:5 deletion event on the relay. It fetches project/repo announcements first, then queries deletions scoped to those coordinates via chunked `#a` filters (3 queries instead of hundreds on staging). - **Activity feed layout polish** — bare event-type glyph beside the headline (no badge circle), timeline spine runs through the avatars connecting consecutive cards, linkable actor/project names are bold in theme foreground, rounded hover state, alignment fixes. - **Create button pinned** — the "+" create menu is pinned to the pane's top-right corner (equal 16px insets) and no longer scrolls away with the page header. - **List controls as a table header** — the scope selector (left) and sort + layout toggle (right) render as the first row of the list container on the Projects/Repositories/PRs/Issues tabs; in card view the identical bar stands alone with the cards below (`ProjectsListHeaderBar.tsx`). - **Repository rows show the git location** — subtitle is `github.com/org/repo` for external repos or `owner/repo` (resolved profile name) for Buzz-hosted ones, instead of repeating the project name (`repositoryDisplayPath`). - **Uniform work-item row heights** — issue rows previously ran the author chip in inline flow, letting the 20px avatar grow the line box ~3px taller than PR rows; both lists now share the same flex subtitle. 📸 Screenshots: [feed layout / pinned button](https://github.com/block/buzz/pull/5073#issuecomment-5214114069) · [list header / repo subtitles / row heights](https://github.com/block/buzz/pull/5073#issuecomment-5217480712). Note: two empty `chore: retrigger CI` commits exist on the branch from working around the Aug 6 GitHub Actions incident; happy to drop them with a signoff rebase before undrafting if preferred. Latest `main` is merged in (`a0cc35220`). ## Test plan - [x] Desktop unit tests (4,493 pass after merging main), Biome, tsc - [x] New unit tests for scoped deletion enumeration and repo availability re-classification - [x] New unit tests for `repositoryDisplayPath` (external, Buzz-hosted, unresolvable) - [x] Screenshot verification of feed layout, connector spine, and pinned button (top + scrolled states) — posted to the PR - [x] Screenshot verification of the list header row (list + card), repo subtitles, and matching PR/issue row heights — posted to the PR - [ ] Manual pass against staging (projects list load time, restricted-repo states) --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com>
343 lines
9.6 KiB
TypeScript
343 lines
9.6 KiB
TypeScript
import { Eye, MessageSquare } from "lucide-react";
|
|
|
|
import type {
|
|
Project,
|
|
ProjectIssue,
|
|
ProjectIssueListItem,
|
|
Repository,
|
|
} from "@/features/projects/hooks";
|
|
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
|
|
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
|
|
import {
|
|
resolveUserLabel,
|
|
type UserProfileLookup,
|
|
} from "@/features/profile/lib/identity";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { Card } from "@/shared/ui/card";
|
|
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
|
|
import { ProjectAuthorIdentity } from "./ProjectAuthorIdentity";
|
|
import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
|
|
import { ProjectListRowMenu } from "./ProjectListRowMenu";
|
|
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
|
|
import {
|
|
PROJECT_LIST_CONTAINER_CLASS,
|
|
PROJECT_LIST_ROW_CLASS,
|
|
PROJECT_LIST_ROW_CONTENT_CLASS,
|
|
PROJECT_LIST_ROW_DATE_CLASS,
|
|
PROJECT_LIST_ROW_STATUS_CLASS,
|
|
PROJECT_LIST_ROW_SUBTEXT_CLASS,
|
|
PROJECT_LIST_ROW_TITLE_CLASS,
|
|
PROJECT_LIST_ROW_TRAILING_CLASS,
|
|
} from "./projectListRowStyles";
|
|
|
|
type ProjectsIssuesListProps = {
|
|
/** Render without container chrome — a parent table container provides border and rounding. */
|
|
embedded?: boolean;
|
|
error: unknown;
|
|
failedSections: ProjectWorkItemSection[];
|
|
isLoading: boolean;
|
|
isRetrying: boolean;
|
|
onOpen: (
|
|
project: Project,
|
|
repository: Repository,
|
|
issue: ProjectIssue,
|
|
) => void;
|
|
onRetry: () => void;
|
|
profiles?: UserProfileLookup;
|
|
issues: ProjectIssueListItem[];
|
|
viewMode: "grid" | "list";
|
|
};
|
|
|
|
function nextStepLabel(status: ProjectIssue["status"]) {
|
|
if (status === "Done" || status === "Closed") return "View issue";
|
|
if (status === "In Review") return "Review issue";
|
|
if (status === "Triage") return "Triage issue";
|
|
return "Open issue";
|
|
}
|
|
|
|
function IssueHeader({
|
|
authorTestId,
|
|
includeDate = true,
|
|
issue,
|
|
profiles,
|
|
project,
|
|
}: {
|
|
authorTestId?: string;
|
|
includeDate?: boolean;
|
|
issue: ProjectIssue;
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
}) {
|
|
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
|
|
|
|
return (
|
|
<div className="-mt-0.5 min-w-0 flex-1">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{issue.title}</p>
|
|
</div>
|
|
{/* Flex (not inline flow) so the 20px author avatar cannot grow the
|
|
line box — keeps row heights identical to the PR list. */}
|
|
<div
|
|
className={`flex min-w-0 items-center gap-x-1.5 overflow-hidden whitespace-nowrap ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}
|
|
>
|
|
<span>{project.name}</span>
|
|
{includeDate ? (
|
|
<>
|
|
<span>·</span>
|
|
<span>created {relativeTime(issue.createdAt)}</span>
|
|
</>
|
|
) : null}
|
|
<span>·</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<span>by</span>
|
|
<ProjectAuthorIdentity
|
|
label={authorLabel}
|
|
profiles={profiles}
|
|
pubkey={issue.author}
|
|
testId={authorTestId}
|
|
/>
|
|
</span>
|
|
{includeDate ? (
|
|
<>
|
|
<span>·</span>
|
|
<span>{issue.status}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="md:hidden">·</span>
|
|
<span className="md:hidden">{issue.status}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function IssueGridCard({
|
|
issue,
|
|
onOpen,
|
|
profiles,
|
|
project,
|
|
}: {
|
|
issue: ProjectIssue;
|
|
onOpen: (project: Project, issue: ProjectIssue) => void;
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
}) {
|
|
return (
|
|
<Card
|
|
className="group relative flex min-h-40 flex-col overflow-hidden border-border/60 bg-transparent p-4 shadow-none transition-colors duration-150 hover:bg-muted/20"
|
|
data-projects-grid-card
|
|
>
|
|
<button
|
|
className="absolute inset-0"
|
|
onClick={() => onOpen(project, issue)}
|
|
type="button"
|
|
>
|
|
<span className="sr-only">View {issue.title}</span>
|
|
</button>
|
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<ProjectEventTypeIcon className="h-5 w-5" kind="issue" />
|
|
<IssueHeader issue={issue} profiles={profiles} project={project} />
|
|
<Button
|
|
className="relative z-10 h-7 shrink-0 px-2.5"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onOpen(project, issue);
|
|
}}
|
|
size="xs"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
{nextStepLabel(issue.status)}
|
|
</Button>
|
|
</div>
|
|
|
|
{issue.content ? (
|
|
<p className="line-clamp-2 text-sm text-foreground/90">
|
|
{issue.content}
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="mt-auto border border-border/60 bg-muted/30 px-2.5 py-2">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-foreground/80">
|
|
<span className="font-mono text-foreground">
|
|
#{issue.id.slice(0, 8)}
|
|
</span>
|
|
{issue.comments.length > 0 ? (
|
|
<span className="flex items-center gap-1">
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
{issue.comments.length}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function IssueListRow({
|
|
issue,
|
|
onOpen,
|
|
profiles,
|
|
project,
|
|
}: {
|
|
issue: ProjectIssue;
|
|
onOpen: (project: Project, issue: ProjectIssue) => void;
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={PROJECT_LIST_ROW_CLASS}
|
|
data-testid={`projects-issue-row-${issue.id}`}
|
|
>
|
|
<button
|
|
className="absolute inset-0"
|
|
onClick={() => onOpen(project, issue)}
|
|
type="button"
|
|
>
|
|
<span className="sr-only">View {issue.title}</span>
|
|
</button>
|
|
<div className={PROJECT_LIST_ROW_CONTENT_CLASS}>
|
|
<ProjectEventTypeIcon className="h-5 w-5" kind="issue" />
|
|
<IssueHeader
|
|
authorTestId="projects-issue-author"
|
|
includeDate={false}
|
|
issue={issue}
|
|
profiles={profiles}
|
|
project={project}
|
|
/>
|
|
<div className={PROJECT_LIST_ROW_TRAILING_CLASS}>
|
|
<span className={PROJECT_LIST_ROW_STATUS_CLASS}>{issue.status}</span>
|
|
<div className="hidden w-14 shrink-0 justify-end md:flex">
|
|
{issue.comments.length > 0 ? (
|
|
<span className="flex items-center gap-1 text-2xs leading-3 text-muted-foreground">
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
{issue.comments.length}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<span
|
|
className={PROJECT_LIST_ROW_DATE_CLASS}
|
|
data-testid="projects-row-date"
|
|
title={new Date(issue.createdAt * 1_000).toLocaleString()}
|
|
>
|
|
{relativeTime(issue.createdAt)}
|
|
</span>
|
|
<ProjectListRowMenu label={`More options for ${issue.title}`}>
|
|
<DropdownMenuItem onSelect={() => onOpen(project, issue)}>
|
|
<Eye className="h-4 w-4" />
|
|
{nextStepLabel(issue.status)}
|
|
</DropdownMenuItem>
|
|
</ProjectListRowMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ProjectsIssuesList({
|
|
embedded,
|
|
error,
|
|
failedSections,
|
|
isLoading,
|
|
isRetrying,
|
|
issues,
|
|
onOpen,
|
|
onRetry,
|
|
profiles,
|
|
viewMode,
|
|
}: ProjectsIssuesListProps) {
|
|
if (isLoading) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"px-4 py-12 text-center text-sm text-muted-foreground",
|
|
!embedded && "border border-border/60",
|
|
)}
|
|
>
|
|
Loading issues...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const loadNotice = (
|
|
<ProjectsWorkItemsLoadNotice
|
|
error={error}
|
|
failedSections={failedSections}
|
|
isRetrying={isRetrying}
|
|
onRetry={onRetry}
|
|
subject="issues"
|
|
/>
|
|
);
|
|
|
|
if (error && issues.length === 0) {
|
|
return loadNotice;
|
|
}
|
|
|
|
if (issues.length === 0) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{loadNotice}
|
|
<div
|
|
className={cn(
|
|
"px-4 py-12 text-center text-sm text-muted-foreground",
|
|
!embedded && "border border-dashed border-border/60",
|
|
)}
|
|
>
|
|
No issues yet.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (viewMode === "grid") {
|
|
return (
|
|
<div className="space-y-3">
|
|
{loadNotice}
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{issues.map(({ project, issue, repository }) => (
|
|
<IssueGridCard
|
|
issue={issue}
|
|
key={`${repository.id}:${issue.id}`}
|
|
onOpen={(selectedProject, selectedIssue) =>
|
|
onOpen(selectedProject, repository, selectedIssue)
|
|
}
|
|
profiles={profiles}
|
|
project={project}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{loadNotice}
|
|
<div
|
|
className={
|
|
embedded ? "divide-y divide-border/60" : PROJECT_LIST_CONTAINER_CLASS
|
|
}
|
|
data-testid="projects-list-container"
|
|
>
|
|
{issues.map(({ project, issue, repository }) => (
|
|
<IssueListRow
|
|
issue={issue}
|
|
key={`${repository.id}:${issue.id}`}
|
|
onOpen={(selectedProject, selectedIssue) =>
|
|
onOpen(selectedProject, repository, selectedIssue)
|
|
}
|
|
profiles={profiles}
|
|
project={project}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|