feat(jira): choose planning columns in the issue list (#28615)

Choose and sort Jira planning columns while bounding optional field discovery, preserving complete Cloud searches and aligning copy/workspace actions in responsive rows.

Co-authored-by: Dagan <idan.d@imagen-ai.com>
This commit is contained in:
Neil
2026-10-14 03:39:31 -07:00
committed by GitHub
co-authored by Dagan
parent d7315af456
commit 4731172bd8
34 changed files with 1925 additions and 138 deletions
+1 -2
View File
@@ -219,7 +219,7 @@
1 src/renderer/src/components/tab-group/workspace-tab-close-commands.ts
1 src/renderer/src/components/task-page-jira-status-order.ts
2 src/renderer/src/components/task-page-linear-issue-model.tsx
11 src/renderer/src/components/task-page/TaskPage.tsx
10 src/renderer/src/components/task-page/TaskPage.tsx
2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx
1 src/renderer/src/components/task-page/linear/Filters.tsx
1 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx
@@ -255,7 +255,6 @@
4 src/renderer/src/components/use-task-page-jira-creation-metadata.ts
4 src/renderer/src/components/use-task-page-jira-issue-creation.ts
2 src/renderer/src/components/use-task-page-jira-list-effects.ts
2 src/renderer/src/components/use-task-page-jira-list-state.ts
4 src/renderer/src/components/use-task-page-linear-board.ts
6 src/renderer/src/components/use-task-page-linear-creation-state.ts
4 src/renderer/src/components/use-task-page-linear-issue-creation.ts
+5
View File
@@ -21,6 +21,11 @@ const {
releaseMock: vi.fn()
}))
vi.mock('./jira-agile-fields', () => ({
getAgileFieldIds: async () => ({}),
agileFieldIdList: () => []
}))
vi.mock('./request-queue', () => ({ acquire: acquireMock, release: releaseMock }))
vi.mock('./authenticated-request', () => ({
+4
View File
@@ -13,6 +13,10 @@ vi.mock('./authenticated-request', () => ({
jiraRequestBinary: vi.fn(),
JiraApiError: class extends Error {}
}))
vi.mock('./jira-agile-fields', () => ({
getAgileFieldIds: async () => ({}),
agileFieldIdList: () => []
}))
vi.mock('./request-queue', () => ({ acquire, release }))
import { listIssues, searchIssues } from './jira-issue-search'
@@ -0,0 +1,196 @@
import { describe, expect, it, vi } from 'vitest'
import type { JiraClientForSite } from './authenticated-request'
import { searchIssues } from './jira-issue-search'
import { getIssue } from './jira-issue-read'
const { getClientsMock, jiraRequestMock } = vi.hoisted(() => ({
getClientsMock: vi.fn(),
jiraRequestMock: vi.fn()
}))
vi.mock('./client', () => ({
getClients: (...args: unknown[]) => getClientsMock(...args),
clearToken: vi.fn(),
isAuthError: () => false
}))
vi.mock('./authenticated-request', () => ({
apiBasePath: (site: { authType?: string }) =>
site.authType === 'server' ? '/rest/api/2' : '/rest/api/3',
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args)
}))
function client(id: string): JiraClientForSite {
return {
site: {
id,
siteUrl: `https://${id}.atlassian.net`,
email: '',
displayName: id,
accountId: 'viewer'
},
authorization: 'Basic fixture'
}
}
describe('planning fields in Jira searches', () => {
it('requests all discovered points fields and maps the populated project field', async () => {
getClientsMock.mockReturnValue([client('field-request-site')])
jiraRequestMock.mockImplementation(async (_entry, path) =>
path.endsWith('/field')
? [
{
id: 'customfield_7',
name: 'Sprint',
schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' }
},
{
id: 'customfield_8',
name: 'Story Points',
schema: { custom: 'com.atlassian.jira.plugin.system.customfieldtypes:float' }
},
{
id: 'customfield_9',
name: 'Story point estimate',
schema: { custom: 'com.pyxis.greenhopper.jira:jsw-story-points' }
}
]
: {
issues: [
{
id: '1',
key: 'DEMO-1',
fields: {
summary: 'Planning',
customfield_7: [{ name: 'Sprint 2', state: 'active' }],
customfield_8: null,
customfield_9: 0,
timeoriginalestimate: 3600,
timeestimate: 0,
parent: { key: 'DEMO-10', fields: { summary: 'Epic' } }
}
}
]
}
)
const issues = await searchIssues('project = DEMO', 30, 'field-request-site')
expect(issues[0]).toMatchObject({
sprint: 'Sprint 2',
storyPoints: 0,
originalEstimateSeconds: 3600,
remainingEstimateSeconds: 0,
parent: { key: 'DEMO-10', title: 'Epic' }
})
const init = jiraRequestMock.mock.calls.find((call) => call[1].endsWith('/search/jql'))?.[2]
expect(JSON.parse(init.body).fields).toEqual(
expect.arrayContaining([
'parent',
'timeoriginalestimate',
'timeestimate',
'customfield_7',
'customfield_8',
'customfield_9'
])
)
})
it('does not deadlock when more sites than queue slots discover fields together', async () => {
const clients = Array.from({ length: 6 }, (_, index) => client(`queue-site-${index}`))
getClientsMock.mockReturnValue(clients)
jiraRequestMock
.mockClear()
.mockImplementation(async (_entry, path) =>
path.endsWith('/field')
? []
: { issues: [{ key: 'DEMO-1', fields: { summary: 'Planning' } }] }
)
expect(await searchIssues('project = DEMO', 30, 'all')).toHaveLength(6)
expect(jiraRequestMock).toHaveBeenCalledTimes(12)
})
it.each(['search', 'detail'] as const)(
'keeps ordinary Server %s reads available when optional discovery hangs',
async (kind) => {
vi.useFakeTimers()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const entry = client(`hung-${kind}`)
entry.site.authType = 'server'
getClientsMock.mockReturnValue([entry])
jiraRequestMock
.mockClear()
.mockImplementation((_entry, path) =>
path.endsWith('/field')
? new Promise(() => {})
: Promise.resolve(
path.includes('/search')
? { issues: [{ key: 'DEMO-1', fields: { summary: 'Still available' } }] }
: { key: 'DEMO-1', fields: { summary: 'Still available' } }
)
)
const read = () =>
kind === 'search'
? searchIssues('project = DEMO', 30, entry.site.id)
: getIssue('DEMO-1', entry.site.id)
const first = read()
await vi.advanceTimersByTimeAsync(500)
expect(await first).toMatchObject(
kind === 'search' ? [{ title: 'Still available' }] : { title: 'Still available' }
)
expect(jiraRequestMock.mock.calls[0][2].signal.aborted).toBe(true)
const before = jiraRequestMock.mock.calls.length
await read()
expect(jiraRequestMock.mock.calls.length).toBe(before + 1)
expect(
jiraRequestMock.mock.calls.filter((call) => call[1].endsWith('/field'))
).toHaveLength(1)
} finally {
warn.mockRestore()
vi.useRealTimers()
}
}
)
it('preserves planning fields when a list issue is hydrated from its detail read', async () => {
getClientsMock.mockReturnValue([client('detail-field-site')])
jiraRequestMock.mockClear().mockImplementation(async (_entry, path) =>
path.endsWith('/field')
? [
{
id: 'customfield_4',
name: 'Sprint',
schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' }
},
{
id: 'customfield_5',
name: 'Points',
schema: { custom: 'com.pyxis.greenhopper.jira:jsw-story-points' }
}
]
: {
id: '1',
key: 'DEMO-1',
fields: {
summary: 'Planning',
description: 'Detailed planning notes',
customfield_4: [{ name: 'Sprint 3', state: 'active' }],
customfield_5: 0,
timeoriginalestimate: 7200,
timeestimate: 0,
parent: { key: 'DEMO-10', fields: { summary: 'Epic' } }
}
}
)
expect(await getIssue('DEMO-1', 'detail-field-site')).toMatchObject({
description: 'Detailed planning notes',
sprint: 'Sprint 3',
storyPoints: 0,
originalEstimateSeconds: 7200,
remainingEstimateSeconds: 0,
parent: { key: 'DEMO-10', title: 'Epic' }
})
const detailPath = jiraRequestMock.mock.calls.find((call) => call[1].includes('/issue/'))?.[1]
const fields = new URL(detailPath, 'https://example.atlassian.net').searchParams.get('fields')
expect(fields?.split(',')).toEqual(
expect.arrayContaining(['description', 'customfield_4', 'customfield_5'])
)
})
})
+228
View File
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraClientForSite } from './authenticated-request'
import { getAgileFieldIds, pickAgileFieldIds } from './jira-agile-fields'
import { mapJiraIssue, mapSprint } from './jira-issue-mapping'
import type { JiraSite } from '../../shared/jira-types'
const { jiraRequestMock, acquireMock, releaseMock } = vi.hoisted(() => ({
jiraRequestMock: vi.fn(),
acquireMock: vi.fn().mockResolvedValue(undefined),
releaseMock: vi.fn()
}))
vi.mock('./request-queue', () => ({ acquire: acquireMock, release: releaseMock }))
vi.mock('./authenticated-request', () => ({
apiBasePath: (site: { authType?: string }) =>
site.authType === 'server' ? '/rest/api/2' : '/rest/api/3',
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args)
}))
const site: JiraSite = {
id: 'site',
siteUrl: 'https://example.atlassian.net',
email: 'dev@example.com',
displayName: 'Site',
accountId: 'acc-1',
authType: 'cloud'
}
function clientFor(id: string): JiraClientForSite {
return { site: { ...site, id }, authorization: 'Basic token' }
}
describe('jira agile fields', () => {
it('picks sprint and story point custom field ids from the field catalog', () => {
expect(
pickAgileFieldIds([
{
id: 'customfield_1',
name: 'Sprint',
schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' }
},
{
id: 'customfield_2',
name: 'Budget',
schema: { custom: 'com.atlassian.jira.plugin.system.customfieldtypes:float' }
},
{
id: 'customfield_3',
name: 'Story Points',
schema: { custom: 'com.atlassian.jira.plugin.system.customfieldtypes:float' }
}
])
).toEqual({ sprint: 'customfield_1', storyPoints: ['customfield_3'] })
})
it('discovers company and team managed points independently of a translated team field name', () => {
expect(
pickAgileFieldIds([
{
id: 'customfield_1',
name: 'Story Points',
schema: { custom: 'com.atlassian.jira.plugin.system.customfieldtypes:float' }
},
{
id: 'customfield_2',
name: 'Schätzung',
schema: { custom: 'com.pyxis.greenhopper.jira:jsw-story-points' }
},
{ id: '', name: 'Sprint', schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' } }
])
).toEqual({ storyPoints: ['customfield_1', 'customfield_2'] })
})
it('prefers the active sprint and parses Server sprint strings', () => {
expect(
mapSprint([
{ name: 'Sprint 1', state: 'closed' },
{ name: 'Sprint 2', state: 'active' },
{ name: 'Sprint 3', state: 'future' }
])
).toBe('Sprint 2')
// Server orders sprints by id, so the active one can sit between closed ones.
expect(
mapSprint([
'com.atlassian.greenhopper.service.sprint.Sprint@1[id=3,state=CLOSED,name=Sprint 3,goal=]',
'com.atlassian.greenhopper.service.sprint.Sprint@2[id=4,state=ACTIVE,name=Sprint 4,goal=]',
'com.atlassian.greenhopper.service.sprint.Sprint@3[id=5,state=CLOSED,name=Sprint 5,goal=]'
])
).toBe('Sprint 4')
expect(mapSprint(null)).toBeUndefined()
})
it('keeps commas and brackets in Server sprint names', () => {
expect(
mapSprint('Sprint@1[id=1,state=ACTIVE,name=API, UI [October],startDate=<null>,goal=]')
).toBe('API, UI [October]')
expect(mapSprint('Sprint@1[id=1,state=ACTIVE,name=API, UI [October]]')).toBe(
'API, UI [October]'
)
})
describe('getAgileFieldIds', () => {
beforeEach(() => {
jiraRequestMock.mockReset()
acquireMock.mockClear()
releaseMock.mockClear()
})
it('requests the field catalog once per site inside the request queue', async () => {
jiraRequestMock.mockResolvedValue([
{
id: 'customfield_7',
name: 'Sprint',
schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' }
}
])
const client = clientFor('cached-site')
const first = await getAgileFieldIds(client)
const second = await getAgileFieldIds(client)
expect(first).toEqual({ sprint: 'customfield_7' })
expect(second).toBe(first)
expect(jiraRequestMock).toHaveBeenCalledTimes(1)
expect(jiraRequestMock.mock.calls[0]?.[1]).toBe('/rest/api/3/field')
expect(acquireMock).toHaveBeenCalledTimes(1)
expect(releaseMock).toHaveBeenCalledTimes(1)
})
it('refreshes a successful catalog after its bounded lifetime', async () => {
jiraRequestMock.mockResolvedValue([])
const client = clientFor('expired-success')
await getAgileFieldIds(client)
const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 10 * 60_000)
await getAgileFieldIds(client)
expect(jiraRequestMock).toHaveBeenCalledTimes(2)
now.mockRestore()
})
it('isolates credentials and Server catalog paths', async () => {
jiraRequestMock.mockResolvedValue([])
const first = clientFor('isolation-site')
await getAgileFieldIds(first)
await getAgileFieldIds({ ...first, authorization: 'Basic changed-token' })
await getAgileFieldIds({ ...first, site: { ...first.site, authType: 'server' } })
expect(jiraRequestMock).toHaveBeenCalledTimes(3)
expect(jiraRequestMock.mock.calls[2]?.[1]).toBe('/rest/api/2/field')
})
it('shares discovery without one cancelled caller abandoning another', async () => {
const response = Promise.withResolvers<unknown[]>()
jiraRequestMock.mockReturnValue(response.promise)
const client = clientFor('concurrent-site')
const controller = new AbortController()
const first = getAgileFieldIds(client, controller.signal)
const second = getAgileFieldIds(client)
controller.abort()
await expect(first).rejects.toThrow('aborted')
response.resolve([
{ id: 'customfield_9', schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' } }
])
expect(await second).toEqual({ sprint: 'customfield_9' })
expect(jiraRequestMock).toHaveBeenCalledTimes(1)
expect(releaseMock).toHaveBeenCalledTimes(1)
})
it('backs off failed optional discovery so repeated issue reads stay available', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
jiraRequestMock.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce([])
const client = clientFor('flaky-site')
expect(await getAgileFieldIds(client)).toEqual({})
expect(await getAgileFieldIds(client)).toEqual({})
expect(jiraRequestMock).toHaveBeenCalledTimes(1)
expect(releaseMock).toHaveBeenCalledTimes(1)
const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_000)
expect(await getAgileFieldIds(client)).toEqual({})
expect(jiraRequestMock).toHaveBeenCalledTimes(2)
expect(releaseMock).toHaveBeenCalledTimes(2)
now.mockRestore()
warn.mockRestore()
})
it('releases a hung discovery and delays retry without delaying the next issue read', async () => {
vi.useFakeTimers()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
jiraRequestMock.mockReturnValueOnce(new Promise(() => {})).mockResolvedValueOnce([])
const client = clientFor('timeout-site')
const read = getAgileFieldIds(client)
await vi.advanceTimersByTimeAsync(500)
expect(await read).toEqual({})
expect(jiraRequestMock.mock.calls[0]?.[2].signal.aborted).toBe(true)
expect(releaseMock).toHaveBeenCalledTimes(1)
expect(await getAgileFieldIds(client)).toEqual({})
expect(jiraRequestMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60_000)
expect(await getAgileFieldIds(client)).toEqual({})
expect(jiraRequestMock).toHaveBeenCalledTimes(2)
} finally {
warn.mockRestore()
vi.useRealTimers()
}
})
})
it('maps parent, estimates and agile custom fields onto the issue', () => {
const issue = mapJiraIssue(
site,
{
id: '1',
key: 'ALP-1',
fields: {
summary: 'Story',
parent: { key: 'ALP-9', fields: { summary: 'Big epic', issuetype: { name: 'Epic' } } },
timeoriginalestimate: 7200,
timeestimate: 3600,
customfield_1: [{ name: 'Sprint 2', state: 'active' }],
customfield_3: 5
}
},
undefined,
{ sprint: 'customfield_1', storyPoints: ['customfield_3'] }
)
expect(issue.parent).toEqual({ key: 'ALP-9', title: 'Big epic', issueTypeName: 'Epic' })
expect(issue.originalEstimateSeconds).toBe(7200)
expect(issue.remainingEstimateSeconds).toBe(3600)
expect(issue.sprint).toBe('Sprint 2')
expect(issue.storyPoints).toBe(5)
})
})
+109
View File
@@ -0,0 +1,109 @@
import { createHash } from 'node:crypto'
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
import type { JiraAgileFieldIds } from './jira-issue-mapping'
import { asRecord, asString, type JiraRecord } from './jira-record-pages'
import { acquire, release } from './request-queue'
import { settleJiraSummaryRead, withJiraDeadline } from './jira-read-failure'
const SPRINT_FIELD_TYPE = 'com.pyxis.greenhopper.jira:gh-sprint'
const STORY_POINTS_TYPE = 'com.pyxis.greenhopper.jira:jsw-story-points'
const NUMBER_FIELD_TYPE = 'com.atlassian.jira.plugin.system.customfieldtypes:float'
const cache = new Map<string, Promise<JiraAgileFieldIds>>()
const discoveryExpiresAt = new Map<string, number>()
const FAILED_DISCOVERY_RETRY_MS = 60_000
const FIELD_CATALOG_TTL_MS = 10 * 60_000
export function pickAgileFieldIds(fields: readonly JiraRecord[]): JiraAgileFieldIds {
const ids: JiraAgileFieldIds = {}
for (const field of fields) {
const custom = asString(asRecord(field.schema).custom)
const id = asString(field.id)
if (!id) {
continue
}
if (!ids.sprint && custom === SPRINT_FIELD_TYPE) {
ids.sprint = id
}
if (
custom === STORY_POINTS_TYPE ||
(custom === NUMBER_FIELD_TYPE && /story points?/i.test(asString(field.name)))
) {
ids.storyPoints ??= []
if (!ids.storyPoints.includes(id)) {
ids.storyPoints.push(id)
}
}
}
return ids
}
function clientCacheKey(entry: JiraClientForSite): string {
const credential = createHash('sha256').update(entry.authorization).digest('hex')
return JSON.stringify([
entry.site.id,
entry.site.siteUrl,
entry.site.authType,
entry.site.accountId,
credential
])
}
export function getAgileFieldIds(
entry: JiraClientForSite,
signal?: AbortSignal
): Promise<JiraAgileFieldIds> {
if (signal?.aborted) {
return Promise.reject(new Error('Jira field discovery aborted'))
}
const key = clientCacheKey(entry)
const retryAt = discoveryExpiresAt.get(key)
if (retryAt !== undefined && retryAt <= Date.now()) {
cache.delete(key)
discoveryExpiresAt.delete(key)
}
let pending = cache.get(key)
if (!pending) {
// Discovery owns a queue slot; no search may hold one while awaiting it.
pending = withJiraDeadline(undefined, 500, async (requestSignal) => {
await acquire(requestSignal)
try {
const fields = await settleJiraSummaryRead(
jiraRequest<JiraRecord[]>(entry, `${apiBasePath(entry.site)}/field`, {
signal: requestSignal
}),
requestSignal
)
return pickAgileFieldIds(Array.isArray(fields) ? fields : [])
} finally {
release()
}
})
.then((fields) => {
if (cache.get(key) === pending) {
discoveryExpiresAt.set(key, Date.now() + FIELD_CATALOG_TTL_MS)
}
return fields
})
.catch((error) => {
console.warn('[jira] field discovery failed:', error)
if (cache.get(key) === pending) {
discoveryExpiresAt.set(key, Date.now() + FAILED_DISCOVERY_RETRY_MS)
}
return {}
})
if (cache.size >= 32) {
const oldest = cache.keys().next().value
if (oldest) {
cache.delete(oldest)
discoveryExpiresAt.delete(oldest)
}
}
cache.set(key, pending)
}
// A cancelled caller cannot cancel discovery another search is sharing.
return signal ? settleJiraSummaryRead(pending, signal) : pending
}
export function agileFieldIdList(ids: JiraAgileFieldIds): string[] {
return [...(ids.sprint ? [ids.sprint] : []), ...(ids.storyPoints ?? [])]
}
@@ -0,0 +1,264 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraClientForSite } from './authenticated-request'
import type { JiraRecord } from './jira-record-pages'
import { listIssues, searchIssues } from './jira-issue-search'
const { getClientsMock, jiraRequestMock } = vi.hoisted(() => ({
getClientsMock: vi.fn(),
jiraRequestMock: vi.fn()
}))
vi.mock('./client', () => ({
getClients: (...args: unknown[]) => getClientsMock(...args),
clearToken: vi.fn(),
isAuthError: () => false
}))
vi.mock('./authenticated-request', () => ({
apiBasePath: (site: { authType?: string }) =>
site.authType === 'server' ? '/rest/api/2' : '/rest/api/3',
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args)
}))
type CloudSearchBody = {
jql: string
fields: string[]
maxResults: number
nextPageToken?: string
}
type SearchPageRequest = {
batch: 'first' | 'last'
fields: string[]
limit: number
token: string | undefined
}
function parseCloudSearchBody(init?: RequestInit): CloudSearchBody {
if (typeof init?.body !== 'string') {
throw new Error('Expected a JSON search request')
}
const body: unknown = JSON.parse(init.body)
if (
!body ||
typeof body !== 'object' ||
!('jql' in body) ||
typeof body.jql !== 'string' ||
!('fields' in body) ||
!Array.isArray(body.fields) ||
!body.fields.every((field): field is string => typeof field === 'string') ||
!('maxResults' in body) ||
typeof body.maxResults !== 'number'
) {
throw new Error('Expected JQL, fields, result limit, and an optional string token')
}
const nextPageToken = 'nextPageToken' in body ? body.nextPageToken : undefined
if (nextPageToken !== undefined && typeof nextPageToken !== 'string') {
throw new Error('Expected a string page token')
}
return {
jql: body.jql,
fields: body.fields,
maxResults: body.maxResults,
...(nextPageToken !== undefined ? { nextPageToken } : {})
}
}
function client(id: string): JiraClientForSite {
return {
site: {
id,
authType: 'cloud',
siteUrl: `https://${id}.atlassian.net`,
email: '',
displayName: id,
accountId: 'viewer'
},
authorization: 'Basic composition-fixture'
}
}
function planningIssue(
key: string,
updated: string,
points: number,
laterPage = false
): JiraRecord {
return {
id: key,
key,
fields: {
summary: key,
created: '2020-01-01T00:00:00Z',
updated,
project: { id: key.startsWith('LAST') ? '100' : '0', key: key.split('-')[0] },
status: { name: 'In Progress', statusCategory: { key: 'indeterminate' } },
customfield_7: [
{ name: 'Previous sprint', state: 'closed' },
{ name: `${key} sprint`, state: 'active' }
],
customfield_8: laterPage ? null : points,
customfield_9: laterPage ? points : null,
timeoriginalestimate: 3600,
timeestimate: 0,
parent: { key: 'PLAN-1000', fields: { summary: 'Planning epic' } }
}
}
}
beforeEach(() => {
getClientsMock.mockReset()
jiraRequestMock.mockReset()
})
describe('Cloud search with optional planning field discovery', () => {
it.each([3, 6])(
'preserves fields across project batches/pages and returns the newest %i issues',
async (limit) => {
const entry = client(`cloud-columns-limit-${limit}`)
getClientsMock.mockReturnValue([entry])
const requests: SearchPageRequest[] = []
const projectStarts: number[] = []
const customFields = ['customfield_7', 'customfield_8', 'customfield_9']
const firstPage = [planningIssue('FIRST-1', '2026-10-09T12:00:00Z', 5)]
const lastPage = [planningIssue('LAST-1', '2026-10-10T12:00:00Z', 8)]
const firstContinuation = [
planningIssue('FIRST-2', '2026-10-07T12:00:00Z', 0, true),
planningIssue('FIRST-3', '2020-01-01T00:00:00Z', 2, true)
]
const lastContinuation = [
planningIssue('LAST-2', '2026-10-08T12:00:00Z', 0, true),
planningIssue('LAST-3', '2020-01-02T00:00:00Z', 3, true)
]
jiraRequestMock.mockImplementation(
async (requestEntry: JiraClientForSite, path: string, init?: RequestInit) => {
expect(requestEntry).toBe(entry)
if (path === '/rest/api/3/field') {
return [
{
id: customFields[0],
name: 'Sprint',
schema: { custom: 'com.pyxis.greenhopper.jira:gh-sprint' }
},
{
id: customFields[1],
name: 'Story Points',
schema: { custom: 'com.atlassian.jira.plugin.system.customfieldtypes:float' }
},
{
id: customFields[2],
name: 'Story point estimate',
schema: { custom: 'com.pyxis.greenhopper.jira:jsw-story-points' }
}
]
}
if (path.startsWith('/rest/api/3/project/search?')) {
const startAt = Number(new URL(path, entry.site.siteUrl).searchParams.get('startAt'))
projectStarts.push(startAt)
expect([0, 100]).toContain(startAt)
return startAt === 0
? {
maxResults: 100,
total: 101,
values: Array.from({ length: 100 }, (_, index) => ({ id: String(index) }))
}
: { maxResults: 100, total: 101, values: [{ id: '100' }] }
}
expect(path).toBe('/rest/api/3/search/jql')
expect(init?.method).toBe('POST')
expect(init?.signal).toBeInstanceOf(AbortSignal)
const body = parseCloudSearchBody(init)
expect(body.jql).toContain('project in (')
expect(body.jql).toContain('statusCategory != Done')
expect(body.jql).not.toMatch(/resolution|updated\s*[><=]/)
expect(body.fields).toEqual(expect.arrayContaining(customFields))
expect(body.fields).toEqual(
expect.arrayContaining(['parent', 'timeoriginalestimate', 'timeestimate'])
)
expect(body.fields).not.toContain('description')
const batch = body.jql.includes('"100"') ? 'last' : 'first'
if (batch === 'first') {
expect(body.jql).toContain('"0"')
expect(body.jql).toContain('"99"')
expect(body.jql).not.toContain('"100"')
} else {
expect(body.jql).toContain('project in ("100")')
}
requests.push({
batch,
fields: body.fields,
limit: body.maxResults,
token: body.nextPageToken
})
if (!body.nextPageToken) {
return {
issues: batch === 'first' ? firstPage : lastPage,
nextPageToken: `${batch}-next`
}
}
expect(body.nextPageToken).toBe(`${batch}-next`)
return {
issues: batch === 'first' ? firstContinuation : lastContinuation,
isLast: true
}
}
)
const results = await listIssues('all', limit, entry.site.id, true)
expect(results).toHaveLength(limit)
expect(results.slice(0, 3).map((item) => item.key)).toEqual(['LAST-1', 'FIRST-1', 'LAST-2'])
expect(projectStarts).toEqual([0, 100])
expect(
requests.map(({ batch, limit: pageLimit, token }) => ({ batch, limit: pageLimit, token }))
).toEqual([
{ batch: 'first', token: undefined, limit },
{ batch: 'first', token: 'first-next', limit: limit - 1 },
{ batch: 'last', token: undefined, limit },
{ batch: 'last', token: 'last-next', limit: limit - 1 }
])
expect(
requests.every(
(request) => JSON.stringify(request.fields) === JSON.stringify(requests[0]?.fields)
)
).toBe(true)
expect(
jiraRequestMock.mock.calls.filter((call) => call[1] === '/rest/api/3/field')
).toHaveLength(1)
expect(jiraRequestMock).toHaveBeenCalledTimes(7)
expect(results.find((item) => item.key === 'LAST-2')).toMatchObject({
siteId: entry.site.id,
sprint: 'LAST-2 sprint',
storyPoints: 0,
originalEstimateSeconds: 3600,
remainingEstimateSeconds: 0,
parent: { key: 'PLAN-1000', title: 'Planning epic' }
})
if (limit === 6) {
const expectedPoints: Record<string, number> = {
'FIRST-1': 5,
'FIRST-2': 0,
'FIRST-3': 2,
'LAST-1': 8,
'LAST-2': 0,
'LAST-3': 3
}
for (const item of results) {
expect(item.sprint).toBe(`${item.key} sprint`)
expect(item.storyPoints).toBe(expectedPoints[item.key])
}
}
}
)
it.each(['list', 'search'] as const)(
'rejects disconnected strict %s reads before field discovery or any other request',
async (kind) => {
getClientsMock.mockReturnValue([])
const request =
kind === 'list'
? listIssues('all', 3, 'strict-disconnected-cloud', true)
: searchIssues('project = PLAN', 3, 'strict-disconnected-cloud', undefined, true)
await expect(request).rejects.toThrow('one connected site ID')
expect(getClientsMock).toHaveBeenCalledWith('strict-disconnected-cloud')
expect(jiraRequestMock).not.toHaveBeenCalled()
}
)
})
+57 -2
View File
@@ -14,6 +14,7 @@ import {
asRecord,
asString,
asStringArray,
asFiniteNumber,
type JiraPagedResponse,
type JiraRecord
} from './jira-record-pages'
@@ -29,7 +30,10 @@ export const ISSUE_FIELDS = [
'priority',
'labels',
'created',
'updated'
'updated',
'parent',
'timeoriginalestimate',
'timeestimate'
]
// Why: list/typeahead only need identity + metadata; description ADF parse is the hot-path cost.
@@ -182,10 +186,53 @@ export function toBodyText(site: JiraSite, text: string): unknown {
return site.authType === 'server' ? text : textToAdf(text)
}
/** Site-specific custom field ids for agile data; discovered per site, see jira-agile-fields.ts. */
export type JiraAgileFieldIds = {
sprint?: string
storyPoints?: string[]
}
function mapParent(value: unknown): JiraIssue['parent'] {
const parent = asRecord(value)
const key = asString(parent.key)
if (!key) {
return undefined
}
const fields = asRecord(parent.fields)
return {
key,
title: asString(fields.summary, key),
issueTypeName: asString(asRecord(fields.issuetype).name) || undefined
}
}
// Cloud returns sprint objects; Server/DC returns "…Sprint@…[id=1,…,name=Sprint 3,…]" strings.
function sprintName(value: unknown): string | undefined {
if (typeof value === 'string') {
return /name=(.*?)(?:,(?:startDate|endDate|completeDate|sequence|goal)=|\]$)/.exec(value)?.[1]
}
return asString(asRecord(value).name) || undefined
}
function sprintState(value: unknown): string | undefined {
if (typeof value === 'string') {
return /state=([^,\]]+)/.exec(value)?.[1]
}
return asString(asRecord(value).state) || undefined
}
// Why: Jira orders an issue's sprints by id, not state, so the last one may be closed.
export function mapSprint(value: unknown): string | undefined {
const sprints = Array.isArray(value) ? value : value ? [value] : []
const active = sprints.find((sprint) => sprintState(sprint)?.toLowerCase() === 'active')
return sprintName(active ?? sprints.at(-1))
}
export function mapJiraIssue(
site: JiraSite,
raw: JiraRecord,
adfOptions?: AdfToMarkdownOptions
adfOptions?: AdfToMarkdownOptions,
agileFields: JiraAgileFieldIds = {}
): JiraIssue {
const fields = asRecord(raw.fields)
const key = asString(raw.key)
@@ -204,6 +251,14 @@ export function mapJiraIssue(
assignee: mapUser(fields.assignee),
reporter: mapUser(fields.reporter),
priority: mapPriority(fields.priority),
parent: mapParent(fields.parent),
sprint: agileFields.sprint ? mapSprint(fields[agileFields.sprint]) : undefined,
storyPoints:
agileFields.storyPoints
?.map((id) => asFiniteNumber(fields[id]))
.find((value) => value !== null) ?? undefined,
originalEstimateSeconds: asFiniteNumber(fields.timeoriginalestimate) ?? undefined,
remainingEstimateSeconds: asFiniteNumber(fields.timeestimate) ?? undefined,
createdAt: asString(fields.created, new Date().toISOString()),
updatedAt: asString(fields.updated, new Date().toISOString())
}
+12 -4
View File
@@ -3,7 +3,13 @@ import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup'
import { acquire, release } from './request-queue'
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
import { clearToken, getClients, isAuthError } from './client'
import { ISSUE_DETAIL_FIELDS, ISSUE_SUMMARY_FIELDS, mapJiraIssue } from './jira-issue-mapping'
import { agileFieldIdList, getAgileFieldIds } from './jira-agile-fields'
import {
ISSUE_DETAIL_FIELDS,
ISSUE_SUMMARY_FIELDS,
mapJiraIssue,
type JiraAgileFieldIds
} from './jira-issue-mapping'
import {
collectIssueMediaRequest,
flushMediaResolutionWarn,
@@ -32,11 +38,13 @@ export async function getIssue(
let mediaRequest: MediaRequest | undefined
let issue: JiraRecord | undefined
let held = false
let agileFields: JiraAgileFieldIds = {}
try {
agileFields = await getAgileFieldIds(entry)
await acquire()
held = true
const params = new URLSearchParams({
fields: ISSUE_DETAIL_FIELDS.join(','),
fields: [...ISSUE_DETAIL_FIELDS, ...agileFieldIdList(agileFields)].join(','),
expand: 'renderedFields'
})
issue = await jiraRequest<JiraRecord>(
@@ -70,14 +78,14 @@ export async function getIssue(
continue
}
const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined
const mapped = mapJiraIssue(entry.site, issue, prepared?.options)
const mapped = mapJiraIssue(entry.site, issue, prepared?.options, agileFields)
if (prepared) {
flushMediaResolutionWarn(entry, prepared)
}
return mapped
} catch (error) {
console.warn('[jira] getIssue media load failed:', error)
return mapJiraIssue(entry.site, issue)
return mapJiraIssue(entry.site, issue, undefined, agileFields)
}
}
return null
+16 -6
View File
@@ -4,7 +4,8 @@ import { buildJiraPresetJql } from '../../shared/jira-preset-jql'
import { acquire, release } from './request-queue'
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
import { clearToken, getClients, isAuthError } from './client'
import { ISSUE_LIST_FIELDS, mapJiraIssue } from './jira-issue-mapping'
import { ISSUE_LIST_FIELDS, mapJiraIssue, type JiraAgileFieldIds } from './jira-issue-mapping'
import { agileFieldIdList, getAgileFieldIds } from './jira-agile-fields'
import type { JiraSearchResponse } from './jira-record-pages'
import { readProjectsForClient } from './jira-project-queries'
import {
@@ -31,14 +32,15 @@ async function readIssuesForClient(
entry: JiraClientForSite,
query: JiraIssueQuery,
limit: number,
agileFields: JiraAgileFieldIds,
signal: AbortSignal
): Promise<JiraIssue[]> {
if ('jql' in query) {
return searchIssuesForClient(entry, query.jql, limit, signal)
return searchIssuesForClient(entry, query.jql, limit, agileFields, signal)
}
const jql = buildJiraPresetJql(query.filter)
if (query.filter !== 'all' || entry.site.authType === 'server') {
return searchIssuesForClient(entry, jql, limit, signal)
return searchIssuesForClient(entry, jql, limit, agileFields, signal)
}
const projects = await readProjectsForClient(entry, signal, true)
const projectIds = [...new Set(projects.map((project) => project.id || project.key))]
@@ -54,6 +56,7 @@ async function readIssuesForClient(
entry,
`project in (${ids.join(', ')}) AND ${jql}`,
limit,
agileFields,
signal,
true
))
@@ -66,6 +69,7 @@ async function searchIssuesForClient(
entry: JiraClientForSite,
jql: string,
limit: number,
agileFields: JiraAgileFieldIds,
signal?: AbortSignal,
collectPages = false
): Promise<JiraIssue[]> {
@@ -83,7 +87,7 @@ async function searchIssuesForClient(
body: JSON.stringify({
jql,
maxResults: limit - issues.length,
fields: ISSUE_LIST_FIELDS,
fields: [...ISSUE_LIST_FIELDS, ...agileFieldIdList(agileFields)],
...(nextPageToken ? { nextPageToken } : {})
}),
signal
@@ -91,7 +95,11 @@ async function searchIssuesForClient(
if (collectPages && !Array.isArray(result.issues)) {
throw new Error('Jira returned an invalid issue page.')
}
issues.push(...(result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)))
issues.push(
...(result.issues ?? []).map((issue) =>
mapJiraIssue(entry.site, issue, undefined, agileFields)
)
)
if (!collectPages || issues.length >= limit || result.isLast === true) {
return issues.slice(0, limit)
}
@@ -147,10 +155,12 @@ async function runIssueSearch(
const results = await withJiraDeadline(signal, ISSUE_SEARCH_TIMEOUT_MS, (requestSignal) =>
Promise.all(
entries.map(async (entry, index) => {
// Discovery owns a slot, so finish it before acquiring the search slot.
const agileFields = await getAgileFieldIds(entry, requestSignal)
// Why: queueing on an abandoned search would keep occupying the shared Jira pool.
await acquire(requestSignal)
try {
return await readIssuesForClient(entry, query, safeLimit, requestSignal)
return await readIssuesForClient(entry, query, safeLimit, agileFields, requestSignal)
} catch (error) {
if (requestSignal.aborted) {
// Abandoned by the caller: not a site failure, so don't clear tokens or mask a real one.
@@ -14,6 +14,10 @@ vi.mock('./authenticated-request', () => ({
jiraRequest,
JiraApiError: class extends Error {}
}))
vi.mock('./jira-agile-fields', () => ({
getAgileFieldIds: async () => ({}),
agileFieldIdList: () => []
}))
vi.mock('./request-queue', () => ({ acquire: vi.fn(), release: vi.fn() }))
function site(id: string, authType: 'cloud' | 'server' = 'cloud'): JiraClientForSite {
+5
View File
@@ -10,6 +10,11 @@ const { acquireMock, clearTokenMock, getClientsMock, isAuthErrorMock, jiraReques
jiraRequestMock: vi.fn()
}))
vi.mock('./jira-agile-fields', () => ({
getAgileFieldIds: async () => ({}),
agileFieldIdList: () => []
}))
vi.mock('./request-queue', () => ({
acquire: (...args: unknown[]) => acquireMock(...args),
release: vi.fn()
+9
View File
@@ -132,6 +132,9 @@
--color-status-success-background: var(--status-success-background);
--color-status-success-border: var(--status-success-border);
--color-status-warning: var(--status-warning);
--color-jira-priority-high: var(--jira-priority-high);
--color-jira-priority-medium: var(--jira-priority-medium);
--color-jira-priority-low: var(--jira-priority-low);
--color-status-warning-background: var(--status-warning-background);
--color-status-warning-border: var(--status-warning-border);
--color-callout-note: var(--callout-note);
@@ -406,6 +409,9 @@
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
/* Needs-attention states use yellow, separate from errors and working states. */
--status-warning: #ca8a04;
--jira-priority-high: var(--destructive);
--jira-priority-medium: var(--status-warning);
--jira-priority-low: var(--callout-note);
--status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent);
--status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent);
/* GitHub's own hues for the two callout kinds no status token covers. */
@@ -432,6 +438,9 @@
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
--status-warning: #eab308;
--jira-priority-high: var(--destructive);
--jira-priority-medium: var(--status-warning);
--jira-priority-low: var(--callout-note);
--status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent);
--status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent);
--callout-note: #4493f8;
@@ -1,7 +1,8 @@
import type { JiraListColumnId } from './jira-list-columns'
import type { JiraIssue, JiraPriority } from '../../../shared/jira-types'
import { compareNumericLocaleText } from '@/lib/locale-text-collators'
export type JiraIssueSortColumn = 'key' | 'title' | 'status' | 'priority' | 'assignee' | 'updated'
export type JiraIssueSortColumn = JiraListColumnId
export type JiraIssueSortDirection = 'asc' | 'desc'
export type JiraPrioritiesBySite = ReadonlyMap<string, readonly JiraPriority[]>
@@ -86,6 +87,16 @@ export function sortJiraIssues(
comparison = userA.localeCompare(userB)
} else if (orderBy === 'updated') {
comparison = numericKeys.get(a)! - numericKeys.get(b)!
} else if (orderBy === 'parent') {
comparison = compareNumericLocaleText(a.parent?.title ?? '', b.parent?.title ?? '')
} else if (orderBy === 'sprint') {
comparison = compareNumericLocaleText(a.sprint ?? '', b.sprint ?? '')
} else if (orderBy === 'storyPoints') {
comparison = (a.storyPoints ?? -1) - (b.storyPoints ?? -1)
} else if (orderBy === 'originalEstimate') {
comparison = (a.originalEstimateSeconds ?? -1) - (b.originalEstimateSeconds ?? -1)
} else if (orderBy === 'remainingEstimate') {
comparison = (a.remainingEstimateSeconds ?? -1) - (b.remainingEstimateSeconds ?? -1)
}
return orderDirection === 'asc' ? comparison : -comparison
})
@@ -0,0 +1,53 @@
import { Columns3 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import {
JIRA_LIST_COLUMNS,
getJiraListColumnLabel,
type JiraListColumnId
} from './jira-list-columns'
export function JiraListColumnsMenu({
ids,
onToggle
}: {
ids: ReadonlySet<JiraListColumnId>
onToggle: (column: JiraListColumnId) => void
}): React.JSX.Element {
const label = translate('auto.components.TaskPage.jiraColumnsPicker', 'Columns')
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-xs" aria-label={label}>
<Columns3 />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{label}</DropdownMenuLabel>
{JIRA_LIST_COLUMNS.filter((column) => !column.locked).map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={ids.has(column.id)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={() => onToggle(column.id)}
>
{getJiraListColumnLabel(column.id)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,70 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
JIRA_LIST_COLUMNS,
defaultJiraListColumnIds,
fallbackJiraSortColumn,
formatJiraEstimate,
jiraListGridStyle,
jiraListGridTemplate,
loadJiraListColumnIds,
resolveJiraListColumnIds,
saveJiraListColumnIds,
visibleJiraListColumns
} from './jira-list-columns'
afterEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
describe('Jira list columns', () => {
it('defaults to the previous columns with planning fields hidden', () => {
expect([...defaultJiraListColumnIds()]).toEqual([
'key',
'title',
'status',
'priority',
'assignee',
'updated'
])
})
it('drops invalid saved ids and retains the issue identity', () => {
expect(
[...resolveJiraListColumnIds(['sprint', 'storyPoints', 'bogus', null, {}])].sort()
).toEqual(['key', 'sprint', 'storyPoints', 'title'])
expect([...resolveJiraListColumnIds('nope')]).toEqual([...defaultJiraListColumnIds()])
})
it('restores per-device choices and handles corrupt or unavailable storage', () => {
saveJiraListColumnIds(new Set(['key', 'title', 'sprint']))
expect([...loadJiraListColumnIds()].sort()).toEqual(['key', 'sprint', 'title'])
localStorage.setItem('orca.jira.list-columns.v1', '{')
expect(loadJiraListColumnIds()).toEqual(defaultJiraListColumnIds())
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('full')
})
expect(() => saveJiraListColumnIds(new Set(['key', 'title']))).not.toThrow()
})
it('keeps a title floor at each panel tier when all columns are selected', () => {
const columns = visibleJiraListColumns(new Set(JIRA_LIST_COLUMNS.map((column) => column.id)))
for (const tier of [0, 1, 2] as const) {
expect(jiraListGridTemplate(columns, tier)).toContain('minmax(180px,1fr)')
expect(jiraListGridTemplate(columns, tier).endsWith('104px')).toBe(true)
}
const style = jiraListGridStyle(columns)
expect(Number.parseInt(style['--jira-min-md'])).toBeGreaterThan(780)
expect(Number.parseInt(style['--jira-min-lg'])).toBeGreaterThan(1050)
expect(jiraListGridTemplate(columns, 0)).not.toContain('136px')
})
it('chooses a visible fallback when the active sort column is hidden', () => {
expect(fallbackJiraSortColumn(defaultJiraListColumnIds())).toBe('updated')
expect(fallbackJiraSortColumn(new Set(['key', 'title', 'sprint']))).toBe('key')
})
it('formats exact hours without assuming the Jira working day length', () => {
expect(formatJiraEstimate(undefined)).toBe('–')
expect(formatJiraEstimate(0)).toBe('0m')
expect(formatJiraEstimate(45 * 60)).toBe('45m')
expect(formatJiraEstimate(10 * 3600)).toBe('10h')
expect(formatJiraEstimate(40 * 3600 + 30 * 60)).toBe('40h 30m')
})
})
@@ -0,0 +1,173 @@
import type { CSSProperties } from 'react'
import { translate } from '@/i18n/i18n'
export type JiraListColumnId =
| 'key'
| 'title'
| 'status'
| 'priority'
| 'assignee'
| 'parent'
| 'sprint'
| 'storyPoints'
| 'originalEstimate'
| 'remainingEstimate'
| 'updated'
export type JiraListColumn = {
id: JiraListColumnId
widths: readonly [number, number, number]
/** Always shown; not offered in the column picker. */
locked?: boolean
defaultVisible: boolean
}
export const JIRA_LIST_COLUMNS: readonly JiraListColumn[] = [
{ id: 'key', widths: [90, 96, 104], locked: true, defaultVisible: true },
{ id: 'title', widths: [180, 180, 180], locked: true, defaultVisible: true },
{ id: 'status', widths: [128, 132, 144], defaultVisible: true },
{ id: 'priority', widths: [92, 120, 132], defaultVisible: true },
{ id: 'assignee', widths: [0, 136, 160], defaultVisible: true },
{ id: 'parent', widths: [150, 150, 150], defaultVisible: false },
{ id: 'sprint', widths: [110, 110, 110], defaultVisible: false },
{ id: 'storyPoints', widths: [60, 60, 60], defaultVisible: false },
{ id: 'originalEstimate', widths: [88, 88, 88], defaultVisible: false },
{ id: 'remainingEstimate', widths: [88, 88, 88], defaultVisible: false },
{ id: 'updated', widths: [80, 96, 128], defaultVisible: true }
]
// Device-local, like Linear display preferences, rather than synced through ui.set.
const STORAGE_KEY = 'orca.jira.list-columns.v1'
export function getJiraListColumnLabel(id: JiraListColumnId): string {
switch (id) {
case 'key':
return translate('auto.components.TaskPage.37e7ee311e', 'Key')
case 'title':
return translate('auto.components.TaskPage.b1eaa18ace', 'Issue')
case 'status':
return translate('auto.components.TaskPage.154b0fa623', 'Status')
case 'priority':
return translate('auto.components.TaskPage.c8d5bec5f7', 'Priority')
case 'assignee':
return translate('auto.components.TaskPage.d2a876ca53', 'Assignee')
case 'parent':
return translate('auto.components.TaskPage.jiraColumnParent', 'Parent')
case 'sprint':
return translate('auto.components.TaskPage.jiraColumnSprint', 'Sprint')
case 'storyPoints':
return translate('auto.components.TaskPage.jiraColumnStoryPoints', 'Points')
case 'originalEstimate':
return translate('auto.components.TaskPage.jiraColumnOriginalEstimate', 'Estimate')
case 'remainingEstimate':
return translate('auto.components.TaskPage.jiraColumnRemainingEstimate', 'Remaining')
case 'updated':
return translate('auto.components.TaskPage.f362667d55', 'Updated')
}
}
export function defaultJiraListColumnIds(): Set<JiraListColumnId> {
return new Set(JIRA_LIST_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.id))
}
export function resolveJiraListColumnIds(raw: unknown): Set<JiraListColumnId> {
if (!Array.isArray(raw)) {
return defaultJiraListColumnIds()
}
const ids = new Set(
raw.filter(
(id): id is JiraListColumnId =>
typeof id === 'string' && JIRA_LIST_COLUMNS.some((column) => column.id === id)
)
)
for (const column of JIRA_LIST_COLUMNS) {
if (column.locked) {
ids.add(column.id)
}
}
return ids
}
export function loadJiraListColumnIds(): Set<JiraListColumnId> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return resolveJiraListColumnIds(raw ? JSON.parse(raw) : undefined)
} catch {
return defaultJiraListColumnIds()
}
}
export function saveJiraListColumnIds(ids: ReadonlySet<JiraListColumnId>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]))
} catch {
// The live view stays usable when browser storage is unavailable or full.
}
}
export function visibleJiraListColumns(ids: ReadonlySet<JiraListColumnId>): JiraListColumn[] {
return JIRA_LIST_COLUMNS.filter((column) => ids.has(column.id))
}
export function jiraListGridTemplate(
columns: readonly JiraListColumn[],
tier: 0 | 1 | 2 = 1
): string {
return [
...columns
.filter((column) => column.widths[tier] > 0)
.map((column) => (column.id === 'title' ? 'minmax(180px,1fr)' : `${column.widths[tier]}px`)),
'104px'
].join(' ')
}
type JiraGridStyle = CSSProperties & {
'--jira-cols-md': string
'--jira-cols-lg': string
'--jira-cols-xl': string
'--jira-min-md': string
'--jira-min-lg': string
'--jira-min-xl': string
}
function jiraListMinimumWidth(columns: readonly JiraListColumn[], tier: 0 | 1 | 2): string {
const shown = columns.filter((column) => column.widths[tier] > 0)
const tracks = shown.reduce((sum, column) => sum + column.widths[tier], 104)
return `${tracks + shown.length * 12 + 24}px`
}
export function jiraListGridStyle(columns: readonly JiraListColumn[]): JiraGridStyle {
return {
'--jira-cols-md': jiraListGridTemplate(columns, 0),
'--jira-cols-lg': jiraListGridTemplate(columns, 1),
'--jira-cols-xl': jiraListGridTemplate(columns, 2),
'--jira-min-md': jiraListMinimumWidth(columns, 0),
'--jira-min-lg': jiraListMinimumWidth(columns, 1),
'--jira-min-xl': jiraListMinimumWidth(columns, 2)
}
}
export function jiraListColumnVisibility(column: JiraListColumnId): string | undefined {
return column === 'assignee' ? '@max-5xl:!hidden' : undefined
}
export function fallbackJiraSortColumn(ids: ReadonlySet<JiraListColumnId>): JiraListColumnId {
return ids.has('updated') ? 'updated' : 'key'
}
// Hours do not depend on the site's configurable working day/week length.
export function formatJiraEstimate(seconds: number | undefined): string {
if (seconds === undefined || !Number.isFinite(seconds) || seconds < 0) {
return '–'
}
if (seconds === 0) {
return '0m'
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return [hours ? `${hours}h` : '', minutes ? `${minutes}m` : ''].filter(Boolean).join(' ') || '<1m'
}
export function formatJiraStoryPoints(points: number | undefined): string {
return points === undefined ? '–' : String(points)
}
@@ -1,3 +1,4 @@
import { defaultJiraListColumnIds, visibleJiraListColumns } from './jira-list-columns'
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
@@ -174,6 +175,7 @@ describe('Jira issue status grouping', () => {
TooltipProvider,
null,
React.createElement(TaskPageJiraIssueList, {
columns: visibleJiraListColumns(defaultJiraListColumnIds()),
formatUpdatedAt: () => 'today',
getStatusTone: () => 'border-border',
issues: [
@@ -0,0 +1,148 @@
import React from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { JiraIssue } from '../../../shared/jira-types'
import {
formatJiraEstimate,
formatJiraStoryPoints,
type JiraListColumnId
} from './jira-list-columns'
import { getJiraPriorityTone } from './task-page-jira-status-tone'
const MUTED_CELL = 'block truncate text-[12px] text-muted-foreground'
export function noPriorityLabel(): string {
return translate('auto.components.TaskPage.713179dfdc', 'No priority')
}
export function unassignedLabel(): string {
return translate('auto.components.TaskPage.42a9160321', 'Unassigned')
}
export function JiraPriorityText({
issue,
className
}: {
issue: JiraIssue
className?: string
}): React.JSX.Element {
return (
<span className={cn(getJiraPriorityTone(issue.priority?.name), className)}>
{issue.priority?.name ?? noPriorityLabel()}
</span>
)
}
function ParentCell({ issue }: { issue: JiraIssue }): React.JSX.Element {
if (!issue.parent) {
return <span className={MUTED_CELL}>–</span>
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex min-w-0 items-center gap-1.5 text-[12px] text-muted-foreground">
<span className="shrink-0 rounded-sm border border-border bg-muted px-1 font-mono text-[10px] text-muted-foreground">
{issue.parent.key}
</span>
<span className="truncate">{issue.parent.title}</span>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{issue.parent.issueTypeName ? `${issue.parent.issueTypeName}: ` : ''}
{issue.parent.title}
</TooltipContent>
</Tooltip>
)
}
function AssigneeCell({ issue }: { issue: JiraIssue }): React.JSX.Element {
return (
<div className="flex min-w-0 items-center gap-2 text-[12px] text-muted-foreground">
{issue.assignee?.avatarUrl ? (
<img
src={issue.assignee.avatarUrl}
alt={issue.assignee.displayName}
className="size-5 shrink-0 rounded-full"
/>
) : (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted/40 text-[10px]">
{issue.assignee?.displayName?.slice(0, 1) ?? '-'}
</span>
)}
<span className="truncate">{issue.assignee?.displayName ?? unassignedLabel()}</span>
</div>
)
}
/** Desktop cell for every column except `key` and `title`, which the row renders itself. */
export function JiraIssueCell({
column,
issue,
formatUpdatedAt,
getStatusTone
}: {
column: JiraListColumnId
issue: JiraIssue
formatUpdatedAt: (updatedAt: string) => string
getStatusTone: (categoryKey: string) => string
}): React.JSX.Element | null {
switch (column) {
case 'status':
return (
<div className="flex min-w-0">
<span
className={cn(
'inline-flex max-w-full items-center rounded-full border px-2 py-0.5 text-[11px] font-medium',
getStatusTone(issue.status.categoryKey)
)}
>
<span className="truncate">{issue.status.name}</span>
</span>
</div>
)
case 'priority':
return <JiraPriorityText issue={issue} className="block truncate text-[12px]" />
case 'assignee':
return <AssigneeCell issue={issue} />
case 'parent':
return <ParentCell issue={issue} />
case 'sprint':
return <span className={MUTED_CELL}>{issue.sprint ?? '–'}</span>
case 'storyPoints':
return (
<span className={cn(MUTED_CELL, 'text-right tabular-nums')}>
{formatJiraStoryPoints(issue.storyPoints)}
</span>
)
case 'originalEstimate':
return (
<span className={cn(MUTED_CELL, 'text-right tabular-nums')}>
{formatJiraEstimate(issue.originalEstimateSeconds)}
</span>
)
case 'remainingEstimate':
return (
<span className={cn(MUTED_CELL, 'text-right tabular-nums')}>
{formatJiraEstimate(issue.remainingEstimateSeconds)}
</span>
)
case 'updated':
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="block min-w-0 truncate text-[12px] text-muted-foreground">
{formatUpdatedAt(issue.updatedAt)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{new Date(issue.updatedAt).toLocaleString()}
</TooltipContent>
</Tooltip>
)
case 'key':
case 'title':
return null
}
}
@@ -9,6 +9,8 @@ import { cn } from '@/lib/utils'
import { JiraIssueRowCopyMenu } from './JiraIssueRowCopyMenu'
import { JiraLinkedWorkspaceAction } from './JiraLinkedWorkspaceAction'
import type { Worktree } from '../../../shared/worktree/types'
import { jiraListColumnVisibility, type JiraListColumn } from './jira-list-columns'
import { JiraIssueCell, JiraPriorityText, unassignedLabel } from './task-page-jira-issue-cells'
import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/jira-types'
export type TaskPageJiraIssueSection = {
@@ -20,6 +22,7 @@ export type TaskPageJiraIssueSection = {
type TaskPageJiraIssueListProps = {
getIssueWorkspaces?: (issue: JiraIssue) => readonly Worktree[]
onOpenWorkspace?: (workspace: Worktree) => void
columns: readonly JiraListColumn[]
formatUpdatedAt: (updatedAt: string) => string
getStatusTone: (categoryKey: string) => string
issues: JiraIssue[]
@@ -92,6 +95,7 @@ function isSelectedIssue(issue: JiraIssue, selectedIssue: JiraIssue | null): boo
function JiraIssueRow({
getIssueWorkspaces,
onOpenWorkspace,
columns,
formatUpdatedAt,
getStatusTone,
issue,
@@ -100,8 +104,6 @@ function JiraIssueRow({
selected,
showSiteContext
}: {
getIssueWorkspaces?: (issue: JiraIssue) => readonly Worktree[]
onOpenWorkspace?: (workspace: Worktree) => void
formatUpdatedAt: (updatedAt: string) => string
getStatusTone: (categoryKey: string) => string
issue: JiraIssue
@@ -109,6 +111,9 @@ function JiraIssueRow({
onStartWorkspace: (issue: JiraIssue) => void
selected: boolean
showSiteContext: boolean
columns: readonly JiraListColumn[]
getIssueWorkspaces?: (issue: JiraIssue) => readonly Worktree[]
onOpenWorkspace?: (workspace: Worktree) => void
}): React.JSX.Element {
const workspaces = getIssueWorkspaces?.(issue) ?? []
const labels = issue.labels.slice(0, 3)
@@ -137,7 +142,7 @@ function JiraIssueRow({
}
}}
className={cn(
'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring @3xl:grid-cols-[90px_minmax(0,1fr)_128px_92px_80px_104px] @5xl:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_104px] @7xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_104px]',
'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring @3xl:grid-cols-(--jira-cols-md) @3xl:min-w-(--jira-min-md) @5xl:grid-cols-(--jira-cols-lg) @5xl:min-w-(--jira-min-lg) @7xl:grid-cols-(--jira-cols-xl) @7xl:min-w-(--jira-min-xl)',
selected && 'bg-accent'
)}
>
@@ -163,13 +168,9 @@ function JiraIssueRow({
>
<span className="truncate">{issue.status.name}</span>
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{issue.priority?.name ??
translate('auto.components.TaskPage.713179dfdc', 'No priority')}
</span>
<JiraPriorityText issue={issue} className="shrink-0 text-2xs" />
<span className="min-w-0 truncate text-[11px] text-muted-foreground">
{issue.assignee?.displayName ??
translate('auto.components.TaskPage.42a9160321', 'Unassigned')}
{issue.assignee?.displayName ?? unassignedLabel()}
</span>
</div>
<div className="mt-1 flex min-w-0 items-center gap-1 @max-5xl:!hidden">
@@ -192,49 +193,21 @@ function JiraIssueRow({
</div>
</div>
<div className="flex min-w-0 @max-3xl:!hidden">
<span
className={cn(
'inline-flex max-w-full items-center rounded-full border px-2 py-0.5 text-[11px] font-medium',
getStatusTone(issue.status.categoryKey)
)}
>
<span className="truncate">{issue.status.name}</span>
</span>
</div>
<span className="block truncate text-[12px] text-muted-foreground @max-3xl:!hidden">
{issue.priority?.name ?? translate('auto.components.TaskPage.713179dfdc', 'No priority')}
</span>
<div className="flex min-w-0 items-center gap-2 text-[12px] text-muted-foreground @max-5xl:!hidden">
{issue.assignee?.avatarUrl ? (
<img
src={issue.assignee.avatarUrl}
alt={issue.assignee.displayName}
className="size-5 shrink-0 rounded-full"
/>
) : (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted/40 text-[10px]">
{issue.assignee?.displayName?.slice(0, 1) ?? '-'}
</span>
)}
<span className="truncate">
{issue.assignee?.displayName ??
translate('auto.components.TaskPage.42a9160321', 'Unassigned')}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="block min-w-0 truncate text-[12px] text-muted-foreground @max-3xl:!hidden">
{formatUpdatedAt(issue.updatedAt)}
{columns
.filter((column) => !column.locked)
.map((column) => (
<div
key={column.id}
className={cn('min-w-0 @max-3xl:!hidden', jiraListColumnVisibility(column.id))}
>
<JiraIssueCell
column={column.id}
issue={issue}
formatUpdatedAt={formatUpdatedAt}
getStatusTone={getStatusTone}
/>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{new Date(issue.updatedAt).toLocaleString()}
</TooltipContent>
</Tooltip>
))}
<div className="flex shrink-0 items-center justify-end gap-1">
<JiraIssueRowCopyMenu issue={issue} />
@@ -300,6 +273,7 @@ function JiraIssueRow({
export function TaskPageJiraIssueList({
getIssueWorkspaces,
onOpenWorkspace,
columns,
formatUpdatedAt,
getStatusTone,
issues,
@@ -358,9 +332,10 @@ export function TaskPageJiraIssueList({
<CollapsibleContent className="divide-y divide-border/50 border-t border-border/50">
{section.issues.map((issue) => (
<JiraIssueRow
key={`${issue.siteId ?? 'site'}:${issue.id || issue.key}`}
getIssueWorkspaces={getIssueWorkspaces}
onOpenWorkspace={onOpenWorkspace}
columns={columns}
key={`${issue.siteId ?? 'site'}:${issue.id || issue.key}`}
formatUpdatedAt={formatUpdatedAt}
getStatusTone={getStatusTone}
issue={issue}
@@ -5,6 +5,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TooltipProvider } from '@/components/ui/tooltip'
import { TaskPageJiraIssueList } from './task-page-jira-issue-list'
import { defaultJiraListColumnIds, visibleJiraListColumns } from './jira-list-columns'
import type { JiraIssue } from '../../../shared/jira-types'
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
const issue: JiraIssue = {
@@ -24,6 +25,7 @@ function renderList() {
return render(
<TooltipProvider>
<TaskPageJiraIssueList
columns={visibleJiraListColumns(defaultJiraListColumnIds())}
issues={[issue]}
selectedIssue={null}
showSiteContext={false}
@@ -1,3 +1,4 @@
import { defaultJiraListColumnIds, visibleJiraListColumns } from './jira-list-columns'
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
@@ -18,7 +19,12 @@ function renderControls(
): ReturnType<typeof render> {
return render(
<TooltipProvider>
<TaskPageJiraSortControls direction={direction} onSort={onSort} orderBy={orderBy} />
<TaskPageJiraSortControls
columns={visibleJiraListColumns(defaultJiraListColumnIds())}
direction={direction}
onSort={onSort}
orderBy={orderBy}
/>
</TooltipProvider>
)
}
@@ -11,41 +11,30 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import {
getJiraListColumnLabel,
jiraListColumnVisibility,
type JiraListColumn
} from './jira-list-columns'
import type { JiraIssueSortColumn, JiraIssueSortDirection } from './jira-issue-sorter'
type JiraSortColumn = {
id: JiraIssueSortColumn
label: string
className?: string
}
type TaskPageJiraSortControlsProps = {
columns: readonly JiraListColumn[]
direction: JiraIssueSortDirection
onSort: (column: JiraIssueSortColumn) => void
orderBy: JiraIssueSortColumn
}
function getJiraSortColumns(): JiraSortColumn[] {
return [
{ id: 'key', label: translate('auto.components.TaskPage.37e7ee311e', 'Key') },
{ id: 'title', label: translate('auto.components.TaskPage.b1eaa18ace', 'Issue') },
{ id: 'status', label: translate('auto.components.TaskPage.154b0fa623', 'Status') },
{ id: 'priority', label: translate('auto.components.TaskPage.c8d5bec5f7', 'Priority') },
{
id: 'assignee',
label: translate('auto.components.TaskPage.d2a876ca53', 'Assignee'),
className: '@max-5xl:!hidden'
},
{ id: 'updated', label: translate('auto.components.TaskPage.f362667d55', 'Updated') }
]
}
export function TaskPageJiraSortControls({
columns: visibleColumns,
direction,
onSort,
orderBy
}: TaskPageJiraSortControlsProps): React.JSX.Element {
const columns = getJiraSortColumns()
const columns = visibleColumns.map((column) => ({
id: column.id,
label: getJiraListColumnLabel(column.id)
}))
const directionLabel =
direction === 'asc'
? translate('auto.components.TaskPage.jiraSortAscending', 'ascending')
@@ -63,7 +52,7 @@ export function TaskPageJiraSortControls({
return (
<>
<div className="grid h-8 flex-none grid-cols-[90px_minmax(0,1fr)_128px_92px_80px_104px] items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground @max-3xl:!hidden @5xl:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_104px] @7xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_104px]">
<div className="sticky top-0 z-10 grid h-8 flex-none grid-cols-(--jira-cols-md) @3xl:min-w-(--jira-min-md) items-center gap-3 border-b border-border/50 bg-background px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground @max-3xl:!hidden @5xl:grid-cols-(--jira-cols-lg) @5xl:min-w-(--jira-min-lg) @7xl:grid-cols-(--jira-cols-xl) @7xl:min-w-(--jira-min-xl)">
{columns.map((column) => (
<button
key={column.id}
@@ -73,10 +62,14 @@ export function TaskPageJiraSortControls({
aria-pressed={orderBy === column.id}
className={cn(
'flex items-center gap-1 rounded-sm text-left text-[11px] font-semibold tracking-[0.08em] uppercase select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
column.className
(column.id === 'storyPoints' ||
column.id === 'originalEstimate' ||
column.id === 'remainingEstimate') &&
'justify-end text-right',
jiraListColumnVisibility(column.id)
)}
>
{column.label}
<span className="truncate">{column.label}</span>
{orderBy === column.id &&
(direction === 'asc' ? (
<ArrowUp aria-hidden="true" className="size-3" />
@@ -90,12 +83,20 @@ export function TaskPageJiraSortControls({
<div
data-testid="jira-mobile-sort-controls"
className="hidden h-10 flex-none items-center gap-2 border-b border-border/50 bg-muted/25 px-3 @max-3xl:!flex"
className="hidden h-10 flex-none items-center gap-2 border-b border-border/50 bg-background px-3 @max-3xl:!flex"
>
<span className="shrink-0 text-[11px] font-semibold tracking-[0.05em] text-muted-foreground uppercase">
{sortByLabel}
</span>
<Select value={orderBy} onValueChange={(value) => onSort(value as JiraIssueSortColumn)}>
<Select
value={orderBy}
onValueChange={(value) => {
const column = visibleColumns.find((candidate) => candidate.id === value)
if (column) {
onSort(column.id)
}
}}
>
<SelectTrigger
size="sm"
aria-label={sortByLabel}
@@ -38,6 +38,40 @@ describe('TaskPage Jira sorting functionality', () => {
return { id, name }
}
it('sorts optional planning values with missing values distinct from zero', () => {
const empty = jiraIssue('ALP-1', 'Empty', 'To Do')
const small = {
...jiraIssue('ALP-2', 'Small', 'To Do'),
storyPoints: 0,
originalEstimateSeconds: 0,
remainingEstimateSeconds: 0,
sprint: 'Sprint 2',
parent: { key: 'ALP-9', title: 'Epic 2' }
}
const large = {
...jiraIssue('ALP-3', 'Large', 'To Do'),
storyPoints: 5,
originalEstimateSeconds: 7200,
remainingEstimateSeconds: 3600,
sprint: 'Sprint 10',
parent: { key: 'ALP-10', title: 'Epic 10' }
}
for (const column of [
'storyPoints',
'originalEstimate',
'remainingEstimate',
'sprint',
'parent'
] as const) {
expect(
sortJiraIssues([large, small, empty], column, 'asc').map((issue) => issue.key)
).toEqual(['ALP-1', 'ALP-2', 'ALP-3'])
expect(
sortJiraIssues([small, large, empty], column, 'desc').map((issue) => issue.key)
).toEqual(['ALP-3', 'ALP-2', 'ALP-1'])
}
})
describe('getJiraPriorityWeight', () => {
it('returns default weight for missing priority', () => {
expect(getJiraPriorityWeight(undefined, undefined, [])).toBe(0)
@@ -7,3 +7,25 @@ export function getJiraStatusTone(categoryKey: string): string {
}
return 'border-border/50 bg-muted/40 text-muted-foreground'
}
export function getJiraPriorityTone(priorityName: string | undefined): string {
switch (priorityName?.toLowerCase()) {
case 'highest':
case 'blocker':
case 'critical':
case 'high':
case 'major':
return 'text-jira-priority-high'
case 'medium':
case 'normal':
return 'text-jira-priority-medium'
case 'low':
case 'minor':
case 'lowest':
case 'trivial':
return 'text-jira-priority-low'
case undefined:
default:
return 'text-muted-foreground'
}
}
@@ -5,6 +5,7 @@ import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TooltipProvider } from '@/components/ui/tooltip'
import { TaskPageJiraIssueList } from './task-page-jira-issue-list'
import { defaultJiraListColumnIds, visibleJiraListColumns } from './jira-list-columns'
import type { JiraIssue } from '../../../shared/jira-types'
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
@@ -34,6 +35,7 @@ const onOpenIssue = vi.fn()
const onStartWorkspace = vi.fn()
function renderList(workspaces = [first, second]) {
const props = {
columns: visibleJiraListColumns(defaultJiraListColumnIds()),
issues: [issue],
selectedIssue: null,
showSiteContext: false,
@@ -5,6 +5,8 @@ import { LoaderCircle } from 'lucide-react'
import { JiraIcon } from '@/components/icons/JiraIcon'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { jiraListGridStyle } from '../../jira-list-columns'
import { JiraListColumnsMenu } from '../../jira-list-columns-menu'
import { TaskPageJiraSortControls } from '../../task-page-jira-sort-controls'
import { TaskPageJiraErrorBanner } from '../../task-page-linear-jira-list-model'
import { TaskPageJiraTextFallbackNotice } from '../../task-page-jira-text-fallback-notice'
@@ -41,6 +43,9 @@ export function TaskPageJiraContent({
jiraOrderBy,
jiraOrderDirection,
handleJiraSort,
jiraColumnIds,
jiraColumns,
toggleJiraColumn,
displayedJiraIssues,
displayedJiraStatusOrder,
sortedJiraIssues,
@@ -74,28 +79,36 @@ export function TaskPageJiraContent({
</div>
</div>
) : (
<div className="@container flex min-h-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm">
<div
style={jiraListGridStyle(jiraColumns)}
className="@container flex min-h-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm"
>
<div className="flex h-10 flex-none items-center justify-between gap-3 border-b border-border/50 bg-muted/35 px-3">
<div className="min-w-0 text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{translate('auto.components.TaskPage.63b2abd3aa', 'Jira issues')}
</div>
<div className="shrink-0 text-[11px] text-muted-foreground">
{displayedJiraIssues.length} {translate('auto.components.TaskPage.b7bae28b6a', 'shown')}
<div className="flex shrink-0 items-center gap-2 text-2xs text-muted-foreground">
<span>
{displayedJiraIssues.length}{' '}
{translate('auto.components.TaskPage.b7bae28b6a', 'shown')}
</span>
<JiraListColumnsMenu ids={jiraColumnIds} onToggle={toggleJiraColumn} />
</div>
</div>
<TaskPageJiraSortControls
direction={jiraOrderDirection}
onSort={handleJiraSort}
orderBy={jiraOrderBy}
/>
<div
className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek"
className="min-h-0 flex-1 overflow-auto scrollbar-sleek"
style={{
scrollbarGutter: 'stable'
}}
>
<TaskPageJiraSortControls
columns={jiraColumns}
direction={jiraOrderDirection}
onSort={handleJiraSort}
orderBy={jiraOrderBy}
/>
{jiraStatus.credentialError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{jiraStatus.credentialError}
@@ -166,6 +179,7 @@ export function TaskPageJiraContent({
)
}
}}
columns={jiraColumns}
formatUpdatedAt={formatRelativeTime}
getStatusTone={getJiraStatusTone}
issues={sortedJiraIssues}
@@ -0,0 +1,164 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TaskPageLinearViewStateModel } from './use-task-page-linear-view-state'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import type { JiraPriority } from '../../../shared/jira-types'
import { useTaskPageJiraListState } from './use-task-page-jira-list-state'
const transport = vi.hoisted(() => ({ priorities: vi.fn() }))
vi.mock('@/runtime/runtime-jira-client', () => ({
jiraListPrioritiesForTarget: transport.priorities,
jiraListPriorities: transport.priorities
}))
function fixture(
accountHostId: ExecutionHostId = 'runtime:account',
sourceContext: TaskSourceContext | null = null
): TaskPageLinearViewStateModel {
const model = {
settings: { activeRuntimeEnvironmentId: 'focus' },
accountBackedTaskSourceHostId: accountHostId,
jiraConnected: true,
selectedJiraSiteId: 'site-1',
taskSource: 'jira',
jiraTaskSourceContext: sourceContext
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This isolated hook reads only the listed pipeline fields.
return model as unknown as TaskPageLinearViewStateModel
}
async function selectPriority(result: { current: ReturnType<typeof useTaskPageJiraListState> }) {
await act(async () => result.current.setJiraOrderBy('priority'))
await waitFor(() => expect(transport.priorities).toHaveBeenCalledTimes(1))
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
transport.priorities.mockResolvedValue([])
})
afterEach(cleanup)
describe('Jira priority list source owner', () => {
it('uses the captured account owner when the focused server differs', async () => {
const model = fixture()
const { result } = renderHook(() => useTaskPageJiraListState(model))
await selectPriority(result)
expect(transport.priorities).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'account' },
'site-1'
)
})
it('uses an explicit source before the captured account or focused server', async () => {
const model = fixture('runtime:account', {
kind: 'task-source',
provider: 'jira',
projectId: 'project',
hostId: 'runtime:source'
})
const { result } = renderHook(() => useTaskPageJiraListState(model))
await selectPriority(result)
expect(transport.priorities).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'source' },
'site-1'
)
})
it.each(['local', 'ssh:example'] as const)(
'uses the local IPC route for the %s account while another server is focused',
async (hostId) => {
const model = fixture(hostId)
const { result } = renderHook(() => useTaskPageJiraListState(model))
await selectPriority(result)
expect(transport.priorities).toHaveBeenCalledWith({ kind: 'local' }, 'site-1')
}
)
it.each(['account', 'source'] as const)(
'rejects late priorities after the %s owner changes',
async (kind) => {
let resolveOld: (priorities: JiraPriority[]) => void = () => {}
const oldPriorities = new Promise<JiraPriority[]>((resolve) => {
resolveOld = resolve
})
const latest = [{ id: 'latest', name: 'Latest scheme' }]
transport.priorities.mockReturnValueOnce(oldPriorities).mockResolvedValue(latest)
const source: TaskSourceContext | null =
kind === 'source'
? { kind: 'task-source', provider: 'jira', projectId: 'project', hostId: 'runtime:old' }
: null
const model = fixture('runtime:old', source)
const { result, rerender } = renderHook(() => useTaskPageJiraListState(model))
await selectPriority(result)
await act(async () => {
if (source) {
model.jiraTaskSourceContext = { ...source, hostId: 'runtime:new' }
} else {
model.accountBackedTaskSourceHostId = 'runtime:new'
}
rerender()
})
await waitFor(() => expect(result.current.jiraPrioritiesBySite.get('site-1')).toEqual(latest))
expect(transport.priorities).toHaveBeenLastCalledWith(
{ kind: 'environment', environmentId: 'new' },
'site-1'
)
await act(async () => resolveOld([{ id: 'old', name: 'Old scheme' }]))
expect(result.current.jiraPrioritiesBySite.get('site-1')).toEqual(latest)
}
)
it.each(['account', 'source'] as const)(
'hides an already loaded scheme during the next %s owner read',
async (kind) => {
const old = [{ id: 'old', name: 'Old scheme' }]
const response = Promise.withResolvers<JiraPriority[]>()
transport.priorities.mockResolvedValueOnce(old).mockReturnValueOnce(response.promise)
const source: TaskSourceContext | null =
kind === 'source'
? { kind: 'task-source', provider: 'jira', projectId: 'project', hostId: 'runtime:old' }
: null
const model = fixture('runtime:old', source)
const { result, rerender } = renderHook(() => useTaskPageJiraListState(model))
await selectPriority(result)
await waitFor(() => expect(result.current.jiraPrioritiesBySite.get('site-1')).toEqual(old))
await act(async () => {
if (source) {
model.jiraTaskSourceContext = { ...source, hostId: 'runtime:new' }
} else {
model.accountBackedTaskSourceHostId = 'runtime:new'
}
rerender()
})
expect(result.current.jiraPrioritiesBySite.size).toBe(0)
await act(async () => response.resolve([{ id: 'new', name: 'New scheme' }]))
expect(result.current.jiraPrioritiesBySite.get('site-1')).toEqual([
{ id: 'new', name: 'New scheme' }
])
}
)
it.each(['account', 'source'] as const)(
'fails closed for an unresolved %s owner',
async (kind) => {
const model = fixture(
kind === 'account' ? 'runtime:unresolved-owner' : 'local',
kind === 'source'
? {
kind: 'task-source',
provider: 'jira',
projectId: 'project',
hostId: 'runtime:unresolved-owner'
}
: null
)
const { result } = renderHook(() => useTaskPageJiraListState(model))
await act(async () => result.current.setJiraOrderBy('priority'))
expect(transport.priorities).not.toHaveBeenCalled()
expect(result.current.jiraPrioritiesBySite.size).toBe(0)
}
)
})
@@ -10,11 +10,36 @@ import type {
JiraIssueSortDirection,
JiraPrioritiesBySite
} from './jira-issue-sorter'
import { jiraListPriorities } from '@/runtime/runtime-jira-client'
import { jiraListPrioritiesForTarget } from '@/runtime/runtime-jira-client'
import { loadJiraLinkedView, saveJiraLinkedView } from './jira-linked-view-storage'
import { defaultScopeSource } from '@/lib/default-creation-host'
import {
fallbackJiraSortColumn,
loadJiraListColumnIds,
saveJiraListColumnIds,
visibleJiraListColumns,
type JiraListColumnId
} from './jira-list-columns'
import {
ipcOrRuntimeTargetForHostId,
runtimeTargetEnvironmentId
} from '@/runtime/runtime-client-target'
import { getEnvironmentRuntimeContextKey } from '@/lib/provider-runtime-context'
import { getTaskSourceCacheScope } from '../../../shared/task-source-context'
const EMPTY_JIRA_PRIORITIES: JiraPrioritiesBySite = new Map()
export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
const { settings, jiraConnected, selectedJiraSiteId, taskSource, jiraTaskSourceContext } = model
const {
jiraConnected,
selectedJiraSiteId,
taskSource,
jiraTaskSourceContext,
accountBackedTaskSourceHostId
} = model
const owner = useMemo(
() =>
ipcOrRuntimeTargetForHostId(jiraTaskSourceContext?.hostId ?? accountBackedTaskSourceHostId),
[accountBackedTaskSourceHostId, jiraTaskSourceContext]
)
// Jira tab state
const [jiraIssues, setJiraIssues] = useState<JiraIssue[]>([])
const [jiraLoading, setJiraLoading] = useState(false)
@@ -39,10 +64,39 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
order: JiraProjectStatusOrder
scopeKey: string
} | null>(null)
const [jiraOrderBy, setJiraOrderBy] = useState<JiraIssueSortColumn>('updated')
const [jiraOrderDirection, setJiraOrderDirection] = useState<JiraIssueSortDirection>('desc')
const [jiraPrioritiesBySite, setJiraPrioritiesBySite] = useState<JiraPrioritiesBySite>(
() => new Map()
const [jiraColumnIds, setJiraColumnIds] =
useState<ReadonlySet<JiraListColumnId>>(loadJiraListColumnIds)
const jiraColumns = useMemo(() => visibleJiraListColumns(jiraColumnIds), [jiraColumnIds])
const [jiraOrderBy, setJiraOrderBy] = useState<JiraIssueSortColumn>(() =>
fallbackJiraSortColumn(jiraColumnIds)
)
const [jiraOrderDirection, setJiraOrderDirection] = useState<JiraIssueSortDirection>(() =>
jiraOrderBy === 'updated' ? 'desc' : 'asc'
)
const [prioritySnapshot, setPrioritySnapshot] = useState<{
scopeKey: string
priorities: JiraPrioritiesBySite
} | null>(null)
const toggleJiraColumn = useCallback(
(column: JiraListColumnId) => {
if (column === 'key' || column === 'title') {
return
}
const next = new Set(jiraColumnIds)
if (next.has(column)) {
next.delete(column)
} else {
next.add(column)
}
if (!next.has(jiraOrderBy)) {
const fallback = fallbackJiraSortColumn(next)
setJiraOrderBy(fallback)
setJiraOrderDirection(fallback === 'updated' ? 'desc' : 'asc')
}
saveJiraListColumnIds(next)
setJiraColumnIds(next)
},
[jiraColumnIds, jiraOrderBy]
)
const jiraPrioritySiteIdsKey = useMemo(() => {
const siteIds =
@@ -52,40 +106,42 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
// Why: result refreshes replace the issue array; depend on the represented sites, not identity.
return JSON.stringify([...new Set(siteIds)].sort())
}, [jiraIssues, selectedJiraSiteId])
const priorityScopeKey = JSON.stringify([
owner ? getEnvironmentRuntimeContextKey(runtimeTargetEnvironmentId(owner)) : null,
jiraTaskSourceContext ? getTaskSourceCacheScope(jiraTaskSourceContext) : null,
jiraPrioritySiteIdsKey
])
const prioritiesEnabled = taskSource === 'jira' && jiraConnected && jiraOrderBy === 'priority'
const jiraPrioritiesBySite: JiraPrioritiesBySite =
prioritiesEnabled && owner && prioritySnapshot?.scopeKey === priorityScopeKey
? prioritySnapshot.priorities
: EMPTY_JIRA_PRIORITIES
useEffect(() => {
if (taskSource !== 'jira' || !jiraConnected || jiraOrderBy !== 'priority') {
setJiraPrioritiesBySite((current) => (current.size === 0 ? current : new Map()))
if (!prioritiesEnabled || !owner) {
return
}
let cancelled = false
const jiraPrioritySiteIds = JSON.parse(jiraPrioritySiteIdsKey) as string[]
const decodedSiteIds: unknown = JSON.parse(jiraPrioritySiteIdsKey)
const jiraPrioritySiteIds = Array.isArray(decodedSiteIds)
? decodedSiteIds.filter((siteId): siteId is string => typeof siteId === 'string')
: []
void Promise.all(
jiraPrioritySiteIds.map(async (siteId) => {
try {
return [
siteId,
await jiraListPriorities(jiraTaskSourceContext ?? defaultScopeSource(settings), siteId)
] as const
return [siteId, await jiraListPrioritiesForTarget(owner, siteId)] as const
} catch {
return [siteId, [] as JiraPriority[]] as const
}
})
).then((prioritiesBySite) => {
if (!cancelled) {
setJiraPrioritiesBySite(new Map(prioritiesBySite))
setPrioritySnapshot({ scopeKey: priorityScopeKey, priorities: new Map(prioritiesBySite) })
}
})
return () => {
cancelled = true
}
}, [
jiraConnected,
jiraOrderBy,
jiraPrioritySiteIdsKey,
jiraTaskSourceContext,
settings,
taskSource
])
}, [prioritiesEnabled, jiraPrioritySiteIdsKey, priorityScopeKey, owner])
const handleJiraSort = useCallback(
(column: JiraIssueSortColumn) => {
if (jiraOrderBy === column) {
@@ -126,9 +182,11 @@ export function useTaskPageJiraListState(model: TaskPageLinearViewStateModel) {
jiraOrderDirection,
setJiraOrderDirection,
jiraPrioritiesBySite,
setJiraPrioritiesBySite,
jiraPrioritySiteIdsKey,
handleJiraSort
handleJiraSort,
jiraColumnIds,
jiraColumns,
toggleJiraColumn
})
}
export type TaskPageJiraListStateModel = ReturnType<typeof useTaskPageJiraListState>
+6
View File
@@ -2537,6 +2537,12 @@
"linearFetchMore": "Fetch more",
"jiraSortAscending": "ascending",
"jiraSortDescending": "descending",
"jiraColumnsPicker": "Columns",
"jiraColumnParent": "Parent",
"jiraColumnSprint": "Sprint",
"jiraColumnStoryPoints": "Points",
"jiraColumnOriginalEstimate": "Estimate",
"jiraColumnRemainingEstimate": "Remaining",
"jiraSortBy": "Sort by",
"jiraToggleSortDirection": "Sort {{value0}}",
"75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.",
@@ -21,7 +21,6 @@ import { 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'
import {
jiraGetIssueForTarget,
jiraUpdateIssueForTarget,
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { jiraListPriorities } from './runtime-jira-client'
import { jiraListPrioritiesForTarget } from './runtime-jira-detail-client'
import { defaultScopeSource } from '@/lib/default-creation-host'
const transport = vi.hoisted(() => ({ rpc: vi.fn(), local: vi.fn() }))
vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: transport.rpc }))
beforeEach(() => {
vi.clearAllMocks()
transport.rpc.mockResolvedValue([])
transport.local.mockResolvedValue([])
vi.stubGlobal('window', { api: { jira: { listPriorities: transport.local } } })
})
afterEach(() => vi.unstubAllGlobals())
describe('Jira priority owner transport', () => {
it('keeps local site selection and returned priorities intact', async () => {
const priorities = [{ id: 'custom', name: 'Immediate' }]
transport.local.mockResolvedValue(priorities)
await expect(jiraListPrioritiesForTarget({ kind: 'local' }, 'site-1')).resolves.toBe(priorities)
expect(transport.local).toHaveBeenCalledWith({ siteId: 'site-1' })
expect(transport.rpc).not.toHaveBeenCalled()
})
it('keeps absent site selection absent', async () => {
await jiraListPrioritiesForTarget({ kind: 'local' }, null)
expect(transport.local).toHaveBeenCalledWith(undefined)
})
it('sends the captured remote owner, selected site, and existing deadline', async () => {
const target = { kind: 'environment' as const, environmentId: 'owner' }
await jiraListPrioritiesForTarget(target, 'site-1')
expect(transport.rpc).toHaveBeenCalledWith(
target,
'jira.listPriorities',
{ siteId: 'site-1' },
{ timeoutMs: 30_000 }
)
expect(transport.local).not.toHaveBeenCalled()
})
it('preserves the settings compatibility entry point', async () => {
await jiraListPriorities(defaultScopeSource({ activeRuntimeEnvironmentId: 'legacy' }), null)
expect(transport.rpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'legacy' },
'jira.listPriorities',
undefined,
{ timeoutMs: 30_000 }
)
})
it('preserves the explicit-source compatibility entry point', async () => {
await jiraListPriorities(
{ kind: 'task-source', provider: 'jira', projectId: 'project', hostId: 'runtime:source' },
'site-1'
)
expect(transport.rpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'source' },
'jira.listPriorities',
{ siteId: 'site-1' },
{ timeoutMs: 30_000 }
)
})
it('propagates remote failure without querying the local account', async () => {
transport.rpc.mockRejectedValue(new Error('Owner unavailable'))
await expect(
jiraListPrioritiesForTarget({ kind: 'environment', environmentId: 'owner' }, 'site-1')
).rejects.toThrow('Owner unavailable')
expect(transport.local).not.toHaveBeenCalled()
})
})
+6
View File
@@ -120,6 +120,12 @@ export type JiraIssue = {
assignee?: JiraUser
reporter?: JiraUser
priority?: JiraPriority
/** Parent issue (epic for stories, parent story for subtasks). */
parent?: { key: string; title: string; issueTypeName?: string }
sprint?: string
storyPoints?: number
originalEstimateSeconds?: number
remainingEstimateSeconds?: number
updatedAt: string
createdAt: string
}
+78 -7
View File
@@ -1,3 +1,4 @@
import type { Page } from '@stablyai/playwright-test'
import type { JiraIssue } from '../../src/shared/jira-types'
import { expect, test } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
@@ -13,18 +14,21 @@ const ISSUE: JiraIssue = {
issueType: { id: 'task', name: 'Task' },
status: { id: 'open', name: 'To Do', categoryKey: 'new', categoryName: 'To Do' },
labels: [],
parent: { key: 'DEMO-10', title: 'Epic planning' },
sprint: 'Sprint 12',
storyPoints: 5,
originalEstimateSeconds: 7200,
remainingEstimateSeconds: 1800,
priority: { id: 'high', name: 'High' },
assignee: { accountId: 'layout-user', displayName: 'Demo User' },
createdAt: '2026-10-01T00:00:00Z',
updatedAt: '2026-10-13T00:00:00Z'
}
test('Jira titles and sort headings adapt to panel width inside a wide window', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await orcaPage.setViewportSize({ width: 1500, height: 900 })
await orcaPage.evaluate((issue) => {
async function openFixtureJiraList(page: Page): Promise<void> {
await waitForSessionReady(page)
await page.setViewportSize({ width: 1500, height: 900 })
await page.evaluate((issue) => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
@@ -59,6 +63,12 @@ test('Jira titles and sort headings adapt to panel width inside a wide window',
})
store.getState().openTaskPage({ taskSource: 'jira' })
}, ISSUE)
}
test('Jira titles and sort headings adapt to panel width inside a wide window', async ({
orcaPage
}) => {
await openFixtureJiraList(orcaPage)
const title = orcaPage.getByRole('heading', { name: ISSUE_TITLE })
await expect(title).toBeVisible()
@@ -84,7 +94,7 @@ test('Jira titles and sort headings adapt to panel width inside a wide window',
const grids = await title.evaluate((element) => {
const row = element.closest('[role="button"]')
const host = row?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement
const header = host?.children[1]
const header = host?.querySelector('button[aria-pressed]')?.parentElement
if (!row || !header) {
throw new Error('Jira header or row unavailable')
}
@@ -100,3 +110,64 @@ test('Jira titles and sort headings adapt to panel width inside a wide window',
}
}
})
test('Jira planning columns persist and hiding a sorted field selects a visible fallback', async ({
orcaPage
}) => {
await openFixtureJiraList(orcaPage)
const title = orcaPage.getByRole('heading', { name: ISSUE_TITLE })
await expect(title).toBeVisible()
await title.evaluate((element) => {
const host =
element.closest('[role="button"]')?.parentElement?.parentElement?.parentElement?.parentElement
?.parentElement
if (!(host instanceof HTMLElement)) {
throw new Error('Jira list host unavailable')
}
host.style.width = '780px'
})
await orcaPage.getByRole('button', { name: 'Columns', exact: true }).click()
for (const name of ['Parent', 'Sprint', 'Points', 'Estimate', 'Remaining']) {
await orcaPage.getByRole('menuitemcheckbox', { name, exact: true }).click()
}
await orcaPage.keyboard.press('Escape')
await expect(orcaPage.getByText('Epic planning', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('Sprint 12', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('2h', { exact: true })).toBeVisible()
await expect(orcaPage.getByText('30m', { exact: true })).toBeVisible()
const layout = await title.evaluate((element) => {
const row = element.closest('[role="button"]')
const scroll = row?.parentElement?.parentElement?.parentElement?.parentElement
if (!(scroll instanceof HTMLElement)) {
throw new Error('Scroll container unavailable')
}
return {
title: element.getBoundingClientRect().width,
content: scroll.scrollWidth,
viewport: scroll.clientWidth
}
})
expect(layout.title).toBeGreaterThanOrEqual(180)
expect(layout.content).toBeGreaterThan(layout.viewport)
await orcaPage.getByRole('button', { name: 'Points', exact: true }).click()
await expect(orcaPage.getByRole('button', { name: 'Points, ascending' })).toHaveAttribute(
'aria-pressed',
'true'
)
await orcaPage.getByRole('button', { name: 'Columns', exact: true }).click()
await orcaPage.getByRole('menuitemcheckbox', { name: 'Points', exact: true }).click()
await orcaPage.keyboard.press('Escape')
await expect(orcaPage.getByRole('button', { name: 'Updated, descending' })).toHaveAttribute(
'aria-pressed',
'true'
)
await orcaPage.reload()
await openFixtureJiraList(orcaPage)
await orcaPage.getByRole('button', { name: 'Columns', exact: true }).click()
await expect(
orcaPage.getByRole('menuitemcheckbox', { name: 'Sprint', exact: true })
).toHaveAttribute('aria-checked', 'true')
await expect(
orcaPage.getByRole('menuitemcheckbox', { name: 'Points', exact: true })
).toHaveAttribute('aria-checked', 'false')
})