diff --git a/e2e/wiremock-mappings/mockedteams/search-project.json b/e2e/wiremock-mappings/mockedteams/search-project.json index 65df64710..4880ec9ad 100644 --- a/e2e/wiremock-mappings/mockedteams/search-project.json +++ b/e2e/wiremock-mappings/mockedteams/search-project.json @@ -1,16 +1,11 @@ { "request": { "method": "GET", - "urlPath": "/rest/api/2/project/search", - "queryParameters": { - "orderBy": { - "equalTo": "key" - } - } + "urlPathPattern": "/rest/api/2/project/search" }, "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" } 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 1068ecca6..b6a5f3d50 100644 --- a/src/ipc/issueActions.ts +++ b/src/ipc/issueActions.ts @@ -91,6 +91,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; @@ -234,6 +241,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..674f6756b 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,83 @@ 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 { + // 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, + }; + } + + // 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: filteredProjects, + total: filteredProjects.length, + hasMore: false, + }; + } 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 5137a34bc..750a3f37d 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 = { @@ -1287,6 +1295,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..9d3a434cc 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,30 @@ 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 (Cloud only) ); - this._projectsWithCreateIssuesPermission = { [siteId]: projectsWithPermission }; - return projectsWithPermission; + this._projectsWithCreateIssuesPermission[cacheKey] = paginatedResult; + return paginatedResult; } private async selectedProjectHasCreatePermission(project: Project): Promise { @@ -416,12 +428,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 +504,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 +729,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;