Keep your New Linear and Jira creation drafts after an accidental dismissal (#12167)
* Keep your New Linear and Jira creation drafts after an accidental dismissal * Avoid draft store writes while typing * Update draft retention refs after commit
This commit is contained in:
parent
6f28c118bb
commit
4481b9b241
|
|
@ -239,6 +239,13 @@ import {
|
|||
resolveUserRepoSwitchReset,
|
||||
resolveVanishedNewIssueRepoReset
|
||||
} from '@/components/task-page-new-issue-draft'
|
||||
import {
|
||||
isTaskCreationDraftContentful,
|
||||
type NewJiraIssueDraft,
|
||||
type NewLinearIssueDraft,
|
||||
type NewLinearProjectDraft
|
||||
} from '@/store/slices/task-creation-drafts'
|
||||
import { useTaskCreationDraftRetention } from '@/components/use-task-creation-draft-retention'
|
||||
import { findTaskPageJiraIssue } from '@/components/task-page-jira-cache-selectors'
|
||||
import { getRepoBackedTaskEmptyState } from '@/components/task-page-empty-state'
|
||||
import {
|
||||
|
|
@ -3045,6 +3052,33 @@ const hasUpstreamCandidateDivergence = (
|
|||
!!s.sources.upstreamCandidate &&
|
||||
!sameGitHubOwnerRepo(s.sources.originCandidate, s.sources.upstreamCandidate)
|
||||
|
||||
function writeNewLinearProjectDraft(draft: NewLinearProjectDraft | null): void {
|
||||
const state = useAppStore.getState()
|
||||
if (draft && isTaskCreationDraftContentful(draft)) {
|
||||
state.setNewLinearProjectDraft(draft)
|
||||
} else {
|
||||
state.clearNewLinearProjectDraft()
|
||||
}
|
||||
}
|
||||
|
||||
function writeNewLinearIssueDraft(draft: NewLinearIssueDraft | null): void {
|
||||
const state = useAppStore.getState()
|
||||
if (draft && isTaskCreationDraftContentful(draft)) {
|
||||
state.setNewLinearIssueDraft(draft)
|
||||
} else {
|
||||
state.clearNewLinearIssueDraft()
|
||||
}
|
||||
}
|
||||
|
||||
function writeNewJiraIssueDraft(draft: NewJiraIssueDraft | null): void {
|
||||
const state = useAppStore.getState()
|
||||
if (draft && isTaskCreationDraftContentful(draft)) {
|
||||
state.setNewJiraIssueDraft(draft)
|
||||
} else {
|
||||
state.clearNewJiraIssueDraft()
|
||||
}
|
||||
}
|
||||
|
||||
export default function TaskPage(): React.JSX.Element {
|
||||
useTranslation()
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
|
|
@ -5638,6 +5672,16 @@ export default function TaskPage(): React.JSX.Element {
|
|||
setNewLinearProjectLabelIds([])
|
||||
}, [newLinearProjectTargetTeam?.id, newLinearProjectTargetTeam?.workspaceId])
|
||||
|
||||
const discardNewLinearProjectDraft = useTaskCreationDraftRetention({
|
||||
open: newLinearProjectOpen,
|
||||
draft: {
|
||||
name: newLinearProjectName,
|
||||
description: newLinearProjectDescription,
|
||||
content: newLinearProjectContent
|
||||
},
|
||||
writeDraft: writeNewLinearProjectDraft
|
||||
})
|
||||
|
||||
// New Linear issue dialog state
|
||||
const [newLinearIssueOpen, setNewLinearIssueOpen] = useState(false)
|
||||
const [newLinearIssueTitle, setNewLinearIssueTitle] = useState('')
|
||||
|
|
@ -5651,6 +5695,12 @@ export default function TaskPage(): React.JSX.Element {
|
|||
const [newLinearIssueProjectId, setNewLinearIssueProjectId] = useState<string | null>(null)
|
||||
const [newLinearIssueLabelIds, setNewLinearIssueLabelIds] = useState<string[]>([])
|
||||
|
||||
const discardNewLinearIssueDraft = useTaskCreationDraftRetention({
|
||||
open: newLinearIssueOpen,
|
||||
draft: { title: newLinearIssueTitle, body: newLinearIssueBody },
|
||||
writeDraft: writeNewLinearIssueDraft
|
||||
})
|
||||
|
||||
const newLinearIssueTargetTeam = useMemo(
|
||||
() => availableTeams.find((t) => t.id === newLinearIssueTeamId) ?? availableTeams[0] ?? null,
|
||||
[availableTeams, newLinearIssueTeamId]
|
||||
|
|
@ -5788,6 +5838,12 @@ export default function TaskPage(): React.JSX.Element {
|
|||
const [newJiraIssueCustomFieldValues, setNewJiraIssueCustomFieldValues] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
|
||||
const discardNewJiraIssueDraft = useTaskCreationDraftRetention({
|
||||
open: newJiraIssueOpen,
|
||||
draft: { title: newJiraIssueTitle, body: newJiraIssueBody },
|
||||
writeDraft: writeNewJiraIssueDraft
|
||||
})
|
||||
const includeJiraSiteNameInProjectLabel = selectedJiraSiteId === 'all'
|
||||
const previousProviderRuntimeContextKeyRef = useRef(providerRuntimeContextKey)
|
||||
|
||||
|
|
@ -6929,6 +6985,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
: undefined
|
||||
}
|
||||
)
|
||||
discardNewLinearProjectDraft()
|
||||
setNewLinearProjectOpen(false)
|
||||
setNewLinearProjectName('')
|
||||
setNewLinearProjectDescription('')
|
||||
|
|
@ -6971,7 +7028,8 @@ export default function TaskPage(): React.JSX.Element {
|
|||
newLinearProjectTargetTeam,
|
||||
openLinearProjectContext,
|
||||
linearTaskSourceContext,
|
||||
settings
|
||||
settings,
|
||||
discardNewLinearProjectDraft
|
||||
])
|
||||
|
||||
const handleCreateNewLinearIssue = useCallback(async (): Promise<void> => {
|
||||
|
|
@ -7032,6 +7090,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
: undefined
|
||||
}
|
||||
)
|
||||
discardNewLinearIssueDraft()
|
||||
setNewLinearIssueOpen(false)
|
||||
setNewLinearIssueTitle('')
|
||||
setNewLinearIssueBody('')
|
||||
|
|
@ -7077,7 +7136,8 @@ export default function TaskPage(): React.JSX.Element {
|
|||
selectedLinearProject,
|
||||
setSelectedLinearIssue,
|
||||
linearTaskSourceContext,
|
||||
settings
|
||||
settings,
|
||||
discardNewLinearIssueDraft
|
||||
])
|
||||
|
||||
const handleCreateNewJiraIssue = useCallback(async (): Promise<void> => {
|
||||
|
|
@ -7126,6 +7186,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
: undefined
|
||||
}
|
||||
)
|
||||
discardNewJiraIssueDraft()
|
||||
setNewJiraIssueOpen(false)
|
||||
setNewJiraIssueTitle('')
|
||||
setNewJiraIssueBody('')
|
||||
|
|
@ -7166,7 +7227,8 @@ export default function TaskPage(): React.JSX.Element {
|
|||
jiraTaskSourceContext,
|
||||
settings,
|
||||
setSelectedJiraIssue,
|
||||
visibleJiraCreateFields
|
||||
visibleJiraCreateFields,
|
||||
discardNewJiraIssueDraft
|
||||
])
|
||||
|
||||
const githubTasksBusy = tasksLoading || tasksRefreshing || tasksFiltering
|
||||
|
|
@ -8595,9 +8657,11 @@ export default function TaskPage(): React.JSX.Element {
|
|||
size="icon"
|
||||
onClick={() => {
|
||||
if (linearMode === 'projects' && !selectedLinearProject) {
|
||||
setNewLinearProjectName('')
|
||||
setNewLinearProjectDescription('')
|
||||
setNewLinearProjectContent('')
|
||||
// Why: restore dismissed typed text (accidental dismissal recoverable); pickers keep their fresh open-time defaults.
|
||||
const draft = useAppStore.getState().newLinearProjectDraft
|
||||
setNewLinearProjectName(draft?.name ?? '')
|
||||
setNewLinearProjectDescription(draft?.description ?? '')
|
||||
setNewLinearProjectContent(draft?.content ?? '')
|
||||
setNewLinearProjectTeamId(availableTeams[0]?.id ?? null)
|
||||
setNewLinearProjectLeadId(null)
|
||||
setNewLinearProjectMemberIds([])
|
||||
|
|
@ -8608,8 +8672,10 @@ export default function TaskPage(): React.JSX.Element {
|
|||
setNewLinearProjectOpen(true)
|
||||
return
|
||||
}
|
||||
setNewLinearIssueTitle('')
|
||||
setNewLinearIssueBody('')
|
||||
// Why: restore dismissed typed text (accidental dismissal recoverable); pickers keep their fresh open-time defaults.
|
||||
const issueDraft = useAppStore.getState().newLinearIssueDraft
|
||||
setNewLinearIssueTitle(issueDraft?.title ?? '')
|
||||
setNewLinearIssueBody(issueDraft?.body ?? '')
|
||||
const projectTeamId =
|
||||
selectedLinearProject?.teams?.[0]?.id ??
|
||||
availableTeams.find(
|
||||
|
|
@ -8826,8 +8892,10 @@ export default function TaskPage(): React.JSX.Element {
|
|||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setNewJiraIssueTitle('')
|
||||
setNewJiraIssueBody('')
|
||||
// Why: restore dismissed typed text (accidental dismissal recoverable); pickers keep their fresh open-time defaults.
|
||||
const draft = useAppStore.getState().newJiraIssueDraft
|
||||
setNewJiraIssueTitle(draft?.title ?? '')
|
||||
setNewJiraIssueBody(draft?.body ?? '')
|
||||
setNewJiraIssueProjectId(
|
||||
sortedAvailableJiraProjects[0]
|
||||
? getJiraProjectSelectionKey(sortedAvailableJiraProjects[0])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const taskPageSource = readFileSync(new URL('./TaskPage.tsx', import.meta.url), 'utf8')
|
||||
|
||||
function sectionBetween(startAnchor: string, endAnchor: string): string {
|
||||
const start = taskPageSource.indexOf(startAnchor)
|
||||
expect(start, `missing anchor: ${startAnchor}`).toBeGreaterThanOrEqual(0)
|
||||
const end = taskPageSource.indexOf(endAnchor, start)
|
||||
expect(end, `missing anchor: ${endAnchor}`).toBeGreaterThan(start)
|
||||
return taskPageSource.slice(start, end)
|
||||
}
|
||||
|
||||
describe('TaskPage Linear/Jira creation drafts', () => {
|
||||
it('uses the contentful gate for each session draft writer', () => {
|
||||
expect(taskPageSource.split('isTaskCreationDraftContentful(draft)')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('retains all three drafts on dismissal without subscribing TaskPage to draft actions', () => {
|
||||
expect(taskPageSource.split('useTaskCreationDraftRetention({')).toHaveLength(4)
|
||||
expect(taskPageSource).not.toMatch(
|
||||
/useAppStore\(\(s\) => s\.(?:set|clear)New(?:LinearProject|LinearIssue|JiraIssue)Draft\)/
|
||||
)
|
||||
})
|
||||
|
||||
it('restores dismissed typed text when each dialog reopens', () => {
|
||||
expect(taskPageSource).toContain("setNewLinearProjectName(draft?.name ?? '')")
|
||||
expect(taskPageSource).toContain("setNewLinearProjectDescription(draft?.description ?? '')")
|
||||
expect(taskPageSource).toContain("setNewLinearProjectContent(draft?.content ?? '')")
|
||||
expect(taskPageSource).toContain("setNewLinearIssueTitle(issueDraft?.title ?? '')")
|
||||
expect(taskPageSource).toContain("setNewLinearIssueBody(issueDraft?.body ?? '')")
|
||||
expect(taskPageSource).toContain("setNewJiraIssueTitle(draft?.title ?? '')")
|
||||
expect(taskPageSource).toContain("setNewJiraIssueBody(draft?.body ?? '')")
|
||||
})
|
||||
|
||||
it('discards each recovery draft only on a successful create', () => {
|
||||
const linearProjectSection = sectionBetween(
|
||||
'const handleCreateNewLinearProject',
|
||||
'const handleCreateNewLinearIssue'
|
||||
)
|
||||
expect(linearProjectSection).toContain('discardNewLinearProjectDraft()')
|
||||
|
||||
const linearIssueSection = sectionBetween(
|
||||
'const handleCreateNewLinearIssue',
|
||||
'const handleCreateNewJiraIssue'
|
||||
)
|
||||
expect(linearIssueSection).toContain('discardNewLinearIssueDraft()')
|
||||
|
||||
const jiraIssueSection = sectionBetween(
|
||||
'const handleCreateNewJiraIssue',
|
||||
'const githubTasksBusy'
|
||||
)
|
||||
expect(jiraIssueSection).toContain('discardNewJiraIssueDraft()')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useTaskCreationDraftRetention } from './use-task-creation-draft-retention'
|
||||
|
||||
type Draft = { title: string; body: string }
|
||||
|
||||
const emptyDraft = (): Draft => ({ title: '', body: '' })
|
||||
|
||||
describe('useTaskCreationDraftRetention', () => {
|
||||
it('does not fan out writes while typing and saves the latest text on dismissal', () => {
|
||||
const writeDraft = vi.fn()
|
||||
const view = renderHook(
|
||||
({ open, draft }) => useTaskCreationDraftRetention({ open, draft, writeDraft }),
|
||||
{ initialProps: { open: true, draft: emptyDraft() } }
|
||||
)
|
||||
|
||||
view.rerender({ open: true, draft: { title: 'Bug', body: '' } })
|
||||
view.rerender({ open: true, draft: { title: 'Bug', body: 'Latest details' } })
|
||||
|
||||
expect(writeDraft).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender({ open: false, draft: emptyDraft() })
|
||||
|
||||
expect(writeDraft).toHaveBeenCalledOnce()
|
||||
expect(writeDraft).toHaveBeenCalledWith({ title: 'Bug', body: 'Latest details' })
|
||||
})
|
||||
|
||||
it('saves the latest text when an open composer unmounts', () => {
|
||||
const writeDraft = vi.fn()
|
||||
const view = renderHook(
|
||||
({ draft }) => useTaskCreationDraftRetention({ open: true, draft, writeDraft }),
|
||||
{ initialProps: { draft: { title: 'First', body: '' } } }
|
||||
)
|
||||
view.rerender({ draft: { title: 'Latest', body: 'Details' } })
|
||||
|
||||
view.unmount()
|
||||
|
||||
expect(writeDraft).toHaveBeenCalledOnce()
|
||||
expect(writeDraft).toHaveBeenCalledWith({ title: 'Latest', body: 'Details' })
|
||||
})
|
||||
|
||||
it('clears a successful draft without resurrecting it during close cleanup', () => {
|
||||
const writeDraft = vi.fn()
|
||||
const view = renderHook(
|
||||
({ open }) =>
|
||||
useTaskCreationDraftRetention({
|
||||
open,
|
||||
draft: { title: 'Created', body: 'Details' },
|
||||
writeDraft
|
||||
}),
|
||||
{ initialProps: { open: true } }
|
||||
)
|
||||
|
||||
act(() => view.result.current())
|
||||
view.rerender({ open: false })
|
||||
view.unmount()
|
||||
|
||||
expect(writeDraft).toHaveBeenCalledOnce()
|
||||
expect(writeDraft).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('does not create a draft when an unopened composer unmounts', () => {
|
||||
const writeDraft = vi.fn()
|
||||
const view = renderHook(() =>
|
||||
useTaskCreationDraftRetention({ open: false, draft: emptyDraft(), writeDraft })
|
||||
)
|
||||
|
||||
view.unmount()
|
||||
|
||||
expect(writeDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
type TaskCreationDraftRetentionOptions<Draft> = {
|
||||
open: boolean
|
||||
draft: Draft
|
||||
writeDraft: (draft: Draft | null) => void
|
||||
}
|
||||
|
||||
export function useTaskCreationDraftRetention<Draft>({
|
||||
open,
|
||||
draft,
|
||||
writeDraft
|
||||
}: TaskCreationDraftRetentionOptions<Draft>): () => void {
|
||||
const draftRef = useRef(draft)
|
||||
const writeDraftRef = useRef(writeDraft)
|
||||
const discardRef = useRef(false)
|
||||
|
||||
// Why: every committed open render refreshes refs without letting discarded renders leak into cleanup.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
draftRef.current = draft
|
||||
writeDraftRef.current = writeDraft
|
||||
})
|
||||
|
||||
// Why: closing cleanup captures the latest render once without a global store write per keystroke.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
discardRef.current = false
|
||||
return () => {
|
||||
if (!discardRef.current) {
|
||||
writeDraftRef.current(draftRef.current)
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return useCallback(() => {
|
||||
discardRef.current = true
|
||||
writeDraftRef.current(null)
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ import { createPinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-conf
|
|||
import { createRecentlyClosedTabsSlice } from './slices/recently-closed-tabs'
|
||||
import { createOrcaProfilesSlice } from './slices/orca-profiles'
|
||||
import { createNewIssueDraftSlice } from './slices/new-issue-draft'
|
||||
import { createTaskCreationDraftsSlice } from './slices/task-creation-drafts'
|
||||
import { createRemoteServerUpdatesSlice } from './slices/remote-server-updates'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import type { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-session'
|
||||
|
|
@ -92,6 +93,7 @@ export const useAppStore = create<AppState>()((...a) => {
|
|||
...createRecentlyClosedTabsSlice(...a),
|
||||
...createOrcaProfilesSlice(...a),
|
||||
...createNewIssueDraftSlice(...a),
|
||||
...createTaskCreationDraftsSlice(...a),
|
||||
...createRemoteServerUpdatesSlice(...a)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
|
|||
import { createRecentlyClosedTabsSlice } from './recently-closed-tabs'
|
||||
import { createOrcaProfilesSlice } from './orca-profiles'
|
||||
import { createNewIssueDraftSlice } from './new-issue-draft'
|
||||
import { createTaskCreationDraftsSlice } from './task-creation-drafts'
|
||||
import { createRemoteServerUpdatesSlice } from './remote-server-updates'
|
||||
|
||||
function createTestStore() {
|
||||
|
|
@ -187,6 +188,7 @@ function createTestStore() {
|
|||
...createRecentlyClosedTabsSlice(...a),
|
||||
...createOrcaProfilesSlice(...a),
|
||||
...createNewIssueDraftSlice(...a),
|
||||
...createTaskCreationDraftsSlice(...a),
|
||||
...createRemoteServerUpdatesSlice(...a)
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { createPinnedTabCloseConfirmSlice } from './pinned-tab-close-confirm'
|
|||
import { createRecentlyClosedTabsSlice } from './recently-closed-tabs'
|
||||
import { createOrcaProfilesSlice } from './orca-profiles'
|
||||
import { createNewIssueDraftSlice } from './new-issue-draft'
|
||||
import { createTaskCreationDraftsSlice } from './task-creation-drafts'
|
||||
import { createRemoteServerUpdatesSlice } from './remote-server-updates'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
|
|
@ -99,6 +100,7 @@ export function createTestStore() {
|
|||
...createRecentlyClosedTabsSlice(...a),
|
||||
...createOrcaProfilesSlice(...a),
|
||||
...createNewIssueDraftSlice(...a),
|
||||
...createTaskCreationDraftsSlice(...a),
|
||||
...createRemoteServerUpdatesSlice(...a)
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { create } from 'zustand'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createTaskCreationDraftsSlice,
|
||||
isTaskCreationDraftContentful
|
||||
} from './task-creation-drafts'
|
||||
import type { AppState } from '../types'
|
||||
|
||||
function makeStore() {
|
||||
return create<
|
||||
Pick<
|
||||
AppState,
|
||||
| 'newLinearIssueDraft'
|
||||
| 'setNewLinearIssueDraft'
|
||||
| 'clearNewLinearIssueDraft'
|
||||
| 'newLinearProjectDraft'
|
||||
| 'setNewLinearProjectDraft'
|
||||
| 'clearNewLinearProjectDraft'
|
||||
| 'newJiraIssueDraft'
|
||||
| 'setNewJiraIssueDraft'
|
||||
| 'clearNewJiraIssueDraft'
|
||||
>
|
||||
>()((...args) =>
|
||||
createTaskCreationDraftsSlice(...(args as Parameters<typeof createTaskCreationDraftsSlice>))
|
||||
)
|
||||
}
|
||||
|
||||
describe('createTaskCreationDraftsSlice', () => {
|
||||
it('starts with no drafts', () => {
|
||||
const state = makeStore().getState()
|
||||
expect(state.newLinearIssueDraft).toBeNull()
|
||||
expect(state.newLinearProjectDraft).toBeNull()
|
||||
expect(state.newJiraIssueDraft).toBeNull()
|
||||
})
|
||||
|
||||
it('stores and clears each draft independently', () => {
|
||||
const store = makeStore()
|
||||
|
||||
store.getState().setNewLinearIssueDraft({ title: 'Linear bug', body: 'details' })
|
||||
store.getState().setNewLinearProjectDraft({
|
||||
name: 'Roadmap',
|
||||
description: 'summary',
|
||||
content: 'brief'
|
||||
})
|
||||
store.getState().setNewJiraIssueDraft({ title: 'Jira bug', body: 'steps' })
|
||||
|
||||
store.getState().clearNewLinearIssueDraft()
|
||||
|
||||
expect(store.getState().newLinearIssueDraft).toBeNull()
|
||||
expect(store.getState().newLinearProjectDraft).toEqual({
|
||||
name: 'Roadmap',
|
||||
description: 'summary',
|
||||
content: 'brief'
|
||||
})
|
||||
expect(store.getState().newJiraIssueDraft).toEqual({ title: 'Jira bug', body: 'steps' })
|
||||
})
|
||||
|
||||
it('replaces a draft wholesale on set', () => {
|
||||
const store = makeStore()
|
||||
store.getState().setNewJiraIssueDraft({ title: 'first', body: 'text' })
|
||||
|
||||
store.getState().setNewJiraIssueDraft({ title: 'second', body: '' })
|
||||
|
||||
expect(store.getState().newJiraIssueDraft).toEqual({ title: 'second', body: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTaskCreationDraftContentful', () => {
|
||||
it('rejects an all-empty or whitespace-only form', () => {
|
||||
expect(isTaskCreationDraftContentful({ title: '', body: '' })).toBe(false)
|
||||
expect(isTaskCreationDraftContentful({ title: ' ', body: '\n\t' })).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts any field with typed text', () => {
|
||||
expect(isTaskCreationDraftContentful({ title: 'Bug', body: '' })).toBe(true)
|
||||
expect(isTaskCreationDraftContentful({ name: '', description: '', content: 'brief' })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
|
||||
/** Session-only text drafts for Linear/Jira creation dialogs; picker selections stay fresh. */
|
||||
export type NewLinearIssueDraft = { title: string; body: string }
|
||||
export type NewLinearProjectDraft = { name: string; description: string; content: string }
|
||||
export type NewJiraIssueDraft = { title: string; body: string }
|
||||
|
||||
/** Empty forms do not replace a later open with a meaningless draft. */
|
||||
export function isTaskCreationDraftContentful(fields: Record<string, string>): boolean {
|
||||
return Object.values(fields).some((value) => value.trim().length > 0)
|
||||
}
|
||||
|
||||
export type TaskCreationDraftsSlice = {
|
||||
newLinearIssueDraft: NewLinearIssueDraft | null
|
||||
setNewLinearIssueDraft: (draft: NewLinearIssueDraft) => void
|
||||
clearNewLinearIssueDraft: () => void
|
||||
newLinearProjectDraft: NewLinearProjectDraft | null
|
||||
setNewLinearProjectDraft: (draft: NewLinearProjectDraft) => void
|
||||
clearNewLinearProjectDraft: () => void
|
||||
newJiraIssueDraft: NewJiraIssueDraft | null
|
||||
setNewJiraIssueDraft: (draft: NewJiraIssueDraft) => void
|
||||
clearNewJiraIssueDraft: () => void
|
||||
}
|
||||
|
||||
export const createTaskCreationDraftsSlice: StateCreator<
|
||||
AppState,
|
||||
[],
|
||||
[],
|
||||
TaskCreationDraftsSlice
|
||||
> = (set) => ({
|
||||
newLinearIssueDraft: null,
|
||||
setNewLinearIssueDraft: (draft) => set({ newLinearIssueDraft: draft }),
|
||||
clearNewLinearIssueDraft: () => set({ newLinearIssueDraft: null }),
|
||||
newLinearProjectDraft: null,
|
||||
setNewLinearProjectDraft: (draft) => set({ newLinearProjectDraft: draft }),
|
||||
clearNewLinearProjectDraft: () => set({ newLinearProjectDraft: null }),
|
||||
newJiraIssueDraft: null,
|
||||
setNewJiraIssueDraft: (draft) => set({ newJiraIssueDraft: draft }),
|
||||
clearNewJiraIssueDraft: () => set({ newJiraIssueDraft: null })
|
||||
})
|
||||
|
|
@ -37,6 +37,7 @@ import type { PinnedTabCloseConfirmSlice } from './slices/pinned-tab-close-confi
|
|||
import type { RecentlyClosedTabsSlice } from './slices/recently-closed-tabs'
|
||||
import type { OrcaProfilesSlice } from './slices/orca-profiles'
|
||||
import type { NewIssueDraftSlice } from './slices/new-issue-draft'
|
||||
import type { TaskCreationDraftsSlice } from './slices/task-creation-drafts'
|
||||
import type { RemoteServerUpdatesSlice } from './slices/remote-server-updates'
|
||||
|
||||
export type AppState = RepoSlice &
|
||||
|
|
@ -78,4 +79,5 @@ export type AppState = RepoSlice &
|
|||
RecentlyClosedTabsSlice &
|
||||
OrcaProfilesSlice &
|
||||
NewIssueDraftSlice &
|
||||
TaskCreationDraftsSlice &
|
||||
RemoteServerUpdatesSlice
|
||||
|
|
|
|||
Loading…
Reference in New Issue