diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 8358490a9ba..1e2d446fb9a 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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, b: ReadonlySet): 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[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[number] | undefined, - fallback: string -): Record | 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 -): Record | undefined { - const customFields: Record = {} - 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() - 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, diff --git a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts index 984d73db719..bcbdbb750e6 100644 --- a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts +++ b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts @@ -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) diff --git a/src/renderer/src/components/task-page-github-task-kind.test.ts b/src/renderer/src/components/task-page-github-task-kind.test.ts new file mode 100644 index 00000000000..08ce6af3777 --- /dev/null +++ b/src/renderer/src/components/task-page-github-task-kind.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/task-page-github-task-kind.ts b/src/renderer/src/components/task-page-github-task-kind.ts new file mode 100644 index 00000000000..4b3a1a73645 --- /dev/null +++ b/src/renderer/src/components/task-page-github-task-kind.ts @@ -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}` +} diff --git a/src/renderer/src/components/task-page-jira-create-fields.test.ts b/src/renderer/src/components/task-page-jira-create-fields.test.ts new file mode 100644 index 00000000000..9c702b021c3 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-create-fields.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/renderer/src/components/task-page-jira-create-fields.ts b/src/renderer/src/components/task-page-jira-create-fields.ts new file mode 100644 index 00000000000..568621ee3e7 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-create-fields.ts @@ -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[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[number] | undefined, + fallback: string +): Record | 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 +): Record | undefined { + const customFields: Record = {} + 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 +} diff --git a/src/renderer/src/components/task-page-jira-project-selection.test.ts b/src/renderer/src/components/task-page-jira-project-selection.test.ts new file mode 100644 index 00000000000..82638f607cb --- /dev/null +++ b/src/renderer/src/components/task-page-jira-project-selection.test.ts @@ -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 { + 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']) + }) +}) diff --git a/src/renderer/src/components/task-page-jira-project-selection.ts b/src/renderer/src/components/task-page-jira-project-selection.ts new file mode 100644 index 00000000000..fe004b6cdf4 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-project-selection.ts @@ -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) +} diff --git a/src/renderer/src/components/task-page-jira-status-tone.test.ts b/src/renderer/src/components/task-page-jira-status-tone.test.ts new file mode 100644 index 00000000000..1c28127ce53 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-status-tone.test.ts @@ -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' + ) + }) + } +}) diff --git a/src/renderer/src/components/task-page-jira-status-tone.ts b/src/renderer/src/components/task-page-jira-status-tone.ts new file mode 100644 index 00000000000..adfb9730a89 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-status-tone.ts @@ -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' +} diff --git a/src/renderer/src/components/task-page-pagination-page-numbers.test.ts b/src/renderer/src/components/task-page-pagination-page-numbers.test.ts new file mode 100644 index 00000000000..a665ca4df92 --- /dev/null +++ b/src/renderer/src/components/task-page-pagination-page-numbers.test.ts @@ -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]) + }) +}) diff --git a/src/renderer/src/components/task-page-pagination-page-numbers.ts b/src/renderer/src/components/task-page-pagination-page-numbers.ts new file mode 100644 index 00000000000..f06e1accb24 --- /dev/null +++ b/src/renderer/src/components/task-page-pagination-page-numbers.ts @@ -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() + 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 +} diff --git a/src/renderer/src/components/task-page-pr-delta-summary.test.ts b/src/renderer/src/components/task-page-pr-delta-summary.test.ts new file mode 100644 index 00000000000..e3a48c47bd5 --- /dev/null +++ b/src/renderer/src/components/task-page-pr-delta-summary.test.ts @@ -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 { + 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') + }) +}) diff --git a/src/renderer/src/components/task-page-pr-delta-summary.ts b/src/renderer/src/components/task-page-pr-delta-summary.ts new file mode 100644 index 00000000000..9711d087cb0 --- /dev/null +++ b/src/renderer/src/components/task-page-pr-delta-summary.ts @@ -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 +} diff --git a/src/renderer/src/components/task-page-string-set-equality.test.ts b/src/renderer/src/components/task-page-string-set-equality.test.ts new file mode 100644 index 00000000000..bb69322c80d --- /dev/null +++ b/src/renderer/src/components/task-page-string-set-equality.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/task-page-string-set-equality.ts b/src/renderer/src/components/task-page-string-set-equality.ts new file mode 100644 index 00000000000..6314e801091 --- /dev/null +++ b/src/renderer/src/components/task-page-string-set-equality.ts @@ -0,0 +1,3 @@ +export function areStringSetsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + return a.size === b.size && [...a].every((value) => b.has(value)) +}