From 028a5bb7fadd48cbf5eab949fb1caa110a8f6ab4 Mon Sep 17 00:00:00 2001 From: Olena Lymar Date: Thu, 16 Oct 2025 14:39:41 +0300 Subject: [PATCH 1/6] AXON-1137 - added pagination for Project selection field on Create Jira Issue page --- src/constants.ts | 6 ++ src/ipc/issueActions.ts | 11 +++ src/ipc/issueMessaging.ts | 6 ++ src/jira/projectManager.ts | 64 +++++++++++++ .../issue/AbstractIssueEditorPage.tsx | 75 +++++++++++++++ .../components/issue/LazyLoadingSelect.tsx | 93 ++++++++++++++++++ .../create-issue-screen/CreateIssuePage.tsx | 20 ++++ src/webviews/createIssueWebview.test.ts | 33 +++++-- src/webviews/createIssueWebview.ts | 94 ++++++++++++++++--- 9 files changed, 380 insertions(+), 22 deletions(-) create mode 100644 src/webviews/components/issue/LazyLoadingSelect.tsx diff --git a/src/constants.ts b/src/constants.ts index 8c7289eff..c556c83ef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -106,3 +106,9 @@ export const enum Commands { DebugQuickLogin = 'atlascode.debug.quickLogin', DebugQuickLogout = 'atlascode.debug.quickLogout', } + +// Jira projects field pagination +export const ProjectsPagination = { + pageSize: 50, + startAt: 0, +} as const; diff --git a/src/ipc/issueActions.ts b/src/ipc/issueActions.ts index 92a5f93f6..de2aa7b35 100644 --- a/src/ipc/issueActions.ts +++ b/src/ipc/issueActions.ts @@ -89,6 +89,13 @@ export interface ScreensForSiteAction extends Action { site: DetailedSiteInfo; } +export interface LoadMoreProjectsAction extends Action { + action: 'loadMoreProjects'; + maxResults?: number; + startAt?: number; + query?: string; +} + export interface CreateSelectOptionAction extends Action { fieldKey: string; siteDetails: DetailedSiteInfo; @@ -226,6 +233,10 @@ export function isScreensForSite(a: Action): a is ScreensForSiteAction { return (a).site !== undefined; } +export function isLoadMoreProjects(a: Action): a is LoadMoreProjectsAction { + return a && a.action === 'loadMoreProjects'; +} + export function isCreateSelectOption(a: Action): a is CreateSelectOptionAction { return a && (a).createData !== undefined; } diff --git a/src/ipc/issueMessaging.ts b/src/ipc/issueMessaging.ts index d6b4c665f..15f34fef9 100644 --- a/src/ipc/issueMessaging.ts +++ b/src/ipc/issueMessaging.ts @@ -39,6 +39,12 @@ export interface CreateIssueData extends Message {} export interface CreateIssueData extends IssueTypeUI { currentUser: User; transformerProblems: CreateMetaTransformerProblems; + projectPagination?: { + total: number; + loaded: number; + hasMore: boolean; + isLoadingMore: boolean; + }; } export const emptyCreateIssueData: CreateIssueData = { diff --git a/src/jira/projectManager.ts b/src/jira/projectManager.ts index eaefcd323..b256d605a 100644 --- a/src/jira/projectManager.ts +++ b/src/jira/projectManager.ts @@ -2,6 +2,7 @@ import { emptyProject, Project } from '@atlassianlabs/jira-pi-common-models'; import { Disposable } from 'vscode'; import { DetailedSiteInfo } from '../atlclients/authInfo'; +import { ProjectsPagination } from '../constants'; import { Container } from '../container'; import { Logger } from '../logger'; @@ -70,6 +71,69 @@ export class JiraProjectManager extends Disposable { return foundProjects; } + async getProjectsPaginated( + site: DetailedSiteInfo, + maxResults: number = ProjectsPagination.pageSize, + startAt: number = ProjectsPagination.startAt, + orderBy?: OrderBy, + query?: string, + action?: 'view' | 'browse' | 'edit' | 'create', + ): Promise<{ projects: Project[]; total: number; hasMore: boolean }> { + try { + const client = await Container.clientManager.jiraClient(site); + const order = orderBy !== undefined ? orderBy : 'key'; + const url = site.baseApiUrl + '/rest/api/2/project/search'; + const auth = await client.authorizationProvider('GET', url); + + const queryParams: { + maxResults: number; + startAt: number; + orderBy: string; + query?: string; + action?: string; + } = { + maxResults, + startAt, + orderBy: order, + }; + + if (query) { + queryParams.query = query; + } + + if (action) { + queryParams.action = action; + } + + const response = await client.transportFactory().get(url, { + headers: { + Authorization: auth, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + method: 'GET', + params: queryParams, + }); + + const projects = response.data?.values || []; + const total = response.data?.total || 0; + const hasMore = startAt + maxResults < total; + + return { + projects, + total, + hasMore, + }; + } catch (e) { + Logger.debug(`Failed to fetch paginated projects ${e}`); + return { + projects: [], + total: 0, + hasMore: false, + }; + } + } + public async checkProjectPermission( site: DetailedSiteInfo, projectKey: string, diff --git a/src/webviews/components/issue/AbstractIssueEditorPage.tsx b/src/webviews/components/issue/AbstractIssueEditorPage.tsx index 8e8e8802c..33eb8c66b 100644 --- a/src/webviews/components/issue/AbstractIssueEditorPage.tsx +++ b/src/webviews/components/issue/AbstractIssueEditorPage.tsx @@ -35,6 +35,7 @@ import EdiText, { EdiTextType } from 'react-editext'; import { v4 } from 'uuid'; import { DetailedSiteInfo, emptySiteInfo } from '../../../atlclients/authInfo'; +import { ProjectsPagination } from '../../../constants'; import { OpenJiraIssueAction } from '../../../ipc/issueActions'; import { CreatedSelectOption, @@ -61,6 +62,7 @@ import JiraIssueTextAreaEditor from './common/JiraIssueTextArea'; import { EditRenderedTextArea } from './EditRenderedTextArea'; import InlineIssueLinksEditor from './InlineIssueLinkEditor'; import InlineSubtaskEditor from './InlineSubtaskEditor'; +import { LazyLoadingSelect } from './LazyLoadingSelect'; import { ParticipantList } from './ParticipantList'; import { TextAreaEditor } from './TextAreaEditor'; @@ -93,6 +95,12 @@ export interface CommonEditorViewState extends Message { isGeneratingSuggestions?: boolean; summaryKey: string; isAtlaskitEditorEnabled: boolean; + projectPagination?: { + total: number; + loaded: number; + hasMore: boolean; + isLoadingMore: boolean; + }; } export const emptyCommonEditorState: CommonEditorViewState = { @@ -1277,6 +1285,55 @@ export abstract class AbstractIssueEditorPage< if (fieldArgs.error === 'EMPTY') { errDiv = {field.name} is required; } + if (field.valueType === ValueType.Project) { + return ( + + 'Type to search'} + isClearable={this.isClearableSelect(selectField)} + options={this.state.selectFieldOptions[field.key]} + isDisabled={ + this.state.isSomethingLoading && + this.state.loadingField !== field.key + } + isLoading={this.state.loadingField === field.key} + hasMore={this.state.projectPagination?.hasMore || false} + isLoadingMore={this.state.projectPagination?.isLoadingMore || false} + totalCount={this.state.projectPagination?.total || 0} + loadedCount={this.state.projectPagination?.loaded || 0} + onLoadMore={this.handleLoadMoreProjects} + loadOptions={async (input: any) => + await this.loadSelectOptionsForField( + field as SelectFieldUI, + input, + ) + } + onChange={FieldValidators.chain( + fieldArgs.fieldProps.onChange, + (selected: any) => { + this.handleSelectChange(selectField, selected); + }, + )} + onMenuClose={() => { + if (this.state.loadingField === field.key) { + this.setState({ + isSomethingLoading: false, + loadingField: '', + }); + } + }} + /> + {errDiv} + + ); + } + return ( { + if (this.state.projectPagination) { + this.setState({ + projectPagination: { + ...this.state.projectPagination, + isLoadingMore: true, + }, + }); + } + + this.postMessage({ + action: 'loadMoreProjects', + maxResults: ProjectsPagination.pageSize, + startAt: startAt, + nonce: v4(), + }); + }; } diff --git a/src/webviews/components/issue/LazyLoadingSelect.tsx b/src/webviews/components/issue/LazyLoadingSelect.tsx new file mode 100644 index 000000000..602f419d9 --- /dev/null +++ b/src/webviews/components/issue/LazyLoadingSelect.tsx @@ -0,0 +1,93 @@ +import { AsyncSelect } from '@atlaskit/select'; +import { components } from '@atlaskit/select'; +import Spinner from '@atlaskit/spinner'; +import React, { useCallback, useMemo } from 'react'; + +interface LazyLoadingSelectProps + extends Omit, 'defaultOptions' | 'onMenuScrollToBottom'> { + options: any[]; + onLoadMore?: (startAt: number) => void; + hasMore?: boolean; + isLoadingMore?: boolean; + loadedCount?: number; + totalCount?: number; + pageSize?: number; +} + +const LoadingOption = (props: any) => ( + +
+ +
+
+); + +export const LazyLoadingSelect: React.FC = ({ + options, + onLoadMore, + hasMore = false, + isLoadingMore = false, + loadedCount = 0, + totalCount = 0, + pageSize = 50, + loadOptions, + ...selectProps +}) => { + const handleMenuScrollToBottom = useCallback(() => { + if (hasMore && !isLoadingMore && onLoadMore) { + onLoadMore(loadedCount); + } + }, [hasMore, isLoadingMore, onLoadMore, loadedCount]); + + const finalOptions = useMemo(() => { + if (hasMore && isLoadingMore) { + return [...options, { value: '__loading__', label: 'Loading...', isDisabled: true }]; + } + return options; + }, [options, hasMore, isLoadingMore]); + + const customComponents = useMemo(() => { + const customComponents = { ...selectProps.components }; + + if (hasMore && isLoadingMore) { + const OriginalOption = selectProps.components?.Option || components.Option; + customComponents.Option = (props: any) => { + if (props.data.value === '__loading__') { + return ; + } + return ; + }; + } + + return customComponents; + }, [hasMore, isLoadingMore, selectProps.components]); + + // If loadOptions is provided, use it, otherwise filter current options by input + const handleLoadOptions = useCallback( + (inputValue: string, callback: any) => { + if (loadOptions) { + return loadOptions(inputValue, callback); + } + if (!inputValue) { + return Promise.resolve(finalOptions); + } + const filtered = finalOptions.filter((option: any) => { + const label = option.label || option.name || ''; + return label.toLowerCase().includes(inputValue.toLowerCase()); + }); + return Promise.resolve(filtered); + }, + [loadOptions, finalOptions], + ); + + return ( + + ); +}; diff --git a/src/webviews/components/issue/create-issue-screen/CreateIssuePage.tsx b/src/webviews/components/issue/create-issue-screen/CreateIssuePage.tsx index 9e386e5f5..8199f367b 100644 --- a/src/webviews/components/issue/create-issue-screen/CreateIssuePage.tsx +++ b/src/webviews/components/issue/create-issue-screen/CreateIssuePage.tsx @@ -216,6 +216,26 @@ export default class CreateIssuePage extends AbstractIssueEditorPage ({ getFirstProject: jest.fn(), getProjectForKey: jest.fn(), getProjects: jest.fn(), + getProjectsPaginated: jest.fn(), filterProjectsByPermission: jest.fn(), }, pmfStats: { @@ -175,6 +176,11 @@ describe('CreateIssueWebview', () => { Container.jiraProjectManager.getFirstProject = jest.fn().mockResolvedValue(mockProject); Container.jiraProjectManager.getProjectForKey = jest.fn().mockResolvedValue(mockProject); Container.jiraProjectManager.getProjects = jest.fn().mockResolvedValue([mockProject]); + Container.jiraProjectManager.getProjectsPaginated = jest.fn().mockResolvedValue({ + projects: [mockProject], + total: 1, + hasMore: false, + }); Container.jiraProjectManager.filterProjectsByPermission = jest.fn().mockResolvedValue([mockProject]); Container.clientManager.jiraClient = jest.fn().mockResolvedValue(mockClient); @@ -306,7 +312,14 @@ describe('CreateIssueWebview', () => { expect(fetchIssue.fetchCreateIssueUI).toHaveBeenCalledWith(mockSiteDetails, mockProject.key); expect(Container.siteManager.getSitesAvailable).toHaveBeenCalledWith(ProductJira); - expect(Container.jiraProjectManager.getProjects).toHaveBeenCalledWith(mockSiteDetails); + expect(Container.jiraProjectManager.getProjectsPaginated).toHaveBeenCalledWith( + mockSiteDetails, + 50, + 0, + 'key', + undefined, + 'create', + ); expect(webviewPostMessageMock).toHaveBeenCalled(); }); @@ -377,10 +390,13 @@ describe('CreateIssueWebview', () => { await webview.forceUpdateFields(); - expect(Container.jiraProjectManager.filterProjectsByPermission).toHaveBeenCalledWith( + expect(Container.jiraProjectManager.getProjectsPaginated).toHaveBeenCalledWith( mockSiteDetails, - [mockProject], - 'CREATE_ISSUES', + 50, + 0, + 'key', + undefined, + 'create', ); expect(fetchIssue.fetchCreateIssueUI).toHaveBeenCalledWith(mockSiteDetails, mockProject.key); expect(webviewPostMessageMock).toHaveBeenCalledWith( @@ -402,10 +418,11 @@ describe('CreateIssueWebview', () => { }); // Mock projects with permission (different from current) - const projectsWithPermission = [mockProject]; - Container.jiraProjectManager.filterProjectsByPermission = jest - .fn() - .mockResolvedValue(projectsWithPermission); + Container.jiraProjectManager.getProjectsPaginated = jest.fn().mockResolvedValue({ + projects: [mockProject], + total: 1, + hasMore: false, + }); await webview.forceUpdateFields(); diff --git a/src/webviews/createIssueWebview.ts b/src/webviews/createIssueWebview.ts index 0c0d3699a..d801b9a0c 100644 --- a/src/webviews/createIssueWebview.ts +++ b/src/webviews/createIssueWebview.ts @@ -17,13 +17,14 @@ import { IssueSuggestionSettings, SimplifiedTodoIssueData, } from '../config/configuration'; -import { Commands } from '../constants'; +import { Commands, ProjectsPagination } from '../constants'; import { Container } from '../container'; import { CreateIssueAction, isAiSuggestionFeedback, isCreateIssue, isGenerateIssueSuggestions, + isLoadMoreProjects, isScreensForProjects, isScreensForSite, isSetIssueType, @@ -77,7 +78,9 @@ export class CreateIssueWebview private _screenData: CreateMetaTransformerResult; private _selectedIssueTypeId: string | undefined; private _siteDetails: DetailedSiteInfo; - private _projectsWithCreateIssuesPermission: { [siteId: string]: Project[] }; + private _projectsWithCreateIssuesPermission: { + [siteId: string]: Project[] | { projects: Project[]; total: number; hasMore: boolean }; + }; private _issueSuggestionSettings: IssueSuggestionSettings | undefined; private _todoData: SimplifiedTodoIssueData | undefined; @@ -291,21 +294,36 @@ export class CreateIssueWebview } } - private async getProjectsWithPermission(siteDetails: DetailedSiteInfo) { + private async getProjectsWithPermission( + siteDetails: DetailedSiteInfo, + maxResults: number = ProjectsPagination.pageSize, + startAt: number = ProjectsPagination.startAt, + query?: string, + ) { const siteId = siteDetails.id; - if (this._projectsWithCreateIssuesPermission[siteId]) { - return this._projectsWithCreateIssuesPermission[siteId]; + const cacheKey = JSON.stringify({ siteId, startAt, maxResults, query: query || null }); + + if (this._projectsWithCreateIssuesPermission[cacheKey]) { + return this._projectsWithCreateIssuesPermission[cacheKey]; } - const availableProjects = await Container.jiraProjectManager.getProjects(siteDetails); - const projectsWithPermission = await Container.jiraProjectManager.filterProjectsByPermission( + const paginatedResult = await Container.jiraProjectManager.getProjectsPaginated( siteDetails, - availableProjects, - 'CREATE_ISSUES', + maxResults, + startAt, + 'key', + query, + 'create', // Filter by CREATE_ISSUES permission on the API side ); - this._projectsWithCreateIssuesPermission = { [siteId]: projectsWithPermission }; - return projectsWithPermission; + const result = { + projects: paginatedResult.projects, + total: paginatedResult.total, + hasMore: paginatedResult.hasMore, + }; + + this._projectsWithCreateIssuesPermission[cacheKey] = result; + return result; } private async selectedProjectHasCreatePermission(project: Project): Promise { @@ -416,12 +434,20 @@ export class CreateIssueWebview const availableSites = Container.siteManager.getSitesAvailable(ProductJira); timer.mark(CreateJiraIssueRenderEventName); - const [projectsWithCreateIssuesPermission, screenData] = await Promise.all([ - this.getProjectsWithPermission(this._siteDetails), + const [paginatedProjectsResult, screenData] = await Promise.all([ + this.getProjectsWithPermission( + this._siteDetails, + ProjectsPagination.pageSize, + ProjectsPagination.startAt, + ), fetchCreateIssueUI(this._siteDetails, this._currentProject.key), ]); + + const projectsWithCreateIssuesPermission = ( + paginatedProjectsResult as { projects: Project[]; total: number; hasMore: boolean } + ).projects; const currentProjectHasCreatePermission = projectsWithCreateIssuesPermission.find( - (project) => project.id === this._currentProject?.id, + (project: Project) => project.id === this._currentProject?.id, ); // if the selected or current project does not have create issues permission, we will select the first project with permission @@ -484,6 +510,14 @@ export class CreateIssueWebview ? this._screenData.problems : {}; + const paginatedResult = paginatedProjectsResult as { projects: Project[]; total: number; hasMore: boolean }; + createData.projectPagination = { + total: paginatedResult.total, + loaded: projectsWithCreateIssuesPermission.length, + hasMore: paginatedResult.hasMore, + isLoadingMore: false, + }; + this.postMessage(createData); const createDuration = timer.measureAndClear(CreateJiraIssueRenderEventName); performanceEvent(CreateJiraIssueRenderEventName, createDuration).then((event) => { @@ -701,6 +735,38 @@ export class CreateIssueWebview break; } + case 'loadMoreProjects': { + handled = true; + if (isLoadMoreProjects(msg)) { + try { + const result = await this.getProjectsWithPermission( + this._siteDetails, + msg.maxResults || ProjectsPagination.pageSize, + msg.startAt || ProjectsPagination.startAt, + msg.query, + ); + + const paginatedResult = result as { projects: Project[]; total: number; hasMore: boolean }; + this.postMessage({ + type: 'projectsLoaded', + projects: paginatedResult.projects, + total: paginatedResult.total, + hasMore: paginatedResult.hasMore, + startAt: msg.startAt || ProjectsPagination.startAt, + nonce: msg.nonce, + }); + } catch (error) { + Logger.error(error, 'Failed to load more projects'); + this.postMessage({ + type: 'error', + reason: this.formatErrorReason(error, 'Failed to load more projects'), + nonce: msg.nonce, + }); + } + } + break; + } + //TODO: refactor this case 'createIssue': { handled = true; From d7220fc3a6f5a608912c5eb45e6ba795a1196983 Mon Sep 17 00:00:00 2001 From: Olena Lymar Date: Fri, 17 Oct 2025 14:01:30 +0300 Subject: [PATCH 2/6] AXON-1137 - added wiremock mapping for project search endpoint --- .../mockedteams/project-search.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 e2e/wiremock-mappings/mockedteams/project-search.json diff --git a/e2e/wiremock-mappings/mockedteams/project-search.json b/e2e/wiremock-mappings/mockedteams/project-search.json new file mode 100644 index 000000000..c648c86f0 --- /dev/null +++ b/e2e/wiremock-mappings/mockedteams/project-search.json @@ -0,0 +1,14 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/api/2/project/search" + }, + "response": { + "status": 200, + "body": "{\"self\":\"https://jira.mockeddomain.com/rest/api/2/project/search\",\"maxResults\":50,\"startAt\":0,\"total\":1,\"isLast\":true,\"values\":[{\"expand\":\"description,lead,issueTypes,url,projectKeys,permissions,insight\",\"self\":\"https://jira.mockeddomain.com/rest/api/2/project/10000\",\"id\":\"10000\",\"key\":\"BTS\",\"name\":\"MMOCK\",\"avatarUrls\":{\"48x48\":\"https://jira.mockeddomain.com/rest/api/2/universal_avatar/view/type/project/avatar/10417\",\"24x24\":\"https://jira.mockeddomain.com/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=small\",\"16x16\":\"https://jira.mockeddomain.com/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=xsmall\",\"32x32\":\"https://jira.mockeddomain.com/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=medium\"},\"projectTypeKey\":\"software\",\"simplified\":false,\"style\":\"classic\",\"isPrivate\":false,\"properties\":{}}]}", + "headers": { + "Content-Type": "application/json" + } + } +} + From c19b1ca4de4aa456468f290a2f79c9390df7d03c Mon Sep 17 00:00:00 2001 From: Olena Lymar Date: Fri, 17 Oct 2025 14:30:11 +0300 Subject: [PATCH 3/6] AXON-1137 - fixed e2e tests --- e2e/wiremock-mappings/mockedteams/project-search.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/wiremock-mappings/mockedteams/project-search.json b/e2e/wiremock-mappings/mockedteams/project-search.json index c648c86f0..05a52b9d9 100644 --- a/e2e/wiremock-mappings/mockedteams/project-search.json +++ b/e2e/wiremock-mappings/mockedteams/project-search.json @@ -1,7 +1,7 @@ { "request": { "method": "GET", - "urlPath": "/rest/api/2/project/search" + "urlPathPattern": "/rest/api/2/project/search" }, "response": { "status": 200, From dd7c854a8b907cd12af70e198493967e3b5e5191 Mon Sep 17 00:00:00 2001 From: Olena Lymar Date: Fri, 17 Oct 2025 15:44:36 +0300 Subject: [PATCH 4/6] AXON-1137 - fixed e2e tests --- .../mockedteams/project-search.json | 14 -------------- .../mockedteams/search-project.json | 13 +++++++++++-- 2 files changed, 11 insertions(+), 16 deletions(-) delete mode 100644 e2e/wiremock-mappings/mockedteams/project-search.json diff --git a/e2e/wiremock-mappings/mockedteams/project-search.json b/e2e/wiremock-mappings/mockedteams/project-search.json deleted file mode 100644 index 05a52b9d9..000000000 --- a/e2e/wiremock-mappings/mockedteams/project-search.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "request": { - "method": "GET", - "urlPathPattern": "/rest/api/2/project/search" - }, - "response": { - "status": 200, - "body": "{\"self\":\"https://jira.mockeddomain.com/rest/api/2/project/search\",\"maxResults\":50,\"startAt\":0,\"total\":1,\"isLast\":true,\"values\":[{\"expand\":\"description,lead,issueTypes,url,projectKeys,permissions,insight\",\"self\":\"https://jira.mockeddomain.com/rest/api/2/project/10000\",\"id\":\"10000\",\"key\":\"BTS\",\"name\":\"MMOCK\",\"avatarUrls\":{\"48x48\":\"https://jira.mockeddomain.com/rest/api/2/universal_avatar/view/type/project/avatar/10417\",\"24x24\":\"https://jira.mockeddomain.com/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=small\",\"16x16\":\"https://jira.mockeddomain.com/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=xsmall\",\"32x32\":\"https://jira.mockeddomain.com/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=medium\"},\"projectTypeKey\":\"software\",\"simplified\":false,\"style\":\"classic\",\"isPrivate\":false,\"properties\":{}}]}", - "headers": { - "Content-Type": "application/json" - } - } -} - diff --git a/e2e/wiremock-mappings/mockedteams/search-project.json b/e2e/wiremock-mappings/mockedteams/search-project.json index 65df64710..dfdfc7b82 100644 --- a/e2e/wiremock-mappings/mockedteams/search-project.json +++ b/e2e/wiremock-mappings/mockedteams/search-project.json @@ -4,13 +4,22 @@ "urlPath": "/rest/api/2/project/search", "queryParameters": { "orderBy": { - "equalTo": "key" + "matches": "[+-]?(key|name|category|owner)" + }, + "maxResults": { + "matches": "\\d+" + }, + "startAt": { + "matches": "\\d+" + }, + "action": { + "matches": "create|view|browse|edit" } } }, "response": { "status": 200, - "body": "{\"self\":\"https://mockedteams.atlassian.net/rest/api/2/project/search?orderBy=key\",\"maxResults\":50,\"startAt\":0,\"total\":1,\"isLast\":true,\"values\":[{\"self\":\"https://mockedteams.atlassian.net/rest/api/2/project/10000\",\"id\":\"10000\",\"key\":\"BTS\",\"name\":\"KANBAN\",\"projectTypeKey\":\"software\",\"simplified\":true,\"avatarUrls\":{\"48x48\":\"https://mockedteams.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10417\",\"24x24\":\"https://mockedteams.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=small\",\"16x16\":\"https://mockedteams.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=xsmall\",\"32x32\":\"https://mockedteams.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=medium\"}}]}", + "body": "{\"self\":\"https://mockedteams.atlassian.net/rest/api/2/project/search?orderBy=key\",\"maxResults\":50,\"startAt\":0,\"total\":1,\"isLast\":true,\"values\":[{\"self\":\"https://mockedteams.atlassian.net/rest/api/2/project/10000\",\"id\":\"10000\",\"key\":\"BTS\",\"name\":\"MMOCK\",\"projectTypeKey\":\"software\",\"simplified\":true,\"avatarUrls\":{\"48x48\":\"https://mockedteams.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10417\",\"24x24\":\"https://mockedteams.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=small\",\"16x16\":\"https://mockedteams.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=xsmall\",\"32x32\":\"https://mockedteams.atlassian.net/rest/api/2/universal_avatar/view/type/project/avatar/10417?size=medium\"}}]}", "headers": { "Content-Type": "application/json" } From fcb6287320bc96f8ea51594f7d3cb71f7e949a28 Mon Sep 17 00:00:00 2001 From: Olena Lymar Date: Fri, 17 Oct 2025 16:13:50 +0300 Subject: [PATCH 5/6] AXON-1137 - fixed e2e tests --- .../mockedteams/search-project.json | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/e2e/wiremock-mappings/mockedteams/search-project.json b/e2e/wiremock-mappings/mockedteams/search-project.json index dfdfc7b82..4880ec9ad 100644 --- a/e2e/wiremock-mappings/mockedteams/search-project.json +++ b/e2e/wiremock-mappings/mockedteams/search-project.json @@ -1,21 +1,7 @@ { "request": { "method": "GET", - "urlPath": "/rest/api/2/project/search", - "queryParameters": { - "orderBy": { - "matches": "[+-]?(key|name|category|owner)" - }, - "maxResults": { - "matches": "\\d+" - }, - "startAt": { - "matches": "\\d+" - }, - "action": { - "matches": "create|view|browse|edit" - } - } + "urlPathPattern": "/rest/api/2/project/search" }, "response": { "status": 200, From 00fc2967cbd9c590bf8d2371e6548f4713a5a528 Mon Sep 17 00:00:00 2001 From: Olena Lymar Date: Wed, 29 Oct 2025 22:45:22 +0200 Subject: [PATCH 6/6] AXON-1137 - splitted pagination logic for Jira Cloud and Data Center --- src/jira/projectManager.ts | 92 +++++++++++++++++------------- src/webviews/createIssueWebview.ts | 12 +--- 2 files changed, 56 insertions(+), 48 deletions(-) diff --git a/src/jira/projectManager.ts b/src/jira/projectManager.ts index b256d605a..674f6756b 100644 --- a/src/jira/projectManager.ts +++ b/src/jira/projectManager.ts @@ -80,49 +80,63 @@ export class JiraProjectManager extends Disposable { action?: 'view' | 'browse' | 'edit' | 'create', ): Promise<{ projects: Project[]; total: number; hasMore: boolean }> { try { - const client = await Container.clientManager.jiraClient(site); - const order = orderBy !== undefined ? orderBy : 'key'; - const url = site.baseApiUrl + '/rest/api/2/project/search'; - const auth = await client.authorizationProvider('GET', url); - - const queryParams: { - maxResults: number; - startAt: number; - orderBy: string; - query?: string; - action?: string; - } = { - maxResults, - startAt, - orderBy: order, - }; - - if (query) { - queryParams.query = query; + // For Jira Cloud we use /rest/api/2/project/search with native pagination + if (site.isCloud) { + const client = await Container.clientManager.jiraClient(site); + const order = orderBy ?? 'key'; + const url = site.baseApiUrl + '/rest/api/2/project/search'; + const auth = await client.authorizationProvider('GET', url); + + const queryParams: { + maxResults: number; + startAt: number; + orderBy: string; + query?: string; + action?: string; + } = { + maxResults, + startAt, + orderBy: order, + }; + + if (query) { + queryParams.query = query; + } + + if (action) { + queryParams.action = action; + } + + const response = await client.transportFactory().get(url, { + headers: { + Authorization: auth, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + method: 'GET', + params: queryParams, + }); + + const projects = response.data?.values || []; + const total = response.data?.total || 0; + const isLast = response.data?.isLast ?? false; + const hasMore = !isLast; + + return { + projects, + total, + hasMore, + }; } - if (action) { - queryParams.action = action; - } - - const response = await client.transportFactory().get(url, { - headers: { - Authorization: auth, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - method: 'GET', - params: queryParams, - }); - - const projects = response.data?.values || []; - const total = response.data?.total || 0; - const hasMore = startAt + maxResults < total; + // For Jira Data Center we use old approach + const allProjects = await this.getProjects(site, orderBy, query); + const filteredProjects = await this.filterProjectsByPermission(site, allProjects, 'CREATE_ISSUES'); return { - projects, - total, - hasMore, + projects: filteredProjects, + total: filteredProjects.length, + hasMore: false, }; } catch (e) { Logger.debug(`Failed to fetch paginated projects ${e}`); diff --git a/src/webviews/createIssueWebview.ts b/src/webviews/createIssueWebview.ts index d801b9a0c..9d3a434cc 100644 --- a/src/webviews/createIssueWebview.ts +++ b/src/webviews/createIssueWebview.ts @@ -313,17 +313,11 @@ export class CreateIssueWebview startAt, 'key', query, - 'create', // Filter by CREATE_ISSUES permission on the API side + 'create', // Filter by CREATE_ISSUES permission on the API side (Cloud only) ); - const result = { - projects: paginatedResult.projects, - total: paginatedResult.total, - hasMore: paginatedResult.hasMore, - }; - - this._projectsWithCreateIssuesPermission[cacheKey] = result; - return result; + this._projectsWithCreateIssuesPermission[cacheKey] = paginatedResult; + return paginatedResult; } private async selectedProjectHasCreatePermission(project: Project): Promise {