mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user