mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -19715,7 +19715,8 @@
|
||||
"jiraUserPicker": {
|
||||
"select": "{{value0}} を選択してください",
|
||||
"search": "ユーザーを検索する",
|
||||
"empty": "ユーザーが見つかりませんでした"
|
||||
"empty": "ユーザーが見つかりませんでした",
|
||||
"useKnownId": "既知のユーザー ID「{{value0}}」を使用"
|
||||
},
|
||||
"status": {
|
||||
"bar": {
|
||||
|
||||
@@ -19715,7 +19715,8 @@
|
||||
"jiraUserPicker": {
|
||||
"select": "{{value0}}을(를) 선택하세요",
|
||||
"search": "사용자 검색",
|
||||
"empty": "사용자를 찾을 수 없습니다."
|
||||
"empty": "사용자를 찾을 수 없습니다.",
|
||||
"useKnownId": "알고 있는 사용자 ID \"{{value0}}\" 사용"
|
||||
},
|
||||
"status": {
|
||||
"bar": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -19715,7 +19715,8 @@
|
||||
"jiraUserPicker": {
|
||||
"select": "选择{{value0}}",
|
||||
"search": "搜索用户",
|
||||
"empty": "没有找到用户"
|
||||
"empty": "没有找到用户",
|
||||
"useKnownId": "使用已知用户 ID“{{value0}}”"
|
||||
},
|
||||
"status": {
|
||||
"bar": {
|
||||
|
||||
Reference in New Issue
Block a user