diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 8358490a9ba..9c0e5cf6121 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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 {
{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') ? ( - - {issue.assignee?.displayName ?? - translate( - 'auto.components.TaskPage.42a9160321', - 'Unassigned' - )} - + ) : null} {effectiveLinearDisplayProperties.has('team') ? ( {teamLabel} @@ -11637,22 +11636,14 @@ export default function TaskPage(): React.JSX.Element { ) : null}
- {effectiveLinearDisplayProperties.has('labels') && - issue.labels.length > 0 ? ( + {effectiveLinearDisplayProperties.has('labels') ? (
- {labels.map((label) => ( - - {label} - - ))} - {issue.labels.length > labels.length ? ( - - +{issue.labels.length - labels.length} - - ) : null} +
) : null} @@ -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') ? ( - - {issue.assignee?.displayName ?? - translate('auto.components.TaskPage.42a9160321', 'Unassigned')} - + ) : null} {effectiveLinearDisplayProperties.has('team') ? ( @@ -11769,19 +11761,11 @@ export default function TaskPage(): React.JSX.Element { {effectiveLinearDisplayProperties.has('labels') ? (
- {labels.map((label) => ( - - {label} - - ))} - {issue.labels.length > labels.length ? ( - - +{issue.labels.length - labels.length} - - ) : null} +
) : null} @@ -11803,31 +11787,11 @@ export default function TaskPage(): React.JSX.Element { {effectiveLinearDisplayProperties.has('assignee') ? (
- - -
- {issue.assignee?.avatarUrl ? ( - {issue.assignee.displayName} - ) : ( - (issue.assignee?.displayName?.slice(0, 1) ?? '-') - )} -
-
- - {issue.assignee?.displayName ?? - translate('auto.components.TaskPage.42a9160321', 'Unassigned')} - -
+
) : null} diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index 5c9b44b5ac1..303ebfbb63d 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -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'), diff --git a/src/renderer/src/components/task-page-linear-assignee-cell.tsx b/src/renderer/src/components/task-page-linear-assignee-cell.tsx new file mode 100644 index 00000000000..3cb327d1c13 --- /dev/null +++ b/src/renderer/src/components/task-page-linear-assignee-cell.tsx @@ -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' ? ( + {displayName} + ) : issue.assignee ? ( + issue.assignee.avatarUrl ? ( + {issue.assignee.displayName} + ) : ( + + {issue.assignee.displayName.slice(0, 1)} + + ) + ) : ( + - + ) + + return ( + + + + + e.stopPropagation()} + > + + {members.error ? ( +
{members.error}
+ ) : members.loading ? ( +
+ + {translate('auto.components.TaskPage.linearLoadingMembers', 'Loading members')} +
+ ) : members.data.length > 0 ? ( + members.data.map((member) => ( + + )) + ) : ( +
+ {translate('auto.components.TaskPage.linearNoMembersFound', 'No members found')} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/task-page-linear-labels-cell.tsx b/src/renderer/src/components/task-page-linear-labels-cell.tsx new file mode 100644 index 00000000000..cda8375e1fd --- /dev/null +++ b/src/renderer/src/components/task-page-linear-labels-cell.tsx @@ -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 ( + + + + + e.stopPropagation()} + > + {teamLabels.error ? ( +
+ {teamLabels.error} +
+ ) : teamLabels.loading ? ( +
+ + {translate('auto.components.TaskPage.linearLoadingLabels', 'Loading labels')} +
+ ) : teamLabels.data.length > 0 ? ( + teamLabels.data.map((label) => { + const isOn = selectedIdSet.has(label.id) + return ( + + ) + }) + ) : ( +
+ {translate('auto.components.TaskPage.linearNoLabelsFound', 'No labels found')} +
+ )} +
+
+ ) +}