merged from master
This commit is contained in:
Коммит
dcdca8d4f6
|
@ -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,
|
||||
|
|
Загрузка…
Ссылка в новой задаче