mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Filter Jira preset views by multiple workflow statuses (#28623)
Combine remembered Jira status filters with presets and saved JQL on the connection host, protecting obsolete owner actions and keeping incompatible controls hidden in linked mode. Co-authored-by: Brennan Benson <brennankbenson@gmail.com>
This commit is contained in:
+15
-2
@@ -1,3 +1,4 @@
|
||||
import { JiraIssueListFiltersParam } from '../../shared/rpc-contract/jira-quick-filter-params'
|
||||
import { ipcMain } from 'electron'
|
||||
import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client'
|
||||
import { _resetPreflightCache } from './preflight'
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
JiraConnectArgs,
|
||||
JiraCreateIssueArgs,
|
||||
JiraIssueFilter,
|
||||
JiraIssueListFilters,
|
||||
JiraIssueUpdate,
|
||||
JiraSiteSelection
|
||||
} from '../../shared/jira-types'
|
||||
@@ -154,12 +156,23 @@ export function registerJiraHandlers(): void {
|
||||
'jira:listIssues',
|
||||
async (
|
||||
_event,
|
||||
args?: { filter?: JiraIssueFilter; limit?: number; siteId?: JiraSiteSelection }
|
||||
args?: {
|
||||
filter?: JiraIssueFilter
|
||||
limit?: number
|
||||
siteId?: JiraSiteSelection
|
||||
filters?: JiraIssueListFilters
|
||||
}
|
||||
) => {
|
||||
const filter = VALID_FILTERS.has(args?.filter as JiraIssueFilter)
|
||||
? (args!.filter as JiraIssueFilter)
|
||||
: undefined
|
||||
return listIssues(filter, clampLimit(args?.limit), normalizeSiteSelection(args?.siteId))
|
||||
return listIssues(
|
||||
filter,
|
||||
clampLimit(args?.limit),
|
||||
normalizeSiteSelection(args?.siteId),
|
||||
false,
|
||||
args?.filters ? JiraIssueListFiltersParam.parse(args.filters) : undefined
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeJiraQuickFilterState } from '../../shared/jira-quick-filters'
|
||||
import { JiraQuickFilterStateParam } from '../../shared/rpc-contract/jira-quick-filter-params'
|
||||
import {
|
||||
normalizeJiraSavedFilterState,
|
||||
mutateJiraSavedFilterState
|
||||
@@ -64,6 +66,14 @@ export function registerUIHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('ui:getJiraQuickFilters', () =>
|
||||
normalizeJiraQuickFilterState(store.getUI().jiraQuickFilterState)
|
||||
)
|
||||
ipcMain.handle('ui:setJiraQuickFilters', (_event, input: unknown) => {
|
||||
const state = normalizeJiraQuickFilterState(JiraQuickFilterStateParam.parse(input))
|
||||
store.updateUI({ jiraQuickFilterState: state })
|
||||
return state
|
||||
})
|
||||
ipcMain.handle('ui:getJiraSavedFilters', () =>
|
||||
normalizeJiraSavedFilterState(store.getUI().jiraSavedFilterState)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { sortByUpdatedAtDescending } from '../../shared/updated-at-order'
|
||||
import type { JiraIssue, JiraIssueFilter, JiraSiteSelection } from '../../shared/jira-types'
|
||||
import type {
|
||||
JiraIssue,
|
||||
JiraIssueFilter,
|
||||
JiraIssueListFilters,
|
||||
JiraSiteSelection
|
||||
} from '../../shared/jira-types'
|
||||
import { buildJiraPresetJql } from '../../shared/jira-preset-jql'
|
||||
import { acquire, release } from './request-queue'
|
||||
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
|
||||
@@ -18,7 +23,7 @@ import {
|
||||
|
||||
const ISSUE_SEARCH_TIMEOUT_MS = 30_000
|
||||
const PROJECTS_PER_QUERY = 100
|
||||
type JiraIssueQuery = { filter: JiraIssueFilter } | { jql: string }
|
||||
type JiraIssueQuery = { filter: JiraIssueFilter; filters?: JiraIssueListFilters } | { jql: string }
|
||||
|
||||
function clampLimit(limit: number | undefined, fallback = 30): number {
|
||||
return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100)
|
||||
@@ -38,7 +43,7 @@ async function readIssuesForClient(
|
||||
if ('jql' in query) {
|
||||
return searchIssuesForClient(entry, query.jql, limit, agileFields, signal)
|
||||
}
|
||||
const jql = buildJiraPresetJql(query.filter)
|
||||
const jql = buildJiraPresetJql(query.filter, query.filters)
|
||||
if (query.filter !== 'all' || entry.site.authType === 'server') {
|
||||
return searchIssuesForClient(entry, jql, limit, agileFields, signal)
|
||||
}
|
||||
@@ -122,9 +127,10 @@ export async function listIssues(
|
||||
filter: JiraIssueFilter = 'assigned',
|
||||
limit = 30,
|
||||
siteId?: JiraSiteSelection | null,
|
||||
strict = false
|
||||
strict = false,
|
||||
filters?: JiraIssueListFilters
|
||||
): Promise<JiraIssue[]> {
|
||||
return runIssueSearch({ filter }, limit, siteId, undefined, strict)
|
||||
return runIssueSearch({ filter, filters }, limit, siteId, undefined, strict)
|
||||
}
|
||||
|
||||
export async function searchIssues(
|
||||
|
||||
@@ -121,6 +121,23 @@ describe('Jira preset filters', () => {
|
||||
expect(jiraRequest).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('composes multi-status filters inside the bounded Cloud All query', async () => {
|
||||
jiraRequest.mockImplementation(async (_entry, path, init) => {
|
||||
if (path.includes('/project/search')) {
|
||||
return { isLast: true, values: [{ id: '10', key: 'APP' }] }
|
||||
}
|
||||
expect(requestJql(init)).toBe(
|
||||
'project in ("10") AND statusCategory != Done AND status in ("Blocked", "In Progress") ORDER BY updated DESC'
|
||||
)
|
||||
return { issues: [issue('APP-1', false, false)] }
|
||||
})
|
||||
const { listIssues } = await import('./jira-issue-search')
|
||||
await expect(
|
||||
listIssues('all', 20, 'cloud', true, { statuses: ['Blocked', 'In Progress'] })
|
||||
).resolves.toMatchObject([{ key: 'APP-1' }])
|
||||
expect(jiraRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps Server/DC All on classic search without project discovery', async () => {
|
||||
getClients.mockReturnValue([site('server', 'server')])
|
||||
jiraRequest.mockResolvedValue({ issues: [] })
|
||||
|
||||
@@ -22,9 +22,13 @@ it('persists multiple saved filters and the active selection across a profile re
|
||||
activeFilterId: 'bugs'
|
||||
}
|
||||
const store = createStore()
|
||||
store.updateUI({ jiraSavedFilterState: state })
|
||||
store.updateUI({
|
||||
jiraSavedFilterState: state,
|
||||
jiraQuickFilterState: { statuses: ['In Progress', 'Blocked'] }
|
||||
})
|
||||
store.flush()
|
||||
await closeTestStores()
|
||||
const reloaded = createStore()
|
||||
expect(normalizeJiraSavedFilterState(reloaded.getUI().jiraSavedFilterState)).toEqual(state)
|
||||
expect(reloaded.getUI().jiraQuickFilterState).toEqual({ statuses: ['In Progress', 'Blocked'] })
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeJiraQuickFilterState } from '../../../../shared/jira-quick-filters'
|
||||
import { JiraQuickFilterStateParam } from '../../../../shared/rpc-contract/jira-quick-filter-params'
|
||||
import {
|
||||
normalizeJiraSavedFilterState,
|
||||
mutateJiraSavedFilterState
|
||||
@@ -19,6 +21,23 @@ import { TerminalQuickCommandsUpdate } from '../../../../shared/rpc-contract/ter
|
||||
import { AgentLaunchSettingsMutation } from '../../../../shared/rpc-contract/agent-launch-settings-params'
|
||||
|
||||
export const CLIENT_UI_METHODS = [
|
||||
defineMethod({
|
||||
name: 'ui.getJiraQuickFilters',
|
||||
permission: 'workspace',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) =>
|
||||
normalizeJiraQuickFilterState(runtime.getUIState().jiraQuickFilterState)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ui.setJiraQuickFilters',
|
||||
permission: 'workspace',
|
||||
params: JiraQuickFilterStateParam,
|
||||
handler: (params, { runtime }) => {
|
||||
const state = normalizeJiraQuickFilterState(params)
|
||||
runtime.updateUIState({ jiraQuickFilterState: state })
|
||||
return state
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'ui.getJiraSavedFilters',
|
||||
permission: 'workspace',
|
||||
|
||||
@@ -83,3 +83,27 @@ it('routes mobile saved-filter persistence to the execution host', () => {
|
||||
expect(MOBILE_RPC_METHOD_ROUTES.get('ui.getJiraSavedFilters')).toBe('execution-host')
|
||||
expect(MOBILE_RPC_METHOD_ROUTES.get('ui.mutateJiraSavedFilters')).toBe('execution-host')
|
||||
})
|
||||
|
||||
it('persists quick filters on the execution host without rewriting saved JQL or resume fields', async () => {
|
||||
const { dispatch, read, updateUIState } = setup()
|
||||
await dispatch('ui.mutateJiraSavedFilters', { kind: 'save', filter })
|
||||
expect(
|
||||
await dispatch('ui.setJiraQuickFilters', { statuses: ['In Progress', 'Blocked'] })
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
result: { statuses: ['In Progress', 'Blocked'] }
|
||||
})
|
||||
expect(await dispatch('ui.getJiraQuickFilters')).toMatchObject({
|
||||
ok: true,
|
||||
result: { statuses: ['In Progress', 'Blocked'] }
|
||||
})
|
||||
expect(read().jiraSavedFilterState?.filters).toEqual([filter])
|
||||
expect(read().taskResumeState).toEqual({ jiraQuery: 'project = OLD' })
|
||||
expect(updateUIState.mock.calls.every(([update]) => !('taskResumeState' in update))).toBe(true)
|
||||
expect(await dispatch('ui.setJiraQuickFilters', { statuses: ['x'.repeat(129)] })).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'invalid_argument' }
|
||||
})
|
||||
expect(MOBILE_RPC_METHOD_ROUTES.get('ui.getJiraQuickFilters')).toBe('execution-host')
|
||||
expect(MOBILE_RPC_METHOD_ROUTES.get('ui.setJiraQuickFilters')).toBe('execution-host')
|
||||
})
|
||||
|
||||
@@ -109,7 +109,13 @@ describe('jira RPC methods', () => {
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(runtime.jiraListIssues).toHaveBeenCalledWith('assigned', 20, 'site-1', undefined)
|
||||
expect(runtime.jiraListIssues).toHaveBeenCalledWith(
|
||||
'assigned',
|
||||
20,
|
||||
'site-1',
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-3', 'site-1', undefined)
|
||||
expect(runtime.jiraLookupIssueSummary).toHaveBeenCalledWith(
|
||||
'ABC-3',
|
||||
@@ -281,6 +287,28 @@ describe('jira RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards strict reads and optional status filters while accepting older requests', async () => {
|
||||
const jiraListIssues = vi.fn().mockResolvedValue([])
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the runtime identity and list method are invoked by these requests.
|
||||
const runtime = { getRuntimeId: () => 'host-a', jiraListIssues } as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS })
|
||||
expect(
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('jira.listIssues', {
|
||||
filter: 'all',
|
||||
siteId: 'site-1',
|
||||
strict: true,
|
||||
filters: { statuses: ['In Progress'] }
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: true })
|
||||
expect(jiraListIssues).toHaveBeenLastCalledWith('all', undefined, 'site-1', true, {
|
||||
statuses: ['In Progress']
|
||||
})
|
||||
await dispatcher.dispatch(makeRequest('jira.listIssues', { filter: 'all' }))
|
||||
expect(jiraListIssues).toHaveBeenLastCalledWith('all', undefined, undefined, undefined, undefined)
|
||||
})
|
||||
|
||||
describe('Jira title generation RPC', () => {
|
||||
it('binds generation and stop to the authenticated device across one-shot sockets', async () => {
|
||||
const generate = vi.fn().mockResolvedValue({ success: true, summary: 'Title' })
|
||||
|
||||
@@ -119,7 +119,13 @@ export const JIRA_METHODS = [
|
||||
permission: 'workspace',
|
||||
params: ListIssues,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.jiraListIssues(params?.filter, params?.limit, params?.siteId, params?.strict)
|
||||
runtime.jiraListIssues(
|
||||
params?.filter,
|
||||
params?.limit,
|
||||
params?.siteId,
|
||||
params?.strict,
|
||||
params?.filters
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'jira.getIssue',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AssertNoMissingKeys, AssertNoMissingValues } from './ui-state-sche
|
||||
// forgotten, which is what the parity assertion below enforces.
|
||||
type MainOwnedUIState =
|
||||
| 'jiraSavedFilterState'
|
||||
| 'jiraQuickFilterState'
|
||||
| '_explorerDisplayRootMigrated'
|
||||
| 'trayMinimizeNoticeShown'
|
||||
| 'dashboardPopoutBounds'
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
JiraConnectArgs,
|
||||
JiraCreateIssueArgs,
|
||||
JiraIssueFilter,
|
||||
JiraIssueListFilters,
|
||||
JiraIssueUpdate,
|
||||
JiraSiteSelection
|
||||
} from '../../shared/jira-types'
|
||||
@@ -92,9 +93,10 @@ export class RuntimeJiraCommands {
|
||||
filter?: JiraIssueFilter,
|
||||
limit = 30,
|
||||
siteId?: JiraSiteSelection,
|
||||
strict = false
|
||||
strict = false,
|
||||
filters?: JiraIssueListFilters
|
||||
): ReturnType<typeof listIssues> {
|
||||
return listIssues(filter, Math.min(Math.max(1, limit), 100), siteId, strict)
|
||||
return listIssues(filter, Math.min(Math.max(1, limit), 100), siteId, strict, filters)
|
||||
}
|
||||
|
||||
jiraCreateIssue(args: JiraCreateIssueArgs): ReturnType<typeof createIssue> {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { PAIRED_DESKTOP_METHODS } from './runtime-rpc-mobile-paired-desktop-meth
|
||||
export type MobileRpcMethodRoute = 'execution-host' | 'paired-desktop'
|
||||
|
||||
export const EXECUTION_HOST_METHODS = [
|
||||
'ui.getJiraQuickFilters',
|
||||
'ui.setJiraQuickFilters',
|
||||
'ui.getJiraSavedFilters',
|
||||
'ui.mutateJiraSavedFilters',
|
||||
'agent.launch',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
JiraCreateIssueArgs,
|
||||
JiraIssue,
|
||||
JiraIssueFilter,
|
||||
JiraIssueListFilters,
|
||||
JiraIssueType,
|
||||
JiraIssueUpdate,
|
||||
JiraPriority,
|
||||
@@ -46,6 +47,7 @@ export type JiraApi = {
|
||||
filter?: JiraIssueFilter
|
||||
limit?: number
|
||||
siteId?: JiraSiteSelection
|
||||
filters?: JiraIssueListFilters
|
||||
}) => Promise<JiraIssue[]>
|
||||
getIssue: (args: { key: string; siteId?: string }) => Promise<JiraIssue | null>
|
||||
lookupIssueSummary: (args: {
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { KeybindingActionId } from '../../shared/keybindings'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const uiStateAndMenuCommandsApi = {
|
||||
getJiraQuickFilters: () => ipcRenderer.invoke('ui:getJiraQuickFilters'),
|
||||
setJiraQuickFilters: (state) => ipcRenderer.invoke('ui:setJiraQuickFilters', state),
|
||||
getJiraSavedFilters: () => ipcRenderer.invoke('ui:getJiraSavedFilters'),
|
||||
mutateJiraSavedFilters: (mutation) => ipcRenderer.invoke('ui:mutateJiraSavedFilters', mutation),
|
||||
get: () => ipcRenderer.invoke('ui:get'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeFileOpenPosition } from '../../shared/runtime-file-contracts'
|
||||
import type { JiraQuickFilterState } from '../../shared/jira-quick-filters'
|
||||
import type { JiraSavedFilterState, JiraSavedFilterMutation } from '../../shared/jira-saved-filters'
|
||||
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
|
||||
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
|
||||
@@ -51,6 +52,8 @@ import type {
|
||||
export type CloseActiveTabPayload = { sourceId: string }
|
||||
|
||||
export type UiCommandEventApi = {
|
||||
getJiraQuickFilters?: () => Promise<JiraQuickFilterState>
|
||||
setJiraQuickFilters?: (state: JiraQuickFilterState) => Promise<JiraQuickFilterState>
|
||||
getJiraSavedFilters?: () => Promise<JiraSavedFilterState>
|
||||
mutateJiraSavedFilters?: (mutation: JiraSavedFilterMutation) => Promise<JiraSavedFilterState>
|
||||
get: () => Promise<PersistedUIState>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { TaskPageJiraStatusFilters } from './TaskPageJiraStatusFilters'
|
||||
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
|
||||
afterEach(cleanup)
|
||||
function model(): TaskPageComposerActionsModel {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every field read by the status-controls component.
|
||||
return {
|
||||
jiraHasWorkspace: false,
|
||||
selectedJiraStatuses: ['Developed'],
|
||||
selectJiraStatuses: vi.fn().mockResolvedValue(undefined),
|
||||
jiraQuickFiltersReady: true,
|
||||
jiraQuickFiltersPending: false,
|
||||
jiraQuickFiltersLoading: false,
|
||||
jiraQuickFiltersError: null,
|
||||
retryJiraQuickFilters: vi.fn(),
|
||||
jiraStatusCatalog: null,
|
||||
jiraTaskSourceScopeKey: 'jira-owner',
|
||||
selectedJiraSiteId: 'site',
|
||||
activeJiraPreset: 'assigned',
|
||||
jiraSearchInput: '',
|
||||
setJiraSearchInput: vi.fn(),
|
||||
setAppliedJiraSearch: vi.fn(),
|
||||
clearJiraSavedFilterSelection: vi.fn().mockResolvedValue(undefined),
|
||||
jiraSavedFiltersPending: false
|
||||
} as unknown as TaskPageComposerActionsModel
|
||||
}
|
||||
it('keeps a selected status and reachable Clear when the loaded catalog is empty', () => {
|
||||
const state = model()
|
||||
render(<TaskPageJiraStatusFilters model={state} />)
|
||||
expect(screen.getByRole('button', { name: 'Developed' }).getAttribute('aria-pressed')).toBe(
|
||||
'true'
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear statuses' }))
|
||||
expect(state.selectJiraStatuses).toHaveBeenCalledWith([])
|
||||
})
|
||||
it('multi-selects actual statuses and hands the composed query to manual editing', async () => {
|
||||
const state = model()
|
||||
state.selectedJiraStatuses = ['In Progress']
|
||||
state.jiraStatusCatalog = {
|
||||
scopeKey: 'jira-owner::site::assigned',
|
||||
items: [
|
||||
{ name: 'Blocked', categoryKey: 'indeterminate' },
|
||||
{ name: 'In Progress', categoryKey: 'indeterminate' }
|
||||
]
|
||||
}
|
||||
render(<TaskPageJiraStatusFilters model={state} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Blocked' }))
|
||||
expect(state.selectJiraStatuses).toHaveBeenCalledWith(['In Progress', 'Blocked'])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit as JQL' }))
|
||||
await waitFor(() =>
|
||||
expect(state.setJiraSearchInput).toHaveBeenCalledWith(
|
||||
'assignee = currentUser() AND statusCategory != Done AND status in ("In Progress") ORDER BY updated DESC'
|
||||
)
|
||||
)
|
||||
expect(state.setAppliedJiraSearch).toHaveBeenCalledWith(
|
||||
'assignee = currentUser() AND statusCategory != Done AND status in ("In Progress") ORDER BY updated DESC'
|
||||
)
|
||||
})
|
||||
it('hides status controls and preserves an existing manual query', () => {
|
||||
const state = model()
|
||||
state.jiraSearchInput = 'project = CUSTOM ORDER BY created ASC'
|
||||
render(<TaskPageJiraStatusFilters model={state} />)
|
||||
expect(screen.queryByRole('button', { name: 'Developed' })).toBeNull()
|
||||
expect(screen.getByText(/Status filters apply to preset views/)).toBeDefined()
|
||||
expect(state.setJiraSearchInput).not.toHaveBeenCalled()
|
||||
})
|
||||
it('hides statuses and query preview in linked mode without clearing selection', () => {
|
||||
const state = model()
|
||||
state.jiraHasWorkspace = true
|
||||
const { container } = render(<TaskPageJiraStatusFilters model={state} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
expect(state.selectJiraStatuses).not.toHaveBeenCalled()
|
||||
expect(state.selectedJiraStatuses).toEqual(['Developed'])
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { buildJiraPresetJql } from '../../../shared/jira-preset-jql'
|
||||
import { includeSelectedJiraStatuses } from './task-page-jira-status-catalog'
|
||||
import type { TaskPageComposerActionsModel } from './use-task-page-composer-actions'
|
||||
|
||||
export function TaskPageJiraStatusFilters({
|
||||
model
|
||||
}: {
|
||||
model: TaskPageComposerActionsModel
|
||||
}): React.JSX.Element | null {
|
||||
const {
|
||||
jiraHasWorkspace,
|
||||
selectedJiraStatuses,
|
||||
selectJiraStatuses,
|
||||
jiraQuickFiltersReady,
|
||||
jiraQuickFiltersPending,
|
||||
jiraQuickFiltersLoading,
|
||||
jiraQuickFiltersError,
|
||||
retryJiraQuickFilters,
|
||||
jiraStatusCatalog,
|
||||
jiraTaskSourceScopeKey,
|
||||
selectedJiraSiteId,
|
||||
activeJiraPreset,
|
||||
jiraSearchInput,
|
||||
setJiraSearchInput,
|
||||
setAppliedJiraSearch,
|
||||
clearJiraSavedFilterSelection,
|
||||
jiraSavedFiltersPending
|
||||
} = model
|
||||
if (jiraHasWorkspace) {
|
||||
return null
|
||||
}
|
||||
const scopeKey = `${jiraTaskSourceScopeKey}::${selectedJiraSiteId}::${activeJiraPreset}`
|
||||
const statuses = includeSelectedJiraStatuses(
|
||||
jiraStatusCatalog?.scopeKey === scopeKey ? jiraStatusCatalog.items : [],
|
||||
selectedJiraStatuses
|
||||
)
|
||||
const preview = buildJiraPresetJql(activeJiraPreset, { statuses: selectedJiraStatuses })
|
||||
const disabled =
|
||||
!jiraQuickFiltersReady ||
|
||||
jiraQuickFiltersLoading ||
|
||||
jiraQuickFiltersPending ||
|
||||
jiraSavedFiltersPending
|
||||
if (jiraSearchInput.trim()) {
|
||||
return selectedJiraStatuses.length ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'jira.statusFilters.manualQuery',
|
||||
'Status filters apply to preset views. Clear the search to use them.'
|
||||
)}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
{jiraQuickFiltersError ? (
|
||||
<div role="alert" className="flex items-center gap-2 text-xs text-destructive">
|
||||
<span>{jiraQuickFiltersError}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={jiraQuickFiltersLoading || jiraQuickFiltersPending}
|
||||
onClick={retryJiraQuickFilters}
|
||||
>
|
||||
{translate('jira.savedFilters.retry', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{statuses.length ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
variant="segmented"
|
||||
size="xs"
|
||||
spacing={1}
|
||||
value={selectedJiraStatuses}
|
||||
disabled={disabled}
|
||||
aria-label={translate('jira.statusFilters.label', 'Filter Jira issues by status')}
|
||||
onValueChange={(next) => {
|
||||
void selectJiraStatuses(next).catch(() => {})
|
||||
}}
|
||||
className="flex-wrap"
|
||||
>
|
||||
{statuses.map((status) => (
|
||||
<ToggleGroupItem key={status.name} value={status.name}>
|
||||
{status.name}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
{selectedJiraStatuses.length ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
void selectJiraStatuses([]).catch(() => {})
|
||||
}}
|
||||
>
|
||||
{translate('jira.statusFilters.clear', 'Clear statuses')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedJiraStatuses.length ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={preview}
|
||||
aria-label={translate('jira.statusFilters.jql', 'Status filter JQL')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
void clearJiraSavedFilterSelection()
|
||||
.then(() => {
|
||||
setJiraSearchInput(preview)
|
||||
setAppliedJiraSearch(preview)
|
||||
})
|
||||
.catch(() => {})
|
||||
}}
|
||||
>
|
||||
{translate('jira.statusFilters.editJql', 'Edit as JQL')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'jira.statusFilters.catalogHint',
|
||||
'Statuses come from loaded issues. Clear statuses to discover others in this view.'
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import {
|
||||
includeSelectedJiraStatuses,
|
||||
orderJiraStatusCatalog
|
||||
} from './task-page-jira-status-catalog'
|
||||
|
||||
it('orders actual workflow statuses by category and name', () => {
|
||||
expect(
|
||||
orderJiraStatusCatalog([
|
||||
{ name: 'Done', categoryKey: 'done' },
|
||||
{ name: 'Ready', categoryKey: 'indeterminate' },
|
||||
{ name: 'Open', categoryKey: 'new' },
|
||||
{ name: 'Blocked', categoryKey: 'indeterminate' }
|
||||
]).map((status) => status.name)
|
||||
).toEqual(['Open', 'Blocked', 'Ready', 'Done'])
|
||||
})
|
||||
it('keeps selected statuses visible even when a new scope has not loaded them', () => {
|
||||
expect(
|
||||
includeSelectedJiraStatuses([{ name: 'Open', categoryKey: 'new' }], ['Open', 'Developed'])
|
||||
).toEqual([
|
||||
{ name: 'Open', categoryKey: 'new' },
|
||||
{ name: 'Developed', categoryKey: 'other' }
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { JiraIssue } from '../../../shared/jira-types'
|
||||
|
||||
export type JiraStatusCatalogItem = { name: string; categoryKey: string }
|
||||
const STATUS_NAME_COLLATOR = new Intl.Collator('en', { sensitivity: 'base' })
|
||||
const STATUS_NAME_TIE_BREAKER = new Intl.Collator('en')
|
||||
const STATUS_CATEGORY_ORDER = new Map([
|
||||
['new', 0],
|
||||
['indeterminate', 1],
|
||||
['done', 2]
|
||||
])
|
||||
|
||||
export function buildJiraStatusCatalog(issues: readonly JiraIssue[]): JiraStatusCatalogItem[] {
|
||||
const statuses = new Map<string, JiraStatusCatalogItem>()
|
||||
for (const { status } of issues) {
|
||||
if (!statuses.has(status.name)) {
|
||||
statuses.set(status.name, { name: status.name, categoryKey: status.categoryKey })
|
||||
}
|
||||
}
|
||||
return orderJiraStatusCatalog([...statuses.values()])
|
||||
}
|
||||
|
||||
export function orderJiraStatusCatalog(items: JiraStatusCatalogItem[]): JiraStatusCatalogItem[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const category =
|
||||
(STATUS_CATEGORY_ORDER.get(left.categoryKey) ?? 3) -
|
||||
(STATUS_CATEGORY_ORDER.get(right.categoryKey) ?? 3)
|
||||
return (
|
||||
category ||
|
||||
STATUS_NAME_COLLATOR.compare(left.name, right.name) ||
|
||||
STATUS_NAME_TIE_BREAKER.compare(left.name, right.name)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function includeSelectedJiraStatuses(
|
||||
catalog: JiraStatusCatalogItem[],
|
||||
selected: string[]
|
||||
): JiraStatusCatalogItem[] {
|
||||
const known = new Set(catalog.map((status) => status.name))
|
||||
return orderJiraStatusCatalog([
|
||||
...catalog,
|
||||
...selected.filter((name) => !known.has(name)).map((name) => ({ name, categoryKey: 'other' }))
|
||||
])
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TaskPageJiraStatusFilters } from '../../TaskPageJiraStatusFilters'
|
||||
import { TaskPageJiraSavedFilters } from '../../TaskPageJiraSavedFilters'
|
||||
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -177,6 +178,7 @@ export function TaskPageJiraFilters({
|
||||
{jiraSavedFiltersError}
|
||||
</p>
|
||||
) : null}
|
||||
<TaskPageJiraStatusFilters model={model} />
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<div className="relative min-w-[320px] flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
|
||||
@@ -40,6 +40,9 @@ function createModel(): TaskPageLinearCollectionEffectsModel {
|
||||
jiraActiveSavedFilterId: null,
|
||||
jiraSavedFilters: [],
|
||||
jiraSavedFiltersLoading: false,
|
||||
selectedJiraStatuses: [],
|
||||
jiraQuickFiltersLoading: false,
|
||||
setJiraStatusCatalog: vi.fn(),
|
||||
setJiraProjectStatusOrder: vi.fn(),
|
||||
displayedJiraIssues: []
|
||||
}
|
||||
@@ -140,6 +143,40 @@ describe('useTaskPageJiraListEffects refresh wiring', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the preset list path for status filters and restores it after clearing', async () => {
|
||||
const model = createModel()
|
||||
model.selectedJiraStatuses = ['In Progress', 'Blocked']
|
||||
const { rerender } = renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() => expect(listJiraIssues).toHaveBeenCalledTimes(1))
|
||||
expect(listJiraIssues).toHaveBeenLastCalledWith('assigned', 50, {
|
||||
sourceContext: null,
|
||||
force: false,
|
||||
filters: { statuses: ['In Progress', 'Blocked'] }
|
||||
})
|
||||
expect(searchJiraIssues).not.toHaveBeenCalled()
|
||||
await act(async () => {
|
||||
model.selectedJiraStatuses = []
|
||||
rerender()
|
||||
})
|
||||
expect(listJiraIssues).toHaveBeenLastCalledWith('assigned', 50, {
|
||||
sourceContext: null,
|
||||
force: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps manual JQL independent of a persisted status selection', async () => {
|
||||
const model = createModel()
|
||||
model.selectedJiraStatuses = ['Blocked']
|
||||
model.appliedJiraSearch = 'project = APP ORDER BY created ASC'
|
||||
renderHook(() => useTaskPageJiraListEffects(model))
|
||||
await waitFor(() => expect(searchJiraIssues).toHaveBeenCalledTimes(1))
|
||||
expect(searchJiraIssues).toHaveBeenCalledWith('project = APP ORDER BY created ASC', 50, {
|
||||
sourceContext: null,
|
||||
force: false
|
||||
})
|
||||
expect(listJiraIssues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes force through the text-search wrapper on refresh', async () => {
|
||||
const model = createModel()
|
||||
model.appliedJiraSearch = 'fix login'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildJiraStatusCatalog } from './task-page-jira-status-catalog'
|
||||
import type { TaskPageLinearCollectionEffectsModel } from './use-task-page-linear-collection-effects'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
@@ -43,6 +44,9 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
|
||||
jiraActiveSavedFilterId,
|
||||
jiraSavedFilters,
|
||||
jiraSavedFiltersLoading,
|
||||
selectedJiraStatuses,
|
||||
jiraQuickFiltersLoading,
|
||||
setJiraStatusCatalog,
|
||||
setJiraProjectStatusOrder,
|
||||
displayedJiraIssues
|
||||
} = model
|
||||
@@ -76,7 +80,7 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
|
||||
if (taskSource !== 'jira') {
|
||||
return
|
||||
}
|
||||
if (!jiraConnected || jiraSavedFiltersLoading) {
|
||||
if (!jiraConnected || jiraSavedFiltersLoading || jiraQuickFiltersLoading) {
|
||||
return
|
||||
}
|
||||
const force = previousJiraRefreshNonceRef.current !== jiraRefreshNonce
|
||||
@@ -103,7 +107,8 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
|
||||
).then((result) => ({ ...result, error: null }))
|
||||
: listJiraIssues(activeJiraPreset, JIRA_ITEM_LIMIT, {
|
||||
sourceContext: jiraTaskSourceContext,
|
||||
force
|
||||
force,
|
||||
...(selectedJiraStatuses.length ? { filters: { statuses: selectedJiraStatuses } } : {})
|
||||
}).then((issues) => ({ issues, jqlRejection: null, error: null }))
|
||||
void request
|
||||
.then((result) => {
|
||||
@@ -112,6 +117,12 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
|
||||
return
|
||||
}
|
||||
setJiraError(result.error)
|
||||
if (!jiraHasWorkspace && !trimmed && selectedJiraStatuses.length === 0) {
|
||||
setJiraStatusCatalog({
|
||||
scopeKey: `${jiraTaskSourceScopeKey}::${selectedJiraSiteId}::${activeJiraPreset}`,
|
||||
items: buildJiraStatusCatalog(issues)
|
||||
})
|
||||
}
|
||||
setJiraIssues(issues)
|
||||
setJiraJqlRejection(jqlRejection)
|
||||
setJiraLoading(false)
|
||||
@@ -161,6 +172,8 @@ export function useTaskPageJiraListEffects(model: TaskPageLinearCollectionEffect
|
||||
jiraActiveSavedFilterId,
|
||||
jiraSavedFilters,
|
||||
jiraSavedFiltersLoading,
|
||||
selectedJiraStatuses,
|
||||
jiraQuickFiltersLoading,
|
||||
taskResumeApplied,
|
||||
jiraTaskSourceContext,
|
||||
jiraTaskSourceScopeKey
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters'
|
||||
import type { JiraStatusCatalogItem } from './task-page-jira-status-catalog'
|
||||
import { useTaskPageJiraSavedFilters } from './use-task-page-jira-saved-filters'
|
||||
import type { TaskPageLinearViewStateModel } from './use-task-page-linear-view-state'
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
@@ -58,6 +60,11 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
|
||||
setAppliedJiraSearch(jql)
|
||||
}, [])
|
||||
const savedFilters = useTaskPageJiraSavedFilters(model, setSavedFilterQuery)
|
||||
const quickFilters = useTaskPageJiraQuickFilters(model)
|
||||
const [jiraStatusCatalog, setJiraStatusCatalog] = useState<{
|
||||
scopeKey: string
|
||||
items: JiraStatusCatalogItem[]
|
||||
} | null>(null)
|
||||
const [activeJiraPreset, setActiveJiraPreset] = useState<JiraPresetId>('assigned')
|
||||
const [jiraRefreshNonce, setJiraRefreshNonce] = useState(0)
|
||||
const [jiraProjectStatusOrder, setJiraProjectStatusOrder] = useState<{
|
||||
@@ -157,6 +164,9 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
|
||||
jiraHasWorkspace,
|
||||
setJiraHasWorkspace,
|
||||
...savedFilters,
|
||||
...quickFilters,
|
||||
jiraStatusCatalog,
|
||||
setJiraStatusCatalog,
|
||||
jiraIssues,
|
||||
setJiraIssues,
|
||||
jiraLoading,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useTaskPageJiraQuickFilters } from './use-task-page-jira-quick-filters'
|
||||
import type { JiraQuickFilterState } from '../../../shared/jira-quick-filters'
|
||||
import { UNRESOLVED_OWNER_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host'
|
||||
const { read, write } = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() }))
|
||||
vi.mock('@/runtime/runtime-jira-quick-filter-state', () => ({
|
||||
readJiraQuickFilterState: read,
|
||||
writeJiraQuickFilterState: write
|
||||
}))
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
afterEach(cleanup)
|
||||
function model(
|
||||
host: string | null,
|
||||
accountHost = 'account-owner'
|
||||
): Parameters<typeof useTaskPageJiraQuickFilters>[0] {
|
||||
return {
|
||||
accountBackedTaskSourceHostId: toRuntimeExecutionHostId(accountHost),
|
||||
taskSource: 'jira',
|
||||
taskResumeApplied: true,
|
||||
jiraTaskSourceContext: host
|
||||
? {
|
||||
kind: 'task-source',
|
||||
provider: 'jira',
|
||||
projectId: 'project',
|
||||
hostId: toRuntimeExecutionHostId(host)
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
it('uses the captured account owner without a source context and keeps source ownership first', async () => {
|
||||
read.mockResolvedValue({ statuses: ['Blocked'] })
|
||||
write.mockResolvedValue({ statuses: ['Open'] })
|
||||
const { result, rerender } = renderHook(
|
||||
({ sourceHost }) => useTaskPageJiraQuickFilters(model(sourceHost)),
|
||||
{ initialProps: { sourceHost: '' } }
|
||||
)
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'account-owner' })
|
||||
await act(async () => result.current.selectJiraStatuses(['Open']))
|
||||
expect(write).toHaveBeenLastCalledWith(
|
||||
{ kind: 'environment', environmentId: 'account-owner' },
|
||||
{ statuses: ['Open'] }
|
||||
)
|
||||
rerender({ sourceHost: 'source-owner' })
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
expect(read).toHaveBeenLastCalledWith({ kind: 'environment', environmentId: 'source-owner' })
|
||||
})
|
||||
it('does not load or write status filters for an unresolved owner', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTaskPageJiraQuickFilters({
|
||||
...model(null),
|
||||
accountBackedTaskSourceHostId: UNRESOLVED_OWNER_HOST_ID
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
await act(async () => result.current.selectJiraStatuses(['Open']))
|
||||
expect(result.current.jiraQuickFiltersReady).toBe(false)
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
it('restores selection after remount and waits for writes to reach the host', async () => {
|
||||
read
|
||||
.mockResolvedValueOnce({ statuses: ['Blocked'] })
|
||||
.mockResolvedValueOnce({ statuses: ['In Progress'] })
|
||||
let finish: (value: JiraQuickFilterState) => void = () => {}
|
||||
write.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const first = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
|
||||
await waitFor(() => expect(first.result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
let pending: Promise<void> = Promise.resolve()
|
||||
act(() => {
|
||||
pending = first.result.current.selectJiraStatuses(['In Progress'])
|
||||
})
|
||||
expect(first.result.current.selectedJiraStatuses).toEqual(['Blocked'])
|
||||
expect(first.result.current.jiraQuickFiltersPending).toBe(true)
|
||||
await act(async () => {
|
||||
finish({ statuses: ['In Progress'] })
|
||||
await pending
|
||||
})
|
||||
expect(first.result.current.selectedJiraStatuses).toEqual(['In Progress'])
|
||||
first.unmount()
|
||||
const restored = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
|
||||
await waitFor(() => expect(restored.result.current.selectedJiraStatuses).toEqual(['In Progress']))
|
||||
})
|
||||
it('retains selection after a failed write', async () => {
|
||||
read.mockResolvedValue({ statuses: ['Blocked'] })
|
||||
write.mockRejectedValue(new Error('Host unreachable'))
|
||||
const { result } = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
await act(async () => {
|
||||
await expect(result.current.selectJiraStatuses([])).rejects.toThrow('Host unreachable')
|
||||
})
|
||||
expect(result.current.selectedJiraStatuses).toEqual(['Blocked'])
|
||||
expect(result.current.jiraQuickFiltersError).toBe('Host unreachable')
|
||||
})
|
||||
it('ignores a late mutation reply after changing Jira execution hosts', async () => {
|
||||
read
|
||||
.mockResolvedValueOnce({ statuses: ['Blocked'] })
|
||||
.mockResolvedValueOnce({ statuses: ['Open'] })
|
||||
let finish: (value: JiraQuickFilterState) => void = () => {}
|
||||
write.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), {
|
||||
initialProps: { host: 'a' }
|
||||
})
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
let pending: Promise<void> = Promise.resolve()
|
||||
act(() => {
|
||||
pending = result.current.selectJiraStatuses(['In Progress'])
|
||||
})
|
||||
rerender({ host: 'b' })
|
||||
await waitFor(() => expect(result.current.selectedJiraStatuses).toEqual(['Open']))
|
||||
await act(async () => {
|
||||
finish({ statuses: ['In Progress'] })
|
||||
await pending
|
||||
})
|
||||
expect(result.current.selectedJiraStatuses).toEqual(['Open'])
|
||||
})
|
||||
it('keeps unsupported-host empty selection stable across renders', async () => {
|
||||
read.mockRejectedValue(new Error('Update host'))
|
||||
const { result, rerender } = renderHook(() => useTaskPageJiraQuickFilters(model('a')))
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
const first = result.current.selectedJiraStatuses
|
||||
rerender()
|
||||
expect(result.current.selectedJiraStatuses).toBe(first)
|
||||
expect(result.current.jiraQuickFiltersReady).toBe(false)
|
||||
})
|
||||
it('does not send a status filter action retained from a previous account owner', async () => {
|
||||
read.mockResolvedValue({ statuses: ['Blocked'] })
|
||||
const { result, rerender } = renderHook(
|
||||
({ accountHost }) => useTaskPageJiraQuickFilters(model(null, accountHost)),
|
||||
{ initialProps: { accountHost: 'host-a' } }
|
||||
)
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
const oldAction = result.current.selectJiraStatuses
|
||||
rerender({ accountHost: 'host-b' })
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
await act(async () => oldAction(['Open']))
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects a retained status action after returning to its earlier owner', async () => {
|
||||
read.mockResolvedValue({ statuses: ['Open'] })
|
||||
write.mockResolvedValue({ statuses: ['Closed'] })
|
||||
const { result, rerender } = renderHook(({ host }) => useTaskPageJiraQuickFilters(model(host)), {
|
||||
initialProps: { host: 'a' }
|
||||
})
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
const oldAction = result.current.selectJiraStatuses
|
||||
rerender({ host: 'b' })
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
rerender({ host: 'a' })
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
await act(async () => oldAction(['Closed']))
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['success', 'failure'] as const)(
|
||||
'keeps a new owner visit pending after an old %s settlement',
|
||||
async (outcome) => {
|
||||
read.mockResolvedValue({ statuses: ['Open'] })
|
||||
let finishOld: (state: JiraQuickFilterState) => void = () => {}
|
||||
let failOld: (reason: Error) => void = () => {}
|
||||
let finishNew: (state: JiraQuickFilterState) => void = () => {}
|
||||
write
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve, reject) => {
|
||||
finishOld = resolve
|
||||
failOld = reject
|
||||
})
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishNew = resolve
|
||||
})
|
||||
)
|
||||
const { result, rerender } = renderHook(
|
||||
({ host }) => useTaskPageJiraQuickFilters(model(host)),
|
||||
{ initialProps: { host: 'a' } }
|
||||
)
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
let oldWrite = Promise.resolve()
|
||||
act(() => {
|
||||
oldWrite = result.current.selectJiraStatuses(['Closed']).catch(() => {})
|
||||
})
|
||||
rerender({ host: 'b' })
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
rerender({ host: 'a' })
|
||||
await waitFor(() => expect(result.current.jiraQuickFiltersLoading).toBe(false))
|
||||
let newWrite = Promise.resolve()
|
||||
act(() => {
|
||||
newWrite = result.current.selectJiraStatuses(['In Progress'])
|
||||
})
|
||||
await act(async () => {
|
||||
if (outcome === 'success') {
|
||||
finishOld({ statuses: ['Closed'] })
|
||||
} else {
|
||||
failOld(new Error('Old request failed'))
|
||||
}
|
||||
await oldWrite
|
||||
})
|
||||
expect(result.current.jiraQuickFiltersPending).toBe(true)
|
||||
expect(result.current.selectedJiraStatuses).toEqual(['Open'])
|
||||
expect(result.current.jiraQuickFiltersError).toBeNull()
|
||||
await act(async () => {
|
||||
finishNew({ statuses: ['In Progress'] })
|
||||
await newWrite
|
||||
})
|
||||
expect(result.current.selectedJiraStatuses).toEqual(['In Progress'])
|
||||
expect(result.current.jiraQuickFiltersPending).toBe(false)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target'
|
||||
import {
|
||||
readJiraQuickFilterState,
|
||||
writeJiraQuickFilterState
|
||||
} from '@/runtime/runtime-jira-quick-filter-state'
|
||||
import {
|
||||
normalizeJiraQuickFilterState,
|
||||
type JiraQuickFilterState
|
||||
} from '../../../shared/jira-quick-filters'
|
||||
import type { TaskPageLinearViewStateModel } from './use-task-page-linear-view-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const EMPTY_QUICK_FILTER_STATE: JiraQuickFilterState = { statuses: [] }
|
||||
type JiraQuickFilterModel = Pick<
|
||||
TaskPageLinearViewStateModel,
|
||||
'jiraTaskSourceContext' | 'accountBackedTaskSourceHostId' | 'taskResumeApplied' | 'taskSource'
|
||||
>
|
||||
|
||||
export function useTaskPageJiraQuickFilters(model: JiraQuickFilterModel) {
|
||||
const ownerKey = model.jiraTaskSourceContext?.hostId ?? model.accountBackedTaskSourceHostId
|
||||
const target = useMemo(() => ipcOrRuntimeTargetForHostId(ownerKey), [ownerKey])
|
||||
const [snapshot, setSnapshot] = useState({
|
||||
ownerKey: '',
|
||||
state: normalizeJiraQuickFilterState(undefined)
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const lifetime = useMemo(() => ({ ownerKey }), [ownerKey])
|
||||
const [pendingLifetime, setPendingLifetime] = useState<typeof lifetime | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refresh, setRefresh] = useState(0)
|
||||
const lifetimeRef = useRef<typeof lifetime | null>(null)
|
||||
const writePendingRef = useRef<typeof lifetime | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
lifetimeRef.current = lifetime
|
||||
return () => {
|
||||
lifetimeRef.current = null
|
||||
}
|
||||
}, [lifetime])
|
||||
const ready = target !== null && snapshot.ownerKey === ownerKey
|
||||
const state = ready ? snapshot.state : EMPTY_QUICK_FILTER_STATE
|
||||
useEffect(() => {
|
||||
if (!model.taskResumeApplied || model.taskSource !== 'jira') {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const load = async () => {
|
||||
try {
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
const next = await readJiraQuickFilterState(target)
|
||||
if (!cancelled) {
|
||||
setSnapshot({ ownerKey, state: next })
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: translate('jira.statusFilters.loadFailed', 'Could not load Jira status filters.')
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [ownerKey, target, model.taskResumeApplied, model.taskSource, refresh])
|
||||
|
||||
const select = async (statuses: string[]): Promise<void> => {
|
||||
if (
|
||||
!ready ||
|
||||
loading ||
|
||||
writePendingRef.current === lifetime ||
|
||||
!target ||
|
||||
lifetimeRef.current !== lifetime
|
||||
) {
|
||||
return
|
||||
}
|
||||
const expectedOwner = ownerKey
|
||||
const stillCurrent = () => lifetimeRef.current === lifetime
|
||||
writePendingRef.current = lifetime
|
||||
setPendingLifetime(lifetime)
|
||||
setError(null)
|
||||
try {
|
||||
const next = await writeJiraQuickFilterState(target, { statuses })
|
||||
if (stillCurrent()) {
|
||||
setSnapshot({ ownerKey: expectedOwner, state: next })
|
||||
}
|
||||
} catch (reason) {
|
||||
if (stillCurrent()) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: translate('jira.statusFilters.saveFailed', 'Could not save Jira status filters.')
|
||||
)
|
||||
}
|
||||
throw reason
|
||||
} finally {
|
||||
if (stillCurrent()) {
|
||||
writePendingRef.current = null
|
||||
setPendingLifetime(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
selectedJiraStatuses: state.statuses,
|
||||
jiraQuickFiltersReady: ready,
|
||||
jiraQuickFiltersLoading: loading,
|
||||
jiraQuickFiltersPending: pendingLifetime === lifetime,
|
||||
jiraQuickFiltersError: error,
|
||||
retryJiraQuickFilters: () => setRefresh((value) => value + 1),
|
||||
selectJiraStatuses: select
|
||||
}
|
||||
}
|
||||
@@ -20045,6 +20045,16 @@
|
||||
"noMatch": "No matching filters.",
|
||||
"loadFailed": "Could not load saved Jira filters.",
|
||||
"saveFailed": "Could not save Jira filters."
|
||||
},
|
||||
"statusFilters": {
|
||||
"loadFailed": "Could not load Jira status filters.",
|
||||
"saveFailed": "Could not save Jira status filters.",
|
||||
"manualQuery": "Status filters apply to preset views. Clear the search to use them.",
|
||||
"label": "Filter Jira issues by status",
|
||||
"clear": "Clear statuses",
|
||||
"jql": "Status filter JQL",
|
||||
"editJql": "Edit as JQL",
|
||||
"catalogHint": "Statuses come from loaded issues. Clear statuses to discover others in this view."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19964,6 +19964,16 @@
|
||||
"noMatch": "No hay filtros coincidentes.",
|
||||
"loadFailed": "No se pudieron cargar los filtros guardados de Jira.",
|
||||
"saveFailed": "No se pudieron guardar los filtros de Jira."
|
||||
},
|
||||
"statusFilters": {
|
||||
"loadFailed": "No se pudieron cargar los filtros de estado de Jira.",
|
||||
"saveFailed": "No se pudieron guardar los filtros de estado de Jira.",
|
||||
"manualQuery": "Los filtros de estado se aplican a las vistas predefinidas. Borra la búsqueda para usarlos.",
|
||||
"label": "Filtrar incidencias de Jira por estado",
|
||||
"clear": "Borrar estados",
|
||||
"jql": "JQL del filtro de estado",
|
||||
"editJql": "Editar como JQL",
|
||||
"catalogHint": "Los estados provienen de las incidencias cargadas. Borra los estados para descubrir otros en esta vista."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19933,6 +19933,16 @@
|
||||
"noMatch": "Aucun filtre correspondant.",
|
||||
"loadFailed": "Impossible de charger les filtres Jira enregistrés.",
|
||||
"saveFailed": "Impossible d’enregistrer les filtres Jira."
|
||||
},
|
||||
"statusFilters": {
|
||||
"loadFailed": "Impossible de charger les filtres de statut Jira.",
|
||||
"saveFailed": "Impossible d’enregistrer les filtres de statut Jira.",
|
||||
"manualQuery": "Les filtres de statut s’appliquent aux vues prédéfinies. Effacez la recherche pour les utiliser.",
|
||||
"label": "Filtrer les tickets Jira par statut",
|
||||
"clear": "Effacer les statuts",
|
||||
"jql": "JQL du filtre de statut",
|
||||
"editJql": "Modifier en JQL",
|
||||
"catalogHint": "Les statuts proviennent des tickets chargés. Effacez les statuts pour en découvrir d’autres dans cette vue."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19933,6 +19933,16 @@
|
||||
"noMatch": "一致するフィルターはありません。",
|
||||
"loadFailed": "Jiraの保存済みフィルターを読み込めませんでした。",
|
||||
"saveFailed": "Jiraフィルターを保存できませんでした。"
|
||||
},
|
||||
"statusFilters": {
|
||||
"loadFailed": "Jira ステータスフィルターを読み込めませんでした。",
|
||||
"saveFailed": "Jira ステータスフィルターを保存できませんでした。",
|
||||
"manualQuery": "ステータスフィルターはプリセットビューに適用されます。使用するには検索をクリアしてください。",
|
||||
"label": "ステータスで Jira 課題を絞り込む",
|
||||
"clear": "ステータスをクリア",
|
||||
"jql": "ステータスフィルターの JQL",
|
||||
"editJql": "JQL として編集",
|
||||
"catalogHint": "ステータスは読み込まれた課題から取得されます。このビューで他のステータスを表示するには、選択をクリアしてください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19933,6 +19933,16 @@
|
||||
"noMatch": "일치하는 필터가 없습니다.",
|
||||
"loadFailed": "저장된 Jira 필터를 불러올 수 없습니다.",
|
||||
"saveFailed": "Jira 필터를 저장할 수 없습니다."
|
||||
},
|
||||
"statusFilters": {
|
||||
"loadFailed": "Jira 상태 필터를 불러올 수 없습니다.",
|
||||
"saveFailed": "Jira 상태 필터를 저장할 수 없습니다.",
|
||||
"manualQuery": "상태 필터는 사전 설정 보기에 적용됩니다. 사용하려면 검색을 지우세요.",
|
||||
"label": "상태별로 Jira 이슈 필터링",
|
||||
"clear": "상태 지우기",
|
||||
"jql": "상태 필터 JQL",
|
||||
"editJql": "JQL로 편집",
|
||||
"catalogHint": "상태는 불러온 이슈에서 가져옵니다. 이 보기에서 다른 상태를 찾으려면 선택을 지우세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19933,6 +19933,16 @@
|
||||
"noMatch": "没有匹配的筛选器。",
|
||||
"loadFailed": "无法加载已保存的 Jira 筛选器。",
|
||||
"saveFailed": "无法保存 Jira 筛选器。"
|
||||
},
|
||||
"statusFilters": {
|
||||
"loadFailed": "无法加载 Jira 状态筛选器。",
|
||||
"saveFailed": "无法保存 Jira 状态筛选器。",
|
||||
"manualQuery": "状态筛选器适用于预设视图。清除搜索后即可使用。",
|
||||
"label": "按状态筛选 Jira 议题",
|
||||
"clear": "清除状态",
|
||||
"jql": "状态筛选器 JQL",
|
||||
"editJql": "作为 JQL 编辑",
|
||||
"catalogHint": "状态来自已加载的议题。清除状态选择以查看此视图中的其他状态。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
|
||||
JIRA_STATUS_FILTER_UPDATE_REQUIRED
|
||||
} from '../../../shared/jira-quick-filters'
|
||||
import type {
|
||||
JiraAuthType,
|
||||
JiraComment,
|
||||
@@ -5,6 +9,7 @@ import type {
|
||||
JiraCreateField,
|
||||
JiraIssue,
|
||||
JiraIssueFilter,
|
||||
JiraIssueListFilters,
|
||||
JiraIssueType,
|
||||
JiraIssueUpdate,
|
||||
JiraMutationResult,
|
||||
@@ -17,7 +22,7 @@ import type {
|
||||
} from '../../../shared/jira-types'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
import { searchLocalJiraIssues } from './local-jira-search-cancellation'
|
||||
import { callRuntimeRpc } from './runtime-rpc-client'
|
||||
import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client'
|
||||
import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds'
|
||||
import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target'
|
||||
import { parseJiraConnectionStatus } from './runtime-jira-connection-status'
|
||||
@@ -150,13 +155,32 @@ export async function jiraListIssues(
|
||||
settings: RuntimeJiraSettings,
|
||||
filter?: JiraIssueFilter,
|
||||
limit?: number,
|
||||
siteId?: JiraSiteSelection | null
|
||||
siteId?: JiraSiteSelection | null,
|
||||
filters?: JiraIssueListFilters
|
||||
): Promise<JiraIssue[]> {
|
||||
const target = getJiraRuntimeTarget(settings)
|
||||
const args = { filter, limit, siteId: siteId ?? undefined }
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<JiraIssue[]>(target, 'jira.listIssues', args, { timeoutMs: 30_000 })
|
||||
: window.api.jira.listIssues(args)
|
||||
return jiraListIssuesForTarget(getJiraRuntimeTarget(settings), filter, limit, siteId, filters)
|
||||
}
|
||||
|
||||
export async function jiraListIssuesForTarget(
|
||||
target: RuntimeClientTarget,
|
||||
filter?: JiraIssueFilter,
|
||||
limit?: number,
|
||||
siteId?: JiraSiteSelection | null,
|
||||
filters?: JiraIssueListFilters
|
||||
): Promise<JiraIssue[]> {
|
||||
const args = { filter, limit, siteId: siteId ?? undefined, ...(filters ? { filters } : {}) }
|
||||
if (target.kind === 'environment') {
|
||||
if (filters?.statuses?.length) {
|
||||
await assertRuntimeEnvironmentCapability(
|
||||
target.environmentId,
|
||||
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
|
||||
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
|
||||
15_000
|
||||
)
|
||||
}
|
||||
return callRuntimeRpc<JiraIssue[]>(target, 'jira.listIssues', args, { timeoutMs: 30_000 })
|
||||
}
|
||||
return window.api.jira.listIssues(args)
|
||||
}
|
||||
|
||||
export async function jiraGetIssue(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
|
||||
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
|
||||
normalizeJiraQuickFilterState,
|
||||
type JiraQuickFilterState
|
||||
} from '../../../shared/jira-quick-filters'
|
||||
import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client'
|
||||
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
|
||||
|
||||
export async function readJiraQuickFilterState(
|
||||
target: RuntimeClientTarget
|
||||
): Promise<JiraQuickFilterState> {
|
||||
if (target.kind === 'environment') {
|
||||
await assertRuntimeEnvironmentCapability(
|
||||
target.environmentId,
|
||||
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
|
||||
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
|
||||
15_000
|
||||
)
|
||||
return normalizeJiraQuickFilterState(
|
||||
await callRuntimeRpc(target, 'ui.getJiraQuickFilters', undefined, { timeoutMs: 15_000 })
|
||||
)
|
||||
}
|
||||
if (!window.api.ui.getJiraQuickFilters) {
|
||||
throw new Error(JIRA_STATUS_FILTER_UPDATE_REQUIRED)
|
||||
}
|
||||
return normalizeJiraQuickFilterState(await window.api.ui.getJiraQuickFilters())
|
||||
}
|
||||
|
||||
export async function writeJiraQuickFilterState(
|
||||
target: RuntimeClientTarget,
|
||||
state: JiraQuickFilterState
|
||||
): Promise<JiraQuickFilterState> {
|
||||
if (target.kind === 'environment') {
|
||||
await assertRuntimeEnvironmentCapability(
|
||||
target.environmentId,
|
||||
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
|
||||
JIRA_STATUS_FILTER_UPDATE_REQUIRED,
|
||||
15_000
|
||||
)
|
||||
return normalizeJiraQuickFilterState(
|
||||
await callRuntimeRpc(target, 'ui.setJiraQuickFilters', state, { timeoutMs: 15_000 })
|
||||
)
|
||||
}
|
||||
if (!window.api.ui.setJiraQuickFilters) {
|
||||
throw new Error(JIRA_STATUS_FILTER_UPDATE_REQUIRED)
|
||||
}
|
||||
return normalizeJiraQuickFilterState(await window.api.ui.setJiraQuickFilters(state))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { jiraListIssues, jiraListIssuesForTarget } from './runtime-jira-client'
|
||||
import {
|
||||
readJiraQuickFilterState,
|
||||
writeJiraQuickFilterState
|
||||
} from './runtime-jira-quick-filter-state'
|
||||
import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from '../../../shared/jira-quick-filters'
|
||||
const { assertCapability, rpc } = vi.hoisted(() => ({ assertCapability: vi.fn(), rpc: vi.fn() }))
|
||||
vi.mock('./runtime-rpc-client', () => ({
|
||||
assertRuntimeEnvironmentCapability: assertCapability,
|
||||
callRuntimeRpc: rpc
|
||||
}))
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
const target = { kind: 'environment', environmentId: 'jira-owner' } as const
|
||||
it('keeps the old request shape when no statuses are selected', async () => {
|
||||
rpc.mockResolvedValue([])
|
||||
await jiraListIssues(target, 'all', 50, 'site-a')
|
||||
expect(assertCapability).not.toHaveBeenCalled()
|
||||
expect(rpc).toHaveBeenCalledWith(
|
||||
target,
|
||||
'jira.listIssues',
|
||||
{ filter: 'all', limit: 50, siteId: 'site-a' },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
})
|
||||
it('gates status list filters on the called execution host', async () => {
|
||||
rpc.mockResolvedValue([])
|
||||
await jiraListIssuesForTarget(target, 'all', 50, 'site-a', { statuses: ['In Progress'] })
|
||||
expect(assertCapability).toHaveBeenCalledWith(
|
||||
'jira-owner',
|
||||
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
|
||||
expect.any(String),
|
||||
15_000
|
||||
)
|
||||
expect(rpc).toHaveBeenCalledWith(
|
||||
target,
|
||||
'jira.listIssues',
|
||||
{ filter: 'all', limit: 50, siteId: 'site-a', filters: { statuses: ['In Progress'] } },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
})
|
||||
it('refuses an older host without silently dropping selection or writing strict resume fields', async () => {
|
||||
assertCapability.mockRejectedValue(new Error('Update the Jira host'))
|
||||
await expect(
|
||||
jiraListIssues(target, 'all', 50, 'site-a', { statuses: ['Blocked'] })
|
||||
).rejects.toThrow('Update')
|
||||
await expect(writeJiraQuickFilterState(target, { statuses: ['Blocked'] })).rejects.toThrow(
|
||||
'Update'
|
||||
)
|
||||
expect(rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
it('routes persisted state through host UI methods and acknowledges the write', async () => {
|
||||
rpc.mockResolvedValue({ statuses: ['Blocked'] })
|
||||
expect(await readJiraQuickFilterState(target)).toEqual({ statuses: ['Blocked'] })
|
||||
expect(await writeJiraQuickFilterState(target, { statuses: ['Blocked'] })).toEqual({
|
||||
statuses: ['Blocked']
|
||||
})
|
||||
expect(rpc).toHaveBeenLastCalledWith(
|
||||
target,
|
||||
'ui.setJiraQuickFilters',
|
||||
{ statuses: ['Blocked'] },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
})
|
||||
it('uses local IPC and reports a lost write instead of pretending to save', async () => {
|
||||
const read = vi.fn(async () => ({ statuses: ['Open'] }))
|
||||
const write = vi.fn(async () => {
|
||||
throw new Error('Lost host')
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: { ui: { getJiraQuickFilters: read, setJiraQuickFilters: write } }
|
||||
})
|
||||
expect(await readJiraQuickFilterState({ kind: 'local' })).toEqual({ statuses: ['Open'] })
|
||||
await expect(writeJiraQuickFilterState({ kind: 'local' }, { statuses: [] })).rejects.toThrow(
|
||||
'Lost host'
|
||||
)
|
||||
expect(rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { jiraListIssues, jiraSearchIssues } from '@/runtime/runtime-jira-client'
|
||||
import { normalizeJiraStatusSelection } from '../../../../shared/jira-quick-filters'
|
||||
import { jiraListIssuesForTarget, jiraSearchIssues } from '@/runtime/runtime-jira-client'
|
||||
import { getJiraRuntimeTarget } from '@/runtime/runtime-jira-target'
|
||||
import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors'
|
||||
import type { JiraIssue, JiraSiteSelection } from '../../../../shared/jira-types'
|
||||
import type { JiraSlice, JiraSliceGet, JiraSliceSet } from './jira-slice-contract'
|
||||
@@ -148,9 +150,11 @@ export function createJiraCollectionReadActions(
|
||||
listJiraIssues: async (filter = 'assigned', limit = 30, options) => {
|
||||
const scope = getJiraReadScope(get().settings, options?.sourceContext)
|
||||
const siteId = getSelectedJiraSiteId(get().jiraStatus)
|
||||
const statuses = normalizeJiraStatusSelection(options?.filters?.statuses).sort()
|
||||
const filters = statuses.length ? { statuses } : undefined
|
||||
const cacheKey = scopedJiraCacheKey(
|
||||
scope,
|
||||
`${siteId ?? 'default'}::list::${filter}::${limit}`
|
||||
`${siteId ?? 'default'}::list::${filter}::${limit}${filters ? `::${JSON.stringify(filters)}` : ''}`
|
||||
)
|
||||
const cached = get().jiraSearchCache[cacheKey]
|
||||
if (!options?.force && isFreshJiraCacheEntry(cached)) {
|
||||
@@ -167,7 +171,14 @@ export function createJiraCollectionReadActions(
|
||||
return inflight.promise
|
||||
}
|
||||
let entry: InflightJiraReadRequest<JiraIssue[]>
|
||||
const promise = jiraListIssues(scope.settings, filter, limit, siteId)
|
||||
const request = jiraListIssuesForTarget(
|
||||
getJiraRuntimeTarget(scope.settings),
|
||||
filter,
|
||||
limit,
|
||||
siteId,
|
||||
filters
|
||||
)
|
||||
const promise = request
|
||||
.then((issues) => {
|
||||
if (
|
||||
inflightListRequests.get(cacheKey) === entry &&
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
JiraConnectionStatus,
|
||||
JiraIssue,
|
||||
JiraIssueFilter,
|
||||
JiraIssueListFilters,
|
||||
JiraSiteSelection,
|
||||
JiraViewer
|
||||
} from '../../../../shared/jira-types'
|
||||
@@ -16,7 +17,10 @@ export type JiraReadOptions = {
|
||||
siteId?: JiraSiteSelection | null
|
||||
}
|
||||
|
||||
export type JiraCollectionReadOptions = JiraReadOptions & { force?: boolean }
|
||||
export type JiraCollectionReadOptions = JiraReadOptions & {
|
||||
force?: boolean
|
||||
filters?: JiraIssueListFilters
|
||||
}
|
||||
export type JiraSearchOptions = JiraCollectionReadOptions & { signal?: AbortSignal }
|
||||
export type JiraPatchOptions = {
|
||||
sourceContext?: TaskSourceContext | null
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { JiraIssue, JiraIssueListFilters } from '../../../../shared/jira-types'
|
||||
import { createJiraSlice } from './jira'
|
||||
const { list } = vi.hoisted(() => ({ list: vi.fn() }))
|
||||
vi.mock('@/runtime/runtime-jira-client', () => ({
|
||||
jiraListIssues: list,
|
||||
jiraListIssuesForTarget: list,
|
||||
jiraSearchIssues: vi.fn()
|
||||
}))
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
function issue(key: string): JiraIssue {
|
||||
return {
|
||||
id: key,
|
||||
key,
|
||||
title: key,
|
||||
url: `https://jira.example/browse/${key}`,
|
||||
project: { id: 'project', key: 'APP', name: 'App' },
|
||||
issueType: { id: 'bug', name: 'Bug' },
|
||||
status: { id: 'open', name: 'Open', categoryKey: 'new', categoryName: 'To Do' },
|
||||
labels: [],
|
||||
createdAt: '2026-10-01',
|
||||
updatedAt: '2026-10-01'
|
||||
}
|
||||
}
|
||||
it('isolates status results from preset caches and reuses an equivalent selection order', async () => {
|
||||
const store = create<AppState>()(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only settings and Jira slice fields are accessed by these list reads.
|
||||
(...args) => ({ settings: null, ...createJiraSlice(...args) }) as AppState
|
||||
)
|
||||
list.mockImplementation(
|
||||
async (_source, _preset, _limit, _site, filters?: JiraIssueListFilters) => [
|
||||
issue(filters?.statuses?.join('-') || 'default')
|
||||
]
|
||||
)
|
||||
expect(await store.getState().listJiraIssues('all', 50)).toMatchObject([{ key: 'default' }])
|
||||
expect(
|
||||
await store.getState().listJiraIssues('all', 50, { filters: { statuses: ['Open'] } })
|
||||
).toMatchObject([{ key: 'Open' }])
|
||||
expect(await store.getState().listJiraIssues('all', 50)).toMatchObject([{ key: 'default' }])
|
||||
expect(
|
||||
await store
|
||||
.getState()
|
||||
.listJiraIssues('all', 50, { filters: { statuses: ['In Progress', 'Blocked'] } })
|
||||
).toMatchObject([{ key: 'Blocked-In Progress' }])
|
||||
await store
|
||||
.getState()
|
||||
.listJiraIssues('all', 50, { filters: { statuses: ['Blocked', 'In Progress'] } })
|
||||
expect(list).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
@@ -34,6 +34,7 @@ vi.mock('@/runtime/runtime-jira-client', () => ({
|
||||
jiraListCreateFields: vi.fn(),
|
||||
jiraListIssueTypes: vi.fn(),
|
||||
jiraListIssues: (...args: unknown[]) => jiraListIssues(...args),
|
||||
jiraListIssuesForTarget: (...args: unknown[]) => jiraListIssues(...args),
|
||||
jiraReadStatus: (...args: unknown[]) => jiraReadStatus(...args),
|
||||
jiraListPriorities: vi.fn(),
|
||||
jiraListProjects: vi.fn(),
|
||||
@@ -208,7 +209,13 @@ describe('createJiraSlice runtime context', () => {
|
||||
|
||||
sourceResult.resolve([{ ...issue('ALP-1'), title: 'Source issue' }])
|
||||
await expect(request).resolves.toMatchObject([{ key: 'ALP-1', title: 'Source issue' }])
|
||||
expect(jiraListIssues).toHaveBeenCalledWith(sourceContext, 'assigned', 30, 'site-1')
|
||||
expect(jiraListIssues).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'source-runtime' },
|
||||
'assigned',
|
||||
30,
|
||||
'site-1',
|
||||
undefined
|
||||
)
|
||||
expect(Object.values(store.getState().jiraSearchCache)).toHaveLength(1)
|
||||
expect(store.getState().jiraSearchCache['site-1::list::assigned::30']).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY = 'jira.issue-summary-generation.v1' as const
|
||||
export const JIRA_USER_FIELDS_RUNTIME_CAPABILITY = 'jira.user-fields.v1' as const
|
||||
export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE =
|
||||
'Creating Jira issues with user fields requires a newer Orca server. Update the server and try again.'
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildJiraPresetJql } from './jira-preset-jql'
|
||||
import { normalizeJiraQuickFilterState } from './jira-quick-filters'
|
||||
import { JiraIssueListFiltersParam } from './rpc-contract/jira-quick-filter-params'
|
||||
|
||||
describe('Jira preset status predicates', () => {
|
||||
it.each([
|
||||
['assigned', 'assignee = currentUser() AND statusCategory != Done'],
|
||||
['reported', 'reporter = currentUser() AND statusCategory != Done'],
|
||||
['all', 'statusCategory != Done'],
|
||||
['done', 'assignee = currentUser() AND statusCategory = Done']
|
||||
] as const)('preserves %s scope and combines statuses with OR', (preset, base) => {
|
||||
expect(buildJiraPresetJql(preset)).toBe(`${base} ORDER BY updated DESC`)
|
||||
expect(buildJiraPresetJql(preset, { statuses: ['Blocked', 'In Progress'] })).toBe(
|
||||
`${base} AND status in ("Blocked", "In Progress") ORDER BY updated DESC`
|
||||
)
|
||||
})
|
||||
it('escapes quoted status names without treating ORDER BY as syntax', () => {
|
||||
expect(
|
||||
buildJiraPresetJql('all', { statuses: ['Needs "QA" \\ Review', 'ORDER BY cleanup'] })
|
||||
).toBe(
|
||||
'statusCategory != Done AND status in ("Needs \\"QA\\" \\\\ Review", "ORDER BY cleanup") ORDER BY updated DESC'
|
||||
)
|
||||
})
|
||||
it('bounds persisted state while preserving actual status names', () => {
|
||||
expect(
|
||||
normalizeJiraQuickFilterState({
|
||||
statuses: [' In Progress ', 'Done', 'Done', 42, '', 'x'.repeat(129)]
|
||||
})
|
||||
).toEqual({ statuses: [' In Progress ', 'Done'] })
|
||||
expect(normalizeJiraQuickFilterState(null)).toEqual({ statuses: [] })
|
||||
expect(
|
||||
JiraIssueListFiltersParam.safeParse({ statuses: Array.from({ length: 51 }, () => 'Open') })
|
||||
.success
|
||||
).toBe(false)
|
||||
expect(JiraIssueListFiltersParam.safeParse({ statuses: [' '] }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { JiraIssueFilter } from './jira-types'
|
||||
import type { JiraIssueFilter, JiraIssueListFilters } from './jira-types'
|
||||
import { normalizeJiraStatusSelection } from './jira-quick-filters'
|
||||
|
||||
export function buildJiraPresetJql(filter: JiraIssueFilter): string {
|
||||
if (filter === 'assigned') {
|
||||
return 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC'
|
||||
export function buildJiraPresetJql(
|
||||
filter: JiraIssueFilter,
|
||||
filters?: JiraIssueListFilters
|
||||
): string {
|
||||
const clauses: string[] = []
|
||||
if (filter === 'assigned' || filter === 'done') {
|
||||
clauses.push('assignee = currentUser()')
|
||||
} else if (filter === 'reported') {
|
||||
clauses.push('reporter = currentUser()')
|
||||
}
|
||||
if (filter === 'reported') {
|
||||
return 'reporter = currentUser() AND statusCategory != Done ORDER BY updated DESC'
|
||||
clauses.push(filter === 'done' ? 'statusCategory = Done' : 'statusCategory != Done')
|
||||
const statuses = normalizeJiraStatusSelection(filters?.statuses)
|
||||
if (statuses.length > 0) {
|
||||
const names = statuses.map((name) => `"${name.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
|
||||
clauses.push(`status in (${names.join(', ')})`)
|
||||
}
|
||||
if (filter === 'done') {
|
||||
return 'assignee = currentUser() AND statusCategory = Done ORDER BY updated DESC'
|
||||
}
|
||||
return 'statusCategory != Done ORDER BY updated DESC'
|
||||
return `${clauses.join(' AND ')} ORDER BY updated DESC`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export const JIRA_STATUS_FILTER_RUNTIME_CAPABILITY = 'jira.issue-list-statuses.v1' as const
|
||||
export const JIRA_STATUS_FILTER_UPDATE_REQUIRED = 'Update the Jira host to use status filters.'
|
||||
export const MAX_JIRA_STATUS_FILTERS = 50
|
||||
export const MAX_JIRA_STATUS_NAME_LENGTH = 128
|
||||
|
||||
export type JiraQuickFilterState = { statuses: string[] }
|
||||
|
||||
export function normalizeJiraStatusSelection(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
value.filter(
|
||||
(item): item is string =>
|
||||
typeof item === 'string' &&
|
||||
item.trim().length > 0 &&
|
||||
item.length <= MAX_JIRA_STATUS_NAME_LENGTH
|
||||
)
|
||||
)
|
||||
].slice(0, MAX_JIRA_STATUS_FILTERS)
|
||||
}
|
||||
|
||||
export function normalizeJiraQuickFilterState(value: unknown): JiraQuickFilterState {
|
||||
return {
|
||||
statuses: normalizeJiraStatusSelection(
|
||||
value && typeof value === 'object' && 'statuses' in value ? value.statuses : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,8 @@ export type JiraIssueUpdate = {
|
||||
|
||||
export type JiraIssueFilter = 'assigned' | 'reported' | 'all' | 'done'
|
||||
|
||||
export type JiraIssueListFilters = { statuses?: string[] }
|
||||
|
||||
export type JiraConnectArgs = {
|
||||
siteUrl: string
|
||||
// Empty for Bearer authentication: self-hosted PAT or scoped Cloud token.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JiraQuickFilterState } from './jira-quick-filters'
|
||||
import type { JiraSavedFilterState } from './jira-saved-filters'
|
||||
import type { ReleaseChannel } from './release-channel'
|
||||
import type { WorkspaceCleanupUIState } from './workspace-cleanup'
|
||||
@@ -29,6 +30,7 @@ import type { PersistedAutomationHostFilter } from './automation-host-filter'
|
||||
|
||||
export type PersistedUIState = {
|
||||
jiraSavedFilterState?: JiraSavedFilterState
|
||||
jiraQuickFilterState?: JiraQuickFilterState
|
||||
lastActiveRepoId: string | null
|
||||
lastActiveWorktreeId: string | null
|
||||
/** Active top-level view at save time, restored on relaunch; sanitized to 'terminal' if unknown or now-gated. */
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from './jira-agent-capability'
|
||||
import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from './jira-quick-filters'
|
||||
import { JIRA_SAVED_FILTER_RUNTIME_CAPABILITY } from './jira-saved-filter-capability'
|
||||
import * as jiraDetailCapabilities from './jira-detail-capabilities'
|
||||
export * from './jira-detail-capabilities'
|
||||
import { WORKSPACE_ATTACHMENT_RUNTIME_CAPABILITIES } from './workspace-attachment-capabilities'
|
||||
import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from './ssh-target-management'
|
||||
import {
|
||||
@@ -103,11 +106,6 @@ export const FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY =
|
||||
'folder-workspace.path-status.v1' as const
|
||||
export const LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY =
|
||||
'linear.issue-attribute-filter.v1' as const
|
||||
export const JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY = 'jira.issue-summary-generation.v1' as const
|
||||
|
||||
export const JIRA_USER_FIELDS_RUNTIME_CAPABILITY = 'jira.user-fields.v1' as const
|
||||
export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE =
|
||||
'Creating Jira issues with user fields requires a newer Orca server. Update the server and try again.'
|
||||
// Why: signals the host exposes the Agent Session History scanner over RPC
|
||||
// (aiVault.listSessions). Registered unconditionally for every build, so it is a
|
||||
// STATIC capability advertised by getStatus() automatically — NOT a runtime
|
||||
@@ -388,9 +386,10 @@ export const RUNTIME_CAPABILITIES = [
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
|
||||
JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
|
||||
JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY,
|
||||
jiraDetailCapabilities.JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
|
||||
jiraDetailCapabilities.JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY,
|
||||
JIRA_SAVED_FILTER_RUNTIME_CAPABILITY,
|
||||
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
|
||||
AI_VAULT_RUNTIME_CAPABILITY,
|
||||
AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY,
|
||||
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { JiraIssueListFiltersParam } from './jira-quick-filter-params'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
OptionalFiniteNumber,
|
||||
@@ -39,7 +40,8 @@ export const ListIssues = z
|
||||
filter: z.enum(VALID_FILTERS).optional(),
|
||||
limit: OptionalFiniteNumber,
|
||||
siteId: OptionalString,
|
||||
strict: z.boolean().optional()
|
||||
strict: z.boolean().optional(),
|
||||
filters: JiraIssueListFiltersParam.optional()
|
||||
})
|
||||
.optional()
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
import { MAX_JIRA_STATUS_FILTERS, MAX_JIRA_STATUS_NAME_LENGTH } from '../jira-quick-filters'
|
||||
|
||||
const JiraStatuses = z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(MAX_JIRA_STATUS_NAME_LENGTH)
|
||||
.refine((name) => name.trim().length > 0)
|
||||
)
|
||||
.max(MAX_JIRA_STATUS_FILTERS)
|
||||
|
||||
export const JiraQuickFilterStateParam = z.strictObject({ statuses: JiraStatuses })
|
||||
export const JiraIssueListFiltersParam = z.strictObject({ statuses: JiraStatuses.optional() })
|
||||
@@ -332,6 +332,7 @@ import {
|
||||
SiteSelection,
|
||||
UserSearch
|
||||
} from './jira-params'
|
||||
import { JiraQuickFilterStateParam } from './jira-quick-filter-params'
|
||||
import { JiraSavedFilterMutationParam } from './jira-saved-filter-params'
|
||||
import {
|
||||
AgentIssueContext,
|
||||
@@ -1279,10 +1280,12 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'terminalDaemon.restartSevered': TerminalDaemonRestartSeveredParams,
|
||||
'terminalDaemon.severedStatus': null,
|
||||
'ui.get': null,
|
||||
'ui.getJiraQuickFilters': null,
|
||||
'ui.getJiraSavedFilters': null,
|
||||
'ui.mutateJiraSavedFilters': JiraSavedFilterMutationParam,
|
||||
'ui.recordFeatureInteraction': FeatureInteractionIdParam,
|
||||
'ui.set': UiUpdate,
|
||||
'ui.setJiraQuickFilters': JiraQuickFilterStateParam,
|
||||
'updater.check': UpdaterCheckParams,
|
||||
'updater.download': null,
|
||||
'updater.getStatus': null,
|
||||
|
||||
@@ -64,6 +64,7 @@ export const UI_STATE_DISPOSITIONS = {
|
||||
item: 38,
|
||||
note: "A 'local' filter also covers the WSL host."
|
||||
},
|
||||
jiraQuickFilterState: notApplicableDisposition(38, 'Filters belong to the Jira host.'),
|
||||
jiraSavedFilterState: notApplicableDisposition(
|
||||
38,
|
||||
'Filters belong to the Jira host, not a moved workspace.'
|
||||
|
||||
Reference in New Issue
Block a user