435 lines
17 KiB
TypeScript
435 lines
17 KiB
TypeScript
import "azure-devops-ui/Core/override.css";
|
|
import "./Hub.scss";
|
|
|
|
import * as React from "react";
|
|
import * as SDK from "azure-devops-extension-sdk";
|
|
import { CommonServiceIds, IGlobalMessagesService, IHostNavigationService, IProjectInfo, IProjectPageService, getClient } from "azure-devops-extension-api";
|
|
|
|
import { Header, TitleSize } from "azure-devops-ui/Header";
|
|
import { Page } from "azure-devops-ui/Page";
|
|
|
|
import { showRootComponent } from "../../Common";
|
|
import { IListBoxItem } from "azure-devops-ui/ListBox";
|
|
import { WorkItem, WorkItemTrackingRestClient, WorkItemType } from "azure-devops-extension-api/WorkItemTracking";
|
|
import { IterationWorkItems, TaskboardColumn, TaskboardColumns, TaskboardWorkItemColumn, TeamSettingsIteration, WorkRestClient } from "azure-devops-extension-api/Work";
|
|
import { CoreRestClient, WebApiTeam } from "azure-devops-extension-api/Core";
|
|
import { Dropdown } from "azure-devops-ui/Dropdown";
|
|
import { ListSelection } from "azure-devops-ui/List";
|
|
|
|
interface IHubContentState {
|
|
selectedTabId: string;
|
|
headerDescription?: string;
|
|
useLargeTitle?: boolean;
|
|
useCompactPivots?: boolean;
|
|
|
|
project: string;
|
|
teams: WebApiTeam[];
|
|
teamIterations: TeamSettingsIteration[];
|
|
selectedTeam: string;
|
|
selectedTeamName: string;
|
|
selectedTeamIteration: string;
|
|
selectedTeamIterationName: string;
|
|
iterationWorkItems?: IterationWorkItems;
|
|
taskboardWorkItemColumns: TaskboardWorkItemColumn[];
|
|
/**
|
|
* All columns used in project team taskboards.
|
|
*/
|
|
taskboardColumns: TaskboardColumn[];
|
|
workItems: WorkItem[];
|
|
/**
|
|
* All work item types, such as Feature, Epic, Bug, Task, User Story.
|
|
*/
|
|
workItemTypes: WorkItemType[];
|
|
}
|
|
|
|
class HubContent extends React.Component<{}, IHubContentState> {
|
|
private project: IProjectInfo | undefined;
|
|
private teams: WebApiTeam[] = [];
|
|
private teamIterations: TeamSettingsIteration[] = [];
|
|
private iterationWorkItems: IterationWorkItems | undefined;
|
|
private taskboardColumns: TaskboardColumns | undefined;
|
|
private workItems: WorkItem[] = [];
|
|
private workItemTypes: WorkItemType[] = [];
|
|
|
|
private teamSelection = new ListSelection();
|
|
private teamIterationSelection = new ListSelection();
|
|
|
|
private queryParamsTeam: string = '';
|
|
private queryParamsTeamIteration: string = '';
|
|
|
|
constructor(props: {}) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
project: '',
|
|
selectedTabId: "navigation",
|
|
teams: [],
|
|
teamIterations: [],
|
|
selectedTeam: '',
|
|
selectedTeamName: '',
|
|
selectedTeamIteration: '',
|
|
selectedTeamIterationName: '',
|
|
taskboardWorkItemColumns: [],
|
|
taskboardColumns: [],
|
|
workItems: [],
|
|
workItemTypes: []
|
|
};
|
|
}
|
|
|
|
public componentDidMount() {
|
|
SDK.init();
|
|
this.getCustomData();
|
|
}
|
|
|
|
public render(): JSX.Element {
|
|
const {
|
|
headerDescription, useLargeTitle,
|
|
teams, teamIterations, workItems
|
|
} = this.state;
|
|
|
|
const interestedWorkItemTypes = ['Epic', 'Feature', 'User Story', 'Task', 'Bug'];
|
|
|
|
function teamDropdownItems(): Array<IListBoxItem<{}>> {
|
|
if (teams) {
|
|
return teams.map<IListBoxItem<{}>>(team => ({
|
|
id: team.id, text: team.name
|
|
}));
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function teamIterationDropdownItems(): Array<IListBoxItem<{}>> {
|
|
if (teamIterations) {
|
|
return teamIterations.map<IListBoxItem<{}>>(teamIteration => ({
|
|
id: teamIteration.id, text: teamIteration.name
|
|
}));
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns all work items (user stories, tasks, bugs) as a custom object for later display.
|
|
*/
|
|
const organizedWorkItems = workItems.map(workItem => {
|
|
const newWorkItem = {
|
|
id: workItem.id,
|
|
title: workItem.fields['System.Title'],
|
|
assignedTo: workItem.fields['System.AssignedTo'] ? workItem.fields['System.AssignedTo'].displayName : 'unassigned',
|
|
url: workItem.url.replace('/_apis/wit/workItems/', '/_workitems/edit/'),
|
|
boardColumn: workItem.fields['System.BoardColumn'],
|
|
state: workItem.fields['System.State'],
|
|
type: workItem.fields['System.WorkItemType'],
|
|
storyPoints: workItem.fields['Microsoft.VSTS.Scheduling.StoryPoints'] ?? 0
|
|
};
|
|
|
|
const taskboardColumn = this.state.taskboardWorkItemColumns.find(wic => wic.workItemId === workItem.id);
|
|
if (taskboardColumn) {
|
|
newWorkItem.boardColumn = taskboardColumn.column;
|
|
}
|
|
|
|
return newWorkItem;
|
|
});
|
|
|
|
const sortedWorkItems = interestedWorkItemTypes.map(workItemType => {
|
|
const typeMatchingWorkItems = organizedWorkItems.filter(wi => wi.type === workItemType);
|
|
|
|
if (typeMatchingWorkItems.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const taskboardColumns = this.state.taskboardColumns;
|
|
|
|
const workItemStates = taskboardColumns.map(column => {
|
|
const columnMatchingWorkItems = typeMatchingWorkItems.filter(wi => wi.boardColumn === column.name);
|
|
|
|
if (columnMatchingWorkItems.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const workItems = columnMatchingWorkItems.map(workItem => {
|
|
return (
|
|
<li key={workItem.id}>
|
|
<a href={workItem.url}>{workItem.id}</a> : {workItem.title} {workItem.storyPoints !== 0 && <span>({workItem.storyPoints})</span>} - {workItem.assignedTo}
|
|
</li>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<div className="work-item-state">
|
|
<h3>{column.name}</h3>
|
|
<ul>{workItems}</ul>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
return (
|
|
<div className="work-item-type">
|
|
<h2>{workItemType}</h2>
|
|
<React.Fragment>
|
|
{workItemStates}
|
|
</React.Fragment>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<Page className="iteration-work-items-hub flex-grow">
|
|
|
|
<Header title="Iteration Work Items Hub"
|
|
description={headerDescription}
|
|
titleSize={TitleSize.Large} />
|
|
|
|
<div id="iteration-selections">
|
|
<p>Select a Team</p>
|
|
<Dropdown
|
|
ariaLabel="Select a team"
|
|
className="example-dropdown"
|
|
placeholder="Select a Team"
|
|
items={teamDropdownItems()}
|
|
selection={this.teamSelection}
|
|
onSelect={this.handleSelectTeam}
|
|
dismissOnSelect={true}
|
|
/>
|
|
|
|
<p>Select an Iteration</p>
|
|
<Dropdown
|
|
ariaLabel="Select a team iteration"
|
|
className="example-dropdown"
|
|
placeholder="Select a Team Iteration"
|
|
items={teamIterationDropdownItems()}
|
|
selection={this.teamIterationSelection}
|
|
onSelect={this.handleSelectTeamIteration}
|
|
dismissOnSelect={true}
|
|
/>
|
|
</div>
|
|
|
|
{this.state.selectedTeamIterationName && <h2 id="selected-iteration">Work Items for {this.state.selectedTeamName} : {this.state.selectedTeamIterationName}</h2>}
|
|
|
|
{sortedWorkItems}
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
private async getCustomData() {
|
|
await SDK.ready();
|
|
|
|
// Get the project.
|
|
const projectService = await SDK.getService<IProjectPageService>(CommonServiceIds.ProjectPageService);
|
|
this.project = await projectService.getProject();
|
|
|
|
if (!this.project) {
|
|
this.showToast('No projects found.');
|
|
return;
|
|
}
|
|
this.setState({ project: this.project.id });
|
|
|
|
// Get teams.
|
|
const coreClient = getClient(CoreRestClient);
|
|
this.teams = await coreClient.getTeams(this.state.project);
|
|
if (!this.teams) {
|
|
this.showToast('No teams found.');
|
|
return;
|
|
}
|
|
this.setState({ teams: this.teams });
|
|
|
|
// Check the URL for a stored team and iteration.
|
|
const queryParams = await this.getQueryParams();
|
|
if (queryParams.queryTeam) {
|
|
this.queryParamsTeam = queryParams.queryTeam;
|
|
if (queryParams.queryTeamIteration) {
|
|
this.queryParamsTeamIteration = queryParams.queryTeamIteration;
|
|
}
|
|
}
|
|
|
|
if (this.teams.length === 1) {
|
|
this.teamSelection.select(0);
|
|
this.setState({
|
|
selectedTeam: this.teams[0].id
|
|
});
|
|
this.setState({
|
|
selectedTeamName: this.teams[0].name
|
|
});
|
|
this.getTeamData();
|
|
} else if (this.queryParamsTeam) {
|
|
// See if the team selection from the URL is a valid team.
|
|
const queryTeamIndex = this.teams.findIndex(t => t.id == this.queryParamsTeam);
|
|
if (queryTeamIndex >= 0) {
|
|
// Select the team.
|
|
this.teamSelection.select(queryTeamIndex);
|
|
this.setState({
|
|
selectedTeam: this.teams[queryTeamIndex].id
|
|
});
|
|
this.setState({
|
|
selectedTeamName: this.teams[queryTeamIndex].name
|
|
});
|
|
this.getTeamData();
|
|
}
|
|
}
|
|
}
|
|
|
|
private async getTeamData() {
|
|
await SDK.ready();
|
|
const teamContext = { projectId: this.state.project, teamId: this.state.selectedTeam, project: "", team: "" };
|
|
|
|
// Get team iterations.
|
|
const workClient = getClient(WorkRestClient);
|
|
this.teamIterations = await workClient.getTeamIterations(teamContext);
|
|
if (!this.teamIterations) {
|
|
this.showToast('No team iterations found.');
|
|
return;
|
|
}
|
|
this.setState({ teamIterations: this.teamIterations });
|
|
|
|
let iterationId = "";
|
|
let iterationName = "";
|
|
if (this.teamIterations.length === 1) {
|
|
this.teamIterationSelection.select(0);
|
|
|
|
iterationId = this.teamIterations[0].id;
|
|
iterationName = this.teamIterations[0].name;
|
|
} else {
|
|
let currentIteration: TeamSettingsIteration | undefined;
|
|
if (this.queryParamsTeamIteration) {
|
|
currentIteration = this.teamIterations.find(i => i.id === this.queryParamsTeamIteration);
|
|
}
|
|
if (!currentIteration) {
|
|
currentIteration = this.teamIterations.find(i => i.attributes.timeFrame === 1);
|
|
}
|
|
|
|
if (currentIteration) {
|
|
this.teamIterationSelection.select(this.teamIterations.indexOf(currentIteration));
|
|
|
|
iterationId = currentIteration.id;
|
|
iterationName = currentIteration.name;
|
|
}
|
|
}
|
|
|
|
if (iterationId !== '') {
|
|
this.setState({
|
|
selectedTeamIteration: iterationId
|
|
});
|
|
this.setState({
|
|
selectedTeamIterationName: iterationName
|
|
})
|
|
this.getTeamIterationData();
|
|
}
|
|
}
|
|
|
|
private async getTeamIterationData() {
|
|
await SDK.ready();
|
|
const teamContext = { projectId: this.state.project, teamId: this.state.selectedTeam, project: "", team: "" };
|
|
|
|
const workClient = getClient(WorkRestClient);
|
|
this.iterationWorkItems = await workClient.getIterationWorkItems(teamContext, this.state.selectedTeamIteration);
|
|
|
|
if (!this.iterationWorkItems || this.iterationWorkItems.workItemRelations.length === 0) {
|
|
this.showToast('No work items found for this iteration.');
|
|
this.setState({ workItems: [] });
|
|
return;
|
|
}
|
|
|
|
// This will give us not only tasks and bugs, but also the user stories.
|
|
// We'll use this full list to get all the work items later.
|
|
this.setState({ iterationWorkItems: this.iterationWorkItems });
|
|
|
|
// Get taskboard columns.
|
|
try {
|
|
this.taskboardColumns = await workClient.getColumns(teamContext);
|
|
} catch (ex) {
|
|
this.taskboardColumns = undefined;
|
|
}
|
|
|
|
if (!this.taskboardColumns || this.taskboardColumns.columns.length === 0) {
|
|
this.showToast('No taskboard columns can be found for this team. Default columns will be used.');
|
|
this.setState({ taskboardColumns: [
|
|
{ id: 'New', name: 'New', order: 0, mappings: [] },
|
|
{ id: 'Active', name: 'Active', order: 1, mappings: [] },
|
|
{ id: 'Resolved', name: 'Resolved', order: 2, mappings: [] },
|
|
{ id: 'Closed', name: 'Closed', order: 3, mappings: [] },
|
|
]});
|
|
} else {
|
|
this.setState({ taskboardColumns: this.taskboardColumns.columns });
|
|
}
|
|
|
|
let manuallyGenerateTaskboardWorkItemColumns = false;
|
|
try {
|
|
const workItemColumns = await workClient.getWorkItemColumns(teamContext, this.state.selectedTeamIteration);
|
|
this.setState({ taskboardWorkItemColumns: workItemColumns });
|
|
} catch (ex) {
|
|
this.showToast('No work item columns were found for this team. These will be generated automatically from the work items.');
|
|
manuallyGenerateTaskboardWorkItemColumns = true;
|
|
}
|
|
|
|
const witClient = getClient(WorkItemTrackingRestClient);
|
|
// TODO handle more than 200 work items; this endpoint only accepts/returns up to 200
|
|
this.workItems = await witClient.getWorkItems(this.iterationWorkItems.workItemRelations.map(wi => wi.target.id));
|
|
this.setState({ workItems: this.workItems });
|
|
|
|
if (manuallyGenerateTaskboardWorkItemColumns) {
|
|
const manualWorkItemColumns = this.workItems.filter(wi => wi.fields['System.WorkItemType'] === 'Task').map<TaskboardWorkItemColumn>(wi => ({
|
|
workItemId: wi.id,
|
|
state: wi.fields['System.State'],
|
|
column: wi.fields['System.State'],
|
|
columnId: wi.fields['System.State']
|
|
}));
|
|
this.setState({ taskboardWorkItemColumns: manualWorkItemColumns });
|
|
}
|
|
|
|
this.workItemTypes = await witClient.getWorkItemTypes(this.state.project);
|
|
this.setState({ workItemTypes: this.workItemTypes });
|
|
}
|
|
|
|
private handleSelectTeam = (_event: React.SyntheticEvent<HTMLElement>, item: IListBoxItem<{}>): void => {
|
|
this.setState({
|
|
selectedTeam: item.id
|
|
});
|
|
this.setState({
|
|
selectedTeamName: item.text ?? ''
|
|
});
|
|
this.setState({
|
|
selectedTeamIteration: ''
|
|
});
|
|
this.setState({
|
|
selectedTeamIterationName: ''
|
|
});
|
|
this.getTeamData();
|
|
this.updateQueryParams();
|
|
}
|
|
|
|
private handleSelectTeamIteration = (_event: React.SyntheticEvent<HTMLElement>, item: IListBoxItem<{}>): void => {
|
|
this.setState({
|
|
selectedTeamIteration: item.id
|
|
});
|
|
this.setState({
|
|
selectedTeamIterationName: item.text ?? ''
|
|
});
|
|
this.getTeamIterationData();
|
|
this.updateQueryParams();
|
|
}
|
|
|
|
private async getQueryParams() {
|
|
const navService = await SDK.getService<IHostNavigationService>(CommonServiceIds.HostNavigationService);
|
|
const hash = await navService.getQueryParams();
|
|
|
|
return { queryTeam: hash['selectedTeam'], queryTeamIteration: hash['selectedTeamIteration'] };
|
|
}
|
|
|
|
private showToast = async (message: string): Promise<void> => {
|
|
const globalMessagesSvc = await SDK.getService<IGlobalMessagesService>(CommonServiceIds.GlobalMessagesService);
|
|
globalMessagesSvc.addToast({
|
|
duration: 3000,
|
|
message: message
|
|
});
|
|
}
|
|
|
|
private updateQueryParams = async () => {
|
|
const navService = await SDK.getService<IHostNavigationService>(CommonServiceIds.HostNavigationService);
|
|
navService.setQueryParams({ selectedTeam: "" + this.state.selectedTeam, selectedTeamIteration: this.state.selectedTeamIteration });
|
|
navService.setDocumentTitle("" + this.state.selectedTeamName + " : " + this.state.selectedTeamIterationName + " - Iteration Work Items");
|
|
}
|
|
}
|
|
|
|
showRootComponent(<HubContent />);
|