Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions e2e/wiremock-mappings/mockedteams/search-project.json
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
6 changes: 6 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
11 changes: 11 additions & 0 deletions src/ipc/issueActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -226,6 +233,10 @@ export function isScreensForSite(a: Action): a is ScreensForSiteAction {
return (<ScreensForSiteAction>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 && (<CreateSelectOptionAction>a).createData !== undefined;
}
Expand Down
6 changes: 6 additions & 0 deletions src/ipc/issueMessaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export interface CreateIssueData extends Message {}
export interface CreateIssueData extends IssueTypeUI<DetailedSiteInfo> {
currentUser: User;
transformerProblems: CreateMetaTransformerProblems;
projectPagination?: {
total: number;
loaded: number;
hasMore: boolean;
isLoadingMore: boolean;
};
}

export const emptyCreateIssueData: CreateIssueData = {
Expand Down
64 changes: 64 additions & 0 deletions src/jira/projectManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions src/webviews/components/issue/AbstractIssueEditorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -1277,6 +1285,55 @@ export abstract class AbstractIssueEditorPage<
if (fieldArgs.error === 'EMPTY') {
errDiv = <ErrorMessage>{field.name} is required</ErrorMessage>;
}
if (field.valueType === ValueType.Project) {
return (
<React.Fragment>
<LazyLoadingSelect
{...fieldArgs.fieldProps}
{...commonProps}
value={defVal}
className="ac-form-select-container"
classNamePrefix="ac-form-select"
placeholder="Type to search"
noOptionsMessage={() => '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}
</React.Fragment>
);
}

return (
<React.Fragment>
<AsyncSelect
Expand Down Expand Up @@ -2028,4 +2085,22 @@ export abstract class AbstractIssueEditorPage<
return 'text';
}
}

protected handleLoadMoreProjects = (startAt: number) => {
if (this.state.projectPagination) {
this.setState({
projectPagination: {
...this.state.projectPagination,
isLoadingMore: true,
},
});
}

this.postMessage({
action: 'loadMoreProjects',
maxResults: ProjectsPagination.pageSize,
startAt: startAt,
nonce: v4(),
});
};
}
93 changes: 93 additions & 0 deletions src/webviews/components/issue/LazyLoadingSelect.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof AsyncSelect>, 'defaultOptions' | 'onMenuScrollToBottom'> {
options: any[];
onLoadMore?: (startAt: number) => void;
hasMore?: boolean;
isLoadingMore?: boolean;
loadedCount?: number;
totalCount?: number;
pageSize?: number;
}

const LoadingOption = (props: any) => (
<components.Option {...props}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spinner size="small" />
</div>
</components.Option>
);

export const LazyLoadingSelect: React.FC<LazyLoadingSelectProps> = ({
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 <LoadingOption {...props} />;
}
return <OriginalOption {...props} />;
};
}

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 (
<AsyncSelect
{...selectProps}
defaultOptions={finalOptions}
loadOptions={handleLoadOptions}
onMenuScrollToBottom={handleMenuScrollToBottom}
components={customComponents}
cacheOptions={false}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,26 @@ export default class CreateIssuePage extends AbstractIssueEditorPage<Emit, Accep
}
break;
}
case 'projectsLoaded': {
handled = true;
const { projects, total, hasMore } = e;
const currentProjects = this.state.selectFieldOptions['project'] || [];
const newProjects = [...currentProjects, ...projects];

this.setState({
selectFieldOptions: {
...this.state.selectFieldOptions,
project: newProjects,
},
projectPagination: {
total,
hasMore,
loaded: newProjects.length,
isLoadingMore: false,
},
});
break;
}
}
}

Expand Down
Loading
Loading