mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Split Linear item drawer responsibilities (#17231)
* Split speech session lifecycle * Split terminal output scheduler pipeline * Split mobile browser pane modules * Prune resolved max-lines suppressions * Split pane tree equalization logic * Extract mobile troubleshoot screen styles * Split external automation manager * Split main window service attachments * Split hosted review creation checks * Split automation dispatch event handling * Split settings navigation metadata * Split daemon initialization lifecycle * Split GitLab item dialog * Split relay dispatcher layers * Split mobile host screen * Retarget mobile view settings source test * Split runtime file client layers * Split ports panel layers * Split runtime environments pane layers * Split local PTY provider responsibilities * Split CDP bridge responsibilities * Split relay Git handler responsibilities * Track moved relay Git fetch audit * Split Linear item drawer responsibilities * Fix F3-speech for #17123 * Fix F1-cycle for #17131 * Fix F4-navtest for #17157 * Fix F2-allowlist for #17161
This commit is contained in:
@@ -30,7 +30,6 @@ inline src/main/ssh/ssh-relay-session.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/WorktreeJumpPalette.tsx
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -206,7 +206,10 @@ describe('feature interaction writer boundaries', () => {
|
||||
})
|
||||
|
||||
it('records Linear provider-depth for inline edits, board drops, creation, and workspace use', () => {
|
||||
const drawerSource = componentSource('LinearItemDrawer.tsx')
|
||||
const drawerSource = [
|
||||
componentSource('linear-item-drawer-edit-controller.tsx'),
|
||||
componentSource('linear-item-drawer-comment-footer.tsx')
|
||||
].join('\n')
|
||||
const linearWriter = "recordFeatureInteraction('linear-tasks')"
|
||||
|
||||
const taskPageSections = [
|
||||
@@ -240,7 +243,7 @@ describe('feature interaction writer boundaries', () => {
|
||||
sourceBetween(drawerSource, 'const handlePriorityChange', 'const handleEstimateChange'),
|
||||
sourceBetween(drawerSource, 'const handleEstimateChange', 'const handleEstimateSubmit'),
|
||||
sourceBetween(drawerSource, 'const handleAssigneeChange', 'const handleLabelToggle'),
|
||||
sourceBetween(drawerSource, 'const handleLabelToggle', 'return ('),
|
||||
sourceBetween(drawerSource, 'const handleLabelToggle', 'return {'),
|
||||
sourceBetween(drawerSource, 'const handleSubmit = useCallback(async () => {', 'return (')
|
||||
]
|
||||
for (const section of drawerMutationSections) {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { LoaderCircle, Send } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getCommentBodySubmitState,
|
||||
hasBoundedCommentBodyText
|
||||
} from '@/lib/comment-body-submit-state'
|
||||
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { useAppStore } from '@/store'
|
||||
import { linearAddIssueComment } from '@/runtime/runtime-linear-issue-mutations'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import type { LinearLocalComment } from '@/components/linear-item-drawer-types'
|
||||
|
||||
export function LinearIssueCommentFooter({
|
||||
issueId,
|
||||
workspaceId,
|
||||
onCommentAdded,
|
||||
variant = 'compact',
|
||||
sourceContext
|
||||
}: {
|
||||
issueId: string
|
||||
workspaceId?: string | null
|
||||
onCommentAdded: (comment: LinearLocalComment) => void
|
||||
variant?: 'compact' | 'linear-page'
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const providerSettings = sourceContext ?? settings
|
||||
const submitShortcutLabel = getScreenSubmitShortcutLabel()
|
||||
const [body, setBody] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const handleFooterRef = useCallback((node: HTMLDivElement | null): void => {
|
||||
// Why: comment submission can resolve after the footer unmounts; the root
|
||||
// ref keeps that completion from writing stale local state without an Effect.
|
||||
mountedRef.current = node !== null
|
||||
}, [])
|
||||
|
||||
const autoGrow = useCallback(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const bodyState = getCommentBodySubmitState(body)
|
||||
if (bodyState.status === 'empty') {
|
||||
return
|
||||
}
|
||||
if (bodyState.status === 'too-large-leading-whitespace') {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.LinearItemDrawer.commentTooLarge',
|
||||
'Comment is too large to submit safely.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await linearAddIssueComment(
|
||||
providerSettings,
|
||||
issueId,
|
||||
bodyState.body,
|
||||
workspaceId
|
||||
)
|
||||
const typed = result as { ok: boolean; id?: string; error?: string }
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (typed.ok) {
|
||||
setBody('')
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
onCommentAdded({
|
||||
id: typed.id ?? createBrowserUuid(),
|
||||
body: bodyState.body,
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
} else {
|
||||
toast.error(
|
||||
typed.error ??
|
||||
translate('auto.components.LinearItemDrawer.6ab35eafd5', 'Failed to add comment')
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate('auto.components.LinearItemDrawer.6ab35eafd5', 'Failed to add comment')
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
}, [body, issueId, onCommentAdded, providerSettings, workspaceId])
|
||||
const canSubmitComment = hasBoundedCommentBodyText(body)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (isScreenSubmitShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
if (variant === 'linear-page') {
|
||||
return (
|
||||
<div
|
||||
ref={handleFooterRef}
|
||||
className="rounded-xl border border-border/70 bg-background shadow-xs"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value)
|
||||
autoGrow()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={translate(
|
||||
'auto.components.LinearItemDrawer.2820f0f0f0',
|
||||
'Leave a comment...'
|
||||
)}
|
||||
rows={3}
|
||||
className="scrollbar-sleek min-h-24 max-h-40 w-full resize-none overflow-y-auto rounded-t-xl bg-transparent px-5 py-4 text-sm placeholder:text-muted-foreground focus-visible:outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-4 pb-3">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{submitShortcutLabel !== 'Unassigned'
|
||||
? translate('auto.components.LinearItemDrawer.fda549766e', '{{value0}} to comment', {
|
||||
value0: submitShortcutLabel
|
||||
})
|
||||
: ''}
|
||||
</span>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmitComment || submitting}
|
||||
aria-label={translate('auto.components.LinearItemDrawer.d369841269', 'Send comment')}
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={handleFooterRef}
|
||||
className="flex items-end gap-2 border-t border-border/60 bg-background/40 px-4 py-3"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value)
|
||||
autoGrow()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={translate('auto.components.LinearItemDrawer.2fcff829a8', 'Add a comment…')}
|
||||
rows={1}
|
||||
className="scrollbar-sleek min-h-[32px] max-h-[96px] flex-1 resize-none overflow-y-auto rounded-md border border-input bg-transparent px-3 py-2 text-[13px] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmitComment || submitting}
|
||||
className="size-8 shrink-0"
|
||||
aria-label={translate('auto.components.LinearItemDrawer.d369841269', 'Send comment')}
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import React from 'react'
|
||||
import { LoaderCircle } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
getLinearStateMarkerStyle,
|
||||
getLinearStatePillStyle
|
||||
} from '@/components/linear-state-pill-style'
|
||||
import { LinearPriorityIcon } from '@/components/linear-priority-icon'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
LINEAR_EDIT_CHIP_CLASS,
|
||||
LINEAR_EDIT_MENU_ITEM_CLASS,
|
||||
LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS,
|
||||
LINEAR_ESTIMATE_PRESETS,
|
||||
LinearEditChipAdornment,
|
||||
PRIORITY_LABELS,
|
||||
formatLinearEstimateLabel
|
||||
} from '@/components/linear-item-drawer-edit-controls'
|
||||
import type { LinearIssueEditController } from '@/components/linear-item-drawer-edit-controller'
|
||||
|
||||
export function renderLinearIssueChipsLayout(
|
||||
controller: LinearIssueEditController
|
||||
): React.JSX.Element {
|
||||
const {
|
||||
statePending,
|
||||
states,
|
||||
localState,
|
||||
currentStateId,
|
||||
handleStateChange,
|
||||
priorityPending,
|
||||
localPriority,
|
||||
handlePriorityChange,
|
||||
estimatePopoverOpen,
|
||||
handleEstimatePopoverOpenChange,
|
||||
estimatePending,
|
||||
localEstimate,
|
||||
handleEstimateChange,
|
||||
estimateInput,
|
||||
setEstimateInput,
|
||||
handleEstimateSubmit,
|
||||
assigneePending,
|
||||
members,
|
||||
localAssignee,
|
||||
handleAssigneeChange,
|
||||
labelPopoverOpen,
|
||||
setLabelPopoverOpen,
|
||||
labelsPending,
|
||||
localLabels,
|
||||
labelSummary,
|
||||
labels,
|
||||
localLabelIds,
|
||||
handleLabelToggle,
|
||||
checkIcon
|
||||
} = controller
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2.5">
|
||||
{/* Status */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={statePending}
|
||||
className={LINEAR_EDIT_CHIP_CLASS}
|
||||
style={getLinearStatePillStyle(localState.color)}
|
||||
aria-busy={statePending || states.loading}
|
||||
>
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={getLinearStateMarkerStyle(localState.color)}
|
||||
/>
|
||||
<span className="truncate">{localState.name}</span>
|
||||
<LinearEditChipAdornment loading={states.loading} pending={statePending} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-48 p-1" align="start">
|
||||
{states.error ? (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-destructive">{states.error}</div>
|
||||
) : states.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.LinearItemDrawer.59b6cd3706', 'Loading states')}
|
||||
</div>
|
||||
) : states.data.length > 0 ? (
|
||||
<div>
|
||||
{states.data.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => handleStateChange(s.id)}
|
||||
className={cn(
|
||||
LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS,
|
||||
currentStateId === s.id && 'bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-muted-foreground">
|
||||
{translate('auto.components.LinearItemDrawer.780ea6ed89', 'No states found')}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Priority */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={priorityPending}
|
||||
className={LINEAR_EDIT_CHIP_CLASS}
|
||||
aria-busy={priorityPending}
|
||||
>
|
||||
<LinearPriorityIcon priority={localPriority} />
|
||||
<span className="truncate">
|
||||
{PRIORITY_LABELS[localPriority] ?? `P${localPriority}`}
|
||||
</span>
|
||||
<LinearEditChipAdornment pending={priorityPending} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{[0, 1, 2, 3, 4].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => handlePriorityChange(String(p))}
|
||||
className={cn(
|
||||
LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS,
|
||||
localPriority === p && 'bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<LinearPriorityIcon priority={p} />
|
||||
{PRIORITY_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Estimate */}
|
||||
<Popover open={estimatePopoverOpen} onOpenChange={handleEstimatePopoverOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={estimatePending}
|
||||
className={LINEAR_EDIT_CHIP_CLASS}
|
||||
aria-busy={estimatePending}
|
||||
>
|
||||
<span className="truncate">{formatLinearEstimateLabel(localEstimate)}</span>
|
||||
<LinearEditChipAdornment pending={estimatePending} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-3" align="start">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{LINEAR_ESTIMATE_PRESETS.map((estimate) => (
|
||||
<button
|
||||
key={estimate}
|
||||
type="button"
|
||||
onClick={() => handleEstimateChange(estimate)}
|
||||
className={cn(
|
||||
'flex h-8 items-center justify-center rounded-md border border-border text-sm hover:bg-accent',
|
||||
localEstimate === estimate && 'border-primary bg-accent text-foreground'
|
||||
)}
|
||||
>
|
||||
{estimate}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
value={estimateInput}
|
||||
onChange={(event) => setEstimateInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleEstimateSubmit()
|
||||
}
|
||||
}}
|
||||
inputMode="numeric"
|
||||
placeholder={translate(
|
||||
'auto.components.LinearItemDrawer.fbb90300e2',
|
||||
'Custom estimate'
|
||||
)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEstimateChange(null)}
|
||||
>
|
||||
{translate('auto.components.LinearItemDrawer.ceeb8c6153', 'Clear')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleEstimateSubmit}
|
||||
disabled={estimatePending}
|
||||
>
|
||||
{estimatePending ? <LoaderCircle className="size-3.5 animate-spin" /> : null}
|
||||
{translate('auto.components.LinearItemDrawer.b5675b0694', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Assignee */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={assigneePending}
|
||||
className={LINEAR_EDIT_CHIP_CLASS}
|
||||
aria-busy={assigneePending || members.loading}
|
||||
>
|
||||
<span className="truncate">
|
||||
{localAssignee
|
||||
? localAssignee.displayName
|
||||
: translate('auto.components.LinearItemDrawer.d71cd3003e', '+ Assignee')}
|
||||
</span>
|
||||
<LinearEditChipAdornment loading={members.loading} pending={assigneePending} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-48 p-1" align="start">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAssigneeChange('__unassign__')}
|
||||
className={cn(LINEAR_EDIT_MENU_ITEM_CLASS, !localAssignee && 'bg-accent/50')}
|
||||
>
|
||||
{translate('auto.components.LinearItemDrawer.866316f22c', 'Unassigned')}
|
||||
</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.LinearItemDrawer.b2376d0179', 'Loading members')}
|
||||
</div>
|
||||
) : (
|
||||
members.data.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => handleAssigneeChange(m.id)}
|
||||
className={cn(
|
||||
LINEAR_EDIT_MENU_ITEM_CLASS,
|
||||
localAssignee?.id === m.id && 'bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{m.displayName}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Labels */}
|
||||
<Popover open={labelPopoverOpen} onOpenChange={setLabelPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={labelsPending}
|
||||
className={LINEAR_EDIT_CHIP_CLASS}
|
||||
aria-label={
|
||||
localLabels.length
|
||||
? translate('auto.components.LinearItemDrawer.7f7b89b631', 'Labels: {{value0}}', {
|
||||
value0: localLabels.join(', ')
|
||||
})
|
||||
: translate('auto.components.LinearItemDrawer.23886c7eec', 'Add label')
|
||||
}
|
||||
aria-busy={labelsPending || labels.loading}
|
||||
>
|
||||
<span className="truncate">{labelSummary}</span>
|
||||
<LinearEditChipAdornment loading={labels.loading} pending={labelsPending} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-52 p-1" align="start">
|
||||
{labels.error ? (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-destructive">{labels.error}</div>
|
||||
) : labels.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.LinearItemDrawer.cddd9b04a7', 'Loading labels')}
|
||||
</div>
|
||||
) : labels.data.length > 0 ? (
|
||||
<div>
|
||||
{labels.data.map((label) => (
|
||||
<button
|
||||
key={label.id}
|
||||
type="button"
|
||||
onClick={() => handleLabelToggle(label.id)}
|
||||
className={LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-3.5 items-center justify-center rounded-sm border',
|
||||
localLabelIds.includes(label.id)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-input'
|
||||
)}
|
||||
>
|
||||
{localLabelIds.includes(label.id) && checkIcon}
|
||||
</span>
|
||||
<span
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
{label.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-muted-foreground">
|
||||
{translate('auto.components.LinearItemDrawer.367f828482', 'No labels found')}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
useTeamStates,
|
||||
useTeamLabels,
|
||||
useTeamMembers,
|
||||
useImmediateMutation
|
||||
} from '@/hooks/useIssueMetadata'
|
||||
import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatLinearEstimateInput } from '@/components/linear-item-drawer-edit-controls'
|
||||
import type { LinearIssueEditSectionProps } from '@/components/linear-item-drawer-types'
|
||||
|
||||
export function useLinearIssueEditController({
|
||||
issue,
|
||||
editState,
|
||||
onEditStateChange,
|
||||
layout = 'chips',
|
||||
sourceContext
|
||||
}: LinearIssueEditSectionProps) {
|
||||
const [labelPopoverOpen, setLabelPopoverOpen] = useState(false)
|
||||
const [estimatePopoverOpen, setEstimatePopoverOpen] = useState(false)
|
||||
const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const providerSettings = sourceContext ?? settings
|
||||
const { isPending, run } = useImmediateMutation()
|
||||
|
||||
const {
|
||||
state: localState,
|
||||
priority: localPriority,
|
||||
estimate: localEstimate,
|
||||
assignee: localAssignee,
|
||||
labelIds: localLabelIds,
|
||||
labels: localLabels
|
||||
} = editState
|
||||
const [estimateInput, setEstimateInput] = useState(() => formatLinearEstimateInput(localEstimate))
|
||||
|
||||
const teamId = issue.team?.id || null
|
||||
const states = useTeamStates(teamId, providerSettings, issue.workspaceId)
|
||||
const labels = useTeamLabels(teamId, providerSettings, issue.workspaceId)
|
||||
const members = useTeamMembers(teamId, providerSettings, issue.workspaceId)
|
||||
|
||||
const handleEstimatePopoverOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setEstimatePopoverOpen(open)
|
||||
if (open) {
|
||||
setEstimateInput(formatLinearEstimateInput(localEstimate))
|
||||
}
|
||||
},
|
||||
[localEstimate]
|
||||
)
|
||||
|
||||
const handleStateChange = useCallback(
|
||||
(stateId: string) => {
|
||||
const newState = states.data.find((s) => s.id === stateId)
|
||||
if (!newState) {
|
||||
return
|
||||
}
|
||||
|
||||
const prevState = localState
|
||||
const stateValue = { name: newState.name, type: newState.type, color: newState.color }
|
||||
|
||||
run('state', {
|
||||
mutate: () => linearUpdateIssue(providerSettings, issue.id, { stateId }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ state: stateValue })
|
||||
patchLinearIssue(issue.id, { state: stateValue }, { sourceContext })
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ state: prevState })
|
||||
patchLinearIssue(issue.id, { state: prevState }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().invalidateLinearIssueLists({ sourceContext })
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
},
|
||||
[
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
localState,
|
||||
providerSettings,
|
||||
states.data,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
const handlePriorityChange = useCallback(
|
||||
(value: string) => {
|
||||
const priority = Number.parseInt(value, 10)
|
||||
const prevPriority = localPriority
|
||||
run('priority', {
|
||||
mutate: () =>
|
||||
linearUpdateIssue(providerSettings, issue.id, { priority }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ priority })
|
||||
patchLinearIssue(issue.id, { priority }, { sourceContext })
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ priority: prevPriority })
|
||||
patchLinearIssue(issue.id, { priority: prevPriority }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().invalidateLinearIssueLists({ sourceContext })
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
},
|
||||
[
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
localPriority,
|
||||
providerSettings,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
const handleEstimateChange = useCallback(
|
||||
(estimate: number | null) => {
|
||||
const prevEstimate = localEstimate
|
||||
run('estimate', {
|
||||
mutate: () =>
|
||||
linearUpdateIssue(providerSettings, issue.id, { estimate }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ estimate })
|
||||
patchLinearIssue(issue.id, { estimate }, { sourceContext })
|
||||
setEstimatePopoverOpen(false)
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ estimate: prevEstimate })
|
||||
patchLinearIssue(issue.id, { estimate: prevEstimate }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
},
|
||||
[
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
localEstimate,
|
||||
providerSettings,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
const handleEstimateSubmit = useCallback(() => {
|
||||
const trimmed = estimateInput.trim()
|
||||
if (!trimmed) {
|
||||
handleEstimateChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
const estimate = Number(trimmed)
|
||||
if (!Number.isInteger(estimate) || estimate < 0) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.LinearItemDrawer.0be31fef8e',
|
||||
'Estimate must be a non-negative integer'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
handleEstimateChange(estimate)
|
||||
}, [estimateInput, handleEstimateChange])
|
||||
|
||||
const handleAssigneeChange = useCallback(
|
||||
(memberId: string) => {
|
||||
const assigneeId = memberId === '__unassign__' ? null : memberId
|
||||
const member = members.data.find((m) => m.id === memberId)
|
||||
const prevAssignee = localAssignee
|
||||
const newAssignee = member
|
||||
? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl }
|
||||
: undefined
|
||||
run('assignee', {
|
||||
mutate: () =>
|
||||
linearUpdateIssue(providerSettings, issue.id, { assigneeId }, issue.workspaceId),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ assignee: newAssignee })
|
||||
patchLinearIssue(issue.id, { assignee: newAssignee }, { sourceContext })
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ assignee: prevAssignee })
|
||||
patchLinearIssue(issue.id, { assignee: prevAssignee }, { sourceContext })
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().invalidateLinearIssueLists({ sourceContext })
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
},
|
||||
[
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
localAssignee,
|
||||
providerSettings,
|
||||
members.data,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
const handleLabelToggle = useCallback(
|
||||
(labelId: string) => {
|
||||
const prevLabelIds = localLabelIds
|
||||
const prevLabels = localLabels
|
||||
const isRemoving = prevLabelIds.includes(labelId)
|
||||
const newLabelIds = isRemoving
|
||||
? prevLabelIds.filter((id) => id !== labelId)
|
||||
: [...prevLabelIds, labelId]
|
||||
const newLabels = newLabelIds
|
||||
.map((id) => labels.data.find((l) => l.id === id)?.name)
|
||||
.filter((n): n is string => !!n)
|
||||
|
||||
run('labels', {
|
||||
mutate: () =>
|
||||
linearUpdateIssue(
|
||||
providerSettings,
|
||||
issue.id,
|
||||
{ labelIds: newLabelIds },
|
||||
issue.workspaceId
|
||||
),
|
||||
onOptimistic: () => {
|
||||
onEditStateChange({ labelIds: newLabelIds, labels: newLabels })
|
||||
patchLinearIssue(
|
||||
issue.id,
|
||||
{ labelIds: newLabelIds, labels: newLabels },
|
||||
{ sourceContext }
|
||||
)
|
||||
},
|
||||
onRevert: () => {
|
||||
onEditStateChange({ labelIds: prevLabelIds, labels: prevLabels })
|
||||
patchLinearIssue(
|
||||
issue.id,
|
||||
{ labelIds: prevLabelIds, labels: prevLabels },
|
||||
{ sourceContext }
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().invalidateLinearIssueLists({ sourceContext })
|
||||
useAppStore.getState().recordFeatureInteraction('linear-tasks')
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
},
|
||||
[
|
||||
issue.id,
|
||||
issue.workspaceId,
|
||||
localLabelIds,
|
||||
localLabels,
|
||||
providerSettings,
|
||||
labels.data,
|
||||
patchLinearIssue,
|
||||
run,
|
||||
onEditStateChange,
|
||||
sourceContext
|
||||
]
|
||||
)
|
||||
|
||||
const currentStateId = states.data.find(
|
||||
(s) => s.name === localState.name && s.type === localState.type
|
||||
)?.id
|
||||
const statePending = isPending('state')
|
||||
const priorityPending = isPending('priority')
|
||||
const estimatePending = isPending('estimate')
|
||||
const assigneePending = isPending('assignee')
|
||||
const labelsPending = isPending('labels')
|
||||
const labelSummary =
|
||||
localLabels.length === 0
|
||||
? '+ Label'
|
||||
: localLabels.length === 1
|
||||
? localLabels[0]
|
||||
: `${localLabels[0]} +${localLabels.length - 1}`
|
||||
|
||||
const checkIcon = (
|
||||
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M2 6l3 3 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
return {
|
||||
layout,
|
||||
labelPopoverOpen,
|
||||
setLabelPopoverOpen,
|
||||
estimatePopoverOpen,
|
||||
localState,
|
||||
localPriority,
|
||||
localEstimate,
|
||||
localAssignee,
|
||||
localLabelIds,
|
||||
localLabels,
|
||||
estimateInput,
|
||||
setEstimateInput,
|
||||
states,
|
||||
labels,
|
||||
members,
|
||||
handleEstimatePopoverOpenChange,
|
||||
handleStateChange,
|
||||
handlePriorityChange,
|
||||
handleEstimateChange,
|
||||
handleEstimateSubmit,
|
||||
handleAssigneeChange,
|
||||
handleLabelToggle,
|
||||
currentStateId,
|
||||
statePending,
|
||||
priorityPending,
|
||||
estimatePending,
|
||||
assigneePending,
|
||||
labelsPending,
|
||||
labelSummary,
|
||||
checkIcon
|
||||
}
|
||||
}
|
||||
|
||||
export type LinearIssueEditController = ReturnType<typeof useLinearIssueEditController>
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { ChevronDown, LoaderCircle } from 'lucide-react'
|
||||
|
||||
export const PRIORITY_LABELS: Record<number, string> = {
|
||||
0: 'No priority',
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low'
|
||||
}
|
||||
|
||||
export const LINEAR_EDIT_CHIP_CLASS =
|
||||
'inline-flex h-6 min-w-0 max-w-[14rem] cursor-pointer items-center gap-1.5 rounded-full border border-border/70 bg-background/70 px-2.5 text-[11px] font-medium leading-none text-muted-foreground shadow-xs transition-[background-color,border-color,color,box-shadow] hover:border-border hover:bg-accent hover:text-accent-foreground hover:[--linear-state-pill-current-background:var(--linear-state-pill-hover-background)] hover:[--linear-state-pill-current-border:var(--linear-state-pill-hover-border)] hover:[--linear-state-pill-current-foreground:var(--linear-state-pill-hover-foreground)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-80'
|
||||
|
||||
export const LINEAR_EDIT_MENU_ITEM_CLASS =
|
||||
'flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent'
|
||||
|
||||
export const LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS =
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent'
|
||||
|
||||
export const LINEAR_ESTIMATE_PRESETS = [1, 2, 3, 5, 8] as const
|
||||
|
||||
export function formatLinearEstimateLabel(estimate: number | null | undefined): string {
|
||||
return estimate === null || estimate === undefined ? 'Set estimate' : `Estimate ${estimate}`
|
||||
}
|
||||
|
||||
export function formatLinearEstimateInput(estimate: number | null | undefined): string {
|
||||
return estimate === null || estimate === undefined ? '' : String(estimate)
|
||||
}
|
||||
|
||||
export function LinearEditChipAdornment({
|
||||
loading,
|
||||
pending
|
||||
}: {
|
||||
loading?: boolean
|
||||
pending?: boolean
|
||||
}): React.JSX.Element {
|
||||
if (loading || pending) {
|
||||
return <LoaderCircle className="size-3 shrink-0 animate-spin opacity-70" />
|
||||
}
|
||||
|
||||
return <ChevronDown className="size-3 shrink-0 opacity-55" />
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import React from 'react'
|
||||
import { ChevronDown, Gauge, LoaderCircle, Tag, UserRound } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getLinearStateMarkerStyle } from '@/components/linear-state-pill-style'
|
||||
import { LinearPriorityIcon } from '@/components/linear-priority-icon'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
LINEAR_EDIT_MENU_ITEM_CLASS,
|
||||
LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS,
|
||||
LINEAR_ESTIMATE_PRESETS,
|
||||
LinearEditChipAdornment,
|
||||
PRIORITY_LABELS,
|
||||
formatLinearEstimateLabel
|
||||
} from '@/components/linear-item-drawer-edit-controls'
|
||||
import type { LinearIssueEditController } from '@/components/linear-item-drawer-edit-controller'
|
||||
|
||||
export function renderLinearIssuePropertiesLayout(
|
||||
controller: LinearIssueEditController
|
||||
): React.JSX.Element {
|
||||
const {
|
||||
statePending,
|
||||
states,
|
||||
localState,
|
||||
currentStateId,
|
||||
handleStateChange,
|
||||
priorityPending,
|
||||
localPriority,
|
||||
handlePriorityChange,
|
||||
assigneePending,
|
||||
members,
|
||||
localAssignee,
|
||||
handleAssigneeChange,
|
||||
estimatePopoverOpen,
|
||||
handleEstimatePopoverOpenChange,
|
||||
estimatePending,
|
||||
localEstimate,
|
||||
handleEstimateChange,
|
||||
estimateInput,
|
||||
setEstimateInput,
|
||||
handleEstimateSubmit,
|
||||
labelPopoverOpen,
|
||||
setLabelPopoverOpen,
|
||||
labelsPending,
|
||||
localLabels,
|
||||
labelSummary,
|
||||
labels,
|
||||
localLabelIds,
|
||||
handleLabelToggle,
|
||||
checkIcon
|
||||
} = controller
|
||||
|
||||
const propertyRowClass =
|
||||
'flex min-h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-80'
|
||||
const propertyIconClass = 'size-4 shrink-0 text-muted-foreground'
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="rounded-xl border border-border/60 bg-card text-card-foreground shadow-xs">
|
||||
<div className="flex h-10 items-center gap-1 border-b border-border/50 px-4 text-sm font-medium text-muted-foreground">
|
||||
<span>{translate('auto.components.LinearItemDrawer.dd304de85a', 'Properties')}</span>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</div>
|
||||
<div className="space-y-1 p-3">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={statePending}
|
||||
className={propertyRowClass}
|
||||
aria-busy={statePending || states.loading}
|
||||
>
|
||||
<span
|
||||
className="inline-block size-2.5 shrink-0 rounded-full"
|
||||
style={getLinearStateMarkerStyle(localState.color)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{localState.name}</span>
|
||||
<LinearEditChipAdornment loading={states.loading} pending={statePending} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="popover-scroll-content scrollbar-sleek w-48 p-1"
|
||||
align="start"
|
||||
>
|
||||
{states.error ? (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-destructive">
|
||||
{states.error}
|
||||
</div>
|
||||
) : states.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.LinearItemDrawer.59b6cd3706', 'Loading states')}
|
||||
</div>
|
||||
) : states.data.length > 0 ? (
|
||||
<div>
|
||||
{states.data.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => handleStateChange(s.id)}
|
||||
className={cn(
|
||||
LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS,
|
||||
currentStateId === s.id && 'bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-muted-foreground">
|
||||
{translate('auto.components.LinearItemDrawer.780ea6ed89', 'No states found')}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={priorityPending}
|
||||
className={propertyRowClass}
|
||||
aria-busy={priorityPending}
|
||||
>
|
||||
<LinearPriorityIcon priority={localPriority} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{PRIORITY_LABELS[localPriority] ?? `P${localPriority}`}
|
||||
</span>
|
||||
<LinearEditChipAdornment pending={priorityPending} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{[0, 1, 2, 3, 4].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => handlePriorityChange(String(p))}
|
||||
className={cn(
|
||||
LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS,
|
||||
localPriority === p && 'bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<LinearPriorityIcon priority={p} />
|
||||
{PRIORITY_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={assigneePending}
|
||||
className={propertyRowClass}
|
||||
aria-busy={assigneePending || members.loading}
|
||||
>
|
||||
{localAssignee?.avatarUrl ? (
|
||||
<img
|
||||
src={localAssignee.avatarUrl}
|
||||
alt=""
|
||||
className="size-4 shrink-0 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<UserRound className={propertyIconClass} />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{localAssignee
|
||||
? localAssignee.displayName
|
||||
: translate('auto.components.LinearItemDrawer.866316f22c', 'Unassigned')}
|
||||
</span>
|
||||
<LinearEditChipAdornment loading={members.loading} pending={assigneePending} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="popover-scroll-content scrollbar-sleek w-48 p-1"
|
||||
align="start"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAssigneeChange('__unassign__')}
|
||||
className={cn(LINEAR_EDIT_MENU_ITEM_CLASS, !localAssignee && 'bg-accent/50')}
|
||||
>
|
||||
{translate('auto.components.LinearItemDrawer.866316f22c', 'Unassigned')}
|
||||
</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.LinearItemDrawer.b2376d0179', 'Loading members')}
|
||||
</div>
|
||||
) : (
|
||||
members.data.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => handleAssigneeChange(m.id)}
|
||||
className={cn(
|
||||
LINEAR_EDIT_MENU_ITEM_CLASS,
|
||||
localAssignee?.id === m.id && 'bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{m.displayName}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={estimatePopoverOpen} onOpenChange={handleEstimatePopoverOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={estimatePending}
|
||||
className={propertyRowClass}
|
||||
aria-busy={estimatePending}
|
||||
>
|
||||
<Gauge className={propertyIconClass} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{formatLinearEstimateLabel(localEstimate)}
|
||||
</span>
|
||||
<LinearEditChipAdornment pending={estimatePending} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-3" align="start">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{LINEAR_ESTIMATE_PRESETS.map((estimate) => (
|
||||
<button
|
||||
key={estimate}
|
||||
type="button"
|
||||
onClick={() => handleEstimateChange(estimate)}
|
||||
className={cn(
|
||||
'flex h-8 items-center justify-center rounded-md border border-border text-sm hover:bg-accent',
|
||||
localEstimate === estimate && 'border-primary bg-accent text-foreground'
|
||||
)}
|
||||
>
|
||||
{estimate}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
value={estimateInput}
|
||||
onChange={(event) => setEstimateInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleEstimateSubmit()
|
||||
}
|
||||
}}
|
||||
inputMode="numeric"
|
||||
placeholder={translate(
|
||||
'auto.components.LinearItemDrawer.fbb90300e2',
|
||||
'Custom estimate'
|
||||
)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEstimateChange(null)}
|
||||
>
|
||||
{translate('auto.components.LinearItemDrawer.ceeb8c6153', 'Clear')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleEstimateSubmit}
|
||||
disabled={estimatePending}
|
||||
>
|
||||
{estimatePending ? <LoaderCircle className="size-3.5 animate-spin" /> : null}
|
||||
{translate('auto.components.LinearItemDrawer.b5675b0694', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/60 bg-card text-card-foreground shadow-xs">
|
||||
<div className="flex h-10 items-center gap-1 border-b border-border/50 px-4 text-sm font-medium text-muted-foreground">
|
||||
<span>{translate('auto.components.LinearItemDrawer.64bfffc4dd', 'Labels')}</span>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<Popover open={labelPopoverOpen} onOpenChange={setLabelPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={labelsPending}
|
||||
className={propertyRowClass}
|
||||
aria-label={
|
||||
localLabels.length
|
||||
? translate(
|
||||
'auto.components.LinearItemDrawer.7f7b89b631',
|
||||
'Labels: {{value0}}',
|
||||
{ value0: localLabels.join(', ') }
|
||||
)
|
||||
: translate('auto.components.LinearItemDrawer.23886c7eec', 'Add label')
|
||||
}
|
||||
aria-busy={labelsPending || labels.loading}
|
||||
>
|
||||
<Tag className={propertyIconClass} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{localLabels.length
|
||||
? labelSummary
|
||||
: translate('auto.components.LinearItemDrawer.23886c7eec', 'Add label')}
|
||||
</span>
|
||||
<LinearEditChipAdornment loading={labels.loading} pending={labelsPending} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="popover-scroll-content scrollbar-sleek w-52 p-1"
|
||||
align="start"
|
||||
>
|
||||
{labels.error ? (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-destructive">
|
||||
{labels.error}
|
||||
</div>
|
||||
) : labels.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.LinearItemDrawer.cddd9b04a7', 'Loading labels')}
|
||||
</div>
|
||||
) : labels.data.length > 0 ? (
|
||||
<div>
|
||||
{labels.data.map((label) => (
|
||||
<button
|
||||
key={label.id}
|
||||
type="button"
|
||||
onClick={() => handleLabelToggle(label.id)}
|
||||
className={LINEAR_EDIT_MENU_ITEM_WITH_ICON_CLASS}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-3.5 items-center justify-center rounded-sm border',
|
||||
localLabelIds.includes(label.id)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-input'
|
||||
)}
|
||||
>
|
||||
{localLabelIds.includes(label.id) && checkIcon}
|
||||
</span>
|
||||
<span
|
||||
className="inline-block size-2 rounded-full"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
{label.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-muted-foreground">
|
||||
{translate('auto.components.LinearItemDrawer.367f828482', 'No labels found')}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
import { useLinearIssueEditController } from '@/components/linear-item-drawer-edit-controller'
|
||||
import { renderLinearIssueChipsLayout } from '@/components/linear-item-drawer-edit-chips-layout'
|
||||
import { renderLinearIssuePropertiesLayout } from '@/components/linear-item-drawer-edit-properties-layout'
|
||||
import type { LinearIssueEditSectionProps } from '@/components/linear-item-drawer-types'
|
||||
|
||||
export function LinearIssueEditSection(props: LinearIssueEditSectionProps): React.JSX.Element {
|
||||
const controller = useLinearIssueEditController(props)
|
||||
return controller.layout === 'properties'
|
||||
? renderLinearIssuePropertiesLayout(controller)
|
||||
: renderLinearIssueChipsLayout(controller)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { VisuallyHidden } from 'radix-ui'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor'
|
||||
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
|
||||
import { LinearIssueEditSection } from '@/components/linear-item-drawer-edit-section'
|
||||
import { LinearIssueCommentFooter } from '@/components/linear-item-drawer-comment-footer'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
|
||||
import type { LinearComment, LinearIssue } from '../../../shared/linear/issue-types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import type { LinearEditState, LinearLocalComment } from '@/components/linear-item-drawer-types'
|
||||
|
||||
function LinearIcon({ className }: { className?: string }): React.JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
|
||||
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeTime(input: string): string {
|
||||
return formatUiRelativeTimeFromDate(input)
|
||||
}
|
||||
|
||||
type LinearItemDrawerSheetProps = {
|
||||
issue: LinearIssue | null
|
||||
onClose: () => void
|
||||
displayed: LinearIssue | null
|
||||
handleIssueTextChange: Parameters<typeof LinearIssueTextEditor>[0] extends {
|
||||
onIssueChange: infer Handler
|
||||
}
|
||||
? Handler
|
||||
: never
|
||||
sourceContext: TaskSourceContext | null | undefined
|
||||
editState: LinearEditState | null
|
||||
handleEditStateChange: (patch: Partial<LinearEditState>) => void
|
||||
commentsLoading: boolean
|
||||
comments: LinearComment[]
|
||||
handleCommentAdded: (comment: LinearLocalComment) => void
|
||||
attachedWorkspaceLabel: string | null
|
||||
attachedWorkspace: Worktree | null
|
||||
handleOpenOrUseIssue: () => void
|
||||
onUse: (issue: LinearIssue) => void
|
||||
}
|
||||
|
||||
export function renderLinearItemDrawerSheet({
|
||||
issue,
|
||||
onClose,
|
||||
displayed,
|
||||
handleIssueTextChange,
|
||||
sourceContext,
|
||||
editState,
|
||||
handleEditStateChange,
|
||||
commentsLoading,
|
||||
comments,
|
||||
handleCommentAdded,
|
||||
attachedWorkspaceLabel,
|
||||
attachedWorkspace,
|
||||
handleOpenOrUseIssue,
|
||||
onUse
|
||||
}: LinearItemDrawerSheetProps): React.JSX.Element {
|
||||
return (
|
||||
<Sheet open={issue !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="w-full p-0 sm:max-w-[640px]"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<VisuallyHidden.Root asChild>
|
||||
<SheetTitle>
|
||||
{displayed?.title ??
|
||||
translate('auto.components.LinearItemDrawer.39883467f4', 'Linear issue')}
|
||||
</SheetTitle>
|
||||
</VisuallyHidden.Root>
|
||||
<VisuallyHidden.Root asChild>
|
||||
<SheetDescription>
|
||||
{translate(
|
||||
'auto.components.LinearItemDrawer.04a442f796',
|
||||
'Preview and edit the selected Linear issue.'
|
||||
)}
|
||||
</SheetDescription>
|
||||
</VisuallyHidden.Root>
|
||||
|
||||
{displayed && (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex-none border-b border-border/60 px-4 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<LinearIcon className="mt-1 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-mono text-[12px] text-muted-foreground">
|
||||
{displayed.identifier}
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
<LinearIssueTextEditor
|
||||
issue={displayed}
|
||||
onIssueChange={handleIssueTextChange}
|
||||
density="drawer"
|
||||
fields="title"
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
|
||||
{displayed.workspaceName && <span>{displayed.workspaceName}</span>}
|
||||
{displayed.team?.name && <span>{displayed.team.name}</span>}
|
||||
<span>· {formatRelativeTime(displayed.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => window.api.shell.openUrl(displayed.url)}
|
||||
aria-label={translate(
|
||||
'auto.components.LinearItemDrawer.0190b760c1',
|
||||
'Open on Linear'
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate('auto.components.LinearItemDrawer.0190b760c1', 'Open on Linear')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={onClose}
|
||||
aria-label={translate(
|
||||
'auto.components.LinearItemDrawer.858d0630da',
|
||||
'Close preview'
|
||||
)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate('auto.components.LinearItemDrawer.9dc54172db', 'Close · Esc')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit section */}
|
||||
{editState && (
|
||||
<LinearIssueEditSection
|
||||
issue={displayed}
|
||||
editState={editState}
|
||||
onEditStateChange={handleEditStateChange}
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Body + comments */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
|
||||
<div className="px-4 py-4">
|
||||
<LinearIssueTextEditor
|
||||
issue={displayed}
|
||||
onIssueChange={handleIssueTextChange}
|
||||
density="drawer"
|
||||
fields="description"
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/40 px-4 py-4">
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
{translate('auto.components.LinearItemDrawer.fde849b2b6', 'Comments')}
|
||||
</span>
|
||||
{comments.length > 0 && (
|
||||
<span className="text-[12px] text-muted-foreground">{comments.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{commentsLoading && comments.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{translate('auto.components.LinearItemDrawer.a4fcc57522', 'No comments yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="rounded-lg border border-border/40 bg-background/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border/40 px-3 py-2">
|
||||
{comment.user?.avatarUrl && (
|
||||
<img
|
||||
src={comment.user.avatarUrl}
|
||||
alt={comment.user.displayName}
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<span className="text-[13px] font-semibold text-foreground">
|
||||
{comment.user?.displayName ??
|
||||
translate('auto.components.LinearItemDrawer.48e17e8cbd', 'Unknown')}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
· {formatRelativeTime(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<CommentMarkdown
|
||||
content={comment.body}
|
||||
className="text-[13px] leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment footer + Start/Open workspace */}
|
||||
<LinearIssueCommentFooter
|
||||
issueId={displayed.id}
|
||||
workspaceId={displayed.workspaceId}
|
||||
onCommentAdded={handleCommentAdded}
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
<div className="flex-none space-y-2 border-t border-border/60 bg-background/40 px-4 py-3">
|
||||
{attachedWorkspaceLabel ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[12px] text-muted-foreground">
|
||||
<FolderOpen className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{attachedWorkspaceLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{attachedWorkspace ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<ButtonGroup className="w-full">
|
||||
<Button
|
||||
onClick={handleOpenOrUseIssue}
|
||||
className="flex-1 justify-center gap-2"
|
||||
aria-label={translate(
|
||||
'auto.components.LinearItemDrawer.openAttachedWorkspace',
|
||||
'Open workspace attached to issue'
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="size-4" />
|
||||
{translate(
|
||||
'auto.components.LinearItemDrawer.openWorkspace',
|
||||
'Open workspace'
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
aria-label={translate(
|
||||
'auto.components.LinearItemDrawer.moreWorkspaceActions',
|
||||
'More issue workspace actions'
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</ButtonGroup>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onUse(displayed)}>
|
||||
<Plus className="size-4" />
|
||||
{translate(
|
||||
'auto.components.LinearItemDrawer.startNewWorkspace',
|
||||
'Start new workspace'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleOpenOrUseIssue}
|
||||
className="w-full justify-center gap-2"
|
||||
aria-label={translate(
|
||||
'auto.components.LinearItemDrawer.04008e6c46',
|
||||
'Start workspace from issue'
|
||||
)}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.LinearItemDrawer.04008e6c46',
|
||||
'Start workspace from issue'
|
||||
)}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { LinearIssue } from '../../../shared/linear/issue-types'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
|
||||
export type LinearItemDrawerProps = {
|
||||
issue: LinearIssue | null
|
||||
onUse: (issue: LinearIssue) => void
|
||||
onClose: () => void
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
export type LinearEditState = {
|
||||
state: LinearIssue['state']
|
||||
priority: number
|
||||
estimate: number | null | undefined
|
||||
assignee: LinearIssue['assignee']
|
||||
labelIds: string[]
|
||||
labels: string[]
|
||||
}
|
||||
|
||||
export type LinearIssueEditSectionProps = {
|
||||
issue: LinearIssue
|
||||
editState: LinearEditState
|
||||
onEditStateChange: (patch: Partial<LinearEditState>) => void
|
||||
layout?: 'chips' | 'properties'
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
export type LinearLocalComment = { id: string; body: string; createdAt: string }
|
||||
|
||||
export function initLinearIssueEditState(issue: LinearIssue): LinearEditState {
|
||||
return {
|
||||
state: issue.state,
|
||||
priority: issue.priority,
|
||||
estimate: issue.estimate,
|
||||
assignee: issue.assignee,
|
||||
labelIds: issue.labelIds,
|
||||
labels: issue.labels
|
||||
}
|
||||
}
|
||||
@@ -71,13 +71,13 @@ describe('task drawer source boundaries', () => {
|
||||
})
|
||||
|
||||
it('uses Linear task source context for drawer reads, mutations, and optimistic patches', () => {
|
||||
const source = componentSource('LinearItemDrawer.tsx')
|
||||
const editSection = sourceBetween(
|
||||
source,
|
||||
'export function LinearIssueEditSection',
|
||||
'export function LinearIssueCommentFooter'
|
||||
const drawerSource = componentSource('LinearItemDrawer.tsx')
|
||||
const editSection = componentSource('linear-item-drawer-edit-controller.tsx')
|
||||
const drawer = sourceBetween(
|
||||
drawerSource,
|
||||
'export default function LinearItemDrawer',
|
||||
'return renderLinearItemDrawerSheet'
|
||||
)
|
||||
const drawer = sourceBetween(source, 'export default function LinearItemDrawer', 'return (')
|
||||
|
||||
expect(editSection).toContain('const providerSettings = sourceContext ?? settings')
|
||||
expect(editSection).toContain('linearUpdateIssue(providerSettings')
|
||||
|
||||
Reference in New Issue
Block a user