Skip to content

Commit bee1e24

Browse files
AXON-1137 - added pagination for Project selection field on Create Jira Issue page (#1189)
1 parent 2eab749 commit bee1e24

File tree

10 files changed

+390
-29
lines changed

10 files changed

+390
-29
lines changed

e2e/wiremock-mappings/mockedteams/search-project.json

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
{
22
"request": {
33
"method": "GET",
4-
"urlPath": "/rest/api/2/project/search",
5-
"queryParameters": {
6-
"orderBy": {
7-
"equalTo": "key"
8-
}
9-
}
4+
"urlPathPattern": "/rest/api/2/project/search"
105
},
116
"response": {
127
"status": 200,
13-
"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\"}}]}",
8+
"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\"}}]}",
149
"headers": {
1510
"Content-Type": "application/json"
1611
}

src/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,9 @@ export const enum Commands {
106106
DebugQuickLogin = 'atlascode.debug.quickLogin',
107107
DebugQuickLogout = 'atlascode.debug.quickLogout',
108108
}
109+
110+
// Jira projects field pagination
111+
export const ProjectsPagination = {
112+
pageSize: 50,
113+
startAt: 0,
114+
} as const;

src/ipc/issueActions.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ export interface ScreensForSiteAction extends Action {
9191
site: DetailedSiteInfo;
9292
}
9393

94+
export interface LoadMoreProjectsAction extends Action {
95+
action: 'loadMoreProjects';
96+
maxResults?: number;
97+
startAt?: number;
98+
query?: string;
99+
}
100+
94101
export interface CreateSelectOptionAction extends Action {
95102
fieldKey: string;
96103
siteDetails: DetailedSiteInfo;
@@ -256,6 +263,10 @@ export function isScreensForSite(a: Action): a is ScreensForSiteAction {
256263
return (<ScreensForSiteAction>a).site !== undefined;
257264
}
258265

266+
export function isLoadMoreProjects(a: Action): a is LoadMoreProjectsAction {
267+
return a && a.action === 'loadMoreProjects';
268+
}
269+
259270
export function isCreateSelectOption(a: Action): a is CreateSelectOptionAction {
260271
return a && (<CreateSelectOptionAction>a).createData !== undefined;
261272
}

src/ipc/issueMessaging.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ export interface CreateIssueData extends Message {}
3939
export interface CreateIssueData extends IssueTypeUI<DetailedSiteInfo> {
4040
currentUser: User;
4141
transformerProblems: CreateMetaTransformerProblems;
42+
projectPagination?: {
43+
total: number;
44+
loaded: number;
45+
hasMore: boolean;
46+
isLoadingMore: boolean;
47+
};
4248
}
4349

4450
export const emptyCreateIssueData: CreateIssueData = {

src/jira/projectManager.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { emptyProject, Project } from '@atlassianlabs/jira-pi-common-models';
22
import { Disposable } from 'vscode';
33

44
import { DetailedSiteInfo } from '../atlclients/authInfo';
5+
import { ProjectsPagination } from '../constants';
56
import { Container } from '../container';
67
import { Logger } from '../logger';
78

@@ -70,6 +71,83 @@ export class JiraProjectManager extends Disposable {
7071
return foundProjects;
7172
}
7273

74+
async getProjectsPaginated(
75+
site: DetailedSiteInfo,
76+
maxResults: number = ProjectsPagination.pageSize,
77+
startAt: number = ProjectsPagination.startAt,
78+
orderBy?: OrderBy,
79+
query?: string,
80+
action?: 'view' | 'browse' | 'edit' | 'create',
81+
): Promise<{ projects: Project[]; total: number; hasMore: boolean }> {
82+
try {
83+
// For Jira Cloud we use /rest/api/2/project/search with native pagination
84+
if (site.isCloud) {
85+
const client = await Container.clientManager.jiraClient(site);
86+
const order = orderBy ?? 'key';
87+
const url = site.baseApiUrl + '/rest/api/2/project/search';
88+
const auth = await client.authorizationProvider('GET', url);
89+
90+
const queryParams: {
91+
maxResults: number;
92+
startAt: number;
93+
orderBy: string;
94+
query?: string;
95+
action?: string;
96+
} = {
97+
maxResults,
98+
startAt,
99+
orderBy: order,
100+
};
101+
102+
if (query) {
103+
queryParams.query = query;
104+
}
105+
106+
if (action) {
107+
queryParams.action = action;
108+
}
109+
110+
const response = await client.transportFactory().get(url, {
111+
headers: {
112+
Authorization: auth,
113+
'Content-Type': 'application/json',
114+
Accept: 'application/json',
115+
},
116+
method: 'GET',
117+
params: queryParams,
118+
});
119+
120+
const projects = response.data?.values || [];
121+
const total = response.data?.total || 0;
122+
const isLast = response.data?.isLast ?? false;
123+
const hasMore = !isLast;
124+
125+
return {
126+
projects,
127+
total,
128+
hasMore,
129+
};
130+
}
131+
132+
// For Jira Data Center we use old approach
133+
const allProjects = await this.getProjects(site, orderBy, query);
134+
const filteredProjects = await this.filterProjectsByPermission(site, allProjects, 'CREATE_ISSUES');
135+
136+
return {
137+
projects: filteredProjects,
138+
total: filteredProjects.length,
139+
hasMore: false,
140+
};
141+
} catch (e) {
142+
Logger.debug(`Failed to fetch paginated projects ${e}`);
143+
return {
144+
projects: [],
145+
total: 0,
146+
hasMore: false,
147+
};
148+
}
149+
}
150+
73151
public async checkProjectPermission(
74152
site: DetailedSiteInfo,
75153
projectKey: string,

src/webviews/components/issue/AbstractIssueEditorPage.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import EdiText, { EdiTextType } from 'react-editext';
3535
import { v4 } from 'uuid';
3636

3737
import { DetailedSiteInfo, emptySiteInfo } from '../../../atlclients/authInfo';
38+
import { ProjectsPagination } from '../../../constants';
3839
import { OpenJiraIssueAction } from '../../../ipc/issueActions';
3940
import {
4041
CreatedSelectOption,
@@ -61,6 +62,7 @@ import JiraIssueTextAreaEditor from './common/JiraIssueTextArea';
6162
import { EditRenderedTextArea } from './EditRenderedTextArea';
6263
import InlineIssueLinksEditor from './InlineIssueLinkEditor';
6364
import InlineSubtaskEditor from './InlineSubtaskEditor';
65+
import { LazyLoadingSelect } from './LazyLoadingSelect';
6466
import { ParticipantList } from './ParticipantList';
6567
import { TextAreaEditor } from './TextAreaEditor';
6668

@@ -93,6 +95,12 @@ export interface CommonEditorViewState extends Message {
9395
isGeneratingSuggestions?: boolean;
9496
summaryKey: string;
9597
isAtlaskitEditorEnabled: boolean;
98+
projectPagination?: {
99+
total: number;
100+
loaded: number;
101+
hasMore: boolean;
102+
isLoadingMore: boolean;
103+
};
96104
}
97105

98106
export const emptyCommonEditorState: CommonEditorViewState = {
@@ -1297,6 +1305,55 @@ export abstract class AbstractIssueEditorPage<
12971305
if (fieldArgs.error === 'EMPTY') {
12981306
errDiv = <ErrorMessage>{field.name} is required</ErrorMessage>;
12991307
}
1308+
if (field.valueType === ValueType.Project) {
1309+
return (
1310+
<React.Fragment>
1311+
<LazyLoadingSelect
1312+
{...fieldArgs.fieldProps}
1313+
{...commonProps}
1314+
value={defVal}
1315+
className="ac-form-select-container"
1316+
classNamePrefix="ac-form-select"
1317+
placeholder="Type to search"
1318+
noOptionsMessage={() => 'Type to search'}
1319+
isClearable={this.isClearableSelect(selectField)}
1320+
options={this.state.selectFieldOptions[field.key]}
1321+
isDisabled={
1322+
this.state.isSomethingLoading &&
1323+
this.state.loadingField !== field.key
1324+
}
1325+
isLoading={this.state.loadingField === field.key}
1326+
hasMore={this.state.projectPagination?.hasMore || false}
1327+
isLoadingMore={this.state.projectPagination?.isLoadingMore || false}
1328+
totalCount={this.state.projectPagination?.total || 0}
1329+
loadedCount={this.state.projectPagination?.loaded || 0}
1330+
onLoadMore={this.handleLoadMoreProjects}
1331+
loadOptions={async (input: any) =>
1332+
await this.loadSelectOptionsForField(
1333+
field as SelectFieldUI,
1334+
input,
1335+
)
1336+
}
1337+
onChange={FieldValidators.chain(
1338+
fieldArgs.fieldProps.onChange,
1339+
(selected: any) => {
1340+
this.handleSelectChange(selectField, selected);
1341+
},
1342+
)}
1343+
onMenuClose={() => {
1344+
if (this.state.loadingField === field.key) {
1345+
this.setState({
1346+
isSomethingLoading: false,
1347+
loadingField: '',
1348+
});
1349+
}
1350+
}}
1351+
/>
1352+
{errDiv}
1353+
</React.Fragment>
1354+
);
1355+
}
1356+
13001357
return (
13011358
<React.Fragment>
13021359
<AsyncSelect
@@ -2073,4 +2130,22 @@ export abstract class AbstractIssueEditorPage<
20732130
return 'text';
20742131
}
20752132
}
2133+
2134+
protected handleLoadMoreProjects = (startAt: number) => {
2135+
if (this.state.projectPagination) {
2136+
this.setState({
2137+
projectPagination: {
2138+
...this.state.projectPagination,
2139+
isLoadingMore: true,
2140+
},
2141+
});
2142+
}
2143+
2144+
this.postMessage({
2145+
action: 'loadMoreProjects',
2146+
maxResults: ProjectsPagination.pageSize,
2147+
startAt: startAt,
2148+
nonce: v4(),
2149+
});
2150+
};
20762151
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { AsyncSelect } from '@atlaskit/select';
2+
import { components } from '@atlaskit/select';
3+
import Spinner from '@atlaskit/spinner';
4+
import React, { useCallback, useMemo } from 'react';
5+
6+
interface LazyLoadingSelectProps
7+
extends Omit<React.ComponentProps<typeof AsyncSelect>, 'defaultOptions' | 'onMenuScrollToBottom'> {
8+
options: any[];
9+
onLoadMore?: (startAt: number) => void;
10+
hasMore?: boolean;
11+
isLoadingMore?: boolean;
12+
loadedCount?: number;
13+
totalCount?: number;
14+
pageSize?: number;
15+
}
16+
17+
const LoadingOption = (props: any) => (
18+
<components.Option {...props}>
19+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
20+
<Spinner size="small" />
21+
</div>
22+
</components.Option>
23+
);
24+
25+
export const LazyLoadingSelect: React.FC<LazyLoadingSelectProps> = ({
26+
options,
27+
onLoadMore,
28+
hasMore = false,
29+
isLoadingMore = false,
30+
loadedCount = 0,
31+
totalCount = 0,
32+
pageSize = 50,
33+
loadOptions,
34+
...selectProps
35+
}) => {
36+
const handleMenuScrollToBottom = useCallback(() => {
37+
if (hasMore && !isLoadingMore && onLoadMore) {
38+
onLoadMore(loadedCount);
39+
}
40+
}, [hasMore, isLoadingMore, onLoadMore, loadedCount]);
41+
42+
const finalOptions = useMemo(() => {
43+
if (hasMore && isLoadingMore) {
44+
return [...options, { value: '__loading__', label: 'Loading...', isDisabled: true }];
45+
}
46+
return options;
47+
}, [options, hasMore, isLoadingMore]);
48+
49+
const customComponents = useMemo(() => {
50+
const customComponents = { ...selectProps.components };
51+
52+
if (hasMore && isLoadingMore) {
53+
const OriginalOption = selectProps.components?.Option || components.Option;
54+
customComponents.Option = (props: any) => {
55+
if (props.data.value === '__loading__') {
56+
return <LoadingOption {...props} />;
57+
}
58+
return <OriginalOption {...props} />;
59+
};
60+
}
61+
62+
return customComponents;
63+
}, [hasMore, isLoadingMore, selectProps.components]);
64+
65+
// If loadOptions is provided, use it, otherwise filter current options by input
66+
const handleLoadOptions = useCallback(
67+
(inputValue: string, callback: any) => {
68+
if (loadOptions) {
69+
return loadOptions(inputValue, callback);
70+
}
71+
if (!inputValue) {
72+
return Promise.resolve(finalOptions);
73+
}
74+
const filtered = finalOptions.filter((option: any) => {
75+
const label = option.label || option.name || '';
76+
return label.toLowerCase().includes(inputValue.toLowerCase());
77+
});
78+
return Promise.resolve(filtered);
79+
},
80+
[loadOptions, finalOptions],
81+
);
82+
83+
return (
84+
<AsyncSelect
85+
{...selectProps}
86+
defaultOptions={finalOptions}
87+
loadOptions={handleLoadOptions}
88+
onMenuScrollToBottom={handleMenuScrollToBottom}
89+
components={customComponents}
90+
cacheOptions={false}
91+
/>
92+
);
93+
};

src/webviews/components/issue/create-issue-screen/CreateIssuePage.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,26 @@ export default class CreateIssuePage extends AbstractIssueEditorPage<Emit, Accep
216216
}
217217
break;
218218
}
219+
case 'projectsLoaded': {
220+
handled = true;
221+
const { projects, total, hasMore } = e;
222+
const currentProjects = this.state.selectFieldOptions['project'] || [];
223+
const newProjects = [...currentProjects, ...projects];
224+
225+
this.setState({
226+
selectFieldOptions: {
227+
...this.state.selectFieldOptions,
228+
project: newProjects,
229+
},
230+
projectPagination: {
231+
total,
232+
hasMore,
233+
loaded: newProjects.length,
234+
isLoadingMore: false,
235+
},
236+
});
237+
break;
238+
}
219239
}
220240
}
221241

0 commit comments

Comments
 (0)