Pick Jira users for required Reporter and custom user fields (#31972)

* fix(jira): pick real users for required create fields

* test(jira): keep user picker coverage within renderer boundary

* fix(jira): preserve known user identities and metadata choices

* test(jira): preserve full dialog fixture model type

* test(jira): target metadata Select trigger

* fix(jira): show matching people before known ID action
This commit is contained in:
Brennan Benson
2026-10-23 15:58:12 -07:00
committed by GitHub
parent 79cb7fd4bc
commit 87eccfe9f4
13 changed files with 276 additions and 11 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ export async function searchUsers(query?: string, siteId?: string | null): Promi
clearToken(entry.site.id)
throw error
}
// Browse-users permission is optional; the dialog falls back to a text field.
// Browse-users permission is optional; the dialog allows explicit known user IDs.
console.warn('[jira] searchUsers failed:', error)
return []
} finally {
@@ -56,7 +56,8 @@ export function JiraUserPicker({
disabled,
label,
fixedOptions,
searchUsers
searchUsers,
allowKnownUserId
}: {
providerSettings: RuntimeJiraSettings
siteId?: string | null
@@ -69,11 +70,13 @@ export function JiraUserPicker({
fixedOptions?: JiraUserPickerFixedOption[]
/** Replaces the site-wide user search, e.g. with a project-scoped assignable search. Must be referentially stable. */
searchUsers?: (query: string) => Promise<JiraUser[]>
allowKnownUserId?: boolean
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [users, setUsers] = useState<JiraUser[]>([])
const [loading, setLoading] = useState(false)
const knownUserId = query.trim()
useEffect(() => {
if (!open) {
@@ -166,6 +169,20 @@ export function JiraUserPicker({
}}
/>
)}
{allowKnownUserId && knownUserId ? (
<button
type="button"
onClick={() => {
onSelect({ accountId: knownUserId, displayName: knownUserId })
setOpen(false)
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent"
>
{translate('components.jiraUserPicker.useKnownId', 'Use known user ID "{{value0}}"', {
value0: knownUserId
})}
</button>
) : null}
</PopoverContent>
</Popover>
)
@@ -83,6 +83,9 @@ export function buildJiraCreateFieldValue(field: JiraCreateField, draftValue: st
if (!trimmed) {
return undefined
}
if (isJiraScalarUserCreateField(field)) {
return trimmed
}
if (field.schema?.type === 'array') {
const parts = trimmed
.split(',')
@@ -31,8 +31,12 @@ import {
SelectItem
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { getJiraCreateAllowedValueLabel } from '@/components/task-page-jira-create-fields'
import {
getJiraCreateAllowedValueLabel,
isJiraScalarUserCreateField
} from '@/components/task-page-jira-create-fields'
import { TaskPageJiraIssueAssigneeField } from './IssueAssigneeField'
import { TaskPageJiraIssueUserField } from './IssueUserField'
export function TaskPageJiraIssueDialog({
model
}: {
@@ -265,7 +269,28 @@ export function TaskPageJiraIssueDialog({
<label className="text-[11px] font-medium text-muted-foreground">
{field.name}
</label>
{field.allowedValues?.length && field.schema?.type !== 'array' ? (
{isJiraScalarUserCreateField(field) && !field.allowedValues?.length ? (
model.jiraTaskSourceContext ? (
<TaskPageJiraIssueUserField
key={JSON.stringify([
model.providerRuntimeContextKey,
newJiraIssueTargetProjectSelectionKey,
newJiraIssueTargetType?.id
])}
providerSettings={model.jiraTaskSourceContext}
siteId={newJiraIssueTargetProject?.siteId}
value={fieldValue}
onChange={(id) =>
setNewJiraIssueCustomFieldValues((prev) => ({
...prev,
[field.key]: id
}))
}
disabled={newJiraIssueSubmitting}
label={field.name}
/>
) : null
) : field.allowedValues?.length && field.schema?.type !== 'array' ? (
<Select
value={fieldValue}
onValueChange={(value) =>
@@ -0,0 +1,176 @@
// @vitest-environment happy-dom
import { useRef, useState } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { TaskPageJiraIssueDialog } from './IssueDialog'
import { useTaskPageJiraIssueCreation } from '../../use-task-page-jira-issue-creation'
import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions'
import type { JiraCreateField } from '../../../../../shared/jira-types'
import type { TaskSourceContext } from '../../../../../shared/task-source-context'
const { search, create, getIssue } = vi.hoisted(() => ({
search: vi.fn(),
create: vi.fn(),
getIssue: vi.fn()
}))
vi.mock('@/runtime/runtime-jira-client', () => ({
jiraSearchUsers: search,
jiraCreateIssue: create,
jiraGetIssue: getIssue
}))
afterEach(cleanup)
beforeEach(() => {
vi.resetAllMocks()
create.mockResolvedValue({ ok: true, key: 'APP-1' })
getIssue.mockResolvedValue(null)
})
const source: TaskSourceContext = {
kind: 'task-source',
provider: 'jira',
projectId: 'folder-project',
hostId: 'local'
}
const reporter: JiraCreateField = {
key: 'reporter',
name: 'Reporter',
required: true,
schema: { type: 'user' }
}
function CreateDialog({ field = reporter }: { field?: JiraCreateField }) {
const [values, setValues] = useState<Record<string, string>>({})
const [open, setOpen] = useState(true)
const [submitting, setSubmitting] = useState(false)
const model = {
settings: null,
providerRuntimeContextKey: 'local',
providerRuntimeContextKeyRef: useRef('local'),
jiraTaskSourceContext: source,
newJiraIssueOpen: open,
setNewJiraIssueOpen: setOpen,
newJiraIssueTitle: 'Fix reporter selection',
setNewJiraIssueTitle: vi.fn(),
handleNewJiraIssueTitleChange: vi.fn(),
newJiraIssueSummaryAvailable: false,
newJiraIssueSummaryGenerating: false,
newJiraIssueSummaryError: null,
handleGenerateNewJiraIssueSummary: vi.fn(),
handleCancelNewJiraIssueSummaryGeneration: vi.fn(),
newJiraIssueBody: '',
setNewJiraIssueBody: vi.fn(),
newJiraIssueSubmitting: submitting,
setNewJiraIssueSubmitting: setSubmitting,
newJiraIssueProjectComboboxOpen: false,
newJiraIssueProjectQuery: '',
newJiraIssueProjectCommandValue: '',
newJiraIssueTypeId: 'bug',
setNewJiraIssueTypeId: vi.fn(),
availableJiraIssueTypes: [{ id: 'bug', name: 'Bug' }],
jiraIssueTypesLoading: false,
jiraCreateFields: [field],
jiraCreateFieldsLoading: false,
jiraCreateFieldsError: null,
visibleJiraCreateFields: [field],
newJiraIssueCustomFieldValues: values,
setNewJiraIssueCustomFieldValues: setValues,
hasMissingJiraCreateField: !(values[field.key] ?? '').trim(),
sortedAvailableJiraProjects: [],
filteredNewJiraIssueProjects: [],
includeJiraSiteNameInProjectLabel: false,
newJiraIssueTargetProject: { id: '100', key: 'APP', name: 'App', siteId: 'site-1' },
newJiraIssueTargetProjectSelectionKey: 'site-1:100',
newJiraIssueTargetType: { id: 'bug', name: 'Bug' },
newJiraIssueAssignee: null,
setNewJiraIssueAssignee: vi.fn(),
discardNewJiraIssueDraft: vi.fn(),
setJiraRefreshNonce: vi.fn(),
setJiraIssues: vi.fn(),
setSelectedJiraIssue: vi.fn(),
submitShortcutLabel: 'Ctrl+Enter'
} satisfies Partial<TaskPageComposerActionsModel>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Supplies all fields read by the shipping dialog and creation hook; unrelated task-page controls are inactive.
const dialogModel = model as unknown as TaskPageComposerActionsModel
useTaskPageJiraIssueCreation(dialogModel)
return <TaskPageJiraIssueDialog model={dialogModel} />
}
async function submitIdentity(key: string, id: string) {
fireEvent.click(screen.getByRole('button', { name: 'Create issue' }))
await waitFor(() =>
expect(create).toHaveBeenCalledExactlyOnceWith(source, {
siteId: 'site-1',
projectId: '100',
issueTypeId: 'bug',
title: 'Fix reporter selection',
description: undefined,
customFields: { [key]: id },
userFieldKeys: [key]
})
)
}
it('shipping schema dispatch selects a directory user and submits identity, not display text', async () => {
search.mockResolvedValue([{ accountId: 'account-123', displayName: 'Ada Lovelace' }])
render(<CreateDialog />)
fireEvent.click(screen.getByRole('button', { name: 'Reporter' }))
fireEvent.change(screen.getByPlaceholderText('Search users'), {
target: { value: 'Ada Lovelace' }
})
expect(screen.getByRole('button', { name: 'Create issue' }).hasAttribute('disabled')).toBe(true)
expect(create).not.toHaveBeenCalled()
const directoryUser = await screen.findByRole('button', { name: 'Ada Lovelace' })
expect(
screen.getAllByRole('button', { name: /Ada Lovelace/ }).map((row) => row.textContent)
).toEqual(['Ada Lovelace', 'Use known user ID "Ada Lovelace"'])
fireEvent.click(directoryUser)
expect(search).toHaveBeenCalledWith(source, 'Ada Lovelace', 'site-1')
expect(screen.getByRole('button', { name: 'Reporter' }).textContent).toBe('Ada Lovelace')
await submitIdentity('reporter', 'account-123')
})
it.each(['empty', 'denied', 'pending'])(
'explicit known identity remains usable with %s search',
async (result) => {
if (result === 'empty') {
search.mockResolvedValue([])
} else if (result === 'denied') {
search.mockRejectedValue(new Error('Browse users denied'))
} else {
search.mockReturnValue(new Promise(() => {}))
}
render(<CreateDialog />)
fireEvent.click(screen.getByRole('button', { name: 'Reporter' }))
fireEvent.change(screen.getByPlaceholderText('Search users'), {
target: { value: ' account-known ' }
})
await waitFor(() => expect(search).toHaveBeenCalledWith(source, ' account-known ', 'site-1'))
if (result !== 'pending') {
await screen.findByText('No users found')
}
expect(screen.getByRole('button', { name: 'Create issue' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Use known user ID "account-known"' }))
await submitIdentity('reporter', 'account-known')
}
)
it('preserves supplied scalar metadata choices (constructed contract fixture, not observed Jira data)', async () => {
const field: JiraCreateField = {
key: 'customfield_9',
name: 'Reviewer',
required: true,
schema: { type: 'user' },
allowedValues: [{ name: 'ada.login' }]
}
render(<CreateDialog field={field} />)
const user = userEvent.setup()
const trigger = screen.getByText('Select Reviewer').closest('button[role="combobox"]')
if (!trigger) {
throw new Error('Metadata Select trigger missing')
}
await user.click(trigger)
await user.click(screen.getByRole('option', { name: 'ada.login' }))
expect(search).not.toHaveBeenCalled()
await submitIdentity('customfield_9', 'ada.login')
})
@@ -0,0 +1,37 @@
import { useState } from 'react'
import { JiraUserPicker } from '@/components/jira-user-picker'
import type { RuntimeJiraSettings } from '@/runtime/runtime-jira-client'
import type { JiraUser } from '../../../../../shared/jira-types'
export function TaskPageJiraIssueUserField({
providerSettings,
siteId,
value,
onChange,
disabled,
label
}: {
providerSettings: RuntimeJiraSettings
siteId?: string | null
value: string
onChange: (id: string) => void
disabled: boolean
label: string
}): React.JSX.Element {
const [selectedUser, setSelectedUser] = useState<JiraUser | null>(null)
return (
<JiraUserPicker
providerSettings={providerSettings}
siteId={siteId}
value={value}
selectedUser={selectedUser?.accountId === value ? selectedUser : null}
onSelect={(user) => {
setSelectedUser(user)
onChange(user.accountId)
}}
disabled={disabled}
label={label}
allowKnownUserId
/>
)
}
+2 -1
View File
@@ -18812,7 +18812,8 @@
"jiraUserPicker": {
"select": "Select {{value0}}",
"search": "Search users",
"empty": "No users found"
"empty": "No users found",
"useKnownId": "Use known user ID \"{{value0}}\""
},
"native-chat": {
"install": {
+2 -1
View File
@@ -19757,7 +19757,8 @@
"jiraUserPicker": {
"select": "Seleccione {{value0}}",
"search": "Buscar usuarios",
"empty": "No se encontraron usuarios"
"empty": "No se encontraron usuarios",
"useKnownId": "Usar el ID de usuario conocido \"{{value0}}\""
},
"status": {
"bar": {
+2 -1
View File
@@ -19864,7 +19864,8 @@
"jiraUserPicker": {
"select": "Sélectionnez {{value0}}",
"search": "Rechercher des utilisateurs",
"empty": "Aucun utilisateur trouvé"
"empty": "Aucun utilisateur trouvé",
"useKnownId": "Utiliser l’identifiant utilisateur connu « {{value0}} »"
},
"terminalPane": {
"TerminalContextMenu": {
+2 -1
View File
@@ -19715,7 +19715,8 @@
"jiraUserPicker": {
"select": "{{value0}} を選択してください",
"search": "ユーザーを検索する",
"empty": "ユーザーが見つかりませんでした"
"empty": "ユーザーが見つかりませんでした",
"useKnownId": "既知のユーザー ID「{{value0}}」を使用"
},
"status": {
"bar": {
+2 -1
View File
@@ -19715,7 +19715,8 @@
"jiraUserPicker": {
"select": "{{value0}}을(를) 선택하세요",
"search": "사용자 검색",
"empty": "사용자를 찾을 수 없습니다."
"empty": "사용자를 찾을 수 없습니다.",
"useKnownId": "알고 있는 사용자 ID \"{{value0}}\" 사용"
},
"status": {
"bar": {
+2 -1
View File
@@ -19111,7 +19111,8 @@
"jiraUserPicker": {
"select": "Selecionar {{value0}}",
"search": "Pesquisar usuários",
"empty": "Nenhum usuário encontrado"
"empty": "Nenhum usuário encontrado",
"useKnownId": "Usar o ID de usuário conhecido \"{{value0}}\""
},
"native-chat": {
"taskList": {
+2 -1
View File
@@ -19715,7 +19715,8 @@
"jiraUserPicker": {
"select": "选择{{value0}}",
"search": "搜索用户",
"empty": "没有找到用户"
"empty": "没有找到用户",
"useKnownId": "使用已知用户 ID“{{value0}}”"
},
"status": {
"bar": {