refactor(task-page): extract pure task-kind, jira, and pagination helpers

Moves seven closed sets of pure helpers out of TaskPage.tsx (13485 -> 13308
lines) into domain-named sibling modules. Function bodies are byte-identical
cut/paste; the only production edits in TaskPage.tsx are the removed blocks
and the new import statements.

- task-page-github-task-kind.ts: isPRFocusedTaskView, normalizeGitHubTaskPreset,
  getGitHubTaskKind, getDefaultPresetForGitHubTaskKind, scopeGitHubTaskSearch
- task-page-jira-create-fields.ts: the Jira create-field visibility, allowed-value,
  and payload builders
- task-page-jira-project-selection.ts: getJiraProjectSelectionKey,
  compareJiraProjectsByDisplayLabel
- task-page-jira-status-tone.ts: getJiraStatusTone
- task-page-pr-delta-summary.ts: formatPRDelta
- task-page-pagination-page-numbers.ts: getPageNumbers
- task-page-string-set-equality.ts: areStringSetsEqual

Each module gets a characterization test suite that pins current behavior,
including the quirks (case-sensitive matching, truthiness-based option payload
fallbacks, allowedValues winning over schema type). Quirks are documented, not
fixed.

github-enterprise-slug-routing-boundary.test.ts anchored its source-text
sections on `function formatPRDelta` and `function getPageNumbers`; both moved,
so the sentinels advance to the next declarations. The bounded sections and
their assertions are unchanged.

