mirror of
https://github.com/block/buzz.git
synced 2026-11-09 19:02:00 +00:00
feat(desktop): refine repository-aware project workspaces
Make Projects repository-aware across navigation, source management, work items, discussions, and contextual actions while consolidating detail views into consistent single-column workspaces. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -1,4 +1,14 @@
|
||||
import { CircleCheck, CircleDot, CircleX, MessageSquare } from "lucide-react";
|
||||
import {
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
CircleDot,
|
||||
CircleX,
|
||||
MessageSquare,
|
||||
Tag,
|
||||
type LucideIcon,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -17,40 +27,93 @@ import {
|
||||
import { entityDiscussionQuery } from "@/features/projects/lib/discussionChannels";
|
||||
import { issueShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import {
|
||||
projectTaskCategoryLabel,
|
||||
projectTaskUserLabels,
|
||||
} from "@/features/projects/projectTaskCategories";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { ChannelMember } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { IssueAssigneeFacepile, IssueAssigneesRow } from "./IssueAssigneesRow";
|
||||
import {
|
||||
ProjectFeedRow,
|
||||
ProjectFeedRowCluster,
|
||||
ProjectFeedRowMonoCell,
|
||||
} from "./ProjectFeedRow";
|
||||
import { DiscussedInChannels } from "./DiscussionChannels";
|
||||
import { ProjectIssueCommentTimeline } from "./ProjectIssueCommentTimeline";
|
||||
import { ProjectOriginReference } from "./ProjectOriginReference";
|
||||
import { OverviewRailSection } from "./ProjectOverviewPanel";
|
||||
import {
|
||||
ProjectDetailMetaList,
|
||||
ProjectDetailMetaPills,
|
||||
ProjectDetailMetaRow,
|
||||
} from "./ProjectDetailMeta";
|
||||
import { ProjectDetailSection } from "./ProjectDetailSection";
|
||||
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
|
||||
import { ProjectRichContent } from "./ProjectRichContent";
|
||||
import { ShareLinkButton } from "./ShareLinkButton";
|
||||
import { PROJECT_DETAIL_READING_COLUMN_CLASS } from "./projectPanelStyles";
|
||||
import {
|
||||
ProjectStatusProgressIcon,
|
||||
type ProjectStatusProgressState,
|
||||
} from "./ProjectStatusProgressIcon";
|
||||
import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup";
|
||||
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
|
||||
|
||||
export function issueStatusClassName(status: ProjectIssue["status"]) {
|
||||
if (status === "Triage" || status === "In Progress") return "text-amber-500";
|
||||
if (status === "Backlog") return "text-muted-foreground";
|
||||
if (status === "In Review") return "text-green-500";
|
||||
if (status === "Done") return "text-purple-400";
|
||||
if (status === "Closed") return "text-destructive";
|
||||
return "text-green-500";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function issueStatusVisual(status: ProjectIssue["status"]) {
|
||||
function issueStatusVisual(status: ProjectIssue["status"]): {
|
||||
className: string;
|
||||
icon: LucideIcon;
|
||||
progress: ProjectStatusProgressState;
|
||||
} {
|
||||
if (status === "Done") {
|
||||
return { className: "text-purple-400", icon: CircleCheck };
|
||||
return {
|
||||
className: "text-purple-400",
|
||||
icon: CircleCheck,
|
||||
progress: "completed",
|
||||
};
|
||||
}
|
||||
if (status === "Closed") {
|
||||
return { className: "text-destructive", icon: CircleX };
|
||||
return {
|
||||
className: "text-destructive",
|
||||
icon: CircleX,
|
||||
progress: "canceled",
|
||||
};
|
||||
}
|
||||
return { className: "text-green-500", icon: CircleDot };
|
||||
if (status === "Backlog") {
|
||||
return {
|
||||
className: issueStatusClassName(status),
|
||||
icon: Circle,
|
||||
progress: "queued",
|
||||
};
|
||||
}
|
||||
if (status === "Triage") {
|
||||
return {
|
||||
className: issueStatusClassName(status),
|
||||
icon: CircleDashed,
|
||||
progress: "queued",
|
||||
};
|
||||
}
|
||||
return {
|
||||
className: issueStatusClassName(status),
|
||||
icon: CircleDot,
|
||||
progress: status === "In Review" ? "review" : "started",
|
||||
};
|
||||
}
|
||||
|
||||
const ISSUE_STATUS_ORDER: readonly ProjectIssue["status"][] = [
|
||||
"In Review",
|
||||
"In Progress",
|
||||
"Triage",
|
||||
"Backlog",
|
||||
"Done",
|
||||
"Closed",
|
||||
];
|
||||
|
||||
function issueMembers(
|
||||
project: Project,
|
||||
issue: ProjectIssue,
|
||||
@@ -90,67 +153,87 @@ function IssueRow({
|
||||
const status = issueStatusVisual(issue.status);
|
||||
|
||||
return (
|
||||
<ProjectFeedRow
|
||||
meta={
|
||||
<>
|
||||
<ProfileIdentityButton
|
||||
avatarClassName="shrink-0"
|
||||
avatarSize="xs"
|
||||
avatarUrl={authorProfile?.avatarUrl ?? null}
|
||||
isAgent={authorProfile?.isAgent === true}
|
||||
label={authorLabel}
|
||||
pubkey={issue.author}
|
||||
showLabel={false}
|
||||
/>
|
||||
<span className="truncate text-foreground/80">
|
||||
<span className="font-medium">{authorLabel}</span> created this
|
||||
issue
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{issue.status}</span>
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs"
|
||||
key={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
<ProjectWorkItemRow
|
||||
eventId={issue.id}
|
||||
identifier={`#${issue.id.slice(0, 8)}`}
|
||||
identifierTitle="View task"
|
||||
onOpen={onOpen}
|
||||
statusIcon={
|
||||
<status.icon className={`h-3.5 w-3.5 shrink-0 ${status.className}`} />
|
||||
<ProjectStatusProgressIcon
|
||||
aria-label={issue.status}
|
||||
className={`h-3.5 w-3.5 shrink-0 ${status.className}`}
|
||||
state={status.progress}
|
||||
/>
|
||||
}
|
||||
testId="project-issue-row"
|
||||
title={issue.title}
|
||||
trailing={
|
||||
<>
|
||||
<IssueAssigneeFacepile
|
||||
assignees={issue.assignees}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{issue.comments.length > 0 ? (
|
||||
<span
|
||||
className="hidden w-24 shrink-0 truncate text-right text-xs text-muted-foreground md:block"
|
||||
data-testid="project-issue-row-category"
|
||||
>
|
||||
{projectTaskCategoryLabel(issue.category)}
|
||||
</span>
|
||||
<span className="flex w-20 shrink-0 items-center justify-end gap-1">
|
||||
<span
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center"
|
||||
data-testid="project-issue-creator"
|
||||
title={`Created by ${authorLabel}`}
|
||||
>
|
||||
<ProfileIdentityButton
|
||||
avatarClassName="shrink-0"
|
||||
avatarSize="xs"
|
||||
avatarUrl={authorProfile?.avatarUrl ?? null}
|
||||
isAgent={authorProfile?.isAgent === true}
|
||||
label={authorLabel}
|
||||
pubkey={issue.author}
|
||||
showLabel={false}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="flex min-w-5 shrink-0 justify-end text-muted-foreground/45"
|
||||
data-testid="project-issue-assignee-cell"
|
||||
>
|
||||
{issue.assignees.length > 0 ? (
|
||||
<IssueAssigneeFacepile
|
||||
assignees={issue.assignees}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full border border-border/70 bg-muted/35"
|
||||
data-testid="project-issue-assignee-placeholder"
|
||||
title="Unassigned"
|
||||
>
|
||||
<User aria-hidden="true" className="h-3 w-3" />
|
||||
<span className="sr-only">Unassigned</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex w-8 shrink-0 justify-end">
|
||||
<button
|
||||
aria-label={`View ${issue.comments.length} comments`}
|
||||
className="flex items-center gap-1 rounded-md text-xs text-muted-foreground hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={
|
||||
issue.comments.length > 0
|
||||
? `View ${issue.comments.length} comments`
|
||||
: "View comments"
|
||||
}
|
||||
className={`flex items-center gap-1 rounded-md text-xs hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
issue.comments.length > 0
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/45"
|
||||
}`}
|
||||
data-testid="project-issue-comments"
|
||||
onClick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{issue.comments.length}
|
||||
</button>
|
||||
) : null}
|
||||
<ProjectFeedRowCluster>
|
||||
<ProjectFeedRowMonoCell
|
||||
label={`#${issue.id.slice(0, 8)}`}
|
||||
onClick={onOpen}
|
||||
title="View issue"
|
||||
/>
|
||||
</ProjectFeedRowCluster>
|
||||
</span>
|
||||
<span
|
||||
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
|
||||
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground/70 sm:block"
|
||||
data-testid="project-issue-row-date"
|
||||
title={new Date(issue.createdAt * 1_000).toLocaleString()}
|
||||
>
|
||||
@@ -167,12 +250,10 @@ export function ProjectIssueDetail({
|
||||
issue,
|
||||
profiles,
|
||||
project,
|
||||
stackMetaRail = false,
|
||||
}: {
|
||||
issue: ProjectIssue;
|
||||
profiles?: UserProfileLookup;
|
||||
project: Project;
|
||||
stackMetaRail?: boolean;
|
||||
}) {
|
||||
const commentMutation = useCreateProjectIssueCommentMutation(project);
|
||||
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
|
||||
@@ -203,46 +284,99 @@ export function ProjectIssueDetail({
|
||||
},
|
||||
[commentMutation, issue],
|
||||
);
|
||||
const identityQuery = useIdentityQuery();
|
||||
const authorProfile = profiles?.[normalizePubkey(issue.author)];
|
||||
const status = issueStatusVisual(issue.status);
|
||||
const labels = projectTaskUserLabels(issue.labels);
|
||||
const viewerPubkey = identityQuery.data?.pubkey;
|
||||
const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null;
|
||||
const isAuthor = viewer === normalizePubkey(issue.author);
|
||||
const isOwner = viewer === normalizePubkey(project.owner);
|
||||
const isManagedAgentOwner = useIsManagedAgent(project.owner) === true;
|
||||
const canAssignOthers =
|
||||
Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
!stackMetaRail && "xl:grid-cols-[minmax(0,1fr)_18rem]",
|
||||
)}
|
||||
className={PROJECT_DETAIL_READING_COLUMN_CLASS}
|
||||
data-project-detail-panel
|
||||
data-testid="project-issue-detail"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<header className="space-y-3 p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<CircleDot className="h-3.5 w-3.5" />
|
||||
Issue from {authorLabel}
|
||||
<ProjectOriginReference
|
||||
agentName={issue.originAgentName}
|
||||
channelId={issue.channelId}
|
||||
/>
|
||||
</p>
|
||||
<h3 className="mt-1 line-clamp-2 text-base font-semibold text-foreground">
|
||||
{issue.title}{" "}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
#{issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<ShareLinkButton
|
||||
className="ml-1 inline-flex h-6 w-6 align-text-bottom"
|
||||
label="Copy issue link"
|
||||
link={issueShareLink(issue)}
|
||||
testId="project-issue-copy-link"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
{issue.content ? (
|
||||
<ProjectRichContent content={issue.content} tags={issue.tags} />
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="space-y-3 p-4">
|
||||
<header className="space-y-2 px-6 pb-3 pt-5">
|
||||
<h3 className="line-clamp-2 text-lg font-semibold leading-6 text-foreground">
|
||||
{issue.title}{" "}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
#{issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<ShareLinkButton
|
||||
className="ml-1 inline-flex h-7 w-7 align-text-bottom"
|
||||
label="Copy task link"
|
||||
link={issueShareLink(issue)}
|
||||
testId="project-issue-copy-link"
|
||||
/>
|
||||
</h3>
|
||||
<p className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs text-muted-foreground">
|
||||
<ProfileIdentityButton
|
||||
avatarClassName="shrink-0"
|
||||
avatarSize="xs"
|
||||
avatarUrl={authorProfile?.avatarUrl ?? null}
|
||||
isAgent={authorProfile?.isAgent === true}
|
||||
label={authorLabel}
|
||||
pubkey={issue.author}
|
||||
showLabel={false}
|
||||
/>
|
||||
<span className="font-medium text-foreground">{authorLabel}</span>
|
||||
<span
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
title={new Date(issue.createdAt * 1_000).toLocaleString()}
|
||||
>
|
||||
{relativeTime(issue.createdAt)}
|
||||
</span>
|
||||
<ProjectOriginReference
|
||||
agentName={issue.originAgentName}
|
||||
channelId={issue.channelId}
|
||||
/>
|
||||
</p>
|
||||
</header>
|
||||
<ProjectDetailMetaList>
|
||||
<ProjectDetailMetaRow icon={status.icon} label="Status">
|
||||
<span className={`font-medium ${status.className}`}>
|
||||
{issue.status}
|
||||
</span>
|
||||
</ProjectDetailMetaRow>
|
||||
<ProjectDetailMetaRow icon={CircleDot} label="Category">
|
||||
{projectTaskCategoryLabel(issue.category)}
|
||||
</ProjectDetailMetaRow>
|
||||
{issue.assignees.length > 0 || viewer ? (
|
||||
<ProjectDetailMetaRow icon={User} label="Assignees">
|
||||
<IssueAssigneesRow
|
||||
canAssignOthers={canAssignOthers}
|
||||
issue={issue}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
signAsManagedOwner={isManagedAgentOwner && !isOwner}
|
||||
viewerPubkey={viewer}
|
||||
/>
|
||||
</ProjectDetailMetaRow>
|
||||
) : null}
|
||||
{labels.length > 0 ? (
|
||||
<ProjectDetailMetaRow icon={Tag} label="Labels">
|
||||
<ProjectDetailMetaPills labels={labels} />
|
||||
</ProjectDetailMetaRow>
|
||||
) : null}
|
||||
</ProjectDetailMetaList>
|
||||
{issue.content ? (
|
||||
<ProjectDetailSection defaultOpen title="Description">
|
||||
<ProjectRichContent content={issue.content} tags={issue.tags} />
|
||||
</ProjectDetailSection>
|
||||
) : null}
|
||||
<ProjectDetailSection defaultOpen title="Activity">
|
||||
<div className="space-y-3">
|
||||
<DiscussedInChannels
|
||||
entityLabel="this issue"
|
||||
entityLabel="this task"
|
||||
originChannelId={issue.channelId}
|
||||
originCreatedAt={issue.createdAt}
|
||||
originPubkey={issue.author}
|
||||
query={entityDiscussionQuery(issue.id)}
|
||||
testId="issue-discussed-in"
|
||||
/>
|
||||
@@ -251,126 +385,23 @@ export function ProjectIssueDetail({
|
||||
key={issue.id}
|
||||
profiles={profiles}
|
||||
/>
|
||||
<div data-testid="project-issue-comment-composer">
|
||||
<ForumComposer
|
||||
className="border border-border/60 bg-background/45"
|
||||
disabled={commentMutation.isPending}
|
||||
isSending={commentMutation.isPending}
|
||||
members={members}
|
||||
onSubmit={handleCommentSubmit}
|
||||
placeholder="Add a comment…"
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<IssueMetaRail
|
||||
issue={issue}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
stacked={stackMetaRail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Right-hand meta column for the issue detail view: status, assignees,
|
||||
* author, labels, and dates — keeps the conversation column focused. */
|
||||
function IssueMetaRail({
|
||||
issue,
|
||||
profiles,
|
||||
project,
|
||||
stacked = false,
|
||||
}: {
|
||||
issue: ProjectIssue;
|
||||
profiles?: UserProfileLookup;
|
||||
project: Project;
|
||||
stacked?: boolean;
|
||||
}) {
|
||||
const identityQuery = useIdentityQuery();
|
||||
const authorProfile = profiles?.[normalizePubkey(issue.author)];
|
||||
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
|
||||
const status = issueStatusVisual(issue.status);
|
||||
const viewerPubkey = identityQuery.data?.pubkey;
|
||||
const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null;
|
||||
const isAuthor = viewer === normalizePubkey(issue.author);
|
||||
const isOwner = viewer === normalizePubkey(project.owner);
|
||||
const isManagedAgentOwner = useIsManagedAgent(project.owner) === true;
|
||||
// Same trust rule as parsing (assigneesForIssue): the issue author or
|
||||
// repo owner (directly or via a managed agent) can assign anyone;
|
||||
// everyone else who is signed in may still self-assign.
|
||||
const canAssignOthers =
|
||||
Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"space-y-6 border-border/60 p-4",
|
||||
stacked ? "border-t" : "border-t xl:border-l xl:border-t-0",
|
||||
)}
|
||||
>
|
||||
<OverviewRailSection title="Status">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2.5 py-1 text-xs font-medium ${status.className}`}
|
||||
>
|
||||
<status.icon className="h-3.5 w-3.5" />
|
||||
{issue.status}
|
||||
</span>
|
||||
</OverviewRailSection>
|
||||
{issue.assignees.length > 0 || viewer ? (
|
||||
<OverviewRailSection title="Assignees">
|
||||
<IssueAssigneesRow
|
||||
canAssignOthers={canAssignOthers}
|
||||
issue={issue}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
signAsManagedOwner={isManagedAgentOwner && !isOwner}
|
||||
viewerPubkey={viewer}
|
||||
/>
|
||||
</OverviewRailSection>
|
||||
) : null}
|
||||
<OverviewRailSection title="Author">
|
||||
<ProfileIdentityButton
|
||||
align="center"
|
||||
avatarSize="xs"
|
||||
avatarUrl={authorProfile?.avatarUrl ?? null}
|
||||
isAgent={authorProfile?.isAgent === true}
|
||||
label={authorLabel}
|
||||
pubkey={issue.author}
|
||||
</div>
|
||||
</ProjectDetailSection>
|
||||
<div
|
||||
className="border-border/50 border-t px-6 pb-6 pt-4"
|
||||
data-testid="project-issue-comment-composer"
|
||||
>
|
||||
<ForumComposer
|
||||
className="border border-border/60 bg-background/45"
|
||||
disabled={commentMutation.isPending}
|
||||
isSending={commentMutation.isPending}
|
||||
members={members}
|
||||
onSubmit={handleCommentSubmit}
|
||||
placeholder="Add a comment…"
|
||||
profiles={profiles}
|
||||
/>
|
||||
</OverviewRailSection>
|
||||
{issue.labels.length > 0 ? (
|
||||
<OverviewRailSection title="Labels">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs text-muted-foreground"
|
||||
key={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</OverviewRailSection>
|
||||
) : null}
|
||||
<OverviewRailSection title="Activity">
|
||||
<dl className="space-y-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt>Created</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{relativeTime(issue.createdAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt>Updated</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{relativeTime(issue.updatedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</OverviewRailSection>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -391,15 +422,15 @@ export function ProjectIssuesPanel({
|
||||
issues.find((issue) => issue.id === selectedIssueId) ?? null;
|
||||
|
||||
if (issuesQuery.isLoading) {
|
||||
return <p className="p-4 text-sm text-muted-foreground">Loading issues…</p>;
|
||||
return <BuzzLoadingState label="Loading tasks" />;
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<p className="p-4 text-sm text-muted-foreground">
|
||||
{issuesQuery.error
|
||||
? "Could not load issues for this repository."
|
||||
: "No issues yet."}
|
||||
? "Could not load tasks for this repository."
|
||||
: "No tasks yet."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -414,16 +445,38 @@ export function ProjectIssuesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const groups = ISSUE_STATUS_ORDER.map((status) => ({
|
||||
items: issues.filter((issue) => issue.status === status),
|
||||
status,
|
||||
})).filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border/50">
|
||||
{issues.map((issue) => (
|
||||
<IssueRow
|
||||
issue={issue}
|
||||
key={issue.id}
|
||||
onOpen={() => onSelectedIssueIdChange(issue.id)}
|
||||
profiles={profiles}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
{groups.map(({ items, status }) => {
|
||||
const visual = issueStatusVisual(status);
|
||||
return (
|
||||
<ProjectWorkItemGroup
|
||||
count={items.length}
|
||||
icon={
|
||||
<ProjectStatusProgressIcon
|
||||
className={`h-4 w-4 ${visual.className}`}
|
||||
state={visual.progress}
|
||||
/>
|
||||
}
|
||||
key={status}
|
||||
label={status}
|
||||
>
|
||||
{items.map((issue) => (
|
||||
<IssueRow
|
||||
issue={issue}
|
||||
key={issue.id}
|
||||
onOpen={() => onSelectedIssueIdChange(issue.id)}
|
||||
profiles={profiles}
|
||||
/>
|
||||
))}
|
||||
</ProjectWorkItemGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user