fix(jira): support user-picker fields in create-issue dialog

Jira's create-issue dialog rendered user fields (e.g. Reporter, schema
type "user") as a plain text Input and submitted the raw string. Jira
Cloud v3 requires the { id: accountId } object shape (display names and
usernames were removed in the 2019 GDPR change), so any required user
field rejected the create with "Reporter is required".

This adds schema-driven user-picker support end to end:

- buildJiraCreateFieldValue now maps user-picker drafts to { id } (single)
  or [{ id }, ...] (multi-user array), extracted alongside the rest of the
  create-field value logic into jira-create-field-value.ts so it is unit
  testable.
- A searchable JiraCreateUserPicker combobox replaces the text Input for
  user fields, backed by a new project-scoped search
  (listAssignableUsersForCreate -> /user/assignable/multiProjectSearch with
  a /user/search fallback) wired through IPC, the runtime RPC registry, and
  the runtime-jira-client wrapper so it works over SSH/remote runtimes.
- Required user fields default to the connected site's token owner so the
  dialog submits with zero interaction.

Detection is generic (schema type/items/custom), so assignee and custom
user-pickers work too, not just Reporter.

Fixes #4643
This commit is contained in:
Neil
2026-06-27 23:42:03 -07:00
parent b45cb545c5
commit 3ffbaf2e61
19 changed files with 800 additions and 84 deletions
+15
View File
@@ -7,6 +7,7 @@ import {
getIssue,
getIssueComments,
listAssignableUsers,
listAssignableUsersForCreate,
listCreateFields,
listIssueTypes,
listIssues,
@@ -253,6 +254,20 @@ export function registerJiraHandlers(): void {
}
)
ipcMain.handle(
'jira:listAssignableUsersForCreate',
async (_event, args: { projectKeyOrId: string; query?: string; siteId?: string }) => {
if (typeof args?.projectKeyOrId !== 'string' || !args.projectKeyOrId.trim()) {
return []
}
return listAssignableUsersForCreate(
args.projectKeyOrId.trim(),
typeof args.query === 'string' ? args.query : undefined,
normalizeSiteId(args.siteId)
)
}
)
ipcMain.handle('jira:listTransitions', async (_event, args: { key: string; siteId?: string }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
return []
+45
View File
@@ -365,6 +365,51 @@ describe('Jira issue operations', () => {
)
})
it('lists project-scoped assignable users for create via multiProjectSearch', async () => {
jiraRequestMock.mockResolvedValueOnce([
{ accountId: '5b10ac', displayName: 'Alex' },
{ displayName: 'No account id' }
])
const { listAssignableUsersForCreate } = await import('./issues')
await expect(listAssignableUsersForCreate('ORCA', 'al', 'site-1')).resolves.toEqual([
{ accountId: '5b10ac', displayName: 'Alex', email: undefined, avatarUrl: undefined }
])
const path = String(jiraRequestMock.mock.calls[0][1])
expect(path).toContain('/rest/api/3/user/assignable/multiProjectSearch?')
expect(path).toContain('projectKeys=ORCA')
expect(path).toContain('query=al')
})
it('falls back to /user/search when multiProjectSearch is unavailable (404)', async () => {
jiraRequestMock
.mockRejectedValueOnce(Object.assign(new Error('Not Found'), { status: 404 }))
.mockResolvedValueOnce([{ accountId: '5b10ac', displayName: 'Alex' }])
const { listAssignableUsersForCreate } = await import('./issues')
await expect(listAssignableUsersForCreate('ORCA', 'al', 'site-1')).resolves.toEqual([
{ accountId: '5b10ac', displayName: 'Alex', email: undefined, avatarUrl: undefined }
])
expect(String(jiraRequestMock.mock.calls[1][1])).toContain('/rest/api/3/user/search?')
})
it('clears the token and rethrows when create assignable search hits an auth error', async () => {
const authError = Object.assign(new Error('Unauthorized'), { status: 401 })
isAuthErrorMock.mockImplementation((error) => error === authError)
jiraRequestMock.mockRejectedValueOnce(authError)
const { listAssignableUsersForCreate } = await import('./issues')
await expect(listAssignableUsersForCreate('ORCA', undefined, 'site-1')).rejects.toThrow(
'Unauthorized'
)
expect(clearTokenMock).toHaveBeenCalledWith('site-1')
})
it('maps comments from the Jira comments page key', async () => {
jiraRequestMock.mockResolvedValueOnce({
comments: [
+53
View File
@@ -780,6 +780,59 @@ export async function listAssignableUsers(
}
}
export async function listAssignableUsersForCreate(
projectKeyOrId: string,
query?: string,
siteId?: string | null
): Promise<JiraUser[]> {
const entry = getClients(siteId)[0]
if (!entry) {
return []
}
const trimmedQuery = query?.trim() ?? ''
await acquire()
try {
// Why: the assignable/search endpoint needs an existing issueKey, which does
// not exist during create; multiProjectSearch is the project-scoped variant.
const multiProjectParams = new URLSearchParams({
projectKeys: projectKeyOrId,
maxResults: '50'
})
if (trimmedQuery) {
multiProjectParams.set('query', trimmedQuery)
}
try {
const response = await jiraRequest<JiraRecord[]>(
entry,
`/rest/api/3/user/assignable/multiProjectSearch?${multiProjectParams.toString()}`
)
return response.map(mapUser).filter((user): user is JiraUser => !!user)
} catch (error) {
// Why: some deployments (older/self-managed) lack multiProjectSearch; fall
// back to the general user search so the picker still resolves accountIds.
if (getErrorStatus(error) !== 404) {
throw error
}
const fallbackParams = new URLSearchParams({ maxResults: '50' })
fallbackParams.set('query', trimmedQuery)
const response = await jiraRequest<JiraRecord[]>(
entry,
`/rest/api/3/user/search?${fallbackParams.toString()}`
)
return response.map(mapUser).filter((user): user is JiraUser => !!user)
}
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.site.id)
throw error
}
console.warn('[jira] listAssignableUsersForCreate failed:', error)
return []
} finally {
release()
}
}
export async function listTransitions(
key: string,
siteId?: string | null
+9
View File
@@ -495,6 +495,7 @@ import {
getIssue as getJiraIssue,
getIssueComments as getJiraIssueComments,
listAssignableUsers as listJiraAssignableUsers,
listAssignableUsersForCreate as listJiraAssignableUsersForCreate,
listCreateFields as listJiraCreateFields,
listIssueTypes as listJiraIssueTypes,
listIssues as listJiraIssues,
@@ -20878,6 +20879,14 @@ export class OrcaRuntimeService {
return listJiraAssignableUsers(key, query, siteId)
}
jiraListAssignableUsersForCreate(
projectKeyOrId: string,
query?: string,
siteId?: string
): ReturnType<typeof listJiraAssignableUsersForCreate> {
return listJiraAssignableUsersForCreate(projectKeyOrId, query, siteId)
}
jiraListTransitions(key: string, siteId?: string): ReturnType<typeof listJiraTransitions> {
return listJiraTransitions(key, siteId)
}
@@ -125,6 +125,7 @@ describe('jira RPC methods', () => {
jiraListCreateFields: vi.fn().mockResolvedValue([{ key: 'customfield_10010' }]),
jiraListPriorities: vi.fn().mockResolvedValue([{ id: 'priority-1' }]),
jiraListAssignableUsers: vi.fn().mockResolvedValue([{ accountId: 'user-1' }]),
jiraListAssignableUsersForCreate: vi.fn().mockResolvedValue([{ accountId: 'user-2' }]),
jiraListTransitions: vi.fn().mockResolvedValue([{ id: 'transition-1' }])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS })
@@ -148,6 +149,13 @@ describe('jira RPC methods', () => {
siteId: 'site-1'
})
)
await dispatcher.dispatch(
makeRequest('jira.listAssignableUsersForCreate', {
projectKeyOrId: 'ORCA',
query: 'Ada',
siteId: 'site-1'
})
)
await dispatcher.dispatch(
makeRequest('jira.listTransitions', { key: 'ABC-3', siteId: 'site-1' })
)
@@ -157,6 +165,7 @@ describe('jira RPC methods', () => {
expect(runtime.jiraListCreateFields).toHaveBeenCalledWith('project-1', 'type-1', 'site-1')
expect(runtime.jiraListPriorities).toHaveBeenCalledWith('site-1')
expect(runtime.jiraListAssignableUsers).toHaveBeenCalledWith('ABC-3', 'Ada', 'site-1')
expect(runtime.jiraListAssignableUsersForCreate).toHaveBeenCalledWith('ORCA', 'Ada', 'site-1')
expect(runtime.jiraListTransitions).toHaveBeenCalledWith('ABC-3', 'site-1')
})
})
+16
View File
@@ -88,6 +88,12 @@ const AssignableUsers = z.object({
siteId: OptionalString
})
const AssignableUsersForCreate = z.object({
projectKeyOrId: requiredString('Project is required'),
query: OptionalPlainString,
siteId: OptionalString
})
export const JIRA_METHODS: RpcMethod[] = [
defineMethod({
name: 'jira.connect',
@@ -199,6 +205,16 @@ export const JIRA_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.jiraListAssignableUsers(params.key.trim(), params.query, params.siteId)
}),
defineMethod({
name: 'jira.listAssignableUsersForCreate',
params: AssignableUsersForCreate,
handler: async (params, { runtime }) =>
runtime.jiraListAssignableUsersForCreate(
params.projectKeyOrId.trim(),
params.query,
params.siteId
)
}),
defineMethod({
name: 'jira.listTransitions',
params: IssueKey,
+5
View File
@@ -1725,6 +1725,11 @@ export type PreloadApi = {
query?: string
siteId?: string
}) => Promise<JiraUser[]>
listAssignableUsersForCreate: (args: {
projectKeyOrId: string
query?: string
siteId?: string
}) => Promise<JiraUser[]>
listTransitions: (args: { key: string; siteId?: string }) => Promise<JiraTransition[]>
}
starNag: {
+6
View File
@@ -1535,6 +1535,12 @@ const api = {
siteId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('jira:listAssignableUsers', args),
listAssignableUsersForCreate: (args: {
projectKeyOrId: string
query?: string
siteId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('jira:listAssignableUsersForCreate', args),
listTransitions: (args: { key: string; siteId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('jira:listTransitions', args)
},
+67 -80
View File
@@ -90,6 +90,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import TaskProjectSourceCombobox from '@/components/task-project-source-combobox'
import JiraCreateUserPicker from '@/components/jira-create-user-picker'
import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog'
import { LinearScopeSelector } from '@/components/linear-scope-selector'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
@@ -233,7 +234,13 @@ 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 {
buildJiraCreateCustomFields,
getJiraCreateAllowedValueLabel,
isJiraUserPickerField,
isMultiJiraUserPickerField,
isVisibleJiraCreateField
} from '@/components/jira-create-field-value'
import {
GITHUB_PR_MERGE_METHOD_LABELS,
resolveGitHubPRMergeMethods
@@ -253,6 +260,7 @@ import type {
JiraIssue,
JiraIssueType,
JiraProject,
JiraUser,
LinearIssue,
LinearProjectDetail,
LinearProjectSummary,
@@ -945,84 +953,6 @@ function compareJiraProjectsByDisplayLabel(
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
}
function GHStatusCell({
item,
repo,
@@ -5704,6 +5634,46 @@ export default function TaskPage(): React.JSX.Element {
[jiraCreateFields]
)
// Why: each JiraSite carries the API-token owner's accountId; used to default
// required user-picker fields (e.g. Reporter) so create works with no input.
const newJiraIssueTokenOwner = useMemo<JiraUser | null>(() => {
const sites = jiraStatus.sites ?? []
const projectSiteId = newJiraIssueTargetProject?.siteId
const ownerSite =
(projectSiteId ? sites.find((site) => site.id === projectSiteId) : null) ??
selectedJiraSite ??
(sites.length === 1 ? sites[0] : null)
if (!ownerSite?.accountId) {
return null
}
return { accountId: ownerSite.accountId, displayName: ownerSite.displayName }
}, [jiraStatus.sites, newJiraIssueTargetProject, selectedJiraSite])
const newJiraIssueTokenOwnerKnownUsers = useMemo(
() => (newJiraIssueTokenOwner ? [newJiraIssueTokenOwner] : []),
[newJiraIssueTokenOwner]
)
// Prefill empty required user-picker fields with the token owner so a required
// Reporter resolves to a valid accountId without any user interaction.
useEffect(() => {
const ownerAccountId = newJiraIssueTokenOwner?.accountId
if (!ownerAccountId || visibleJiraCreateFields.length === 0) {
return
}
setNewJiraIssueCustomFieldValues((prev) => {
let changed = false
const next = { ...prev }
for (const field of visibleJiraCreateFields) {
if (isJiraUserPickerField(field) && !(prev[field.key] ?? '').trim()) {
next[field.key] = ownerAccountId
changed = true
}
}
return changed ? next : prev
})
}, [newJiraIssueTokenOwner, visibleJiraCreateFields])
const hasMissingJiraCreateField = useMemo(
() =>
visibleJiraCreateFields.some(
@@ -12336,7 +12306,24 @@ export default function TaskPage(): React.JSX.Element {
<label className="text-[11px] font-medium text-muted-foreground">
{field.name}
</label>
{field.allowedValues?.length && field.schema?.type !== 'array' ? (
{isJiraUserPickerField(field) ? (
<JiraCreateUserPicker
fieldName={field.name}
value={fieldValue}
onChange={(value) =>
setNewJiraIssueCustomFieldValues((prev) => ({
...prev,
[field.key]: value
}))
}
multiple={isMultiJiraUserPickerField(field)}
projectKeyOrId={newJiraIssueTargetProject?.key ?? ''}
siteId={newJiraIssueTargetProject?.siteId}
runtimeSettings={jiraTaskSourceContext ?? settings}
knownUsers={newJiraIssueTokenOwnerKnownUsers}
disabled={newJiraIssueSubmitting}
/>
) : field.allowedValues?.length && field.schema?.type !== 'array' ? (
<Select
value={fieldValue}
onValueChange={(value) =>
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
buildJiraCreateCustomFields,
buildJiraCreateFieldValue,
isJiraUserPickerField,
isMultiJiraUserPickerField
} from './jira-create-field-value'
import type { JiraCreateField } from '../../../shared/types'
function field(overrides: Partial<JiraCreateField> = {}): JiraCreateField {
return {
key: 'reporter',
name: 'Reporter',
required: true,
...overrides
}
}
describe('isJiraUserPickerField', () => {
it('detects schema.type "user"', () => {
expect(isJiraUserPickerField(field({ schema: { type: 'user' } }))).toBe(true)
})
it('detects array of users as a user picker', () => {
expect(isJiraUserPickerField(field({ schema: { type: 'array', items: 'user' } }))).toBe(true)
})
it('detects user-picker custom types', () => {
expect(
isJiraUserPickerField(
field({ schema: { type: 'any', custom: 'com.atlassian.jira.plugin:userpicker' } })
)
).toBe(true)
expect(
isJiraUserPickerField(
field({ schema: { type: 'array', custom: 'com.atlassian.jira.plugin:multiuserpicker' } })
)
).toBe(true)
})
it('is false for non-user fields', () => {
expect(isJiraUserPickerField(field({ schema: { type: 'string' } }))).toBe(false)
expect(isJiraUserPickerField(field({}))).toBe(false)
})
})
describe('isMultiJiraUserPickerField', () => {
it('is true only for array user pickers', () => {
expect(isMultiJiraUserPickerField(field({ schema: { type: 'array', items: 'user' } }))).toBe(
true
)
expect(isMultiJiraUserPickerField(field({ schema: { type: 'user' } }))).toBe(false)
})
})
describe('buildJiraCreateFieldValue user pickers', () => {
it('returns { id: accountId } for a single user-picker field', () => {
expect(buildJiraCreateFieldValue(field({ schema: { type: 'user' } }), '5b10ac')).toEqual({
id: '5b10ac'
})
})
it('trims surrounding whitespace before wrapping the accountId', () => {
expect(buildJiraCreateFieldValue(field({ schema: { type: 'user' } }), ' 5b10ac ')).toEqual({
id: '5b10ac'
})
})
it('returns an array of { id } for a multi-user-picker field', () => {
expect(
buildJiraCreateFieldValue(field({ schema: { type: 'array', items: 'user' } }), 'a1, b2 ,c3')
).toEqual([{ id: 'a1' }, { id: 'b2' }, { id: 'c3' }])
})
it('returns undefined when the user-picker draft is empty', () => {
expect(buildJiraCreateFieldValue(field({ schema: { type: 'user' } }), ' ')).toBeUndefined()
expect(
buildJiraCreateFieldValue(field({ schema: { type: 'array', items: 'user' } }), '')
).toBeUndefined()
})
it('does not regress plain string fields', () => {
expect(
buildJiraCreateFieldValue(field({ key: 'x', schema: { type: 'string' } }), 'hello')
).toBe('hello')
})
})
describe('buildJiraCreateCustomFields', () => {
it('shapes a required Reporter user-picker as { reporter: { id } }', () => {
const fields = [field({ key: 'reporter', schema: { type: 'user' } })]
expect(buildJiraCreateCustomFields(fields, { reporter: '5b10ac' })).toEqual({
reporter: { id: '5b10ac' }
})
})
it('omits empty user-picker fields', () => {
const fields = [field({ key: 'reporter', schema: { type: 'user' } })]
expect(buildJiraCreateCustomFields(fields, { reporter: '' })).toBeUndefined()
})
})
@@ -0,0 +1,119 @@
import type { JiraCreateField } from '../../../shared/types'
import { buildJiraCreateTextAdf } from '@/components/jira-create-adf'
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)
}
// Why: Jira v3 only accepts a user as { id: accountId } (display names/usernames
// were removed in the 2019 GDPR change), so any free-text value is rejected.
// User-picker fields carry no allowedValues, so detect them via the schema.
const JIRA_USER_PICKER_CUSTOM_SUFFIXES = [':userpicker', ':people', ':multiuserpicker']
export function isJiraUserPickerField(field: JiraCreateField): boolean {
const schema = field.schema
if (!schema) {
return false
}
if (schema.type === 'user') {
return true
}
if (schema.type === 'array' && schema.items === 'user') {
return true
}
const custom = schema.custom ?? ''
return JIRA_USER_PICKER_CUSTOM_SUFFIXES.some((suffix) => custom.includes(suffix))
}
export function isMultiJiraUserPickerField(field: JiraCreateField): boolean {
return (
field.schema?.type === 'array' &&
(field.schema.items === 'user' || (field.schema.custom ?? '').includes(':multiuserpicker'))
)
}
export 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 splitCommaSeparated(value: string): string[] {
return value
.split(',')
.map((part) => part.trim())
.filter(Boolean)
}
export function buildJiraCreateFieldValue(field: JiraCreateField, draftValue: string): unknown {
const trimmed = draftValue.trim()
if (!trimmed) {
return undefined
}
// Why: user-picker fields store accountId(s) in the draft and Jira v3 requires
// the { id: accountId } shape, so this must run before the generic array path.
if (isJiraUserPickerField(field)) {
if (isMultiJiraUserPickerField(field)) {
return splitCommaSeparated(trimmed).map((id) => ({ id }))
}
return { id: trimmed }
}
if (field.schema?.type === 'array') {
const parts = splitCommaSeparated(trimmed)
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,219 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronsUpDown, LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Command, CommandInput, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { JiraUser } from '../../../shared/types'
import {
jiraListCreateAssignableUsers,
type RuntimeJiraSettings
} from '@/runtime/runtime-jira-client'
// The Jira create dialog stores selected accountIds (comma-separated for the
// multi-user variant) so the existing Record<string,string> draft shape is kept.
const MULTI_USER_SEPARATOR = ','
type JiraCreateUserPickerProps = {
fieldName: string
/** Comma-separated accountId(s) currently selected. */
value: string
onChange: (next: string) => void
multiple: boolean
projectKeyOrId: string
siteId?: string | null
runtimeSettings: RuntimeJiraSettings
/** Known users (e.g. the prefilled token owner) so labels resolve immediately. */
knownUsers?: readonly JiraUser[]
disabled?: boolean
}
function parseSelectedAccountIds(value: string): string[] {
return value
.split(MULTI_USER_SEPARATOR)
.map((id) => id.trim())
.filter(Boolean)
}
function mergeUsers(...sources: readonly (readonly JiraUser[])[]): JiraUser[] {
const byAccountId = new Map<string, JiraUser>()
for (const source of sources) {
for (const user of source) {
if (!byAccountId.has(user.accountId)) {
byAccountId.set(user.accountId, user)
}
}
}
return [...byAccountId.values()]
}
export default function JiraCreateUserPicker({
fieldName,
value,
onChange,
multiple,
projectKeyOrId,
siteId,
runtimeSettings,
knownUsers,
disabled
}: JiraCreateUserPickerProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<JiraUser[]>([])
const [loading, setLoading] = useState(false)
const requestIdRef = useRef(0)
const selectedAccountIds = useMemo(() => parseSelectedAccountIds(value), [value])
// Why: the picker only ever holds accountIds in its draft, so resolved labels
// come from search results plus any users the parent already knows about.
const resolvedUsers = useMemo(() => mergeUsers(results, knownUsers ?? []), [knownUsers, results])
const selectedUsers = useMemo(
() =>
selectedAccountIds.map(
(accountId) =>
resolvedUsers.find((user) => user.accountId === accountId) ?? {
accountId,
displayName: accountId
}
),
[resolvedUsers, selectedAccountIds]
)
useEffect(() => {
if (!open) {
return
}
requestIdRef.current += 1
const requestId = requestIdRef.current
setLoading(true)
// Why: debounce so typing a name does not fire a project-user search per
// keystroke; the runtime wrapper also guards oversized queries before RPC.
const timer = window.setTimeout(() => {
void jiraListCreateAssignableUsers(
runtimeSettings,
projectKeyOrId,
query || undefined,
siteId
)
.then((users) => {
if (requestId === requestIdRef.current) {
setResults(users)
}
})
.catch(() => {
if (requestId === requestIdRef.current) {
setResults([])
}
})
.finally(() => {
if (requestId === requestIdRef.current) {
setLoading(false)
}
})
}, 250)
return () => {
window.clearTimeout(timer)
}
}, [open, projectKeyOrId, query, runtimeSettings, siteId])
const toggleUser = (accountId: string): void => {
if (!multiple) {
onChange(accountId)
setOpen(false)
return
}
const next = selectedAccountIds.includes(accountId)
? selectedAccountIds.filter((id) => id !== accountId)
: [...selectedAccountIds, accountId]
onChange(next.join(MULTI_USER_SEPARATOR))
}
const triggerLabel =
selectedUsers.length === 0
? translate('auto.components.jira.create.user.picker.select', 'Select {{value0}}', {
value0: fieldName
})
: selectedUsers.map((user) => user.displayName).join(', ')
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="h-9 w-full justify-between px-3 text-sm font-normal"
>
<span className={cn('truncate', selectedUsers.length === 0 && 'text-muted-foreground')}>
{triggerLabel}
</span>
<ChevronsUpDown className="size-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(360px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0"
>
<Command shouldFilter={false}>
<CommandInput
autoFocus
placeholder={translate(
'auto.components.jira.create.user.picker.search',
'Search users...'
)}
value={query}
onValueChange={setQuery}
className="text-sm"
/>
<CommandList>
{loading ? (
<div className="flex items-center justify-center gap-2 px-3 py-6 text-xs text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{translate('auto.components.jira.create.user.picker.loading', 'Searching…')}
</div>
) : resolvedUsers.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{translate('auto.components.jira.create.user.picker.empty', 'No users found.')}
</div>
) : (
resolvedUsers.map((user) => {
const selected = selectedAccountIds.includes(user.accountId)
return (
<button
key={user.accountId}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => toggleUser(user.accountId)}
className="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Check
className={cn(
'size-3 text-muted-foreground',
selected ? 'opacity-70' : 'opacity-0'
)}
/>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="size-5 rounded-full" />
) : null}
<div className="min-w-0 flex-1">
<div className="truncate">{user.displayName}</div>
{user.email ? (
<p className="truncate text-[10px] text-muted-foreground">{user.email}</p>
) : null}
</div>
</button>
)
})
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+10
View File
@@ -11738,6 +11738,16 @@
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
"8388bdea2b": "Connect Jira site"
}
},
"create": {
"user": {
"picker": {
"select": "Select {{value0}}",
"search": "Search users...",
"loading": "Searching…",
"empty": "No users found."
}
}
}
},
"rightSidebar": {
+10
View File
@@ -11738,6 +11738,16 @@
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
"8388bdea2b": "Connect Jira site"
}
},
"create": {
"user": {
"picker": {
"select": "Select {{value0}}",
"search": "Search users...",
"loading": "Searching…",
"empty": "No users found."
}
}
}
},
"rightSidebar": {
+10
View File
@@ -11738,6 +11738,16 @@
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
"8388bdea2b": "Connect Jira site"
}
},
"create": {
"user": {
"picker": {
"select": "Select {{value0}}",
"search": "Search users...",
"loading": "Searching…",
"empty": "No users found."
}
}
}
},
"rightSidebar": {
+10
View File
@@ -11738,6 +11738,16 @@
"d785c42b8b": "Jira Cloud 사이트 URL, Atlassian 이메일, API 토큰을 사용해 이슈를 탐색합니다.",
"8388bdea2b": "Jira 사이트 연결"
}
},
"create": {
"user": {
"picker": {
"select": "Select {{value0}}",
"search": "Search users...",
"loading": "Searching…",
"empty": "No users found."
}
}
}
},
"rightSidebar": {
+10
View File
@@ -11738,6 +11738,16 @@
"d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API 令牌来浏览议题。",
"8388bdea2b": "连接 Jira 站点"
}
},
"create": {
"user": {
"picker": {
"select": "Select {{value0}}",
"search": "Search users...",
"loading": "Searching…",
"empty": "No users found."
}
}
}
},
"rightSidebar": {
@@ -1,24 +1,40 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { jiraListAssignableUsers, jiraSearchIssues } from './runtime-jira-client'
import {
jiraListAssignableUsers,
jiraListCreateAssignableUsers,
jiraSearchIssues
} from './runtime-jira-client'
import {
clearRuntimeCompatibilityCacheForTests,
markRuntimeEnvironmentCompatible
} from './runtime-rpc-client'
const jiraSearchIssuesLocal = vi.fn()
const jiraListAssignableUsersLocal = vi.fn()
const jiraListCreateAssignableUsersLocal = vi.fn()
const runtimeCall = vi.fn()
const runtimeEnvironmentsCall = vi.fn()
beforeEach(() => {
jiraSearchIssuesLocal.mockReset()
jiraListAssignableUsersLocal.mockReset()
jiraListCreateAssignableUsersLocal.mockReset()
runtimeCall.mockReset()
runtimeEnvironmentsCall.mockReset()
vi.stubGlobal('window', {
api: {
jira: {
searchIssues: jiraSearchIssuesLocal,
listAssignableUsers: jiraListAssignableUsersLocal
listAssignableUsers: jiraListAssignableUsersLocal,
listAssignableUsersForCreate: jiraListCreateAssignableUsersLocal
},
runtime: {
call: runtimeCall
},
runtimeEnvironments: {
call: runtimeCall
call: runtimeEnvironmentsCall
}
}
})
@@ -26,6 +42,7 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals()
clearRuntimeCompatibilityCacheForTests()
})
describe('runtime Jira client search bounds', () => {
@@ -47,6 +64,53 @@ describe('runtime Jira client search bounds', () => {
).resolves.toEqual([])
expect(jiraListAssignableUsersLocal).not.toHaveBeenCalled()
expect(runtimeCall).not.toHaveBeenCalled()
expect(runtimeEnvironmentsCall).not.toHaveBeenCalled()
})
})
describe('jiraListCreateAssignableUsers', () => {
it('rejects oversized queries before any IPC/RPC', async () => {
await expect(
jiraListCreateAssignableUsers(null, 'ORCA', 'x'.repeat(9 * 1024), 'site-1')
).resolves.toEqual([])
expect(jiraListCreateAssignableUsersLocal).not.toHaveBeenCalled()
expect(runtimeEnvironmentsCall).not.toHaveBeenCalled()
})
it('calls the local IPC target with the project key when no runtime env is set', async () => {
const users = [{ accountId: '5b10ac', displayName: 'Alex' }]
jiraListCreateAssignableUsersLocal.mockResolvedValue(users)
await expect(jiraListCreateAssignableUsers(null, 'ORCA', 'al', 'site-1')).resolves.toEqual(
users
)
expect(jiraListCreateAssignableUsersLocal).toHaveBeenCalledWith({
projectKeyOrId: 'ORCA',
query: 'al',
siteId: 'site-1'
})
expect(runtimeEnvironmentsCall).not.toHaveBeenCalled()
})
it('routes through the runtime environment RPC when one is active', async () => {
const users = [{ accountId: '5b10ac', displayName: 'Alex' }]
runtimeEnvironmentsCall.mockResolvedValue({ ok: true, result: users })
// Pre-mark compatible so the call skips the status.get handshake and we can
// assert the single search RPC routed to the environment target.
markRuntimeEnvironmentCompatible('env-1')
await expect(
jiraListCreateAssignableUsers({ activeRuntimeEnvironmentId: 'env-1' }, 'ORCA', 'al', 'site-1')
).resolves.toEqual(users)
expect(jiraListCreateAssignableUsersLocal).not.toHaveBeenCalled()
expect(runtimeEnvironmentsCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'jira.listAssignableUsersForCreate',
params: { projectKeyOrId: 'ORCA', query: 'al', siteId: 'site-1' },
timeoutMs: 30_000
})
})
})
@@ -269,6 +269,24 @@ export async function jiraListAssignableUsers(
: window.api.jira.listAssignableUsers(args)
}
export async function jiraListCreateAssignableUsers(
settings: RuntimeJiraSettings,
projectKeyOrId: string,
query?: string,
siteId?: string | null
): Promise<JiraUser[]> {
if (!isRuntimeProviderSearchQueryWithinLimit(query)) {
return []
}
const target = getJiraRuntimeTarget(settings)
const args = { projectKeyOrId, query, siteId: siteId ?? undefined }
return target.kind === 'environment'
? callRuntimeRpc<JiraUser[]>(target, 'jira.listAssignableUsersForCreate', args, {
timeoutMs: 30_000
})
: window.api.jira.listAssignableUsersForCreate(args)
}
export async function jiraListTransitions(
settings: RuntimeJiraSettings,
key: string,