This commit is contained in:
Pranoti Rahangdale 2019-08-15 17:18:48 -07:00
Родитель 5d93d63774 c256381aab
Коммит dcdca8d4f6
13 изменённых файлов: 794 добавлений и 285 удалений

Просмотреть файл

@ -46,10 +46,35 @@ export class BacklogConfigurationDataService {
effortFieldRefName: projectEfforFieldRefName,
orderedWorkItemTypes: portfolioLevelsData.orderedWorkItemTypes,
backlogLevelNamesByWorkItemType: portfolioLevelsData.backlogLevelNamesByWorkItemType
backlogLevelNamesByWorkItemType: portfolioLevelsData.backlogLevelNamesByWorkItemType,
};
}
// returns a mapping for portfolio level work item type and its InProgress states.
// For default Agile project, the value will be {epic: ["Active", "Resolved"]}
public async getInProgressStates(projectId: string): Promise<{[key: string]: string[]}> {
const client = this.getWorkClient();
const teamContext: TeamContext = {
projectId: projectId,
team: null,
teamId: null,
project: null
};
const projectBacklogConfiguration: BacklogConfiguration = await client.getBacklogConfigurations(teamContext);
const portfolioLevelsData = this.getPortfolioLevelsData(projectBacklogConfiguration);
const workItemTypeMappedStatesInProgress: {[key: string]: string[]} = {};
projectBacklogConfiguration.workItemTypeMappedStates.forEach(item => {
const workItemTypeKey = item.workItemTypeName.toLowerCase();
if(Object.keys(portfolioLevelsData.backlogLevelNamesByWorkItemType).indexOf(workItemTypeKey) !== -1){
const statesForInProgress = Object.keys(item.states).filter(key => item.states[key] === "InProgress");
workItemTypeMappedStatesInProgress[workItemTypeKey] = statesForInProgress;
}
})
return workItemTypeMappedStatesInProgress;
}
public async getWorkItemTypeIconInfo(projectId: string, workItemType: string): Promise<IWorkItemIcon> {
const client = this.getWorkItemTrackingClient();
const workItemTypeData = await client.getWorkItemType(projectId, workItemType);
@ -62,6 +87,32 @@ export class BacklogConfigurationDataService {
};
}
public async getDefaultWorkItemTypeForV1(projectId: string): Promise<string> {
const client = this.getWorkClient();
const teamContext: TeamContext = {
projectId: projectId,
team: null,
teamId: null,
project: null
};
const backlogConfiguration: BacklogConfiguration = await client.getBacklogConfigurations(teamContext);
if (
backlogConfiguration &&
backlogConfiguration.portfolioBacklogs &&
backlogConfiguration.portfolioBacklogs.length > 0
) {
const allPortfolios = backlogConfiguration.portfolioBacklogs;
if (allPortfolios.length > 1) {
// Sort by rank ascending.
allPortfolios.sort((a, b) => a.rank - b.rank);
// Ignore first level.
allPortfolios.splice(0, 1);
}
return allPortfolios[0].defaultWorkItemType.name;
}
return Promise.resolve(null);
}
private getPortfolioLevelsData(
backlogConfiguration: BacklogConfiguration
): {

Просмотреть файл

@ -17,7 +17,12 @@ import {
PortfolioPlanningFullContentQueryResult,
PortfolioPlanningMetadata,
PortfolioPlanningDependencyQueryInput,
PortfolioPlanningDependencyQueryResult
PortfolioPlanningDependencyQueryResult,
WorkItemLinksQueryInput,
WorkItemLinksQueryResult,
WorkItemLink,
LinkTypeReferenceName,
WorkItemLinkIdType
} from "../../../PortfolioPlanning/Models/PortfolioPlanningQueryModels";
import { ODataClient } from "../ODataClient";
import {
@ -31,6 +36,7 @@ import { GUIDUtil } from "../Utilities/GUIDUtil";
import { IdentityRef } from "VSS/WebApi/Contracts";
import { defaultProjectComparer } from "../Utilities/Comparers";
import { ExtensionConstants } from "../../Contracts";
import { PortfolioTelemetry } from "../Utilities/Telemetry";
export class PortfolioPlanningDataService {
private static _instance: PortfolioPlanningDataService;
@ -102,109 +108,32 @@ export class PortfolioPlanningDataService {
);
}
public async runWorkItemLinksQuery(queryInput: WorkItemLinksQueryInput): Promise<WorkItemLinksQueryResult> {
if (!queryInput || !queryInput.WorkItemIds || queryInput.WorkItemIds.length === 0) {
return Promise.resolve({
exceptionMessage: null,
Links: [],
QueryInput: queryInput
});
}
const odataQueryString = ODataQueryBuilder.WorkItemLinksQueryString(queryInput);
const client = await ODataClient.getInstance();
const fullQueryUrl = client.generateProjectLink(queryInput.ProjectId, odataQueryString);
return client
.runPostQuery(fullQueryUrl)
.then(
(results: any) => this.ParseODataWorkItemLinksQueryQueryResultResponseAsBatch(results, queryInput),
error => this.ParseODataErrorResponse(error)
);
}
public async runDependencyQuery(
queryInput: PortfolioPlanningDependencyQueryInput
): Promise<PortfolioPlanningDependencyQueryResult> {
const DependsOn: PortfolioPlanningQueryResultItem[] = [];
const HasDependency: PortfolioPlanningQueryResultItem[] = [];
DependsOn.push({
WorkItemId: 68,
WorkItemType: "Epic",
Title: `${1}`,
State: "In Progress",
StartDate: new Date(),
TargetDate: new Date(),
ProjectId: `a3bb44bc-725d-4732-81e8-1543b8b34a24`,
AreaId: `${1}`,
TeamId: `${1}`,
CompletedCount: 1,
TotalCount: 10,
CompletedEffort: 1 * 5,
TotalEffort: 50,
EffortProgress: 0.1 * 1,
CountProgress: 0.1 * 1
});
HasDependency.push({
WorkItemId: 65,
WorkItemType: "Scenario",
Title: `${1}`,
State: "In Progress",
StartDate: new Date(),
TargetDate: new Date(),
ProjectId: `a3bb44bc-725d-4732-81e8-1543b8b34a24`,
AreaId: `${1}`,
TeamId: `${1}`,
CompletedCount: 1,
TotalCount: 10,
CompletedEffort: 1 * 5,
TotalEffort: 50,
EffortProgress: 0.1 * 1,
CountProgress: 0.1 * 1
});
/*
for (let i = 1; i < 26; i += 2) {
const title = ["WO"];
for (let j = 0; j < i; j++) {
title.push("MO");
}
DependsOn.push({
WorkItemId: i,
WorkItemType: "Epic",
Title: `${title}`,
State: "In Progress",
StartDate: new Date(),
TargetDate: new Date(),
ProjectId: `${i % 2}`,
AreaId: `${i}`,
TeamId: `${i}`,
CompletedCount: i,
TotalCount: 10,
CompletedEffort: i * 5,
TotalEffort: 50,
EffortProgress: 0.1 * i,
CountProgress: 0.1 * i
});
HasDependency.push({
WorkItemId: i + 1,
WorkItemType: "Epic",
Title: `${title}`,
State: "In Progress",
StartDate: new Date(),
TargetDate: new Date(),
ProjectId: `${(i + 1) % 2}`,
AreaId: `${i + 1}`,
TeamId: `${i + 1}`,
CompletedCount: i + 1,
TotalCount: 10,
CompletedEffort: (i + 1) * 5,
TotalEffort: 50,
EffortProgress: 0.1 * (i + 1),
CountProgress: 0.1 * (i + 1)
});
}
*/
// return Promise.resolve({
// DependsOn: [],
// HasDependency: [],
// exceptionMessage: null
// });
return Promise.resolve({
DependsOn: DependsOn,
HasDependency: HasDependency,
exceptionMessage: ""
});
// return Promise.resolve({
// DependsOn: DependsOn,
// HasDependency: HasDependency,
// exceptionMessage: "Big problems. Seriously like some really big problems happened!"
// });
return DependencyQuery.runDependencyQuery(queryInput);
}
public async getODataColumnNameFromWorkItemFieldReferenceName(fieldReferenceName: string): Promise<string> {
@ -550,6 +479,52 @@ export class PortfolioPlanningDataService {
}
}
private ParseODataWorkItemLinksQueryQueryResultResponseAsBatch(
results: any,
queryInput: WorkItemLinksQueryInput
): WorkItemLinksQueryResult {
try {
const rawResponseValue = this.ParseODataBatchResponse(results);
if (
!rawResponseValue ||
(rawResponseValue.exceptionMessage && rawResponseValue.exceptionMessage.length > 0)
) {
const errorMessage =
rawResponseValue!.exceptionMessage || "No response payload found in OData batch query";
return {
exceptionMessage: errorMessage,
Links: [],
QueryInput: queryInput
};
}
return {
exceptionMessage: null,
Links: this.WorkItemLinksQueryResultItems(rawResponseValue.responseValue),
QueryInput: queryInput
};
} catch (error) {
console.log(error);
return {
exceptionMessage: error,
Links: [],
QueryInput: queryInput
};
}
}
private WorkItemLinksQueryResultItems(jsonValuePayload: any): WorkItemLink[] {
if (!jsonValuePayload || !jsonValuePayload["value"]) {
return null;
}
const rawResult: WorkItemLink[] = jsonValuePayload.value;
return rawResult;
}
private ParseODataPortfolioPlanningQueryResultResponseAsBatch(
results: any,
aggregationClauses: WorkItemTypeAggregationClauses
@ -793,6 +768,8 @@ export class PortfolioPlanningDataService {
export class ODataQueryBuilder {
private static readonly ProjectEntitySelect: string = "ProjectSK,ProjectName";
private static readonly WorkItemLinksEntitySelect: string =
"WorkItemLinkSK,SourceWorkItemId,TargetWorkItemId,LinkTypeReferenceName,ProjectSK";
public static WorkItemsQueryString(
input: PortfolioPlanningQueryInput
@ -1058,4 +1035,277 @@ export class ODataQueryBuilder {
return result;
}
/**
*
$select=WorkItemLinkSK,SourceWorkItemId,TargetWorkItemId,LinkTypeReferenceName,ProjectSK
&
$filter=(
LinkTypeReferenceName eq 'System.LinkTypes.Dependency-Reverse'
and (
SourceWorkItemId eq 175
or SourceWorkItemId eq 176)
)
*
*/
public static WorkItemLinksQueryString(input: WorkItemLinksQueryInput): string {
// prettier-ignore
return "WorkItemLinks" +
"?" +
`$select=${ODataQueryBuilder.WorkItemLinksEntitySelect}` +
"&" +
`$filter=${this.BuildODataWorkItemLinksQueryFilter(input)}`;
}
private static BuildODataWorkItemLinksQueryFilter(input: WorkItemLinksQueryInput): string {
const workItemIdFilters = input.WorkItemIds.map(
workItemId => `${input.WorkItemIdColumn} eq ${workItemId.toString()}`
);
// prettier-ignore
return "( " +
`LinkTypeReferenceName eq '${input.RefName}' ` +
`and (${workItemIdFilters.join(" or ")}) ` +
")";
}
}
export class DependencyQuery {
public static async runDependencyQuery(
queryInput: PortfolioPlanningDependencyQueryInput
): Promise<PortfolioPlanningDependencyQueryResult> {
try {
const dataService = PortfolioPlanningDataService.getInstance();
const workItemLinksQueryResults = await this.GetWorkItemLinks(queryInput);
const { linksResultsIndexed, workItemRollUpQueryInput } = this.BuildPortfolioItemsQuery(
queryInput,
workItemLinksQueryResults
);
if (!linksResultsIndexed || Object.keys(linksResultsIndexed).length === 0) {
return {
byProject: {},
exceptionMessage: null
};
}
const dependenciesRollUpQueryResult = await dataService.runPortfolioItemsQuery(workItemRollUpQueryInput);
const results = DependencyQuery.MatchWorkItemLinksAndRollUpValues(
queryInput,
dependenciesRollUpQueryResult,
linksResultsIndexed
);
// Sort items by target date before returning
Object.keys(results.byProject).forEach(projectIdKey => {
results.byProject[projectIdKey].Predecessors.sort((a, b) => (a.TargetDate > b.TargetDate ? 1 : -1));
results.byProject[projectIdKey].Successors.sort((a, b) => (a.TargetDate > b.TargetDate ? 1 : -1));
});
return results;
} catch (error) {
console.log(error);
return {
exceptionMessage: error,
byProject: {}
};
}
}
private static MatchWorkItemLinksAndRollUpValues(
queryInput: PortfolioPlanningDependencyQueryInput,
dependenciesRollUpQueryResult: PortfolioPlanningQueryResult,
linksResultsIndexed: { [projectKey: string]: { [linkTypeKey: string]: number[] } }
) {
if (
dependenciesRollUpQueryResult.exceptionMessage &&
dependenciesRollUpQueryResult.exceptionMessage.length > 0
) {
throw new Error(
`runDependencyQuery: Exception running portfolio items query for dependencies. Inner exception: ${
dependenciesRollUpQueryResult.exceptionMessage
}`
);
}
const successorKey = LinkTypeReferenceName.Successor.toLowerCase();
const predecessorKey = LinkTypeReferenceName.Predecessor.toLowerCase();
const rollupsIndexed: {
[projectKey: string]: {
[workItemId: number]: PortfolioPlanningQueryResultItem;
};
} = {};
const result: PortfolioPlanningDependencyQueryResult = {
byProject: {},
exceptionMessage: null
};
// Indexing portfolio items results query by project id and work item id.
dependenciesRollUpQueryResult.items.forEach(resultItem => {
const projectIdKey = resultItem.ProjectId.toLowerCase();
const workItemId = resultItem.WorkItemId;
if (!rollupsIndexed[projectIdKey]) {
rollupsIndexed[projectIdKey] = {};
}
rollupsIndexed[projectIdKey][workItemId] = resultItem;
});
// Walk through all work item links found, and find the corresponding
// roll-up value from the portfolio items query results.
Object.keys(linksResultsIndexed).forEach(projectIdKey => {
const projectConfiguration = queryInput[projectIdKey].projectConfiguration;
Object.keys(linksResultsIndexed[projectIdKey]).forEach(linkTypeKey => {
linksResultsIndexed[projectIdKey][linkTypeKey].forEach(workItemId => {
if (!result.byProject[projectIdKey]) {
result.byProject[projectIdKey] = {
Predecessors: [],
Successors: []
};
}
const rollUpValues = rollupsIndexed[projectIdKey]![workItemId];
if (!rollUpValues) {
// Shouldn't happen. Portfolio items query result should contain an entry for every
// work item link found.
const props = {
["WorkItemId"]: workItemId,
["ProjectId"]: projectIdKey
};
const actionName =
"PortfolioPlanningDataService/runDependencyQuery/MissingWorkItemRollUpValues";
console.log(`${actionName}. ${JSON.stringify(props, null, " ")}`);
PortfolioTelemetry.getInstance().TrackAction(actionName, props);
} else {
// Check if this is a work item type we would like to show as dependency.
// TODO Maybe do this filtering in the portfolio items query?
// WorkItemLinks entity in OData does not have work item type unfortunately.
const wiTypeKey = rollUpValues.WorkItemType.toLowerCase();
if (!projectConfiguration.backlogLevelNamesByWorkItemType[wiTypeKey]) {
// One of the links is of a work item type below "Epics" backlog level,
// so we'll just ignore it. This is to prevent seeing dependencies roll-ups for "User Story"
// work items for example.
const props = {
["WorkItemId"]: workItemId,
["ProjectId"]: projectIdKey,
["LinkType"]: linkTypeKey,
["WorkItemType"]: wiTypeKey
};
const actionName =
"PortfolioPlanningDataService/runDependencyQuery/IgnoringLinkedWorkItemOfType";
console.log(`${actionName}. ${JSON.stringify(props, null, " ")}`);
PortfolioTelemetry.getInstance().TrackAction(actionName, props);
} else if (linkTypeKey === predecessorKey) {
result.byProject[projectIdKey].Predecessors.push(rollUpValues);
} else if (linkTypeKey === successorKey) {
result.byProject[projectIdKey].Successors.push(rollUpValues);
} else {
// Shouldn't happen. We are only querying for these two types of links.
const props = {
["WorkItemId"]: workItemId,
["ProjectId"]: projectIdKey,
["LinkType"]: linkTypeKey
};
const actionName = "PortfolioPlanningDataService/runDependencyQuery/UnknownLinkType";
console.log(`${actionName}. ${JSON.stringify(props, null, " ")}`);
PortfolioTelemetry.getInstance().TrackAction(actionName, props);
}
}
});
});
});
return result;
}
private static async GetWorkItemLinks(
queryInput: PortfolioPlanningDependencyQueryInput
): Promise<WorkItemLinksQueryResult[]> {
const dataService = PortfolioPlanningDataService.getInstance();
const workItemLinkQueries: Promise<WorkItemLinksQueryResult>[] = [];
Object.keys(queryInput).forEach(projectId => {
workItemLinkQueries.push(
dataService.runWorkItemLinksQuery({
ProjectId: projectId,
RefName: LinkTypeReferenceName.Predecessor,
WorkItemIdColumn: WorkItemLinkIdType.Source,
WorkItemIds: queryInput[projectId].workItemIds
})
);
workItemLinkQueries.push(
dataService.runWorkItemLinksQuery({
ProjectId: projectId,
RefName: LinkTypeReferenceName.Successor,
WorkItemIdColumn: WorkItemLinkIdType.Source,
WorkItemIds: queryInput[projectId].workItemIds
})
);
});
return await Promise.all(workItemLinkQueries);
}
private static BuildPortfolioItemsQuery(
queryInput: PortfolioPlanningDependencyQueryInput,
linkResults: WorkItemLinksQueryResult[]
) {
const linksResultsIndexed: { [projectKey: string]: { [linkTypeKey: string]: number[] } } = {};
const targetWorkItemIdsByProject: { [projectKey: string]: { [workItemId: number]: boolean } } = {};
linkResults.forEach(linkQueryResult => {
if (linkQueryResult.exceptionMessage && linkQueryResult.exceptionMessage.length > 0) {
// Throw at first exception.
throw new Error(
`runDependencyQuery: Exception running work item links query. Inner exception: ${
linkQueryResult.exceptionMessage
}`
);
}
const projectIdKey = linkQueryResult.QueryInput.ProjectId.toLowerCase();
linkQueryResult.Links.forEach(linkFound => {
const linkTypeKey = linkFound.LinkTypeReferenceName.toLowerCase();
if (!linksResultsIndexed[projectIdKey]) {
linksResultsIndexed[projectIdKey] = {};
}
if (!linksResultsIndexed[projectIdKey][linkTypeKey]) {
linksResultsIndexed[projectIdKey][linkTypeKey] = [];
}
if (!targetWorkItemIdsByProject[projectIdKey]) {
targetWorkItemIdsByProject[projectIdKey] = {};
}
linksResultsIndexed[projectIdKey][linkTypeKey].push(linkFound.TargetWorkItemId);
targetWorkItemIdsByProject[projectIdKey][linkFound.TargetWorkItemId] = true;
});
});
const workItemRollUpQueryInput: PortfolioPlanningQueryInput = { WorkItems: [] };
Object.keys(targetWorkItemIdsByProject).forEach(projectIdKey => {
const projectConfig = queryInput[projectIdKey].projectConfiguration;
workItemRollUpQueryInput.WorkItems.push({
projectId: projectIdKey,
workItemIds: Object.keys(targetWorkItemIdsByProject[projectIdKey]).map(workItemIdStr =>
Number(workItemIdStr)
),
DescendantsWorkItemTypeFilter: projectConfig.defaultRequirementWorkItemType,
EffortODataColumnName: projectConfig.effortODataColumnName,
EffortWorkItemFieldRefName: projectConfig.effortWorkItemFieldRefName
});
});
return { linksResultsIndexed, workItemRollUpQueryInput };
}
}

Просмотреть файл

@ -1,4 +1,4 @@
import { IProject } from "../../Contracts";
import { IProject, IWorkItem } from "../../Contracts";
import { convertToString } from "./String";
import { Project } from "../../Models/PortfolioPlanningQueryModels";
@ -6,17 +6,17 @@ export function defaultIProjectComparer(firstProject: IProject, secondProject: I
const firstProjectName = convertToString(firstProject.title, true /** upperCase */, true /** useLocale */);
const secondProjectName = convertToString(secondProject.title, true /** upperCase */, true /** useLocale */);
return defaultProjectNameComparer(firstProjectName, secondProjectName);
return defaultStringComparer(firstProjectName, secondProjectName);
}
export function defaultProjectComparer(firstProject: Project, secondProject: Project): number {
const firstProjectName = convertToString(firstProject.ProjectName, true /** upperCase */, true /** useLocale */);
const secondProjectName = convertToString(secondProject.ProjectName, true /** upperCase */, true /** useLocale */);
return defaultProjectNameComparer(firstProjectName, secondProjectName);
return defaultStringComparer(firstProjectName, secondProjectName);
}
function defaultProjectNameComparer(a: string, b: string): number {
function defaultStringComparer(a: string, b: string): number {
if (a === b) {
return 0;
} else if (a < b) {
@ -25,3 +25,10 @@ function defaultProjectNameComparer(a: string, b: string): number {
return 1;
}
}
export function defaultIWorkItemComparer(firstWorkItem: IWorkItem, secondWorkItem: IWorkItem): number {
const firstWorkItemName = convertToString(firstWorkItem.title, true /** upperCase */, true /** useLocale */);
const secondWorkItemName = convertToString(secondWorkItem.title, true /** upperCase */, true /** useLocale */);
return defaultStringComparer(firstWorkItemName, secondWorkItemName);
}

Просмотреть файл

@ -14,17 +14,15 @@
}
.item-list-row {
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.08);
padding: 10px;
width: 100%;
color: $primary-text;
font-weight: 600;
display: flex;
}
.item-list-row-text {
overflow: hidden;
text-overflow: ellipsis;
color: $primary-text;
font-size: $fontSizeM;
font-weight: 600;
}
.loading-projects {
@ -64,12 +62,23 @@
margin-top: 5px;
}
.workItemTypeSectionBody {
margin-left: $spacing-8;
max-width: 95%;
overflow: hidden;
}
.workItemIconClass {
width: 14px;
height: 18px;
margin-right: 5px;
flex-shrink: 0;
}
.workItemTypeEmptyMessage {
margin: 5px 0px 0px 15px;
}
}
.searchTextField {
margin-bottom: 10px;
}

Просмотреть файл

@ -2,7 +2,6 @@ import * as React from "react";
import "./AddItemPanel.scss";
import { Project } from "../../Models/PortfolioPlanningQueryModels";
import {
IWorkItem,
IProject,
IAddItems,
IAddItemPanelProjectItems,
@ -15,8 +14,13 @@ import { Panel } from "azure-devops-ui/Panel";
import { Dropdown, DropdownCallout } from "azure-devops-ui/Dropdown";
import { Location } from "azure-devops-ui/Utilities/Position";
import { IListBoxItem } from "azure-devops-ui/ListBox";
import { ListSelection, ScrollableList, ListItem, IListItemDetails, IListRow } from "azure-devops-ui/List";
import { ArrayItemProvider } from "azure-devops-ui/Utilities/Provider";
import {
DetailsList,
DetailsListLayoutMode,
Selection,
IColumn,
CheckboxVisibility
} from "office-ui-fabric-react/lib/DetailsList";
import { FormItem } from "azure-devops-ui/FormItem";
import { Spinner, SpinnerSize } from "azure-devops-ui/Spinner";
import { CollapsiblePanel } from "../../Common/Components/CollapsiblePanel";
@ -25,6 +29,7 @@ import { ProjectConfigurationDataService } from "../../Common/Services/ProjectCo
import { Image, IImageProps, ImageFit } from "office-ui-fabric-react/lib/Image";
import { PortfolioTelemetry } from "../../Common/Utilities/Telemetry";
import { Tooltip } from "azure-devops-ui/TooltipEx";
import { TextField, TextFieldWidth, TextFieldStyle } from "azure-devops-ui/TextField";
export interface IAddItemPanelProps {
planId: string;
@ -34,12 +39,11 @@ export interface IAddItemPanelProps {
}
interface IAddItemPanelState {
epicsToAdd: IWorkItem[];
projects: IListBoxItem[];
selectedProject: IProject;
selectedProjectBacklogConfiguration: IProjectConfiguration;
/**
* Map of work items to display, grouped by backlog level.
* Map of work items to display, grouped by work item type.
*/
workItemsByLevel: IAddItemPanelProjectItems;
@ -50,15 +54,17 @@ interface IAddItemPanelState {
}
export class AddItemPanel extends React.Component<IAddItemPanelProps, IAddItemPanelState> {
private _selectionByWorkItemType: { [workItemTypeKey: string]: ListSelection } = {};
private _indexToWorkItemIdMap: { [workItemTypeKey: string]: { [index: number]: number } } = {};
private _workItemIdMap: { [index: number]: IAddItem } = {};
private _selectionByWorkItemType: { [workItemTypeKey: string]: Selection } = {};
private _projectConfigurationsCache: { [projectIdKey: string]: IProjectConfiguration } = {};
/**
* Number of work items over which search is available for a work item type section.
*/
private static readonly WORKITEMTYPE_SEARCH_THRESHOLD: number = 20;
constructor(props) {
super(props);
this.state = {
epicsToAdd: [],
projects: [],
selectedProject: null,
selectedProjectBacklogConfiguration: null,
@ -153,15 +159,14 @@ export class AddItemPanel extends React.Component<IAddItemPanelProps, IAddItemPa
};
private _onProjectSelected = async (event: React.SyntheticEvent<HTMLElement>, item: IListBoxItem<{}>) => {
// Clear selection object for ScrollableList
// Clear selection objects for DetailsList.
this._selectionByWorkItemType = {};
this._workItemIdMap = {};
this._indexToWorkItemIdMap = {};
this.setState({
selectedProject: {
id: item.id,
title: item.text
title: item.text,
configuration: null
},
loadingProjectConfiguration: true
});
@ -193,34 +198,33 @@ export class AddItemPanel extends React.Component<IAddItemPanelProps, IAddItemPa
loadingStatus: LoadingStatus.NotLoaded,
loadingErrorMessage: null,
items: null,
workItemsFoundInProject: 0
workItemsFoundInProject: 0,
searchKeyword: null
};
});
// Populating work items for first type.
const items: IListBoxItem[] = [];
const items: { [workItemId: number]: IAddItem } = {};
if (workItemsOfType.exceptionMessage && workItemsOfType.exceptionMessage.length > 0) {
projectItems[firstWorkItemTypeKey] = {
workItemTypeDisplayName: firstWorkItemType,
loadingStatus: LoadingStatus.Loaded,
loadingErrorMessage: workItemsOfType.exceptionMessage,
items: null,
workItemsFoundInProject: 0
workItemsFoundInProject: 0,
searchKeyword: null
};
} else {
workItemsOfType.workItems.forEach(workItem => {
// Only show work items not yet included in the plan.
if (!this.props.epicsInPlan[workItem.WorkItemId]) {
const itemData: IAddItem = {
items[workItem.WorkItemId] = {
id: workItem.WorkItemId,
workItemType: workItem.WorkItemType
};
items.push({
id: workItem.WorkItemId.toString(),
key: workItem.WorkItemId,
text: workItem.Title,
data: itemData
});
workItemType: workItem.WorkItemType,
hide: false
};
}
});
@ -229,7 +233,8 @@ export class AddItemPanel extends React.Component<IAddItemPanelProps, IAddItemPa
loadingStatus: LoadingStatus.Loaded,
loadingErrorMessage: null,
items,
workItemsFoundInProject: workItemsOfType.workItems.length
workItemsFoundInProject: workItemsOfType.workItems.length,
searchKeyword: null
};
}
@ -272,26 +277,163 @@ export class AddItemPanel extends React.Component<IAddItemPanelProps, IAddItemPa
No work items of this type were found in the project.
</div>
);
} else if (content.items.length === 0) {
return <div className="workItemTypeEmptyMessage">All work items are already added to plan.</div>;
} else if (Object.keys(content.items).length === 0) {
return <div className="workItemTypeEmptyMessage">All work items were already added to the plan.</div>;
} else {
if (!this._selectionByWorkItemType[workItemTypeKey]) {
this._selectionByWorkItemType[workItemTypeKey] = new ListSelection(true);
const selection = new Selection({
onSelectionChanged: () => this._onWorkItemSelectionChanged(workItemTypeKey)
});
this._selectionByWorkItemType[workItemTypeKey] = selection;
}
return (
<ScrollableList
className="item-list"
itemProvider={new ArrayItemProvider<IListBoxItem>(content.items)}
renderRow={this.renderRow}
let allItemsCount: number = 0;
const listItems: IAddItem[] = [];
Object.keys(content.items).forEach(workItemId => {
const item = content.items[workItemId];
allItemsCount++;
if (item.hide === false) {
listItems.push(item);
}
});
const columns: IColumn[] = [
{
key: "titleColumn",
name: "Title",
fieldName: "text",
minWidth: 100,
isResizable: false,
isIconOnly: true
}
];
const list: JSX.Element = (
<DetailsList
isHeaderVisible={false}
checkboxVisibility={CheckboxVisibility.hidden}
items={listItems}
columns={columns}
setKey="set"
onRenderItemColumn={this._onRenderItemColumn}
layoutMode={DetailsListLayoutMode.justified}
selection={this._selectionByWorkItemType[workItemTypeKey]}
onSelect={this._onSelectionChanged}
selectionPreservedOnEmptyClick={true}
ariaLabelForSelectionColumn="Toggle selection"
ariaLabelForSelectAllCheckbox="Toggle selection for all items"
checkButtonAriaLabel="Row checkbox"
/>
);
const searchFilter =
allItemsCount > AddItemPanel.WORKITEMTYPE_SEARCH_THRESHOLD
? this._renderWorkItemTypeSectionFilter(workItemType)
: null;
return (
<div className={"workItemTypeSectionBody"}>
{searchFilter}
{list}
</div>
);
}
}
};
private _onRenderItemColumn = (item: IAddItem, index: number, column: IColumn): JSX.Element => {
const workItemTypeKey = item.workItemType.toLowerCase();
const iconProps = this.state.selectedProjectBacklogConfiguration.iconInfoByWorkItemType[workItemTypeKey];
const imageProps: IImageProps = {
src: iconProps.url,
className: "workItemIconClass",
imageFit: ImageFit.center,
maximizeFrame: true
};
return (
<div className="item-list-row">
<Image {...imageProps as any} />
<Tooltip overflowOnly={true}>
<span className="item-list-row-text">
{item.id} - {item.text}
</span>
</Tooltip>
</div>
);
};
private _onWorkItemSelectionChanged = (workItemTypeKey: string) => {
const { selectedWorkItems, workItemsByLevel } = this.state;
const selection = this._selectionByWorkItemType[workItemTypeKey];
const newSelectedWorkItems: { [workItemId: number]: IAddItem } = [];
const workItemsInlevel = workItemsByLevel[workItemTypeKey];
selection.getSelection().forEach(selectedWorkItem => {
const workItemId: number = selectedWorkItem.key as number;
newSelectedWorkItems[workItemId] = workItemsInlevel.items[workItemId];
});
if (Object.keys(newSelectedWorkItems).length === 0) {
delete selectedWorkItems[workItemTypeKey];
} else {
selectedWorkItems[workItemTypeKey] = newSelectedWorkItems;
}
this.setState({
selectedWorkItems
});
};
private _renderWorkItemTypeSectionFilter = (workItemType: string): JSX.Element => {
const searchKeyword = this.state.workItemsByLevel[workItemType].searchKeyword || "";
return (
<TextField
value={searchKeyword}
onChange={(e, value) => this._onChangeSearchKeywordTextField(e, value, workItemType)}
placeholder={"Search keyword"}
width={TextFieldWidth.auto}
style={TextFieldStyle.inline}
className={"searchTextField"}
/>
);
};
private _onChangeSearchKeywordTextField = (
ev: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
value: string,
workItemType: string
): void => {
const { workItemsByLevel } = this.state;
const filterEnabled = value && value.length > 0;
const valueLowerCase = value!.toLowerCase() || "";
Object.keys(workItemsByLevel[workItemType].items).forEach(itemKey => {
const item = workItemsByLevel[workItemType].items[itemKey] as IAddItem;
item.hide = filterEnabled === true && !this._matchesSearchKeywordFilter(valueLowerCase, item);
});
workItemsByLevel[workItemType].searchKeyword = value;
this.setState({
workItemsByLevel: workItemsByLevel
});
};
private _matchesSearchKeywordFilter = (keywordLowerCase: string, item: IAddItem): boolean => {
// prettier-ignore
return (
// Matches work item id?
item.id.toString().indexOf(keywordLowerCase) >= 0
||
// Matches work item text?
item.text.toLowerCase().indexOf(keywordLowerCase) >= 0
);
};
private _renderEpics = () => {
const { loadingProjectConfiguration, workItemsByLevel } = this.state;
@ -324,7 +466,10 @@ export class AddItemPanel extends React.Component<IAddItemPanelProps, IAddItemPa
});
return (
<FormItem message={this.state.errorMessage} error={this.state.errorMessage !== ""}>
<FormItem
message={this.state.errorMessage}
error={this.state.errorMessage && this.state.errorMessage !== ""}
>
{workItemTypeSections}
</FormItem>
);
@ -349,7 +494,7 @@ export class AddItemPanel extends React.Component<IAddItemPanelProps, IAddItemPa
// Load work items for this type.
let errorMessage: string = null;
let items: IListBoxItem[] = [];
const items: { [workItemId: number]: IAddItem } = {};
let workItemsFoundInProject = 0;
try {
@ -372,16 +517,13 @@ export class AddItemPanel extends React.Component<IAddItemPanelProps, IAddItemPa
workItemsOfType.workItems.forEach(workItem => {
// Only show work items not yet included in the plan.
if (!this.props.epicsInPlan[workItem.WorkItemId]) {
const itemData: IAddItem = {
items[workItem.WorkItemId] = {
id: workItem.WorkItemId,
workItemType: workItemTypeKey
};
items.push({
id: workItem.WorkItemId.toString(),
key: workItem.WorkItemId,
text: workItem.Title,
data: itemData
});
workItemType: workItemTypeKey,
hide: false
};
}
});
}
@ -401,73 +543,6 @@ export class AddItemPanel extends React.Component<IAddItemPanelProps, IAddItemPa
}
};
private renderRow = (
index: number,
epic: IListBoxItem,
details: IListItemDetails<IListBoxItem>,
key?: string
): JSX.Element => {
const itemData: IAddItem = epic.data as IAddItem;
const workItemTypeKey = itemData.workItemType.toLowerCase();
if (!this._indexToWorkItemIdMap[workItemTypeKey]) {
this._indexToWorkItemIdMap[workItemTypeKey] = {};
}
this._indexToWorkItemIdMap[workItemTypeKey][index] = Number(epic.id);
this._workItemIdMap[itemData.id] = itemData;
const iconProps = this.state.selectedProjectBacklogConfiguration.iconInfoByWorkItemType[workItemTypeKey];
const imageProps: IImageProps = {
src: iconProps.url,
className: "workItemIconClass",
imageFit: ImageFit.center,
maximizeFrame: true
};
return (
<ListItem key={key || "list-item" + index} index={index} details={details}>
<div className="item-list-row">
<Image {...imageProps as any} />
<Tooltip overflowOnly={true}>
<span className="item-list-row-text">
{epic.id} - {epic.text}
</span>
</Tooltip>
</div>
</ListItem>
);
};
private _onSelectionChanged = (event: React.SyntheticEvent<HTMLElement>, listRow: IListRow<IListBoxItem>) => {
const newSelectedEpics: { [workItemId: number]: IAddItem } = [];
const selectedIndexes: number[] = [];
const rowData: IAddItem = listRow.data.data as IAddItem;
const workItemTypeKey = rowData.workItemType.toLowerCase();
const { selectedWorkItems } = this.state;
this._selectionByWorkItemType[workItemTypeKey].value.forEach(selectedGroup => {
for (let index = selectedGroup.beginIndex; index <= selectedGroup.endIndex; index++) {
selectedIndexes.push(index);
}
});
selectedIndexes.forEach(index => {
const workItemId = this._indexToWorkItemIdMap[workItemTypeKey][index];
newSelectedEpics[workItemId] = this._workItemIdMap[workItemId];
});
if (Object.keys(newSelectedEpics).length === 0) {
delete selectedWorkItems[workItemTypeKey];
} else {
selectedWorkItems[workItemTypeKey] = newSelectedEpics;
}
this.setState({
selectedWorkItems
});
};
private _onAddEpics = (): void => {
const items: IAddItem[] = [];

Просмотреть файл

@ -34,12 +34,18 @@
display: flex;
cursor: pointer;
.info-icon,
.item-list-icon {
width: 14px;
margin-right: $spacing-8;
flex-shrink: 0;
}
.info-icon {
vertical-align: middle;
color: red;
}
.item-text-and-progress {
display: flex;
flex-grow: 1;

Просмотреть файл

@ -1,8 +1,9 @@
import * as React from "react";
import { Panel } from "azure-devops-ui/Panel";
import "./DependencyPanel.scss";
import { ITimelineItem, LoadingStatus, ProgressTrackingCriteria, IWorkItemIcon } from "../../Contracts";
import { ITimelineItem, LoadingStatus, ProgressTrackingCriteria, IWorkItemIcon, IProject } from "../../Contracts";
import { PortfolioPlanningDataService } from "../../Common/Services/PortfolioPlanningDataService";
import { BacklogConfigurationDataService } from "../../Common/Services/BacklogConfigurationDataService";
import {
PortfolioPlanningDependencyQueryResult,
PortfolioPlanningQueryResultItem
@ -15,15 +16,18 @@ import { IListBoxItem } from "azure-devops-ui/ListBox";
import { Tooltip } from "azure-devops-ui/TooltipEx";
import { ProgressDetails } from "../../Common/Components/ProgressDetails";
import { Image, ImageFit, IImageProps } from "office-ui-fabric-react/lib/Image";
import { BacklogConfigurationDataService } from "../../Common/Services/BacklogConfigurationDataService";
import { MessageCard, MessageCardSeverity } from "azure-devops-ui/MessageCard";
import { launchWorkItemForm } from "../../../../src/common/redux/actions/launchWorkItemForm";
import { connect } from 'react-redux';
import { Icon } from "azure-devops-ui/Icon";
import moment = require("moment");
type WorkItemIconMap = { [projectId: string]: { [workItemType: string]: IWorkItemIcon } };
type WorkItemIconMap = { [workItemType: string]: IWorkItemIcon };
type WorkItemInProgressStatesMap = { [WorkItemType: string]: string[] };
export interface IDependencyPanelProps {
workItem: ITimelineItem;
projectInfo: IProject;
progressTrackingCriteria: ProgressTrackingCriteria;
onDismiss: () => void;
}
@ -31,9 +35,10 @@ export interface IDependencyPanelProps {
export interface IDependencyPanelState {
loading: LoadingStatus;
errorMessage: string;
dependsOn: PortfolioPlanningQueryResultItem[];
hasDependency: PortfolioPlanningQueryResultItem[];
predecessors: PortfolioPlanningQueryResultItem[];
successors: PortfolioPlanningQueryResultItem[];
workItemIcons: WorkItemIconMap;
workItemTypeMappedStatesInProgress: WorkItemInProgressStatesMap;
}
interface IDependencyItemRenderData {
@ -41,6 +46,8 @@ interface IDependencyItemRenderData {
workItemType: string;
completed: number;
total: number;
showInfoIcon: boolean;
infoMessage: string;
}
export class DependencyPanel extends React.Component<IDependencyPanelProps & typeof Actions, IDependencyPanelState> {
@ -49,28 +56,43 @@ export class DependencyPanel extends React.Component<IDependencyPanelProps & typ
this.state = {
loading: LoadingStatus.NotLoaded,
dependsOn: [],
hasDependency: [],
predecessors: [],
successors: [],
workItemIcons: {},
errorMessage: ""
errorMessage: "",
workItemTypeMappedStatesInProgress: {}
};
this._getDependencies().then(
dependencies => {
this._getWorkItemIcons(dependencies.DependsOn.concat(dependencies.HasDependency)).then(
workItemIcons => {
this.setState({
loading: LoadingStatus.Loaded,
dependsOn: dependencies.DependsOn,
hasDependency: dependencies.HasDependency,
workItemIcons: workItemIcons,
errorMessage: dependencies.exceptionMessage
});
},
error => {
this.setState({ errorMessage: error.message, loading: LoadingStatus.NotLoaded });
}
);
const projectIdKey = this.props.projectInfo.id.toLowerCase();
const { configuration } = this.props.projectInfo;
let Predecessors: PortfolioPlanningQueryResultItem[] = [];
let Successors: PortfolioPlanningQueryResultItem[] = [];
if (dependencies && dependencies.byProject[projectIdKey]) {
Predecessors = dependencies.byProject[projectIdKey].Predecessors;
Successors = dependencies.byProject[projectIdKey].Successors;
}
this.setState({
loading: LoadingStatus.Loaded,
predecessors: Predecessors,
successors: Successors,
workItemIcons: configuration.iconInfoByWorkItemType,
errorMessage: dependencies.exceptionMessage
});
},
error => {
this.setState({ errorMessage: error.message, loading: LoadingStatus.NotLoaded });
}
);
this._getWorkItemTypeMappedStatesInProgress().then(
workItemTypeMappedStatesInProgress => {
this.setState({
workItemTypeMappedStatesInProgress: workItemTypeMappedStatesInProgress
});
},
error => {
this.setState({ errorMessage: error.message, loading: LoadingStatus.NotLoaded });
@ -79,7 +101,6 @@ export class DependencyPanel extends React.Component<IDependencyPanelProps & typ
}
public render(): JSX.Element {
// TODO: Sort dependencies by target date
// TODO: Add red ! icon to indicate problems
// TODO: Dependencies should probably be links
return (
@ -112,7 +133,7 @@ export class DependencyPanel extends React.Component<IDependencyPanelProps & typ
animate={false}
headerLabel="Waiting for others"
headerClassName={"list-header"}
renderContent={(key: string) => this._renderDependencyGroup(this.state.dependsOn)}
renderContent={(key: string) => this._renderDependencyGroup(this.state.predecessors, true)}
isCollapsible={true}
initialIsExpanded={true}
forceContentUpdate={true}
@ -123,7 +144,7 @@ export class DependencyPanel extends React.Component<IDependencyPanelProps & typ
animate={false}
headerLabel="Others waiting on"
headerClassName={"list-header"}
renderContent={(key: string) => this._renderDependencyGroup(this.state.hasDependency)}
renderContent={(key: string) => this._renderDependencyGroup(this.state.successors, false)}
isCollapsible={true}
initialIsExpanded={true}
forceContentUpdate={true}
@ -134,7 +155,7 @@ export class DependencyPanel extends React.Component<IDependencyPanelProps & typ
}
}
private _renderDependencyGroup = (dependencies: PortfolioPlanningQueryResultItem[]): JSX.Element => {
private _renderDependencyGroup = (dependencies: PortfolioPlanningQueryResultItem[], isPredecessor): JSX.Element => {
const items: IListBoxItem<IDependencyItemRenderData>[] = [];
if (dependencies.length === 0) {
@ -155,7 +176,11 @@ export class DependencyPanel extends React.Component<IDependencyPanelProps & typ
total:
this.props.progressTrackingCriteria === ProgressTrackingCriteria.CompletedCount
? dependency.TotalCount
: dependency.TotalEffort
: dependency.TotalEffort,
showInfoIcon: this._showInfoIcon(dependency, isPredecessor),
infoMessage: isPredecessor
? "Target date is later than " + this.props.workItem.title + "'s start date"
: "Start date is earlier than " + this.props.workItem.title + "'s target date"
}
});
});
@ -169,14 +194,31 @@ export class DependencyPanel extends React.Component<IDependencyPanelProps & typ
);
};
private _showInfoIcon = (item: PortfolioPlanningQueryResultItem, isPredecessor: boolean): boolean => {
// only show info icon if the item is in InProgress state.
const statesForInProgress = this.state.workItemTypeMappedStatesInProgress[item.WorkItemType.toLowerCase()];
if (statesForInProgress.indexOf(item.State) === -1) return false;
// if this depends-on item has end date later than selected work item's start date.
if (moment(item.TargetDate) > this.props.workItem.start_time && isPredecessor) {
return true;
}
// if this has-dependency item has start date earlier than selected work item's end date.
if (moment(item.StartDate) < this.props.workItem.end_time && !isPredecessor) {
return true;
}
return false;
};
private _renderDependencyItem = (
index: number,
item: IListBoxItem<IDependencyItemRenderData>,
details: IListItemDetails<IListBoxItem<IDependencyItemRenderData>>,
key?: string
): JSX.Element => {
const workItemTypeKey = item.data.workItemType.toLowerCase();
const imageProps: IImageProps = {
src: this.state.workItemIcons[item.data.projectId][item.data.workItemType].url,
src: this.state.workItemIcons[workItemTypeKey].url,
className: "item-list-icon",
imageFit: ImageFit.center,
maximizeFrame: true
@ -185,6 +227,13 @@ export class DependencyPanel extends React.Component<IDependencyPanelProps & typ
return (
<ListItem key={key || item.id} index={index} details={details}>
<div className="item-list-row" >
{item.data.showInfoIcon ? (
<Tooltip text={item.data.infoMessage}>
<div>
<Icon ariaLabel="Info icon" iconName="Info" className="info-icon" />
</div>
</Tooltip>
) : null}
<Image {...imageProps as any} />
<div className="item-text-and-progress" >
<Tooltip overflowOnly={true} >
@ -206,33 +255,22 @@ export class DependencyPanel extends React.Component<IDependencyPanelProps & typ
};
private _getDependencies = async (): Promise<PortfolioPlanningDependencyQueryResult> => {
const { id, configuration } = this.props.projectInfo;
const dependencies = await PortfolioPlanningDataService.getInstance().runDependencyQuery({
WorkItemId: this.props.workItem.id
[id.toLowerCase()]: {
workItemIds: [this.props.workItem.id],
projectConfiguration: configuration
}
});
return dependencies;
};
private _getWorkItemIcons = async (workItems: PortfolioPlanningQueryResultItem[]): Promise<WorkItemIconMap> => {
const workItemIconMap: WorkItemIconMap = {};
await Promise.all(
workItems.map(async workItem => {
if (workItemIconMap[workItem.ProjectId] === undefined) {
workItemIconMap[workItem.ProjectId] = {};
}
if (workItemIconMap[workItem.ProjectId][workItem.WorkItemType] === undefined) {
await BacklogConfigurationDataService.getInstance()
.getWorkItemTypeIconInfo(workItem.ProjectId, workItem.WorkItemType)
.then(workItemTypeInfo => {
workItemIconMap[workItem.ProjectId][workItem.WorkItemType] = workItemTypeInfo;
});
}
})
private _getWorkItemTypeMappedStatesInProgress = async (): Promise<WorkItemInProgressStatesMap> => {
const workItemTypeMappedStatesInProgress = await BacklogConfigurationDataService.getInstance().getInProgressStates(
this.props.projectInfo.id
);
return workItemIconMap;
return workItemTypeMappedStatesInProgress;
};
}

Просмотреть файл

@ -1,6 +1,6 @@
import * as React from "react";
import * as moment from "moment";
import { ITimelineGroup, ITimelineItem, ITeam, ProgressTrackingCriteria } from "../../Contracts";
import { ITimelineGroup, ITimelineItem, ITeam, ProgressTrackingCriteria, IProject } from "../../Contracts";
import Timeline, { TimelineHeaders, DateHeader } from "react-calendar-timeline";
import "./PlanTimeline.scss";
import { IPortfolioPlanningState } from "../../Redux/Contracts";
@ -8,7 +8,8 @@ import {
getTimelineGroups,
getTimelineItems,
getProjectNames,
getTeamNames
getTeamNames,
getIndexedProjects
} from "../../Redux/Selectors/EpicTimelineSelectors";
import { EpicTimelineActions } from "../../Redux/Actions/EpicTimelineActions";
import { connect } from "react-redux";
@ -52,6 +53,7 @@ interface IPlanTimelineMappedProps {
exceptionMessage: string;
setDatesDialogHidden: boolean;
progressTrackingCriteria: ProgressTrackingCriteria;
projects: { [projectIdKey: string]: IProject };
}
interface IPlanTimelineState {
@ -131,6 +133,7 @@ export class PlanTimeline extends React.Component<IPlanTimelineProps, IPlanTimel
return (
<ConnectedDependencyPanel
workItem={this.state.contextMenuItem}
projectInfo={this.props.projects[this.state.contextMenuItem.projectId.toLowerCase()]}
progressTrackingCriteria={this.props.progressTrackingCriteria}
onDismiss={() => this.setState({ dependencyPanelOpen: false })}
/>
@ -374,8 +377,8 @@ export class PlanTimeline extends React.Component<IPlanTimelineProps, IPlanTimel
{...getItemProps({
className: "plan-timeline-item",
style: {
background: item.canMove? "white" : "#f8f8f8",
fontWeight: item.canMove? 600 : 900,
background: item.canMove ? "white" : "#f8f8f8",
fontWeight: item.canMove ? 600 : 900,
color: "black",
...borderStyle,
borderRadius: "4px"
@ -567,7 +570,8 @@ function mapStateToProps(state: IPortfolioPlanningState): IPlanTimelineMappedPro
planOwner: getSelectedPlanOwner(state),
exceptionMessage: state.epicTimelineState.exceptionMessage,
setDatesDialogHidden: state.epicTimelineState.setDatesDialogHidden,
progressTrackingCriteria: state.epicTimelineState.progressTrackingCriteria
progressTrackingCriteria: state.epicTimelineState.progressTrackingCriteria,
projects: getIndexedProjects(state.epicTimelineState)
};
}

Просмотреть файл

@ -1,9 +1,9 @@
import moment = require("moment");
import { IListBoxItem } from "azure-devops-ui/ListBox";
export interface IProject {
id: string;
title: string;
configuration: IProjectConfiguration;
}
export interface IWorkItemIcon {
@ -83,7 +83,10 @@ export interface IAddItems {
export interface IAddItem {
id: number;
key: number;
text: string;
workItemType: string;
hide: boolean;
}
export interface IRemoveItem {
@ -100,6 +103,7 @@ export interface ITimelineItem {
id: number;
group: string;
teamId: string;
projectId: string;
backlogLevel: string;
title: string;
iconUrl: string;
@ -130,11 +134,12 @@ export class IAddItemPanelProjectItems {
workItemTypeDisplayName: string;
loadingStatus: LoadingStatus;
loadingErrorMessage: string;
searchKeyword: string;
/**
* Contains work items that should be displayed in the panel. i.e. work items found in
* project, except those that are already part of the plan.
*/
items: IListBoxItem[];
items: { [workItemId: number]: IAddItem };
/**
* Count of all work items of this type found in the project.

Просмотреть файл

@ -1,5 +1,6 @@
import { ODataQueryProjectInput } from "./ODataQueryModels";
import { IdentityRef } from "VSS/WebApi/Contracts";
import { IProjectConfiguration } from "../Contracts";
export interface PortfolioPlanningQueryInput {
/**
@ -182,10 +183,51 @@ export interface PortfolioPlanningWorkItemTypeFieldNameQueryResult extends IQuer
}
export interface PortfolioPlanningDependencyQueryInput {
WorkItemId: number;
/**
* Work item ids by project.
* ProjectIdKey must be lowercase already.
*/
[projectIdKey: string]: { workItemIds: number[]; projectConfiguration: IProjectConfiguration };
}
export interface PortfolioPlanningDependencyQueryResult extends IQueryResultError {
DependsOn: PortfolioPlanningQueryResultItem[];
HasDependency: PortfolioPlanningQueryResultItem[];
byProject: {
[projectId: string]: {
// predecessor
Predecessors: PortfolioPlanningQueryResultItem[];
// successor
Successors: PortfolioPlanningQueryResultItem[];
};
};
}
export interface WorkItemLinksQueryInput {
RefName: string;
ProjectId: string;
WorkItemIds: number[];
WorkItemIdColumn: WorkItemLinkIdType;
}
export interface WorkItemLinksQueryResult extends IQueryResultError {
Links: WorkItemLink[];
QueryInput: WorkItemLinksQueryInput;
}
export interface WorkItemLink {
WorkItemLinkSK: string;
SourceWorkItemId: number;
TargetWorkItemId: number;
LinkTypeReferenceName: string;
ProjectSK: string;
}
export enum WorkItemLinkIdType {
Source = "SourceWorkItemId",
Target = "TargetWorkItemId"
}
export enum LinkTypeReferenceName {
Successor = "System.LinkTypes.Dependency-Forward",
Predecessor = "System.LinkTypes.Dependency-Reverse"
}

Просмотреть файл

@ -8,7 +8,7 @@ import {
import produce from "immer";
import { ProgressTrackingCriteria, LoadingStatus, IWorkItemIcon } from "../../Contracts";
import { MergeType } from "../../Models/PortfolioPlanningQueryModels";
import { defaultIProjectComparer } from "../../Common/Utilities/Comparers";
import { defaultIProjectComparer, defaultIWorkItemComparer } from "../../Common/Utilities/Comparers";
export function epicTimelineReducer(state: IEpicTimelineState, action: EpicTimelineActions): IEpicTimelineState {
return produce(state || getDefaultState(), (draft: IEpicTimelineState) => {
@ -48,7 +48,7 @@ export function epicTimelineReducer(state: IEpicTimelineState, action: EpicTimel
break;
}
case EpicTimelineActionTypes.UpdateItemFinished: {
const {itemId} = action.payload;
const { itemId } = action.payload;
const epicToUpdate = draft.epics.find(epic => epic.id === itemId);
epicToUpdate.itemUpdating = false;
break;
@ -171,7 +171,8 @@ function handlePortfolioItemsReceived(
draft.projects = projects.projects.map(project => {
return {
id: project.ProjectSK,
title: project.ProjectName
title: project.ProjectName,
configuration: projectConfigurations[project.ProjectSK.toLowerCase()]
};
});
@ -234,7 +235,8 @@ function handlePortfolioItemsReceived(
if (filteredProjects.length === 0) {
draft.projects.push({
id: newProjectInfo.ProjectSK,
title: newProjectInfo.ProjectName
title: newProjectInfo.ProjectName,
configuration: projectConfigurations[newProjectInfo.ProjectSK.toLowerCase()]
});
}
});
@ -303,6 +305,11 @@ function handlePortfolioItemsReceived(
// Not loading anymore.
draft.planLoadingStatus = LoadingStatus.Loaded;
}
// Sort timeline items by name.
if (draft.epics) {
draft.epics.sort(defaultIWorkItemComparer);
}
});
}

Просмотреть файл

@ -138,6 +138,9 @@ class UpgradeFromV1ToV2 {
const allProjectSchemaUpgrades = Object.keys(planInfo.projects).map(async projectKey => {
let { PortfolioWorkItemType, PortfolioBacklogLevelName, WorkItemIds } = planInfo.projects[projectKey];
if (!PortfolioWorkItemType) {
PortfolioWorkItemType = await BacklogConfigurationDataService.getInstance().getDefaultWorkItemTypeForV1(projectKey);
}
const workItemTypeKey = PortfolioWorkItemType.toLowerCase();
// Get work item icon info for work item type.

Просмотреть файл

@ -7,6 +7,17 @@ export function getProjects(state: IEpicTimelineState): IProject[] {
return state.projects;
}
export function getIndexedProjects(state: IEpicTimelineState): { [projectIdKey: string]: IProject } {
const result: { [projectIdKey: string]: IProject } = {};
state.projects.forEach(project => {
const projectIdKey = project.id.toLowerCase();
result[projectIdKey] = project;
});
return result;
}
export function getProjectNames(state: IPortfolioPlanningState): string[] {
return state.epicTimelineState.projects.map(project => project.title);
}
@ -62,6 +73,7 @@ export function getTimelineItems(state: IEpicTimelineState): ITimelineItem[] {
id: epic.id,
group: epic.project,
teamId: epic.teamId,
projectId: epic.project,
backlogLevel: epic.backlogLevel,
title: epic.title,
iconUrl: epic.iconProps.url,