Allow editing Linear assignees and labels in list view

Extract assignee and labels rendering into dedicated components with inline editing support. Both components handle member/label loading, optimistic updates with rollback on error, and loading indicators via popovers.
This commit is contained in:
Jinjing
2026-08-09 19:01:06 -07:00
parent ce20a109da
commit 51ef36a1e6
4 changed files with 500 additions and 66 deletions
+30 -66
View File
@@ -200,6 +200,8 @@ import {
} from '@/components/linear-project-view-surfaces'
import JiraIssueWorkspace from '@/components/JiraIssueWorkspace'
import { TaskPageJiraIssueList } from '@/components/task-page-jira-issue-list'
import { LinearAssigneeCell } from '@/components/task-page-linear-assignee-cell'
import { LinearLabelsCell } from '@/components/task-page-linear-labels-cell'
import {
getSingleJiraProjectScope,
getTaskPageJiraStatusOrderScopeKey,
@@ -11483,7 +11485,6 @@ export default function TaskPage(): React.JSX.Element {
<div className="space-y-2 p-2">
{section.issues.map((issue) => {
const selected = issue.id === selectedLinearIssueId
const labels = issue.labels.slice(0, 2)
const dragging = linearBoardDraggingIssueId === issue.id
const updating = linearBoardUpdatingIssueIds.has(issue.id)
const teamLabel =
@@ -11616,13 +11617,11 @@ export default function TaskPage(): React.JSX.Element {
/>
) : null}
{effectiveLinearDisplayProperties.has('assignee') ? (
<span>
{issue.assignee?.displayName ??
translate(
'auto.components.TaskPage.42a9160321',
'Unassigned'
)}
</span>
<LinearAssigneeCell
issue={issue}
variant="name"
sourceContext={linearTaskSourceContext}
/>
) : null}
{effectiveLinearDisplayProperties.has('team') ? (
<span className="truncate">{teamLabel}</span>
@@ -11637,22 +11636,14 @@ export default function TaskPage(): React.JSX.Element {
</span>
) : null}
</div>
{effectiveLinearDisplayProperties.has('labels') &&
issue.labels.length > 0 ? (
{effectiveLinearDisplayProperties.has('labels') ? (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1">
{labels.map((label) => (
<span
key={label}
className="max-w-[140px] truncate rounded-full border border-border/50 bg-muted/35 px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{label}
</span>
))}
{issue.labels.length > labels.length ? (
<span className="text-[10px] text-muted-foreground">
+{issue.labels.length - labels.length}
</span>
) : null}
<LinearLabelsCell
issue={issue}
maxVisible={2}
className="text-[10px] [&_span]:text-[10px]"
sourceContext={linearTaskSourceContext}
/>
</div>
) : null}
</div>
@@ -11684,7 +11675,6 @@ export default function TaskPage(): React.JSX.Element {
const issue = row.issue
const selected = issue.id === selectedLinearIssueId
const labels = issue.labels.slice(0, 3)
const teamLabel =
selectedLinearWorkspaceId === 'all' && issue.workspaceName
? `${issue.workspaceName} / ${issue.team.name}`
@@ -11748,10 +11738,12 @@ export default function TaskPage(): React.JSX.Element {
/>
) : null}
{effectiveLinearDisplayProperties.has('assignee') ? (
<span className="min-w-0 truncate text-[11px] text-muted-foreground">
{issue.assignee?.displayName ??
translate('auto.components.TaskPage.42a9160321', 'Unassigned')}
</span>
<LinearAssigneeCell
issue={issue}
variant="name"
className="min-w-0 truncate"
sourceContext={linearTaskSourceContext}
/>
) : null}
{effectiveLinearDisplayProperties.has('team') ? (
<span className="min-w-0 truncate text-[11px] text-muted-foreground">
@@ -11769,19 +11761,11 @@ export default function TaskPage(): React.JSX.Element {
{effectiveLinearDisplayProperties.has('labels') ? (
<div className="flex min-w-0 items-center gap-1 max-lg:!hidden">
{labels.map((label) => (
<span
key={label}
className="max-w-[150px] truncate rounded-full border border-border/50 bg-muted/35 px-1.5 py-0.5 text-[11px] text-muted-foreground"
>
{label}
</span>
))}
{issue.labels.length > labels.length ? (
<span className="text-[11px] text-muted-foreground">
+{issue.labels.length - labels.length}
</span>
) : null}
<LinearLabelsCell
issue={issue}
maxVisible={3}
sourceContext={linearTaskSourceContext}
/>
</div>
) : null}
@@ -11803,31 +11787,11 @@ export default function TaskPage(): React.JSX.Element {
{effectiveLinearDisplayProperties.has('assignee') ? (
<div className="flex min-w-0 justify-center max-lg:!hidden">
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted/40 text-[10px] text-muted-foreground"
aria-label={
issue.assignee?.displayName ??
translate('auto.components.TaskPage.42a9160321', 'Unassigned')
}
>
{issue.assignee?.avatarUrl ? (
<img
src={issue.assignee.avatarUrl}
alt={issue.assignee.displayName}
className="size-5 rounded-full"
/>
) : (
(issue.assignee?.displayName?.slice(0, 1) ?? '-')
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{issue.assignee?.displayName ??
translate('auto.components.TaskPage.42a9160321', 'Unassigned')}
</TooltipContent>
</Tooltip>
<LinearAssigneeCell
issue={issue}
variant="avatar"
sourceContext={linearTaskSourceContext}
/>
</div>
) : null}
@@ -190,6 +190,22 @@ describe('feature interaction writer boundaries', () => {
expect(section).toContain(linearWriter)
}
const linearCellSections = [
sourceBetween(
componentSource('task-page-linear-assignee-cell.tsx'),
'export function LinearAssigneeCell',
'return ('
),
sourceBetween(
componentSource('task-page-linear-labels-cell.tsx'),
'export function LinearLabelsCell',
'return ('
)
]
for (const section of linearCellSections) {
expect(section).toContain(linearWriter)
}
const drawerMutationSections = [
sourceBetween(drawerSource, 'const handleStateChange', 'const handlePriorityChange'),
sourceBetween(drawerSource, 'const handlePriorityChange', 'const handleEstimateChange'),
@@ -0,0 +1,222 @@
import React, { useCallback, useRef, useState } from 'react'
import { ChevronDown, LoaderCircle } from 'lucide-react'
import { toast } from 'sonner'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useTeamMembers } from '@/hooks/useIssueMetadata'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { linearUpdateIssue } from '@/runtime/runtime-linear-client'
import { useAppStore } from '@/store'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import type { LinearIssue } from '../../../shared/types'
export function LinearAssigneeCell({
issue,
className,
sourceContext,
variant = 'avatar'
}: {
issue: LinearIssue
className?: string
sourceContext?: TaskSourceContext | null
variant?: 'avatar' | 'name'
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const providerSettings = sourceContext ?? settings
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
const members = useTeamMembers(issue.team.id, providerSettings, issue.workspaceId)
const [open, setOpen] = useState(false)
const [pending, setPending] = useState(false)
const reqRef = useRef(0)
const handleAssigneeChange = useCallback(
(memberId: string | null) => {
const nextAssigneeId = memberId
if ((issue.assignee?.id ?? null) === nextAssigneeId || pending) {
return
}
const member =
nextAssigneeId == null ? undefined : members.data.find((m) => m.id === nextAssigneeId)
if (nextAssigneeId != null && !member) {
return
}
reqRef.current += 1
const reqId = reqRef.current
const previousAssignee = issue.assignee
const nextAssignee = member
? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl }
: undefined
setPending(true)
patchLinearIssue(issue.id, { assignee: nextAssignee }, { sourceContext })
void linearUpdateIssue(
providerSettings,
issue.id,
{ assigneeId: nextAssigneeId },
issue.workspaceId
)
.then((result) => {
if (reqId !== reqRef.current) {
return
}
if (result.ok === false) {
patchLinearIssue(issue.id, { assignee: previousAssignee }, { sourceContext })
toast.error(
result.error ??
translate(
'auto.components.TaskPage.linearAssigneeUpdateFailed',
'Failed to update Linear assignee'
)
)
return
}
useAppStore.getState().invalidateLinearIssueLists({ sourceContext })
useAppStore.getState().recordFeatureInteraction('linear-tasks')
})
.catch(() => {
if (reqId !== reqRef.current) {
return
}
patchLinearIssue(issue.id, { assignee: previousAssignee }, { sourceContext })
toast.error(
translate(
'auto.components.TaskPage.linearAssigneeUpdateFailed',
'Failed to update Linear assignee'
)
)
})
.finally(() => {
if (reqId === reqRef.current) {
setPending(false)
}
})
},
[
issue.assignee,
issue.id,
issue.workspaceId,
members.data,
patchLinearIssue,
pending,
providerSettings,
sourceContext
]
)
const unassignedLabel = translate('auto.components.TaskPage.42a9160321', 'Unassigned')
const displayName = issue.assignee?.displayName ?? unassignedLabel
const triggerContent =
variant === 'name' ? (
<span className="min-w-0 truncate">{displayName}</span>
) : issue.assignee ? (
issue.assignee.avatarUrl ? (
<img
src={issue.assignee.avatarUrl}
alt={issue.assignee.displayName}
className="size-5 rounded-full"
/>
) : (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted/40 text-[10px] text-muted-foreground">
{issue.assignee.displayName.slice(0, 1)}
</span>
)
) : (
<span className="text-xs text-muted-foreground/60">-</span>
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={pending}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className={cn(
'inline-flex min-w-0 max-w-full cursor-pointer! items-center gap-1 text-left transition hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default! disabled:opacity-80',
variant === 'avatar'
? 'justify-center rounded-full p-0.5'
: 'rounded-sm px-1 py-0.5 text-[11px] text-muted-foreground',
className
)}
title={displayName}
aria-label={
issue.assignee
? translate(
'auto.components.TaskPage.linearChangeAssigneeFrom',
'Change assignee from {{value0}}',
{ value0: issue.assignee.displayName }
)
: translate('auto.components.TaskPage.linearAssignIssue', 'Assign Linear issue')
}
aria-busy={pending || members.loading}
>
{triggerContent}
{pending || members.loading ? (
<LoaderCircle className="size-3 shrink-0 animate-spin opacity-70" />
) : variant === 'name' ? (
<ChevronDown className="size-3 shrink-0 opacity-55" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
className="popover-scroll-content scrollbar-sleek w-48 p-1"
align="start"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => {
handleAssigneeChange(null)
setOpen(false)
}}
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent',
issue.assignee == null && 'bg-accent/50'
)}
>
{unassignedLabel}
</button>
{members.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">{members.error}</div>
) : members.loading ? (
<div className="flex items-center gap-2 px-2 py-3 text-[12px] text-muted-foreground">
<LoaderCircle className="size-3 animate-spin" />
{translate('auto.components.TaskPage.linearLoadingMembers', 'Loading members')}
</div>
) : members.data.length > 0 ? (
members.data.map((member) => (
<button
key={member.id}
type="button"
onClick={() => {
handleAssigneeChange(member.id)
setOpen(false)
}}
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent',
issue.assignee?.id === member.id && 'bg-accent/50'
)}
>
{member.avatarUrl ? (
<img src={member.avatarUrl} alt="" className="size-4 shrink-0 rounded-full" />
) : (
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] text-muted-foreground">
{member.displayName.slice(0, 1)}
</span>
)}
<span className="min-w-0 truncate">{member.displayName}</span>
</button>
))
) : (
<div className="px-2 py-3 text-center text-[12px] text-muted-foreground">
{translate('auto.components.TaskPage.linearNoMembersFound', 'No members found')}
</div>
)}
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,232 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, LoaderCircle, Tag } from 'lucide-react'
import { toast } from 'sonner'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useTeamLabels } from '@/hooks/useIssueMetadata'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { linearUpdateIssue } from '@/runtime/runtime-linear-client'
import { useAppStore } from '@/store'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import type { LinearIssue } from '../../../shared/types'
export function LinearLabelsCell({
issue,
className,
sourceContext,
maxVisible = 3
}: {
issue: LinearIssue
className?: string
sourceContext?: TaskSourceContext | null
maxVisible?: number
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const providerSettings = sourceContext ?? settings
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
const teamLabels = useTeamLabels(issue.team.id, providerSettings, issue.workspaceId)
const [open, setOpen] = useState(false)
const [pending, setPending] = useState(false)
const reqRef = useRef(0)
const selectedIds = issue.labelIds
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
const visibleLabels = issue.labels.slice(0, maxVisible)
const handleLabelToggle = useCallback(
(labelId: string) => {
if (pending) {
return
}
const isRemoving = selectedIdSet.has(labelId)
const nextLabelIds = isRemoving
? selectedIds.filter((id) => id !== labelId)
: [...selectedIds, labelId]
const labelNameById = new Map(teamLabels.data.map((label) => [label.id, label.name] as const))
for (let i = 0; i < issue.labelIds.length; i += 1) {
const id = issue.labelIds[i]
const name = issue.labels[i]
if (id && name && !labelNameById.has(id)) {
labelNameById.set(id, name)
}
}
const nextLabels = nextLabelIds
.map((id) => labelNameById.get(id))
.filter((name): name is string => name != null && name.length > 0)
reqRef.current += 1
const reqId = reqRef.current
const previousLabelIds = issue.labelIds
const previousLabels = issue.labels
setPending(true)
patchLinearIssue(issue.id, { labelIds: nextLabelIds, labels: nextLabels }, { sourceContext })
void linearUpdateIssue(
providerSettings,
issue.id,
{ labelIds: nextLabelIds },
issue.workspaceId
)
.then((result) => {
if (reqId !== reqRef.current) {
return
}
if (result.ok === false) {
patchLinearIssue(
issue.id,
{ labelIds: previousLabelIds, labels: previousLabels },
{ sourceContext }
)
toast.error(
result.error ??
translate(
'auto.components.TaskPage.linearLabelsUpdateFailed',
'Failed to update Linear labels'
)
)
return
}
useAppStore.getState().invalidateLinearIssueLists({ sourceContext })
useAppStore.getState().recordFeatureInteraction('linear-tasks')
})
.catch(() => {
if (reqId !== reqRef.current) {
return
}
patchLinearIssue(
issue.id,
{ labelIds: previousLabelIds, labels: previousLabels },
{ sourceContext }
)
toast.error(
translate(
'auto.components.TaskPage.linearLabelsUpdateFailed',
'Failed to update Linear labels'
)
)
})
.finally(() => {
if (reqId === reqRef.current) {
setPending(false)
}
})
},
[
issue.id,
issue.labelIds,
issue.labels,
issue.workspaceId,
patchLinearIssue,
pending,
providerSettings,
selectedIdSet,
selectedIds,
sourceContext,
teamLabels.data
]
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={pending}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className={cn(
'inline-flex min-w-0 max-w-full cursor-pointer! items-center gap-1 rounded-sm text-left transition hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default! disabled:opacity-80',
className
)}
aria-label={
issue.labels.length > 0
? translate('auto.components.TaskPage.linearLabelsAria', 'Labels: {{value0}}', {
value0: issue.labels.join(', ')
})
: translate('auto.components.TaskPage.linearAddLabel', 'Add label')
}
aria-busy={pending || teamLabels.loading}
>
{issue.labels.length > 0 ? (
<span className="flex min-w-0 flex-wrap items-center gap-1">
{visibleLabels.map((label) => (
<span
key={label}
className="max-w-[150px] truncate rounded-full border border-border/50 bg-muted/35 px-1.5 py-0.5 text-[11px] text-muted-foreground"
>
{label}
</span>
))}
{issue.labels.length > visibleLabels.length ? (
<span className="text-[11px] text-muted-foreground">
+{issue.labels.length - visibleLabels.length}
</span>
) : null}
</span>
) : (
<span className="inline-flex items-center gap-1 px-1 py-0.5 text-[11px] text-muted-foreground/70">
<Tag className="size-3 shrink-0 opacity-70" />
{translate('auto.components.TaskPage.linearAddLabel', 'Add label')}
</span>
)}
{pending || teamLabels.loading ? (
<LoaderCircle className="size-3 shrink-0 animate-spin opacity-70" />
) : (
<ChevronDown className="size-3 shrink-0 opacity-55" />
)}
</button>
</PopoverTrigger>
<PopoverContent
className="popover-scroll-content scrollbar-sleek w-52 p-1"
align="start"
onClick={(e) => e.stopPropagation()}
>
{teamLabels.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{teamLabels.error}
</div>
) : teamLabels.loading ? (
<div className="flex items-center gap-2 px-2 py-3 text-[12px] text-muted-foreground">
<LoaderCircle className="size-3 animate-spin" />
{translate('auto.components.TaskPage.linearLoadingLabels', 'Loading labels')}
</div>
) : teamLabels.data.length > 0 ? (
teamLabels.data.map((label) => {
const isOn = selectedIdSet.has(label.id)
return (
<button
key={label.id}
type="button"
disabled={pending}
onClick={() => handleLabelToggle(label.id)}
className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent disabled:opacity-60"
>
<span
className={cn(
'flex size-3.5 shrink-0 items-center justify-center rounded-sm border',
isOn ? 'border-primary bg-primary text-primary-foreground' : 'border-input'
)}
>
{isOn ? <Check className="size-2.5" /> : null}
</span>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: label.color }}
/>
<span className="min-w-0 truncate">{label.name}</span>
</button>
)
})
) : (
<div className="px-2 py-3 text-center text-[12px] text-muted-foreground">
{translate('auto.components.TaskPage.linearNoLabelsFound', 'No labels found')}
</div>
)}
</PopoverContent>
</Popover>
)
}