mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
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:
@@ -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 []
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user