No intentional behavior change.
This commit is contained in:
Jinjing
2026-08-07 22:53:37 -07:00
parent bba32bd00c
commit 408475b36d
16 changed files with 754 additions and 198 deletions
+19 -196
View File
@@ -340,7 +340,6 @@ import {
} from '@/components/task-page-jira-load-state'
import { deriveTaskPagePRCheckSummary } from '@/components/task-page-pr-check-summary'
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
import { buildJiraCreateTextAdf } from '@/components/jira-create-adf'
import {
GITHUB_PR_MERGE_METHOD_LABELS,
resolveGitHubPRMergeMethods
@@ -444,6 +443,25 @@ import {
type LinearViewMode
} from '@/components/task-page-localized-options'
import { useGitHubTaskSearchCommit } from '@/components/use-github-task-search-commit'
import {
getDefaultPresetForGitHubTaskKind,
getGitHubTaskKind,
normalizeGitHubTaskPreset,
scopeGitHubTaskSearch
} from '@/components/task-page-github-task-kind'
import { areStringSetsEqual } from '@/components/task-page-string-set-equality'
import { getJiraStatusTone } from '@/components/task-page-jira-status-tone'
import {
compareJiraProjectsByDisplayLabel,
getJiraProjectSelectionKey
} from '@/components/task-page-jira-project-selection'
import {
buildJiraCreateCustomFields,
getJiraCreateAllowedValueLabel,
isVisibleJiraCreateField
} from '@/components/task-page-jira-create-fields'
import { formatPRDelta } from '@/components/task-page-pr-delta-summary'
import { getPageNumbers } from '@/components/task-page-pagination-page-numbers'
function isGitLabMRFilter(value: GitLabTaskFilter | GitLabIssueFilter): value is GitLabTaskFilter {
return value === 'opened' || value === 'merged' || value === 'closed' || value === 'all'
@@ -608,46 +626,6 @@ const GITHUB_TASK_STICKY_TITLE_CELL_CLASS = cn(
GITHUB_TASK_ROW_HOVER_SURFACE_CLASS
)
function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): boolean {
if (preset === 'prs' || preset === 'my-prs' || preset === 'review') {
return true
}
const parsed = parseTaskQuery(query)
return (
parsed.scope === 'pr' ||
parsed.state === 'merged' ||
parsed.draft ||
parsed.reviewRequested !== null ||
parsed.reviewedBy !== null
)
}
function normalizeGitHubTaskPreset(preset: TaskViewPresetId | null | undefined): TaskViewPresetId {
// Why: the split Issues/PRs tabs dropped the mixed "All" view, so legacy saved defaults land on the first tab instead of mixing rows.
return !preset || preset === 'all' ? 'issues' : preset
}
function getGitHubTaskKind(preset: TaskViewPresetId | null, query: string): GitHubTaskKind {
return isPRFocusedTaskView(preset, query) ? 'prs' : 'issues'
}
function getDefaultPresetForGitHubTaskKind(kind: GitHubTaskKind): TaskViewPresetId {
return kind === 'prs' ? 'prs' : 'issues'
}
function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string {
const trimmed = query.trim()
if (!trimmed) {
return getTaskPresetQuery(getDefaultPresetForGitHubTaskKind(kind))
}
if (/\bis:(?:issue|pr|pull-request)\b/i.test(trimmed)) {
return trimmed
}
const parsed = parseTaskQuery(trimmed)
const inferredKind = parsed.scope === 'pr' ? 'prs' : parsed.scope === 'issue' ? 'issues' : kind
return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}`
}
function formatRelativeTime(input: string): string {
return formatUiRelativeTimeFromDate(input)
}
@@ -994,125 +972,6 @@ function getLinearIssueGridTemplate(visibleProperties: ReadonlySet<LinearDisplay
return columns.join(' ')
}
function areStringSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
return a.size === b.size && [...a].every((value) => b.has(value))
}
function getJiraStatusTone(categoryKey: string): string {
if (categoryKey === 'done') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
}
if (categoryKey === 'indeterminate') {
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200'
}
return 'border-border/50 bg-muted/40 text-muted-foreground'
}
function getJiraProjectSelectionKey(project: JiraProject): string {
return `${project.siteId ?? 'selected'}:${project.id}`
}
const jiraProjectLabelCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base'
})
function compareJiraProjectsByDisplayLabel(
a: JiraProject,
b: JiraProject,
includeSiteName: boolean
): number {
const siteComparison = includeSiteName
? jiraProjectLabelCollator.compare(a.siteName ?? '', b.siteName ?? '')
: 0
if (siteComparison !== 0) {
return siteComparison
}
const nameComparison = jiraProjectLabelCollator.compare(a.name, b.name)
if (nameComparison !== 0) {
return nameComparison
}
return jiraProjectLabelCollator.compare(a.key, b.key)
}
const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set(['project', 'issuetype', 'summary', 'description'])
function isVisibleJiraCreateField(field: JiraCreateField): boolean {
return field.required && !JIRA_CREATE_SYSTEM_FIELD_KEYS.has(field.key)
}
function getJiraCreateAllowedValueLabel(
value: NonNullable<JiraCreateField['allowedValues']>[number]
): string {
return value.name ?? value.value ?? value.id ?? 'Option'
}
function findJiraCreateAllowedValue(field: JiraCreateField, draftValue: string) {
return field.allowedValues?.find((value) => {
return value.id === draftValue || value.value === draftValue || value.name === draftValue
})
}
function getJiraCreateOptionPayload(
value: NonNullable<JiraCreateField['allowedValues']>[number] | undefined,
fallback: string
): Record<string, string> | string {
if (value?.id) {
return { id: value.id }
}
if (value?.value) {
return { value: value.value }
}
if (value?.name) {
return { name: value.name }
}
return fallback
}
function buildJiraCreateFieldValue(field: JiraCreateField, draftValue: string): unknown {
const trimmed = draftValue.trim()
if (!trimmed) {
return undefined
}
if (field.schema?.type === 'array') {
const parts = trimmed
.split(',')
.map((part) => part.trim())
.filter(Boolean)
if (field.allowedValues?.length) {
return parts.map((part) =>
getJiraCreateOptionPayload(findJiraCreateAllowedValue(field, part), part)
)
}
return parts
}
if (field.allowedValues?.length) {
return getJiraCreateOptionPayload(findJiraCreateAllowedValue(field, trimmed), trimmed)
}
if (field.schema?.type === 'number') {
const numberValue = Number(trimmed)
return Number.isFinite(numberValue) ? numberValue : trimmed
}
if (field.schema?.custom?.includes(':textarea') || field.schema?.type === 'textarea') {
return buildJiraCreateTextAdf(trimmed)
}
return trimmed
}
function buildJiraCreateCustomFields(
fields: readonly JiraCreateField[],
values: Record<string, string>
): Record<string, unknown> | undefined {
const customFields: Record<string, unknown> = {}
for (const field of fields) {
const value = buildJiraCreateFieldValue(field, values[field.key] ?? '')
if (value !== undefined) {
customFields[field.key] = value
}
}
return Object.keys(customFields).length > 0 ? customFields : undefined
}
type TaskPageGitHubWorkItemMutationRunner = {
run: (input: {
item: GitHubWorkItem
@@ -1544,20 +1403,6 @@ function GHStatusCell({
)
}
function formatPRDelta(item: GitHubWorkItem): string | null {
const parts: string[] = []
if (typeof item.additions === 'number') {
parts.push(`+${item.additions}`)
}
if (typeof item.deletions === 'number') {
parts.push(`-${item.deletions}`)
}
if (typeof item.changedFiles === 'number') {
parts.push(`${item.changedFiles} ${item.changedFiles === 1 ? 'file' : 'files'}`)
}
return parts.length > 0 ? parts.join(' ') : null
}
function ReviewChipAvatar({
reviewer,
avatarHost
@@ -3005,28 +2850,6 @@ function PRMergeCell({
)
}
// Builds the page-number array with ellipsis gaps: first, last, and a window around the current page.
function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
if (total <= 9) {
return Array.from({ length: total }, (_, i) => i)
}
const pages = new Set<number>()
pages.add(0)
pages.add(total - 1)
for (let i = Math.max(0, current - 2); i <= Math.min(total - 1, current + 2); i++) {
pages.add(i)
}
const sorted = [...pages].sort((a, b) => a - b)
const result: (number | 'ellipsis')[] = []
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
result.push('ellipsis')
}
result.push(sorted[i])
}
return result
}
function PaginationBar({
currentPage,
totalPages,
@@ -17,7 +17,13 @@ function sourceBetween(source: string, startPattern: string, endPattern: string)
describe('GitHub Enterprise slug routing boundaries', () => {
it('keeps work-item URL hosts on TaskPage metadata and issue mutations', () => {
const source = componentSource('TaskPage.tsx')
const statusSection = sourceBetween(source, 'function GHStatusCell', 'function formatPRDelta')
// Why: the end sentinel just bounds GHStatusCell's body — formatPRDelta moved out to
// task-page-pr-delta-summary.ts, so the next declaration is the boundary now.
const statusSection = sourceBetween(
source,
'function GHStatusCell',
'function ReviewChipAvatar'
)
// Why: the end sentinel just bounds GHAssigneesCell's body — getChecksLabel moved out to
// task-page-checks-pill.ts, so the next declaration is the boundary now.
const assigneeSection = sourceBetween(
@@ -34,7 +40,9 @@ describe('GitHub Enterprise slug routing boundaries', () => {
it('uses URL-host fallback for TaskPage reviewer and merge mutations', () => {
const source = componentSource('TaskPage.tsx')
const reviewSection = sourceBetween(source, 'function PRReviewCell', 'function PRChecksCell')
const mergeSection = sourceBetween(source, 'function PRMergeCell', 'function getPageNumbers')
// Why: the end sentinel just bounds PRMergeCell's body — getPageNumbers moved out to
// task-page-pagination-page-numbers.ts, so the next declaration is the boundary now.
const mergeSection = sourceBetween(source, 'function PRMergeCell', 'function PaginationBar')
expect(reviewSection).toContain('resolveTaskPullRequestRepo(item)')
expect(reviewSection.match(/prRepo: reviewRepo/g)).toHaveLength(4)
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import {
getDefaultPresetForGitHubTaskKind,
getGitHubTaskKind,
isPRFocusedTaskView,
normalizeGitHubTaskPreset,
scopeGitHubTaskSearch
} from './task-page-github-task-kind'
describe('isPRFocusedTaskView', () => {
const presetCases = [
{ preset: 'prs' as const, expected: true },
{ preset: 'my-prs' as const, expected: true },
{ preset: 'review' as const, expected: true },
{ preset: 'issues' as const, expected: false },
{ preset: 'my-issues' as const, expected: false },
{ preset: 'all' as const, expected: false },
{ preset: null, expected: false }
]
for (const { preset, expected } of presetCases) {
it(`returns ${expected} for preset ${preset ?? 'null'} with an empty query`, () => {
expect(isPRFocusedTaskView(preset, '')).toBe(expected)
})
}
const queryCases = [
{ query: 'is:pr', expected: true },
{ query: 'is:pull-request', expected: true },
{ query: 'is:merged', expected: true },
{ query: 'is:draft', expected: true },
{ query: 'review-requested:@me', expected: true },
{ query: 'reviewed-by:octocat', expected: true },
{ query: 'is:issue is:open', expected: false },
{ query: 'assignee:@me', expected: false },
{ query: 'flaky login', expected: false },
// characterization: current behavior — is:issue plus is:pr widens scope to
// 'all', which is not PR-focused on its own.
{ query: 'is:issue is:pr', expected: false }
]
for (const { query, expected } of queryCases) {
it(`returns ${expected} for query "${query}" on the issues preset`, () => {
expect(isPRFocusedTaskView('issues', query)).toBe(expected)
})
}
})
describe('normalizeGitHubTaskPreset', () => {
const cases = [
{ input: null, expected: 'issues' },
{ input: undefined, expected: 'issues' },
{ input: 'all' as const, expected: 'issues' },
{ input: 'issues' as const, expected: 'issues' },
{ input: 'my-issues' as const, expected: 'my-issues' },
{ input: 'prs' as const, expected: 'prs' },
{ input: 'my-prs' as const, expected: 'my-prs' },
{ input: 'review' as const, expected: 'review' }
]
for (const { input, expected } of cases) {
it(`maps ${String(input)} to ${expected}`, () => {
expect(normalizeGitHubTaskPreset(input)).toBe(expected)
})
}
})
describe('getGitHubTaskKind', () => {
it('reports prs when the preset is PR-focused', () => {
expect(getGitHubTaskKind('review', '')).toBe('prs')
})
it('reports prs when only the query is PR-focused', () => {
expect(getGitHubTaskKind('issues', 'is:pr is:open')).toBe('prs')
})
it('reports issues otherwise', () => {
expect(getGitHubTaskKind(null, 'is:issue is:open')).toBe('issues')
})
})
describe('getDefaultPresetForGitHubTaskKind', () => {
it('maps prs to the prs preset', () => {
expect(getDefaultPresetForGitHubTaskKind('prs')).toBe('prs')
})
it('maps issues to the issues preset', () => {
expect(getDefaultPresetForGitHubTaskKind('issues')).toBe('issues')
})
})
describe('scopeGitHubTaskSearch', () => {
it('falls back to the kind default preset query when the input is blank', () => {
expect(scopeGitHubTaskSearch('', 'issues')).toBe('is:issue is:open')
expect(scopeGitHubTaskSearch(' ', 'prs')).toBe('is:pr is:open')
})
const alreadyScoped = ['is:pr is:open', 'is:issue label:bug', 'IS:PR is:open', 'is:pull-request']
for (const query of alreadyScoped) {
it(`leaves "${query}" untouched apart from trimming`, () => {
expect(scopeGitHubTaskSearch(` ${query} `, 'issues')).toBe(query)
})
}
it('prefixes is:issue when the caller kind is issues', () => {
expect(scopeGitHubTaskSearch('login bug', 'issues')).toBe('is:issue login bug')
})
it('prefixes is:pr when the caller kind is prs', () => {
expect(scopeGitHubTaskSearch('login bug', 'prs')).toBe('is:pr login bug')
})
it('lets a parsed pr scope override the caller kind', () => {
// characterization: current behavior — `is:draft` implies a pr scope, so the
// issues kind is overridden even though no literal is:pr token is present.
expect(scopeGitHubTaskSearch('is:draft', 'issues')).toBe('is:pr is:draft')
})
it('lets a parsed issue scope override the caller kind', () => {
// characterization: current behavior — scope comes from parseTaskQuery, and
// `no:assignee` alone does not set one, so the caller kind wins.
expect(scopeGitHubTaskSearch('no:assignee', 'prs')).toBe('is:pr no:assignee')
})
it('re-prefixes a merged-state query with the caller kind', () => {
// characterization: current behavior — `is:merged` sets state, not scope, so
// the caller kind decides the prefix.
expect(scopeGitHubTaskSearch('is:merged', 'prs')).toBe('is:pr is:merged')
})
})
@@ -0,0 +1,46 @@
import { getTaskPresetQuery } from '@/lib/new-workspace'
import { parseTaskQuery } from '../../../shared/task-query'
import type { TaskViewPresetId } from '../../../shared/types'
import type { GitHubTaskKind } from '@/components/task-page-localized-options'
export function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): boolean {
if (preset === 'prs' || preset === 'my-prs' || preset === 'review') {
return true
}
const parsed = parseTaskQuery(query)
return (
parsed.scope === 'pr' ||
parsed.state === 'merged' ||
parsed.draft ||
parsed.reviewRequested !== null ||
parsed.reviewedBy !== null
)
}
export function normalizeGitHubTaskPreset(
preset: TaskViewPresetId | null | undefined
): TaskViewPresetId {
// Why: the split Issues/PRs tabs dropped the mixed "All" view, so legacy saved defaults land on the first tab instead of mixing rows.
return !preset || preset === 'all' ? 'issues' : preset
}
export function getGitHubTaskKind(preset: TaskViewPresetId | null, query: string): GitHubTaskKind {
return isPRFocusedTaskView(preset, query) ? 'prs' : 'issues'
}
export function getDefaultPresetForGitHubTaskKind(kind: GitHubTaskKind): TaskViewPresetId {
return kind === 'prs' ? 'prs' : 'issues'
}
export function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string {
const trimmed = query.trim()
if (!trimmed) {
return getTaskPresetQuery(getDefaultPresetForGitHubTaskKind(kind))
}
if (/\bis:(?:issue|pr|pull-request)\b/i.test(trimmed)) {
return trimmed
}
const parsed = parseTaskQuery(trimmed)
const inferredKind = parsed.scope === 'pr' ? 'prs' : parsed.scope === 'issue' ? 'issues' : kind
return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}`
}
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import {
buildJiraCreateCustomFields,
buildJiraCreateFieldValue,
findJiraCreateAllowedValue,
getJiraCreateAllowedValueLabel,
getJiraCreateOptionPayload,
isVisibleJiraCreateField
} from './task-page-jira-create-fields'
import type { JiraCreateField } from '../../../shared/types'
function field(overrides: Partial<JiraCreateField> = {}): JiraCreateField {
return { key: 'customfield_1', name: 'Custom', required: true, ...overrides }
}
describe('isVisibleJiraCreateField', () => {
const cases = [
{ key: 'customfield_1', required: true, expected: true },
{ key: 'customfield_1', required: false, expected: false },
{ key: 'project', required: true, expected: false },
{ key: 'issuetype', required: true, expected: false },
{ key: 'summary', required: true, expected: false },
{ key: 'description', required: true, expected: false },
// characterization: current behavior — the system-field filter is exact-match
// and case-sensitive, so a differently-cased key stays visible.
{ key: 'Summary', required: true, expected: true }
]
for (const { key, required, expected } of cases) {
it(`returns ${expected} for ${key} (required: ${required})`, () => {
expect(isVisibleJiraCreateField(field({ key, required }))).toBe(expected)
})
}
})
describe('getJiraCreateAllowedValueLabel', () => {
it('prefers name, then value, then id', () => {
expect(getJiraCreateAllowedValueLabel({ id: 'i', value: 'v', name: 'n' })).toBe('n')
expect(getJiraCreateAllowedValueLabel({ id: 'i', value: 'v' })).toBe('v')
expect(getJiraCreateAllowedValueLabel({ id: 'i' })).toBe('i')
})
it('falls back to Option when nothing is set', () => {
expect(getJiraCreateAllowedValueLabel({})).toBe('Option')
})
})
describe('findJiraCreateAllowedValue', () => {
const withValues = field({
allowedValues: [
{ id: 'id-1', value: 'val-1', name: 'Name 1' },
{ id: 'id-2', value: 'val-2', name: 'Name 2' }
]
})
it('matches on id, value, or name', () => {
expect(findJiraCreateAllowedValue(withValues, 'id-2')?.id).toBe('id-2')
expect(findJiraCreateAllowedValue(withValues, 'val-1')?.id).toBe('id-1')
expect(findJiraCreateAllowedValue(withValues, 'Name 2')?.id).toBe('id-2')
})
it('returns undefined when there is no match or no allowed values', () => {
expect(findJiraCreateAllowedValue(withValues, 'nope')).toBeUndefined()
expect(findJiraCreateAllowedValue(field(), 'id-1')).toBeUndefined()
})
})
describe('getJiraCreateOptionPayload', () => {
it('prefers id, then value, then name', () => {
expect(getJiraCreateOptionPayload({ id: 'i', value: 'v', name: 'n' }, 'fb')).toEqual({
id: 'i'
})
expect(getJiraCreateOptionPayload({ value: 'v', name: 'n' }, 'fb')).toEqual({ value: 'v' })
expect(getJiraCreateOptionPayload({ name: 'n' }, 'fb')).toEqual({ name: 'n' })
})
it('returns the raw fallback string when the option is absent or empty', () => {
expect(getJiraCreateOptionPayload(undefined, 'fb')).toBe('fb')
expect(getJiraCreateOptionPayload({}, 'fb')).toBe('fb')
})
it('skips empty-string members', () => {
// characterization: current behavior — the checks are truthiness-based, so an
// empty id falls through to the next candidate.
expect(getJiraCreateOptionPayload({ id: '', value: 'v' }, 'fb')).toEqual({ value: 'v' })
})
})
describe('buildJiraCreateFieldValue', () => {
it('returns undefined for blank drafts', () => {
expect(buildJiraCreateFieldValue(field(), '')).toBeUndefined()
expect(buildJiraCreateFieldValue(field(), ' ')).toBeUndefined()
})
it('trims plain string values', () => {
expect(buildJiraCreateFieldValue(field(), ' hello ')).toBe('hello')
})
it('splits array fields on commas and drops blanks', () => {
const arrayField = field({ schema: { type: 'array' } })
expect(buildJiraCreateFieldValue(arrayField, 'a, b ,, c')).toEqual(['a', 'b', 'c'])
})
it('maps array parts through allowed values', () => {
const arrayField = field({
schema: { type: 'array' },
allowedValues: [{ id: 'id-1', name: 'Name 1' }]
})
expect(buildJiraCreateFieldValue(arrayField, 'Name 1, other')).toEqual([
{ id: 'id-1' },
'other'
])
})
it('maps scalar option fields through allowed values', () => {
const optionField = field({ allowedValues: [{ id: 'id-1', name: 'Name 1' }] })
expect(buildJiraCreateFieldValue(optionField, 'Name 1')).toEqual({ id: 'id-1' })
expect(buildJiraCreateFieldValue(optionField, 'Unknown')).toBe('Unknown')
})
it('coerces finite numbers and keeps unparseable text', () => {
const numberField = field({ schema: { type: 'number' } })
expect(buildJiraCreateFieldValue(numberField, ' 42 ')).toBe(42)
expect(buildJiraCreateFieldValue(numberField, 'abc')).toBe('abc')
// characterization: current behavior — Infinity is not finite, so the trimmed
// text is kept instead of the coerced number.
expect(buildJiraCreateFieldValue(numberField, 'Infinity')).toBe('Infinity')
})
it('builds an ADF document for textarea fields', () => {
const custom = field({ schema: { custom: 'com.atlassian.jira:textarea' } })
expect(buildJiraCreateFieldValue(custom, 'line one\nline two')).toEqual({
type: 'doc',
version: 1,
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'line one' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'line two' }] }
]
})
expect(buildJiraCreateFieldValue(field({ schema: { type: 'textarea' } }), 'x')).toEqual({
type: 'doc',
version: 1,
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }]
})
})
it('prefers allowed values over the number and textarea branches', () => {
// characterization: current behavior — the allowedValues branch runs before the
// schema-type branches.
const numeric = field({ schema: { type: 'number' }, allowedValues: [{ id: 'id-1' }] })
expect(buildJiraCreateFieldValue(numeric, '7')).toBe('7')
})
})
describe('buildJiraCreateCustomFields', () => {
it('returns undefined when every field resolves to undefined', () => {
expect(buildJiraCreateCustomFields([field({ key: 'a' })], {})).toBeUndefined()
expect(buildJiraCreateCustomFields([], { a: 'x' })).toBeUndefined()
})
it('collects only the fields with values, keyed by field key', () => {
const fields = [field({ key: 'a' }), field({ key: 'b' }), field({ key: 'c' })]
expect(buildJiraCreateCustomFields(fields, { a: 'one', b: ' ', c: 'three' })).toEqual({
a: 'one',
c: 'three'
})
})
it('treats a missing draft entry as blank', () => {
expect(buildJiraCreateCustomFields([field({ key: 'a' })], { other: 'x' })).toBeUndefined()
})
})
@@ -0,0 +1,80 @@
import { buildJiraCreateTextAdf } from '@/components/jira-create-adf'
import type { JiraCreateField } from '../../../shared/types'
const JIRA_CREATE_SYSTEM_FIELD_KEYS = new Set(['project', 'issuetype', 'summary', 'description'])
export function isVisibleJiraCreateField(field: JiraCreateField): boolean {
return field.required && !JIRA_CREATE_SYSTEM_FIELD_KEYS.has(field.key)
}
export function getJiraCreateAllowedValueLabel(
value: NonNullable<JiraCreateField['allowedValues']>[number]
): string {
return value.name ?? value.value ?? value.id ?? 'Option'
}
export function findJiraCreateAllowedValue(field: JiraCreateField, draftValue: string) {
return field.allowedValues?.find((value) => {
return value.id === draftValue || value.value === draftValue || value.name === draftValue
})
}
export function getJiraCreateOptionPayload(
value: NonNullable<JiraCreateField['allowedValues']>[number] | undefined,
fallback: string
): Record<string, string> | string {
if (value?.id) {
return { id: value.id }
}
if (value?.value) {
return { value: value.value }
}
if (value?.name) {
return { name: value.name }
}
return fallback
}
export function buildJiraCreateFieldValue(field: JiraCreateField, draftValue: string): unknown {
const trimmed = draftValue.trim()
if (!trimmed) {
return undefined
}
if (field.schema?.type === 'array') {
const parts = trimmed
.split(',')
.map((part) => part.trim())
.filter(Boolean)
if (field.allowedValues?.length) {
return parts.map((part) =>
getJiraCreateOptionPayload(findJiraCreateAllowedValue(field, part), part)
)
}
return parts
}
if (field.allowedValues?.length) {
return getJiraCreateOptionPayload(findJiraCreateAllowedValue(field, trimmed), trimmed)
}
if (field.schema?.type === 'number') {
const numberValue = Number(trimmed)
return Number.isFinite(numberValue) ? numberValue : trimmed
}
if (field.schema?.custom?.includes(':textarea') || field.schema?.type === 'textarea') {
return buildJiraCreateTextAdf(trimmed)
}
return trimmed
}
export function buildJiraCreateCustomFields(
fields: readonly JiraCreateField[],
values: Record<string, string>
): Record<string, unknown> | undefined {
const customFields: Record<string, unknown> = {}
for (const field of fields) {
const value = buildJiraCreateFieldValue(field, values[field.key] ?? '')
if (value !== undefined) {
customFields[field.key] = value
}
}
return Object.keys(customFields).length > 0 ? customFields : undefined
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import {
compareJiraProjectsByDisplayLabel,
getJiraProjectSelectionKey
} from './task-page-jira-project-selection'
import type { JiraProject } from '../../../shared/types'
function project(overrides: Partial<JiraProject> = {}): JiraProject {
return { id: '1', key: 'AA', name: 'Alpha', ...overrides }
}
describe('getJiraProjectSelectionKey', () => {
it('qualifies the project id with its site id', () => {
expect(getJiraProjectSelectionKey(project({ id: '10', siteId: 'site-1' }))).toBe('site-1:10')
})
it('falls back to the "selected" site sentinel when siteId is absent', () => {
expect(getJiraProjectSelectionKey(project({ id: '10' }))).toBe('selected:10')
})
it('treats an empty siteId as a real site id', () => {
// characterization: current behavior — `??` only replaces null/undefined, so an
// empty siteId produces a leading-colon key.
expect(getJiraProjectSelectionKey(project({ id: '10', siteId: '' }))).toBe(':10')
})
})
describe('compareJiraProjectsByDisplayLabel', () => {
const sortOf = (projects: JiraProject[], includeSiteName: boolean): string[] =>
[...projects]
.sort((a, b) => compareJiraProjectsByDisplayLabel(a, b, includeSiteName))
.map((entry) => `${entry.siteName ?? ''}/${entry.name}/${entry.key}`)
it('orders by site name first when site names are included', () => {
const projects = [
project({ name: 'Alpha', key: 'AA', siteName: 'Zeta' }),
project({ name: 'Beta', key: 'BB', siteName: 'Acme' })
]
expect(sortOf(projects, true)).toEqual(['Acme/Beta/BB', 'Zeta/Alpha/AA'])
})
it('ignores site name when it is not included', () => {
const projects = [
project({ name: 'Alpha', key: 'AA', siteName: 'Zeta' }),
project({ name: 'Beta', key: 'BB', siteName: 'Acme' })
]
expect(sortOf(projects, false)).toEqual(['Zeta/Alpha/AA', 'Acme/Beta/BB'])
})
it('falls back to project name, then project key', () => {
const projects = [
project({ name: 'Alpha', key: 'AB' }),
project({ name: 'Alpha', key: 'AA' }),
project({ name: 'Beta', key: 'BA' })
]
expect(sortOf(projects, false)).toEqual(['/Alpha/AA', '/Alpha/AB', '/Beta/BA'])
})
it('sorts numeric segments naturally and case-insensitively', () => {
const projects = [project({ name: 'Team 10', key: 'A' }), project({ name: 'team 2', key: 'B' })]
expect(sortOf(projects, false)).toEqual(['/team 2/B', '/Team 10/A'])
})
it('returns 0 for projects with identical display labels', () => {
expect(compareJiraProjectsByDisplayLabel(project(), project({ id: '2' }), true)).toBe(0)
})
it('treats a missing site name as an empty string when comparing sites', () => {
const withSite = project({ name: 'Zeta', key: 'ZZ', siteName: 'Acme' })
const withoutSite = project({ name: 'Alpha', key: 'AA' })
expect(sortOf([withSite, withoutSite], true)).toEqual(['/Alpha/AA', 'Acme/Zeta/ZZ'])
})
})
@@ -0,0 +1,28 @@
import type { JiraProject } from '../../../shared/types'
export function getJiraProjectSelectionKey(project: JiraProject): string {
return `${project.siteId ?? 'selected'}:${project.id}`
}
const jiraProjectLabelCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base'
})
export function compareJiraProjectsByDisplayLabel(
a: JiraProject,
b: JiraProject,
includeSiteName: boolean
): number {
const siteComparison = includeSiteName
? jiraProjectLabelCollator.compare(a.siteName ?? '', b.siteName ?? '')
: 0
if (siteComparison !== 0) {
return siteComparison
}
const nameComparison = jiraProjectLabelCollator.compare(a.name, b.name)
if (nameComparison !== 0) {
return nameComparison
}
return jiraProjectLabelCollator.compare(a.key, b.key)
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { getJiraStatusTone } from './task-page-jira-status-tone'
describe('getJiraStatusTone', () => {
it('uses the emerald tone for the done category', () => {
expect(getJiraStatusTone('done')).toBe(
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
)
})
it('uses the sky tone for the indeterminate category', () => {
expect(getJiraStatusTone('indeterminate')).toBe(
'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200'
)
})
const neutral = ['new', 'undefined', '', 'Done', 'DONE', 'in-progress']
for (const categoryKey of neutral) {
it(`uses the neutral tone for "${categoryKey}"`, () => {
// characterization: current behavior — the match is exact and case-sensitive.
expect(getJiraStatusTone(categoryKey)).toBe(
'border-border/50 bg-muted/40 text-muted-foreground'
)
})
}
})
@@ -0,0 +1,9 @@
export function getJiraStatusTone(categoryKey: string): string {
if (categoryKey === 'done') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
}
if (categoryKey === 'indeterminate') {
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-200'
}
return 'border-border/50 bg-muted/40 text-muted-foreground'
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { getPageNumbers } from './task-page-pagination-page-numbers'
describe('getPageNumbers', () => {
it('lists every zero-based page when the total fits the no-ellipsis threshold', () => {
expect(getPageNumbers(0, 1)).toEqual([0])
expect(getPageNumbers(4, 9)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
})
it('returns an empty list when there are no pages', () => {
expect(getPageNumbers(0, 0)).toEqual([])
})
it('collapses the tail when the current page is near the start', () => {
expect(getPageNumbers(0, 20)).toEqual([0, 1, 2, 'ellipsis', 19])
})
it('collapses the head when the current page is near the end', () => {
expect(getPageNumbers(19, 20)).toEqual([0, 'ellipsis', 17, 18, 19])
})
it('collapses both sides around a middle page', () => {
expect(getPageNumbers(10, 20)).toEqual([0, 'ellipsis', 8, 9, 10, 11, 12, 'ellipsis', 19])
})
it('omits the ellipsis when the window is exactly adjacent to an edge', () => {
// characterization: current behavior — a gap of exactly 1 is not elided.
expect(getPageNumbers(3, 10)).toEqual([0, 1, 2, 3, 4, 5, 'ellipsis', 9])
})
it('clamps the window to the valid page range', () => {
expect(getPageNumbers(-5, 12)).toEqual([0, 'ellipsis', 11])
expect(getPageNumbers(100, 12)).toEqual([0, 'ellipsis', 11])
})
})
@@ -0,0 +1,21 @@
// Builds the page-number array with ellipsis gaps: first, last, and a window around the current page.
export function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
if (total <= 9) {
return Array.from({ length: total }, (_, i) => i)
}
const pages = new Set<number>()
pages.add(0)
pages.add(total - 1)
for (let i = Math.max(0, current - 2); i <= Math.min(total - 1, current + 2); i++) {
pages.add(i)
}
const sorted = [...pages].sort((a, b) => a - b)
const result: (number | 'ellipsis')[] = []
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
result.push('ellipsis')
}
result.push(sorted[i])
}
return result
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { formatPRDelta } from './task-page-pr-delta-summary'
import type { GitHubWorkItem } from '../../../shared/types'
function prItem(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
return {
id: 'pr-1',
type: 'pr',
number: 1,
title: 'Fix login',
state: 'open',
url: 'https://github.com/acme/app/pull/1',
labels: [],
updatedAt: '2026-01-01T00:00:00Z',
author: 'octocat',
repoId: 'repo-1',
...overrides
}
}
describe('formatPRDelta', () => {
it('returns null when no diffstat fields are present', () => {
expect(formatPRDelta(prItem())).toBeNull()
})
it('renders additions, deletions, and file count in order', () => {
expect(formatPRDelta(prItem({ additions: 12, deletions: 3, changedFiles: 4 }))).toBe(
'+12 -3 4 files'
)
})
it('singularizes a one-file change', () => {
expect(formatPRDelta(prItem({ changedFiles: 1 }))).toBe('1 file')
})
it('pluralizes a zero-file change', () => {
// characterization: current behavior — only exactly 1 is singular.
expect(formatPRDelta(prItem({ changedFiles: 0 }))).toBe('0 files')
})
it('keeps zero values because the check is on the type, not truthiness', () => {
expect(formatPRDelta(prItem({ additions: 0, deletions: 0 }))).toBe('+0 -0')
})
it('omits fields that are absent', () => {
expect(formatPRDelta(prItem({ additions: 5 }))).toBe('+5')
expect(formatPRDelta(prItem({ deletions: 5 }))).toBe('-5')
})
it('renders negative deletions with the literal minus prefix', () => {
// characterization: current behavior — the prefix is unconditional.
expect(formatPRDelta(prItem({ deletions: -2 }))).toBe('--2')
})
})
@@ -0,0 +1,15 @@
import type { GitHubWorkItem } from '../../../shared/types'
export function formatPRDelta(item: GitHubWorkItem): string | null {
const parts: string[] = []
if (typeof item.additions === 'number') {
parts.push(`+${item.additions}`)
}
if (typeof item.deletions === 'number') {
parts.push(`-${item.deletions}`)
}
if (typeof item.changedFiles === 'number') {
parts.push(`${item.changedFiles} ${item.changedFiles === 1 ? 'file' : 'files'}`)
}
return parts.length > 0 ? parts.join(' ') : null
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { areStringSetsEqual } from './task-page-string-set-equality'
describe('areStringSetsEqual', () => {
it('treats two empty sets as equal', () => {
expect(areStringSetsEqual(new Set(), new Set())).toBe(true)
})
it('ignores insertion order', () => {
expect(areStringSetsEqual(new Set(['a', 'b']), new Set(['b', 'a']))).toBe(true)
})
it('rejects differing sizes', () => {
expect(areStringSetsEqual(new Set(['a']), new Set(['a', 'b']))).toBe(false)
expect(areStringSetsEqual(new Set(['a', 'b']), new Set(['a']))).toBe(false)
})
it('rejects same-size sets with different members', () => {
expect(areStringSetsEqual(new Set(['a', 'b']), new Set(['a', 'c']))).toBe(false)
})
it('compares by exact string identity', () => {
expect(areStringSetsEqual(new Set(['A']), new Set(['a']))).toBe(false)
expect(areStringSetsEqual(new Set(['']), new Set(['']))).toBe(true)
})
})
@@ -0,0 +1,3 @@
export function areStringSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
return a.size === b.size && [...a].every((value) => b.has(value))
}