This commit is contained in:
Navneet Gupta 2018-05-03 15:10:30 -07:00
Родитель 48e68f1bac
Коммит a7467804ad
101 изменённых файлов: 33763 добавлений и 14 удалений

4
.gitignore поставляемый
Просмотреть файл

@ -57,3 +57,7 @@ typings/
# dotenv environment variables file
.env
# Specific to this repo
*.vsix
*.docx
dist

27
.vscode/launch.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,27 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Jest All",
"program": "${workspaceFolder}/node_modules/jest/bin/jest",
"args": ["--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Jest Current File",
"program": "${workspaceFolder}/node_modules/jest/bin/jest",
"args": ["${relativeFile}"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}

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

@ -1,14 +1,52 @@
# Contributing
This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.microsoft.com.
When you submit a pull request, a CLA-bot will automatically determine whether you need to provide
a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions
provided by the bot. You will only need to do this once across all repos using our CLA.
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
**Plan or track work items in progress by visualizing them on a sprint calendar**.
Portfolio level work items are worked for multiple sprints and this tool helps you visualize features or epics across sprints, yes you heard it right, **cross sprint schedule**!!!
![Feature timeline](images/main-img.png "Feature timeline")
## Get started
Building and testing the extension requires following.
1) [Download and install nodejs](http://nodejs.org "nodejs")
2) [webpack](https://webpack.js.org/)
3) [tfx cli](https://docs.microsoft.com/en-us/vsts/extend/publish/command-line?view=vsts)
4) [TypeScript](https://www.typescriptlang.org/)
```
npm i -g typescript tfs-cli webpack
```
Install dev prerequisites
```
npm install
```
### Create vsix to deploy on test environment
```
webpack && npm run package:dev:http
```
### Run the extension server locally
Execute following commands in two separate Command Prompts
```
webpack --watch
npm run dev:http
```
### Publish the dev extension to marketplace
Follow the instructions here
[Package, publish, unpublish, and install VSTS extensions
](https://docs.microsoft.com/en-us/vsts/extend/publish/overview?view=vsts)
# Contributing
This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.microsoft.com.
When you submit a pull request, a CLA-bot will automatically determine whether you need to provide
a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions
provided by the bot. You will only need to do this once across all repos using our CLA.
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.

6
configs/beta.json Normal file
Просмотреть файл

@ -0,0 +1,6 @@
{
"name": "Feature Timeline (BETA)",
"galleryFlags": [
"Preview"
]
}

3
configs/dev.json Normal file
Просмотреть файл

@ -0,0 +1,3 @@
{
"public": false
}

4
configs/devHttp.json Normal file
Просмотреть файл

@ -0,0 +1,4 @@
{
"public": false,
"baseUri": "http://localhost:8888"
}

3
configs/release.json Normal file
Просмотреть файл

@ -0,0 +1,3 @@
{
"public": true
}

34
details.md Normal file
Просмотреть файл

@ -0,0 +1,34 @@
**Plan or track work items in progress by visualizing them on a sprint calendar**.
Portfolio level work items are worked for multiple sprints and this tool helps you visualize features or epics across sprints, yes you heard it right, **cross sprint schedule**!!!
![Feature timeline](dist/images/main-img.png "Feature timeline")
# Overview
We see two kinds of teams:
Scrum Teams: They commit to User Stories for each sprint and use sprint tools (Sprint backlog, Taskboard) to execute and track their work for each sprint.
Kanban Teams: They pull highest priority work from backlog and execute work to completion before picking the next one. Usually these teams do not set specific sprint/iteration for each work they are executing on.
While both Kanban teams and Scrum Teams have User Stories or Tasks that are smaller chunk of work that is finished within a sprint, they do rollup to a portfolio level work like **feature that span across sprint.**
# How does it work?
If you are a scrum team that sets iteration on each user story then you will be incentivized, because we rollup child user story iterations to its parent feature, yes you are rewarded for planning your child user stories.
![Child rollup](dist/images/child-rollup.png "Child rollup")
If you are a kanban team and does not set iterations for your user story, plan when your features will tentatively start/finish by manually extending your features sprints.
![Manual planning](dist/images/manual-plan1.gif "Manual Planning")
If we do not have both these data points, we put the feature on the feature iteration if available else it goes to current sprint so that you get a chance to set it.
You can also drag drop the work items to plan in the tool.
# Get Started
This extension is a pivot available for portfolio level backlogs. Not to pollute your timeline we only pull work items that are in " **InProgress**" state category.
Give it a try. Looking forward to hearing your feedback.

Двоичные данные
docs/child-rollup.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 38 KiB

Двоичные данные
docs/main-img.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 22 KiB

Двоичные данные
docs/manual-plan1.gif Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 256 KiB

Двоичные данные
images/child-rollup.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 38 KiB

Двоичные данные
images/icon.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 23 KiB

Двоичные данные
images/main-img.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 22 KiB

Двоичные данные
images/manual-plan1.gif Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 256 KiB

90
package.json Normal file
Просмотреть файл

@ -0,0 +1,90 @@
{
"name": "feature-timeline",
"version": "1.0.0",
"author": "ms-devlabs",
"license": "MIT",
"description": "A Work Item view.",
"main": "webpack.config.js",
"scripts": {
"clean": "rimraf dist *.vsix vss-extension-release.json src/*js libs",
"dev": "webpack-dev-server --hot --progress --colors --content-base ./dist --https --port 8888",
"dev:http": "webpack-dev-server --progress --colors --content-base ./ --port 8888",
"package:dev": "node ./scripts/packageDev",
"package:dev:http": "node ./scripts/packageDevHttp",
"package:release": "node ./scripts/packageRelease",
"package:beta": "node ./scripts/packageBeta",
"publish:dev": "npm run package:dev && node ./scripts/publishDev",
"build:release": "set NODE_ENV=production && npm run clean && mkdir dist && webpack --progress --colors --output-path ./dist -p && set NODE_ENV=",
"publish:release": "npm run build:release && node ./scripts/publishRelease",
"test": "jest",
"testupdate": "jest --updateSnapshot",
"postinstall": "typings install"
},
"keywords": [
"timeline",
"work item"
],
"dependencies": {
"office-ui-fabric-react": "^5.68.0",
"react": "^16.2.0",
"react-dnd": "^2.6.0",
"react-dnd-html5-backend": "^2.6.0",
"react-dom": "^16.2.0",
"react-redux": "^5.0.7",
"redux": "^3.7.2",
"redux-logger": "^3.0.6",
"redux-saga": "^0.16.0",
"reselect": "^3.0.1",
"vss-web-extension-sdk": "^5.131.0"
},
"devDependencies": {
"@types/jest": "^22.2.2",
"@types/jquery": "^2.0.41",
"@types/react": "^15.0.21",
"@types/react-dom": "^0.14.23",
"@types/react-redux": "^5.0.15",
"awesome-typescript-loader": "^4.0.1",
"copy-webpack-plugin": "^4.0.1",
"css-loader": "^0.28.0",
"extract-text-webpack-plugin": "^3.0.2",
"jest": "^22.4.3",
"node-sass": "^4.8.3",
"redux-devtools": "^3.4.1",
"rimraf": "^2.6.1",
"sass-loader": "^6.0.7",
"source-map-loader": "^0.2.3",
"style-loader": "^0.16.1",
"tfx-cli": "^0.4.5",
"ts-jest": "^22.4.2",
"ts-loader": "^4.0.1",
"typescript": "^2.7.2",
"typings": "^2.1.0",
"uglifyjs-webpack-plugin": "^0.4.2",
"webpack": "^4.2.0",
"webpack-bundle-analyzer": "^2.11.1",
"webpack-cli": "^2.0.13",
"webpack-dev-server": "^3.1.1"
},
"jest": {
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"jsx",
"json",
"node"
],
"moduleDirectories": [
"node_modules",
"node_modules/vss-web-extension-sdk/lib"
],
"moduleNameMapper": {
"^VSS(.*)$": "<rootDir>/node_modules/vss-web-extension-sdk/lib/VSS.SDK.min.js",
"^TFS(.*)$": "<rootDir>/node_modules/vss-web-extension-sdk/lib/VSS.SDK.min.js"
}
}
}

20
scripts/packageBeta.js Normal file
Просмотреть файл

@ -0,0 +1,20 @@
"use strict";
var exec = require("child_process").exec;
var manifest = require("../vss-extension.json");
var extensionId = manifest.id;
// Package extension
var command = `tfx extension create --extension-id ${extensionId}-beta --overrides-file configs/beta.json --manifest-globs vss-extension.json --no-prompt --json`;
exec(command, (error, stdout) => {
if (error) {
console.error(`Could not create package: '${error}'`);
return;
}
let output = JSON.parse(stdout);
console.log(`Package created ${output.path}`);
}
);

15
scripts/packageDev.js Normal file
Просмотреть файл

@ -0,0 +1,15 @@
var exec = require("child_process").exec;
// Load existing publisher
var manifest = require("../vss-extension.json");
var extensionId = manifest.id;
// Package extension
var command = `tfx extension create --rev-version --overrides-file configs/dev.json --manifest-globs vss-extension.json --extension-id ${extensionId}-dev --no-prompt`;
exec(command, function (error) {
if (error) {
console.log(`Package create error: ${error}`);
} else {
console.log("Package created");
}
});

11
scripts/packageDevHttp.js Normal file
Просмотреть файл

@ -0,0 +1,11 @@
var exec = require("child_process").exec;
// Load existing publisher
var manifest = require("../vss-extension.json");
var extensionId = manifest.id;
// Package extension
var command = `tfx extension create --rev-version --overrides-file configs/devHttp.json --manifest-globs vss-extension.json --extension-id ${extensionId}-dev --no-prompt`;
exec(command, function() {
console.log("Package created");
});

17
scripts/packageRelease.js Normal file
Просмотреть файл

@ -0,0 +1,17 @@
"use strict";
var exec = require("child_process").exec;
// Package extension
var command = `tfx extension create --rev-version --overrides-file configs/release.json --manifest-globs vss-extension.json --no-prompt --json`;
exec(command, (error, stdout) => {
if (error) {
console.error(`Could not create package: '${error}'`);
return;
}
let output = JSON.parse(stdout);
console.log(`Package created ${output.path}`);
}
);

16
scripts/publishDev.js Normal file
Просмотреть файл

@ -0,0 +1,16 @@
var exec = require("child_process").exec;
var manifest = require("../vss-extension.json");
var extensionId = manifest.id;
var extensionPublisher = manifest.publisher;
var extensionVersion = manifest.version;
// Package extension
var command = `tfx extension publish --vsix ${extensionPublisher}.${extensionId}-dev-${extensionVersion}.vsix --save`;
exec(command, function (error) {
if (error) {
console.log("Package publish ERROR:" + error);
} else {
console.log("Package published.");
}
});

28
scripts/publishRelease.js Normal file
Просмотреть файл

@ -0,0 +1,28 @@
"use strict";
var exec = require("child_process").exec;
// Package extension
var command = `tfx extension create --rev-version --overrides-file ../configs/release.json --manifest-globs vss-extension-release.json --no-prompt --json`;
exec(command, {
"cwd": "./dist"
}, (error, stdout) => {
if (error) {
console.error(`Could not create package: '${error}'`);
return;
}
let output = JSON.parse(stdout);
console.log(`Package created ${output.path}`);
var command = `tfx extension publish --vsix ${output.path} --no-prompt`;
exec(command, (error, stdout) => {
if (error) {
console.error(`Could not create package: '${error}'`);
return;
}
console.log("Package published.");
});
});

23
src/FeatureTimeline.tsx Normal file
Просмотреть файл

@ -0,0 +1,23 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import { DragDropGrid } from "./react/Components/FeatureTimelineGrid";
import { iePollyfill } from "./polyfill";
export function initialize(): void {
if (!isBackground()) {
iePollyfill();
ReactDOM.render(
<DragDropGrid /> , document.getElementById("root"));
}
}
export function unmount(): void {
if (!isBackground()) {
ReactDOM.unmountComponentAtNode(document.getElementById("root"));
}
}
function isBackground() {
const contributionContext = VSS.getConfiguration();
return contributionContext.host && contributionContext.host.background;
}

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

@ -0,0 +1,47 @@
import { WorkItemType, WorkItemStateColor } from "TFS/WorkItemTracking/Contracts";
import { getClient } from "VSS/Service";
import { WorkItemTrackingHttpClient } from "TFS/WorkItemTracking/RestClient";
export class WorkItemMetadataService {
private static _instance: WorkItemMetadataService;
public static getInstance(): WorkItemMetadataService {
if (!WorkItemMetadataService._instance) {
WorkItemMetadataService._instance = new WorkItemMetadataService();
}
return WorkItemMetadataService._instance;
}
private _workItemTypes: WorkItemType[] = null;
public async getWorkItemTypes(projectId): Promise<WorkItemType[]> {
if (this._workItemTypes) {
return this._workItemTypes;
}
const witHttpClient = getClient(WorkItemTrackingHttpClient);
this._workItemTypes = await witHttpClient.getWorkItemTypes(projectId);
return this._workItemTypes;
}
private _states: IDictionaryStringTo<WorkItemStateColor[]> = null;
public async getStates(projectId): Promise<IDictionaryStringTo<WorkItemStateColor[]>> {
if (this._states) {
return this._states;
}
const witHttpClient = getClient(WorkItemTrackingHttpClient);
const workItemTypes = await this.getWorkItemTypes(projectId);
const map = {};
for (const wit of workItemTypes) {
map[wit.name] = await witHttpClient.getWorkItemTypeStates(projectId, wit.referenceName);
}
this._states = map;
return map;
}
}

79
src/index.html Normal file
Просмотреть файл

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en" style="height:100%">
<head>
<meta charset="utf-8" />
<!-- VSS Framework -->
<script src="libs/VSS.SDK.min.js"></script>
</head>
<body style="height:100%">
<script>
let FeatureTimeline = null;
VSS.init({
usePlatformStyles: true,
explicitNotifyLoaded: true,
usePlatformScripts: true,
extensionReusedCallback: registerContribution,
moduleLoaderConfig: {
paths: {
"react": "dist/react",
"react-dom": "dist/react-dom",
"FeatureTimeline": "dist/bundle"
}
}
});
// We need to register the new contribution if this extension host is reused
function registerContribution(contribution) {
if (contribution.type === "ms.vss-web.tab") {
// Register the fully-qualified contribution id here.
// Because we're using the contribution id, we do NOT need to define a registeredObjectId in the extension manfiest.
VSS.register(contribution.id, {
pageTitle: "Feature Timeline",
// We set the "dynamic" contribution property to true in the manifest so that it will get the tab name from this function.
name: "Feature Timeline",
title: "Feature Timeline",
updateContext: updateConfiguration,
isInvisible: function (state) {
return false;
}
});
}
}
let previousContext = null
function updateConfiguration(tabContext) {
if (!isBackground() && typeof tabContext === "object" && FeatureTimeline && JSON.stringify(previousContext) !== JSON.stringify(tabContext)) {
FeatureTimeline.unmount();
FeatureTimeline.initialize();
previousContext = tabContext;
}
}
VSS.ready(function () {
registerContribution(VSS.getContribution());
if (isBackground()) {
VSS.notifyLoadSucceeded();
} else {
// Load main entry point for extension
VSS.require(["FeatureTimeline"], function (ft) {
FeatureTimeline = ft;
FeatureTimeline.initialize();
// loading succeeded
VSS.notifyLoadSucceeded();
});
}
});
function isBackground() {
const contributionContext = VSS.getConfiguration();
return contributionContext && contributionContext.host && contributionContext.host.background;
}
</script>
<div id="root" />
</body>
</html>

94
src/polyfill.ts Normal file
Просмотреть файл

@ -0,0 +1,94 @@
export function iePollyfill() {
if (!Array.prototype.find) {
Object.defineProperty(Array.prototype, 'find', {
value: function (predicate) {
// 1. Let O be ? ToObject(this value).
if (this == null) {
throw new TypeError('"this" is null or not defined');
}
var o = Object(this);
// 2. Let len be ? ToLength(? Get(O, "length")).
var len = o.length >>> 0;
// 3. If IsCallable(predicate) is false, throw a TypeError exception.
if (typeof predicate !== 'function') {
throw new TypeError('predicate must be a function');
}
// 4. If thisArg was supplied, let T be thisArg; else let T be undefined.
var thisArg = arguments[1];
// 5. Let k be 0.
var k = 0;
// 6. Repeat, while k < len
while (k < len) {
// a. Let Pk be ! ToString(k).
// b. Let kValue be ? Get(O, Pk).
// c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)).
// d. If testResult is true, return kValue.
var kValue = o[k];
if (predicate.call(thisArg, kValue, k, o)) {
return kValue;
}
// e. Increase k by 1.
k++;
}
// 7. Return undefined.
return undefined;
},
configurable: true,
writable: true
});
}
// https://tc39.github.io/ecma262/#sec-array.prototype.findIndex
if (!Array.prototype.findIndex) {
Object.defineProperty(Array.prototype, 'findIndex', {
value: function (predicate) {
// 1. Let O be ? ToObject(this value).
if (this == null) {
throw new TypeError('"this" is null or not defined');
}
var o = Object(this);
// 2. Let len be ? ToLength(? Get(O, "length")).
var len = o.length >>> 0;
// 3. If IsCallable(predicate) is false, throw a TypeError exception.
if (typeof predicate !== 'function') {
throw new TypeError('predicate must be a function');
}
// 4. If thisArg was supplied, let T be thisArg; else let T be undefined.
var thisArg = arguments[1];
// 5. Let k be 0.
var k = 0;
// 6. Repeat, while k < len
while (k < len) {
// a. Let Pk be ! ToString(k).
// b. Let kValue be ? Get(O, Pk).
// c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)).
// d. If testResult is true, return k.
var kValue = o[k];
if (predicate.call(thisArg, kValue, k, o)) {
return k;
}
// e. Increase k by 1.
k++;
}
// 7. Return -1.
return -1;
},
configurable: true,
writable: true
});
}
}

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

@ -0,0 +1,46 @@
import { DropTarget } from 'react-dnd';
import { IIterationSahdowProps, IterationShadow } from './IterationShadow';
import React = require('react');
import { IWorkItemRendererProps } from './WorkItem/WorkItemRenderer';
export class DroppableIterationShadow extends React.Component<IIterationSahdowProps, {}> {
public render() {
return <IterationShadow {...this.props} />
}
}
const iterationDropTarget = {
canDrop(dropTargetProps: IIterationSahdowProps, monitor) {
// let item = monitor.getItem() as IDraggableWorkItemRendererProps;
// item = null;
return true;
},
drop(dropTargetProps: IIterationSahdowProps, monitor, component) {
if (monitor.didDrop()) {
return;
}
const draggedItem = monitor.getItem() as IWorkItemRendererProps;
dropTargetProps.changeIteration(draggedItem.id, dropTargetProps.teamIteration, draggedItem.allowOverride);
return { moved: true };
}
}
function collect(connect, monitor) {
return {
// Call this function inside render()
// to let React DnD handle the drag events:
connectDropTarget: connect.dropTarget(),
// You can ask the monitor about the current drag state:
isOver: monitor.isOver(),
isOverCurrent: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
itemType: monitor.getItemType()
};
}
export const IterationDropTarget = DropTarget("WorkItem", iterationDropTarget, collect)(DroppableIterationShadow);

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

@ -0,0 +1,75 @@
.feature-timeline-main-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
}
.root-container {
height: 100%;
}
.container {
display: -ms-grid;
display: grid;
grid-column-gap: 5px;
}
.feature-container {
overflow: hidden;
text-overflow: ellipsis;
}
@mixin button-dimension {
height: 20px;
padding: 3px;
margin: 2px;
border-radius: 5px;
border: solid 1px lightgray;
cursor: pointer;
font-family: sans-serif;
}
.command {
@include button-dimension();
background: lightgray;
color: black;
overflow: ellipse;
white-space: nowrap;
}
.button {
@include button-dimension();
background: lightgray;
color: white;
}
.non-button {
@include button-dimension();
background: transparent;
border: none;
}
.iteration-options {
display: flex;
justify-content: flex-end;
}
.command-right-section,
.last-header-column-command {
display: flex;
flex-direction: column;
.non-button,
.button {
margin-left: auto;
margin-right: 0;
}
}
.single-column-commands {
display: flex;
justify-content: space-between;
}
#root {
height: 100%;
}

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

@ -0,0 +1,354 @@
import * as React from 'react';
import configureStore from '../../redux/configureStore';
import DraggableWorkItemRenderer from './WorkItem/DraggableWorkItemRenderer';
import HTML5Backend from 'react-dnd-html5-backend';
import {
changeDisplayIterationCount,
displayAllIterations,
shiftDisplayIterationLeft,
shiftDisplayIterationRight
} from '../../redux/store/teamiterations/actionCreators';
import { clearOverrideIteration, launchWorkItemForm, startUpdateWorkItemIteration } from '../../redux/store/workitems/actionCreators';
import { closeDetails, createInitialize, showDetails } from '../../redux/store/common/actioncreators';
import { connect, Provider } from 'react-redux';
import { DragDropContext } from 'react-dnd';
import { endOverrideIteration, overrideHoverOverIteration, startOverrideIteration } from '../../redux/store/overrideIterationProgress/actionCreators';
import {
getBacklogLevel,
getProjectId,
getRawState,
getTeamId,
gridViewSelector,
uiStatusSelector
} from '../../redux/selectors';
import { getRowColumnStyle, getTemplateColumns } from './gridhelper';
import { IFeatureTimelineRawState, IWorkItemOverrideIteration } from '../../redux/store';
import { IGridView } from '../../redux/selectors/gridViewSelector';
import { IterationDropTarget } from './DroppableIterationShadow';
import { IterationRenderer } from './IterationRenderer';
import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar';
import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner';
import { TeamSettingsIteration } from 'TFS/Work/Contracts';
import { TimelineDialog } from './TimelineDialog';
import { UIStatus } from '../../redux/types';
import { WorkitemGap } from './WorkItem/WorkItemGap';
import { WorkItemShadow } from './WorkItem/WorkItemShadow';
import './FeatureTimelineGrid.scss';
export interface IFeatureTimelineGridProps {
projectId: string;
teamId: string;
rawState: IFeatureTimelineRawState;
uiState: UIStatus;
gridView: IGridView,
childItems: number[];
launchWorkItemForm: (id: number) => void;
showDetails: (id: number) => void;
closeDetails: (id: number) => void;
clearOverrideIteration: (id: number) => void;
dragHoverOverIteration: (iteration: string) => void;
overrideIterationStart: (payload: IWorkItemOverrideIteration) => void;
overrideIterationEnd: () => void;
changeIteration: (id: number, teamIteration: TeamSettingsIteration, override: boolean) => void;
showThreeIterations: (projectId: string, teamId: string) => void;
showFiveIterations: (projectId: string, teamId: string) => void;
shiftDisplayIterationLeft: () => void;
shiftDisplayIterationRight: () => void;
showAllIterations: () => void;
}
const makeMapStateToProps = () => {
return (state: IFeatureTimelineRawState) => {
return {
projectId: getProjectId(),
teamId: getTeamId(),
rawState: getRawState(state),
uiState: uiStatusSelector()(state),
gridView: gridViewSelector()(state),
childItems: state.workItemDetails
}
}
}
const mapDispatchToProps = (dispatch) => {
return {
launchWorkItemForm: (id: number) => {
if (id) {
dispatch(launchWorkItemForm(id));
}
},
showDetails: (id: number) => {
dispatch(showDetails(id));
},
closeDetails: (id: number) => {
dispatch(closeDetails(id));
},
dragHoverOverIteration: (iterationId: string) => {
dispatch(overrideHoverOverIteration(iterationId));
},
overrideIterationStart: (payload: IWorkItemOverrideIteration) => {
dispatch(startOverrideIteration(payload));
},
overrideIterationEnd: () => {
dispatch(endOverrideIteration());
},
clearOverrideIteration: (id: number) => {
dispatch(clearOverrideIteration(id));
},
changeIteration: (id: number, teamIteration: TeamSettingsIteration, override: boolean) => {
dispatch(startUpdateWorkItemIteration([id], teamIteration, override));
},
showThreeIterations: (projectId: string, teamId: string) => {
dispatch(changeDisplayIterationCount(3, projectId, teamId));
},
showFiveIterations: (projectId: string, teamId: string) => {
dispatch(changeDisplayIterationCount(5, projectId, teamId));
},
showAllIterations: () => {
dispatch(displayAllIterations());
},
shiftDisplayIterationLeft: () => {
dispatch(shiftDisplayIterationLeft(1));
},
shiftDisplayIterationRight: () => {
dispatch(shiftDisplayIterationRight(1));
}
};
};
interface IFeatureTimelineGridState {
collapsedGroups: IDictionaryNumberTo<boolean>;
}
export class FeatureTimelineGrid extends React.Component<IFeatureTimelineGridProps, IFeatureTimelineGridState> {
constructor() {
super();
this.state = {
collapsedGroups: {}
};
}
public render(): JSX.Element {
const {
uiState,
projectId,
teamId
} = this.props;
if (!this.props.rawState || uiState === UIStatus.Loading) {
return (
<Spinner size={SpinnerSize.large} label="Loading..." />
);
}
if (this.props.rawState.error) {
return (
<MessageBar
messageBarType={MessageBarType.error}
isMultiline={false}
>
{this.props.rawState.error}
</MessageBar>
);
}
if (uiState === UIStatus.NoTeamIterations) {
return (
<MessageBar
messageBarType={MessageBarType.error}
isMultiline={false}
>
{"The team does not have any iteration selected, please visit team admin page and select team iterations."}
</MessageBar>
);
}
if (uiState === UIStatus.NoWorkItems) {
return (<MessageBar
messageBarType={MessageBarType.info}
isMultiline={false}
>
{"No in-progress Features for the timeline."}
</MessageBar>);
}
const {
emptyHeaderRow,
iterationHeader,
iterationShadow,
workItems,
workItemShadow,
iterationDisplayOptions,
isSubGrid
} = this.props.gridView;
const columnHeading = iterationHeader.map((iteration, index) => {
const style = getRowColumnStyle(iteration.dimension);
return (
<div className="columnheading" style={style}>
<IterationRenderer iteration={iteration.teamIteration} />
</div>
);
});
const shadows = iterationShadow.map((shadow, index) => {
return (
<IterationDropTarget
{...shadow}
isOverrideIterationInProgress={!!this.props.rawState.workItemOverrideIteration}
onOverrideIterationOver={this.props.dragHoverOverIteration.bind(this)}
changeIteration={this.props.changeIteration.bind(this)}
>
&nbsp;
</IterationDropTarget>
);
});
let workItemShadowCell = null;
if (workItemShadow) {
const workItem = workItems.filter(w => !w.isGap && w.workItem.id === workItemShadow)[0];
workItemShadowCell = (
<WorkItemShadow {...workItem.dimension} />
);
}
const workItemCells = workItems.filter(w => !w.isGap && w.workItem.id).map(w => {
return (
<DraggableWorkItemRenderer
id={w.workItem.id}
title={w.workItem.title}
color={w.workItem.color}
isRoot={w.workItem.isRoot}
iterationDuration={w.workItem.iterationDuration}
dimension={w.dimension}
onClick={id => this.props.launchWorkItemForm(id)}
shouldShowDetails={w.workItem.shouldShowDetails}
showDetails={id => this.props.showDetails(id)}
overrideIterationStart={payload => this.props.overrideIterationStart(payload)}
overrideIterationEnd={() => this.props.overrideIterationEnd()}
allowOverride={!this.props.gridView.isSubGrid}
crop={w.crop}
/>
);
});
const workItemGaps = workItems.filter(w => w.isGap).map(w => {
return (
<WorkitemGap {...w.dimension} />
);
});
const extraColumns = this.props.gridView.hideParents ? [] : ['minmax(100px, 10%)'];
const gridStyle = getTemplateColumns(extraColumns, shadows.length, 'minmax(10%, 400px)');
let childDialog = null;
if (this.props.childItems.length > 0) {
const props = { ...this.props, id: this.props.childItems[0] };
childDialog = <TimelineDialog {...props} />
}
let leftButton = <span className="non-button"></span>;
if (iterationDisplayOptions && iterationDisplayOptions.startIndex > 0) {
leftButton = <span className="button" onClick={() => this.props.shiftDisplayIterationLeft()}>{"<<"}</span>;
}
let rightButton = <span className="non-button"></span>;
if (iterationDisplayOptions && iterationDisplayOptions.endIndex < (iterationDisplayOptions.totalIterations - 1)) {
rightButton = <span className="button" onClick={() => this.props.shiftDisplayIterationRight()}>{">>"}</span>
}
let displayOptions = null;
let commandHeading = [];
if (!isSubGrid && (iterationDisplayOptions || columnHeading.length > 3)) {
displayOptions = (
<div className="iteration-options">
<span className="command" onClick={() => this.props.showThreeIterations(projectId, teamId)}>Show three Sprints</span>
<span className="command" onClick={() => this.props.showFiveIterations(projectId, teamId)}>Show five Sprints</span>
<span className="command" onClick={() => this.props.showAllIterations()}>Show all sprints</span>
</div>
);
if (emptyHeaderRow.length === 1) {
// Special case only one column
let rowColumnStyle = getRowColumnStyle(emptyHeaderRow[0]);
const commands = (
<div style={rowColumnStyle} className="single-column-commands">
<div className="command-left-section">
{leftButton}
</div>
<div className="command-right-section">
{rightButton}
</div>
</div>
);
commandHeading.push(commands);
} else {
// Add left button to first empty heading cell
let rowColumnStyle = getRowColumnStyle(emptyHeaderRow[0]);
const firstHeaderColumnCommand = (
<div style={rowColumnStyle} className="first-header-column-command">
{leftButton}
</div>
);
commandHeading.push(firstHeaderColumnCommand);
// Add display options and right button on last empty heading cell
rowColumnStyle = getRowColumnStyle(emptyHeaderRow[emptyHeaderRow.length - 1]);
const lastHeaderColumnCommand = (
<div style={rowColumnStyle} className="last-header-column-command">
{rightButton}
</div>
);
commandHeading.push(lastHeaderColumnCommand);
}
}
return (
<div className="root-container">
{displayOptions}
<div className="feature-timeline-main-container">
<div className="container" style={gridStyle}>
{commandHeading}
{columnHeading}
{shadows}
{workItemShadowCell}
{workItemCells}
{workItemGaps}
{childDialog}
</div>
</div>
</div>
);
}
}
const ConntectedFeatureTimeline = connect(
makeMapStateToProps, mapDispatchToProps
)(FeatureTimelineGrid);
export const PrimaryGrid = () => {
const initialState: IFeatureTimelineRawState = {
loading: true
} as IFeatureTimelineRawState;
const store = configureStore(initialState);
const projectId = getProjectId();
const teamId = getTeamId();
const backlogLevel = getBacklogLevel();
const action = createInitialize(projectId, teamId, backlogLevel);
store.dispatch(action);
return (
<Provider store={store}>
<ConntectedFeatureTimeline />
</Provider>);
}
export const DragDropGrid = DragDropContext(HTML5Backend)(PrimaryGrid);

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

@ -0,0 +1,10 @@
.info-icon {
color: transparent !important;
cursor: pointer;
margin-top: 2px;
margin-left: 2px;
}
.info-icon:hover {
color: white !important;
}

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

@ -0,0 +1,18 @@
import './InfoIcon.scss';
import * as React from 'react';
export interface IInfoIconProps {
id: number;
onClick: (id: number) => void;
}
export class InfoIcon extends React.Component<IInfoIconProps, {}> {
public render() {
return (
<div className="bowtie-icon bowtie-status-info info-icon"
onClick={() => this.props.onClick(this.props.id)}>
&nbsp;
</div>
);
}
}

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

@ -0,0 +1,38 @@
.iteration {
display: flex;
flex-direction: column;
background: #f4f4f4;
padding: 0px;
text-align: center;
border-radius: 3px;
border: 1px solid lightgrey;
font-family: "Segoe UI VSS (Regular)", "-apple-system", BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
height: 100%;
.iterationname {
color: #212121;
font-weight: bold;
font-size: 12px;
padding: 2px;
}
.dates {
color: #666666;
font-size: 11px;
padding: 2px;
}
}
.current-sprint-marker {
margin-left: 3px;
padding-left: 8px;
padding-right: 8px;
padding-top: 1px;
padding-bottom: 2px;
background-color: rgb(16, 110, 190);
color: rgb(255, 255, 255);
font-size: 10px;
line-height: 1.5;
width: 100px;
min-width: 60px;
text-align: center;
border-radius: 5px;
}

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

@ -0,0 +1,52 @@
import "./IterationRenderer.scss";
import * as React from "react";
import { TeamSettingsIteration, TimeFrame } from "TFS/Work/Contracts";
export interface IIterationRendererProps {
iteration: TeamSettingsIteration;
}
function getMMDD(date: Date) {
var mm = date.getMonth() < 9 ? "0" + (date.getMonth() + 1) : (date.getMonth() + 1); // getMonth() is zero-based
var dd = date.getDate() < 10 ? "0" + date.getDate() : date.getDate();
return `${mm}/${dd}`;
}
export class IterationRenderer extends React.Component<IIterationRendererProps, {}> {
public render(): JSX.Element {
const {
iteration
} = this.props;
// TODO: Start and end date conversion?
const startDate = iteration.attributes["startDate"] ? getMMDD(new Date(iteration.attributes["startDate"])) : null;
const endDate = iteration.attributes["finishDate"] ? getMMDD(new Date(iteration.attributes["finishDate"])) : null;
let dates: JSX.Element = null;
if (startDate && endDate) {
dates = (
<div className="dates">
{`${startDate} - ${endDate}`}
</div>
);
}
let currentMarker = null;
if (TimeFrame && iteration.attributes.timeFrame === TimeFrame.Current) {
currentMarker = (
<span className="current-sprint-marker">Current</span>
);
}
return (
<div className="iteration">
<div className="iterationname">
<span>{iteration.name}</span>
{currentMarker}
</div>
{dates}
</div>
);
}
}

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

@ -0,0 +1,9 @@
.columnshadow {
margin-top: 2px;
background: #f4f4f4;
min-height: 36px;
}
.highlight {
border: 1px solid yellowgreen;
}

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

@ -0,0 +1,70 @@
import './IterationShadow.scss';
import * as React from 'react';
import { IGridIteration } from '../../redux/selectors/gridViewSelector';
import { TeamSettingsIteration } from 'TFS/Work/Contracts';
import { getRowColumnStyle } from './gridhelper';
export interface IIterationSahdowProps extends IGridIteration {
isOverrideIterationInProgress: boolean;
onOverrideIterationOver: (iteration: string) => void;
changeIteration: (id: number, teamIteration: TeamSettingsIteration, override: boolean) => void;
connectDropTarget?: (element: JSX.Element) => JSX.Element;
isOver?: boolean;
canDrop?: () => boolean;
}
export interface IIterationSahdowState {
shouldHighlight: boolean;
}
export class IterationShadow extends React.Component<IIterationSahdowProps, IIterationSahdowState> {
private _div: HTMLDivElement;
public constructor(props) {
super(props);
this.state = {
shouldHighlight: false
};
}
public render() {
const className = "columnshadow" + (this.state.shouldHighlight || this.props.isOver ? " highlight" : "");
const style = getRowColumnStyle(this.props.dimension);
const { connectDropTarget } = this.props;
return connectDropTarget(
<div
className={className}
ref={(e) => this._div = e}
onMouseMove={this._onMouseEnter}
onDragOver={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}
style={style}
>
{this.props.children}
</div>
);
}
private _onMouseEnter = () => {
if (this.props.isOverrideIterationInProgress) {
this.setState({
shouldHighlight: true
});
this.props.onOverrideIterationOver(this.props.teamIteration.id);
}
}
private _onMouseLeave = () => {
if (this.props.isOverrideIterationInProgress)
this.setState({
shouldHighlight: false
})
}
}

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

@ -0,0 +1,47 @@
.timeline-dialog {
max-width: 60% !important;
}
.timeline-dialog .container {
max-height: 300px;
overflow: auto;
}
.custom-duration-footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
.div {
margin: 10px;
}
}
.dialog-contents {
display: flex;
flex-direction: column;
}
.dialog-grid-container {
margin-bottom: 20px;
}
.custom-duration-container {
display: flex;
flex-direction: column;
}
.custom-duration-iterations {
display: flex;
flex-direction: row;
margin-top: 10px;
}
.custom-duration-iteration {
width: 100%;
padding: 5px;
margin: 10px;
}
.text {
font-weight: bold;
}

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

@ -0,0 +1,140 @@
import './TimelineDialog.scss'
import * as React from 'react';
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
import { FeatureTimelineGrid, IFeatureTimelineGridProps } from './FeatureTimelineGrid';
import { getGridView } from '../../redux/selectors/gridViewSelector';
import { getTeamIterations } from '../../redux/selectors/teamIterations';
import { IterationDurationKind } from '../../redux/store';
import { IterationRenderer } from './IterationRenderer';
import { Button, PrimaryButton } from 'office-ui-fabric-react/lib/Button';
export interface ITimelineDialogProps extends IFeatureTimelineGridProps {
id: number;
clearOverrideIteration: (id: number) => void;
}
export class TimelineDialog extends React.Component<ITimelineDialogProps, {}> {
public render() {
const gridWorkItem = this._getGridWorkItem();
let dialogDetails = null;
let footer = null;
switch (gridWorkItem.workItem.iterationDuration.kind) {
case IterationDurationKind.UserOverridden:
dialogDetails = this._getCustomIterationDurationDetails();
break;
default:
dialogDetails = this._getChildrenFeatureTimelineGrid();
footer = (<DialogFooter>
<div>
<PrimaryButton onClick={() => this.props.closeDetails(this.props.id)}>Close</PrimaryButton>
</div>
</DialogFooter>)
}
return (
<Dialog
hidden={false}
onDismiss={() => this.props.closeDetails(this.props.id)}
dialogContentProps={
{
type: DialogType.close,
title: gridWorkItem.workItem.title
}
}
modalProps={
{
isBlocking: true,
containerClassName: "timeline-dialog"
}
}
>
{dialogDetails}
{footer}
</Dialog>
);
}
private _getChildrenFeatureTimelineGrid() {
const gridWorkItem = this._getGridWorkItem();
const gridView = getGridView(
this.props.uiState,
getTeamIterations(this.props.projectId, this.props.teamId, this.props.uiState, this.props.rawState),
[gridWorkItem.workItem],
/* workItemOverrideIteration */ null,
/* iterationDisplayOptions */ null,
/* isSubGrid */ true);
const childItems = this.props.childItems.filter(id => id !== this.props.id);
const props = { ...this.props, gridView, childItems };
if (gridView.workItems.length > 0) {
return (
<FeatureTimelineGrid {...props}>
</FeatureTimelineGrid>
);
}
return null;
}
private _getGridWorkItem() {
return this.props.gridView.workItems.filter(w => !w.isGap && w.workItem.id === this.props.id)[0];
}
private _getCustomIterationDurationDetails() {
const gridWorkItem = this._getGridWorkItem();
const {
overridedBy,
startIteration,
endIteration
} = gridWorkItem.workItem.iterationDuration;
const title = `${overridedBy} has set following start and end iteration for this workitem.`;
return (
<div className="dialog-contents">
<div className="dialog-grid-container">
{this._getChildrenFeatureTimelineGrid()}
</div>
<div className="custom-duration-container">
<div className="custom-duration-title">
{title}
</div>
<div className="custom-duration-iterations">
<div className="custom-duration-iteration text">
{"Start Iteration"}
</div>
<div className="custom-duration-iteration text">
{"End Iteration"}
</div>
</div>
<div className="custom-duration-iterations">
<div className="custom-duration-iteration">
<IterationRenderer iteration={startIteration} />
</div>
<div className="custom-duration-iteration">
<IterationRenderer iteration={endIteration} />
</div>
</div>
</div>
<div className="custom-duration-footer">
<div>
<Button onClick={this._onClear}>Clear</Button>
</div>
<div>
<PrimaryButton onClick={() => this.props.closeDetails(this.props.id)}>Close</PrimaryButton>
</div>
</div>
</div>
);
}
private _onClear = () => {
this.props.closeDetails(this.props.id);
this.props.clearOverrideIteration(this.props.id);
}
}

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

@ -0,0 +1,28 @@
import { DragSource } from 'react-dnd';
import { IWorkItemRendererProps, WorkItemRenderer } from './WorkItemRenderer';
import * as React from 'react';
class DraggableHelper extends React.Component<IWorkItemRendererProps, {}> {
public render() {
return <WorkItemRenderer {...this.props} />;
}
}
const WorkItemSource = {
beginDrag(props: IWorkItemRendererProps) {
return props;
}
}
/**
* Specifies the props to inject into your component.
*/
function collect(connect, monitor) {
return {
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging()
};
}
export default DragSource("WorkItem", WorkItemSource, collect)(DraggableHelper);

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

@ -0,0 +1,16 @@
import "./WorkItemRenderer.scss";
import * as React from 'react';
import { IDimension } from '../../../redux/types';
import { getRowColumnStyle } from '../gridhelper';
export class WorkitemGap extends React.Component<IDimension, {}> {
public render() {
const style = getRowColumnStyle(this.props);
return (
<div className="work-item-gap" style={style}>
<div className="title"></div>
</div>
);
}
}

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

@ -0,0 +1,92 @@
@mixin default {
display: flex;
flex-direction: row;
color: white;
text-align: center;
margin-top: 10px;
font-size: 15px;
padding: 2px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
border-radius: 20px;
}
@mixin work-item-iteration-indicator {
cursor: pointer;
background: white;
opacity: 0.7;
color: black;
padding-left: 5px;
padding-right: 5px;
padding-bottom: 2px;
font-size: 12px;
margin-top: 2px;
}
.work-item-start-iteration-indicator {
@include work-item-iteration-indicator;
border-top-left-radius: 50px;
border-bottom-left-radius: 50px;
}
.work-item-end-iteration-indicator {
@include work-item-iteration-indicator;
border-top-right-radius: 50px;
border-bottom-right-radius: 50px;
}
.small-border {
width: 5px;
background: transparent;
cursor: ew-resize;
}
.work-item-details-container {
display: flex;
justify-content: space-between;
}
.title-without-infoicon {
width: calc(100% - 10px);
}
.title-with-infoicon {
width: calc(100% - 30px);
}
.title-contents {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
cursor: pointer;
padding-left: 2px;
padding-right: 2px;
padding-bottom: 2px;
flex-grow: 1 0 auto;
}
.work-item-shadow {
@include default;
}
.work-item-details-container:hover+.info-icon {
color: white !important;
}
.work-item {
@include default;
}
.root-work-item {
@include default;
margin-top: 5px;
border-radius: 5px;
height: calc(100% - 5px);
}
.work-item-gap {
margin-top: 10px;
border: 1px solid lightgrey;
}

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

@ -0,0 +1,300 @@
import './WorkItemRenderer.scss';
import * as React from 'react';
import { InfoIcon } from '../InfoIcon/InfoIcon';
import { IIterationDuration, IWorkItemOverrideIteration } from '../../../redux/store';
import { IDimension, CropWorkItem } from '../../../redux/types';
import { getRowColumnStyle } from '../gridhelper';
import {
TooltipHost, TooltipOverflowMode
} from 'office-ui-fabric-react/lib/Tooltip';
import { css } from '@uifabric/utilities/lib/css';
export interface IWorkItemRendererProps {
id: number;
title: string;
color: string;
isRoot: boolean;
shouldShowDetails: boolean;
allowOverride: boolean;
iterationDuration: IIterationDuration;
dimension: IDimension;
crop: CropWorkItem;
onClick: (id: number) => void;
showDetails: (id: number) => void;
overrideIterationStart: (payload: IWorkItemOverrideIteration) => void;
overrideIterationEnd: () => void;
isDragging?: boolean;
connectDragSource?: (element: JSX.Element) => JSX.Element;
}
export interface IWorkItemRendrerState {
left: number;
width: number;
top: number;
height: number;
resizing: boolean;
isLeft: boolean;
}
export class WorkItemRenderer extends React.Component<IWorkItemRendererProps, IWorkItemRendrerState> {
private _div: HTMLDivElement;
private _origPageX: number;
private _origWidth: number;
public constructor(props: IWorkItemRendererProps) {
super(props);
this.state = {
resizing: false
} as IWorkItemRendrerState;
}
public render() {
const {
id,
title,
onClick,
showDetails,
isRoot,
shouldShowDetails,
allowOverride,
isDragging,
crop,
iterationDuration
} = this.props;
const {
resizing,
left,
top,
height,
width
} = this.state
let style = {};
if (!resizing) {
style = getRowColumnStyle(this.props.dimension);
} else {
style['position'] = 'fixed';
style['left'] = left + "px";
style['width'] = width + "px";
style['top'] = top + "px";
style['height'] = height + "px";
}
if (isDragging) {
style['background'] = hexToRgb(this.props.color, 0.1);
} else {
style['background'] = hexToRgb(this.props.color, 0.8);
}
const className = isRoot ? "root-work-item" : "work-item";
let cropClassName = "crop-none";
let canOverrideLeft = allowOverride;
let canOverrideRight = allowOverride;
let leftCropped = false;
let rightCropped = false;
switch (crop) {
case CropWorkItem.Left: {
cropClassName = "crop-left";
canOverrideLeft = false;
leftCropped = true;
break;
}
case CropWorkItem.Right: {
cropClassName = "crop-right";
canOverrideRight = false;
rightCropped = true;
break;
}
case CropWorkItem.Both: {
cropClassName = "crop-both";
canOverrideLeft = false;
canOverrideRight = false;
leftCropped = true;
rightCropped = true;
break;
}
}
const infoIcon = shouldShowDetails ? <InfoIcon id={id} onClick={id => showDetails(id)} /> : null;
const additionalTitleClass = infoIcon ? "title-with-infoicon" : "title-without-infoicon";
let leftHandle = null;
let rightHandle = null;
if (!isRoot && allowOverride) {
leftHandle = canOverrideLeft && (
<div
className="small-border"
onMouseDown={this._leftMouseDown}
onMouseUp={this._mouseUp}
/>
);
rightHandle = canOverrideRight && (
<div
className="small-border"
onMouseDown={this._rightMouseDown}
onMouseUp={this._mouseUp}
/>
);
}
let startsFrom = <div />;
if (leftCropped) {
startsFrom = (<TooltipHost
content={`Starts at ${iterationDuration.startIteration.name}`}>
<div className="work-item-start-iteration-indicator">{`${iterationDuration.startIteration.name}`}</div>
</TooltipHost>
);
}
let endsAt = <div />;
if (rightCropped) {
endsAt = (<TooltipHost
content={`Ends at ${iterationDuration.endIteration.name}`}>
<div className="work-item-end-iteration-indicator">{`${iterationDuration.endIteration.name}`}</div>
</TooltipHost>
);
}
const item = (
<div style={style}
className={css(className, cropClassName)}
ref={(e) => this._div = e}
>
{leftHandle}
<div
className={css("work-item-details-container", additionalTitleClass)}
>
{startsFrom}
<div
className="title-contents"
onClick={() => onClick(id)}
>
<TooltipHost
content={title}
overflowMode={TooltipOverflowMode.Parent}
>
{title}
</TooltipHost>
</div>
{endsAt}
</div>
{infoIcon}
{rightHandle}
</div>
);
if (isRoot) {
return item;
}
const { connectDragSource } = this.props;
return connectDragSource(item);
}
private _leftMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
this._resizeStart(e, true);
}
private _rightMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
this._resizeStart(e, false);
}
private _mouseUp = () => {
window.removeEventListener("mousemove", this._mouseMove);
window.removeEventListener("mouseup", this._mouseUp);
this.setState({
resizing: false
});
this.props.overrideIterationEnd();
}
private _resizeStart(e: React.MouseEvent<HTMLDivElement>, isLeft: boolean) {
e.preventDefault();
const rect = this._div.getBoundingClientRect() as ClientRect;
this._origPageX = e.pageX;
this._origWidth = rect.width;
this.props.overrideIterationStart({
workItemId: this.props.id,
iterationDuration: {
startIterationId: this.props.iterationDuration.startIteration.id,
endIterationId: this.props.iterationDuration.endIteration.id,
user: VSS.getWebContext().user.uniqueName
},
changingStart: isLeft
});
window.addEventListener("mousemove", this._mouseMove);
window.addEventListener("mouseup", this._mouseUp);
this.setState({
left: rect.left,
width: rect.width,
top: rect.top - 10, //The rect.top does not contain margin-top
height: rect.height,
resizing: true,
isLeft: isLeft
});
}
private _mouseMove = (ev: MouseEvent) => {
ev.preventDefault();
const newPageX = ev.pageX;
if (this.state.isLeft) {
let width = 0;
// moved mouse left we need to increase the width
if (newPageX < this._origPageX) {
width = this._origWidth + (this._origPageX - newPageX);
} else {
// moved mouse right we need to decrease the width
width = this._origWidth - (newPageX - this._origPageX);
}
if (width > 100) {
this.setState({
left: ev.clientX,
width: width
});
}
} else {
let width = 0;
// movd left we need to decrease the width
if (newPageX < this._origPageX) {
width = this._origWidth - (this._origPageX - newPageX);
} else {
// We need to increase the width
width = this._origWidth + (newPageX - this._origPageX);
}
if (width > 100) {
this.setState({
width: width
});
}
}
}
}
function hexToRgb(hex: string, opacity: number) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
const rgb = result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
if (rgb) {
const {
r,
g,
b
} = rgb;
return `rgba(${r},${g},${b}, ${opacity})`;
}
}

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

@ -0,0 +1,16 @@
import "./WorkItemRenderer.scss";
import * as React from 'react';
import { IDimension } from '../../../redux/types';
import { getRowColumnStyle } from '../gridhelper';
export class WorkItemShadow extends React.Component<IDimension, {}> {
public render() {
const style = getRowColumnStyle(this.props);
return (
<div className="work-item-shadow" style={style}>
<div className="title">x</div>
</div>
);
}
}

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

@ -0,0 +1,31 @@
import { IDimension } from "../../redux/types";
export function getRowColumnStyle(dimension: IDimension) {
return getStyle(dimension.startRow, dimension.endRow, dimension.startCol, dimension.endCol);
}
export function getStyle(startRow, endRow, startCol, endCol) {
return {
'grid-column': `${startCol} / ${endCol}`,
'grid-row': `${startRow} / ${endRow}`,
'-ms-grid-row': `${startRow}`,
'-ms-grid-row-span': `${endRow - startRow}`,
'-ms-grid-column': `${startCol}`,
'-ms-grid-column-span': `${endCol - startCol}`
}
}
export function getTemplateColumns(fixedColumns: string[], count: number, size: string) {
let str = '';
for (let i = 0; i < count; i++) {
str = str + size + ' ';
}
const fixedColumnsStr = fixedColumns.join(' ');
return {
'grid-template-columns': `${fixedColumnsStr} repeat(${count}, ${size})`,
'-ms-grid-columns': `${fixedColumnsStr} ${str}`
};
}

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

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

@ -0,0 +1,26 @@
import { createStore, applyMiddleware, Store, compose } from 'redux';
import { reducers, IFeatureTimelineRawState } from './store/index';
import createSagaMiddleware from 'redux-saga'
import { trackActions } from './sagas/trackActions';
import { watchSagaActions } from './sagas';
export default function configureStore(
initialState: IFeatureTimelineRawState
): Store<IFeatureTimelineRawState> {
const sagaMiddleWare = createSagaMiddleware();
const middleware = applyMiddleware(sagaMiddleWare, trackActions);
// Setup for using the redux dev tools in chrome
// https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd
const composeEnhancers = window["__REDUX_DEVTOOLS_EXTENSION_COMPOSE__"] || compose;
const store = createStore<IFeatureTimelineRawState>(
reducers,
initialState,
composeEnhancers(middleware));
sagaMiddleWare.run(watchSagaActions);
return store;
}

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

@ -0,0 +1,38 @@
import { TeamSettingsIteration } from "TFS/Work/Contracts";
export function compareIteration(i1: TeamSettingsIteration, i2: TeamSettingsIteration) : number {
if (hasDates(i1) && !hasDates(i2)) {
return -1;
}
if (hasDates(i2) && !hasDates(i1)) {
return 1;
}
if (!hasDates(i1) && !hasDates(i2)) {
return comparePath(i1, i2);
}
if (getStartTime(i1) === getStartTime(i2)) {
return getFinishTime(i1) - getFinishTime(i2);
}
return getStartTime(i1) - getStartTime(i2);
}
function hasDates(iteration: TeamSettingsIteration): boolean {
return !!iteration.attributes.startDate && !!iteration.attributes.finishDate;
}
function comparePath(i1: TeamSettingsIteration, i2: TeamSettingsIteration): number {
return i1.path.localeCompare(i2.path);
}
function getStartTime(iteration: TeamSettingsIteration): number {
return iteration.attributes.startDate.getTime();
}
function getFinishTime(iteration: TeamSettingsIteration): number {
return iteration.attributes.finishDate.getTime();
}

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

@ -0,0 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`test initialize saga basic test 1`] = `
Object {
"done": false,
"value": Object {
"@@redux-saga/IO": true,
"PUT": Object {
"action": Object {
"payload": true,
"type": "@@loading/loading",
},
"channel": null,
},
},
}
`;
exports[`test initialize saga basic test 2`] = `
Object {
"done": false,
"value": Object {
"@@redux-saga/IO": true,
"CALL": Object {
"args": Array [
Object {
"payload": Object {
"backlogLevelName": "backlogLevel",
"projectId": "projectId",
"teamId": "teamId",
},
"type": "@@common/initialize",
},
],
"context": null,
"fn": [Function],
},
},
}
`;
exports[`test initialize saga basic test 3`] = `
Object {
"done": false,
"value": Object {
"@@redux-saga/IO": true,
"PUT": Object {
"action": Object {
"payload": false,
"type": "@@loading/loading",
},
"channel": null,
},
},
}
`;
exports[`test initialize saga basic test 4`] = `
Object {
"done": true,
"value": undefined,
}
`;

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

@ -0,0 +1,21 @@
declare var it, expect;
import { callinitialize } from '../initialize';
import { createInitialize } from '../../store/common/actioncreators';
it('test initialize saga basic test', () => {
const initializeAction = createInitialize("projectId", "teamId", "backlogLevel");
const saga = callinitialize(initializeAction);
// Expect loading true
expect(saga.next()).toMatchSnapshot();
// Expect call to initialize saga
expect(saga.next()).toMatchSnapshot();
// Expect loading false
expect(saga.next()).toMatchSnapshot();
// Expect null
expect(saga.next()).toMatchSnapshot();
});

17
src/redux/sagas/index.ts Normal file
Просмотреть файл

@ -0,0 +1,17 @@
import { takeEvery } from "redux-saga/effects";
import { ClearOverrideIterationType, LaunchWorkItemFormActionType, StartUpdateWorkitemIterationActionType } from "../store/workItems/actions";
import { launchWorkItemFormSaga } from "./launchWorkItemFormSaga";
import { InitializeType } from "../store/common/actions";
import { callinitialize } from "./initialize";
import { launchOverrideWorkItemIteration, launchClearOverrideIteration, launchSaveOverrideIteration } from "./workItemOverrideIterationListner";
import { OverrideIterationEndType, SaveOverrideIterationActionType } from "../store/overrideIterationProgress/actions";
import { updateWorkItemIteration } from "./updateWorkItemIterationListner";
export function* watchSagaActions() {
yield takeEvery(OverrideIterationEndType, launchOverrideWorkItemIteration);
yield takeEvery(SaveOverrideIterationActionType, launchSaveOverrideIteration);
yield takeEvery(ClearOverrideIterationType, launchClearOverrideIteration);
yield takeEvery(LaunchWorkItemFormActionType, launchWorkItemFormSaga);
yield takeEvery(InitializeType, callinitialize);
yield takeEvery(StartUpdateWorkitemIterationActionType, updateWorkItemIteration);
}

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

@ -0,0 +1,286 @@
import * as VSS_Service from 'VSS/Service';
import {
all,
call,
put
} from 'redux-saga/effects';
import { backlogConfigurationReceived } from '../store/backlogconfiguration/actionCreators';
import { genericError } from '../store/error/actionCreators';
import { WorkItemTrackingHttpClient } from 'TFS/WorkItemTracking/RestClient';
import { WorkHttpClient } from 'TFS/Work/RestClient';
import { InitializeAction } from '../store/common/actions';
import { teamSettingsIterationReceived, changeDisplayIterationCount } from '../store/teamiterations/actionCreators';
import { workItemLinksReceived, workItemsReceived, setOverrideIteration } from '../store/workitems/actionCreators';
import { WorkItemMetadataService } from '../../Services/WorkItemMetadataService';
import { workItemTypesReceived } from '../store/workitemmetadata/actionCreators';
import TFS_Core_Contracts = require('TFS/Core/Contracts');
import Contracts = require('TFS/Work/Contracts');
import WitContracts = require('TFS/WorkItemTracking/Contracts');
import { loading } from '../store/loading/actionCreators';
import { IOverriddenIterationDuration } from '../store';
// For sagas read https://redux-saga.js.org/docs/introduction/BeginnerTutorial.html
// For details saga effects read https://redux-saga.js.org/docs/basics/DeclarativeEffects.html
// Setup to call initialize saga for every initialize action
export function* callinitialize(action: InitializeAction) {
yield put(loading(true));
yield call(handleInitialize, action);
yield put(loading(false));
}
export function* handleInitialize(action: InitializeAction) {
const {
projectId,
teamId
} = action.payload;
const teamContext = {
teamId,
projectId
} as TFS_Core_Contracts.TeamContext;
const workHttpClient = VSS_Service.getClient(WorkHttpClient);
const metadatService = WorkItemMetadataService.getInstance();
const witHttpClient = VSS_Service.getClient(WorkItemTrackingHttpClient);
const dataService = yield call(VSS.getService, VSS.ServiceIds.ExtensionData);
if (!workHttpClient.getBacklogConfigurations) {
yield put(genericError("This extension is supported on Team Foundation Server 2018 or above."));
return;
}
try {
// Fetch backlog config, team iterations, workItem types and state metadata in parallel
const [bc, tis, wits, overriddenWorkItemIterations, iterationDisplayOptions, ts, tfv] = yield all([
call(workHttpClient.getBacklogConfigurations.bind(workHttpClient), teamContext),
call(workHttpClient.getTeamIterations.bind(workHttpClient), teamContext),
//call(metadatService.getStates.bind(metadatService), projectId),
call(metadatService.getWorkItemTypes.bind(metadatService), projectId),
call(dataService.getValue.bind(dataService), "overriddenWorkItemIterations"),
call(dataService.getValue.bind(dataService), "iterationDisplayOptions"),
call(workHttpClient.getTeamSettings.bind(workHttpClient), teamContext),
call(workHttpClient.getTeamFieldValues.bind(workHttpClient), teamContext)
]);
yield put(backlogConfigurationReceived(projectId, teamId, bc));
yield put(teamSettingsIterationReceived(projectId, teamId, tis));
if (iterationDisplayOptions) {
yield put(changeDisplayIterationCount(iterationDisplayOptions.count, iterationDisplayOptions.projectId, iterationDisplayOptions.teamId));
}
yield put(workItemTypesReceived(projectId, wits));
//yield put(workItemStateColorsReceived(projectId, stateColors));
const backlogConfig: Contracts.BacklogConfiguration = bc;
const teamSettings: Contracts.TeamSetting = ts;
const teamFieldValues: Contracts.TeamFieldValues = tfv;
// For now show only lowest level of portfolio backlog
backlogConfig.portfolioBacklogs.sort((b1, b2) => b1.rank - b2.rank);
const currentBacklogLevel = backlogConfig.portfolioBacklogs[0];
const workItemTypes = currentBacklogLevel.workItemTypes.map(w => `'${w.name}'`).join(",");
const stateInfo: Contracts.WorkItemTypeStateInfo[] = backlogConfig.workItemTypeMappedStates.filter(wtms => currentBacklogLevel.workItemTypes.some(wit => wit.name.toLowerCase() === wtms.workItemTypeName.toLowerCase()));
const orderField = backlogConfig.backlogFields.typeFields["Order"];
let backlogIteration = teamSettings.backlogIteration.path || teamSettings.backlogIteration.name;
if (backlogIteration[0] === "\\") {
const webContext = VSS.getWebContext();
backlogIteration = webContext.project.name + backlogIteration;
}
backlogIteration = _escape(backlogIteration);
const workItemTypeAndStatesClause =
stateInfo
.map(si => {
const states = Object.keys(si.states).filter(state => si.states[state] === "InProgress")
.map(state => _escape(state))
.join("', '");
return `(
[System.WorkItemType] = '${_escape(si.workItemTypeName)}'
AND [System.State] IN ('${states}')
)`;
}).join(" OR ");
const teamFieldClause = teamFieldValues.values.map((tfValue) => {
const operator = tfValue.includeChildren ? "UNDER" : "=";
return `[${_escape(teamFieldValues.field.referenceName)}] ${operator} '${_escape(tfValue.value)}'`;
}).join(" OR ");
const wiql = `SELECT System.Id
FROM WorkItems
WHERE [System.WorkItemType] IN (${workItemTypes})
AND [System.IterationPath] UNDER '${backlogIteration}'
AND (${workItemTypeAndStatesClause})
AND (${teamFieldClause})
ORDER BY [${orderField}] ASC,[System.Id] ASC`;
const queryResults: WitContracts.WorkItemQueryResult = yield call(witHttpClient.queryByWiql.bind(witHttpClient), { query: wiql }, projectId);
// Get work items for backlog level
const backlogLevelWorkItemIds: number[] = [];
let childWorkItemIds: number[] = [];
let parentWorkItemIds: number[] = [];
let workItemsToPage: number[] = [];
// Get child work items and page all work items
if (queryResults && queryResults.workItems && queryResults.workItems.length > 0) {
const potentialBacklogLevelWorkItemIds = queryResults.workItems.map(w => w.id);
let pagedWorkItems = yield call(_pageWorkItemFields, potentialBacklogLevelWorkItemIds, [orderField]);
pagedWorkItems = pagedWorkItems.filter((wi) => _isInProgress(wi, bc));
const childBacklogLevel = yield call(_findChildBacklogLevel, currentBacklogLevel, bc);
const parentBacklogLevel = yield call(_findParentBacklogLevel, currentBacklogLevel, bc);
backlogLevelWorkItemIds.push(...pagedWorkItems.map((wi) => wi.id));
const childQueryResult: WitContracts.WorkItemQueryResult = yield call(_runChildWorkItemQuery, backlogLevelWorkItemIds, projectId, childBacklogLevel);
if (childQueryResult && childQueryResult.workItemRelations) {
childWorkItemIds = childQueryResult.workItemRelations
.filter(link => link.target && link.rel)
.map((link) => link.target.id);
workItemsToPage.push(...childWorkItemIds);
}
let parentLinks = [];
if (parentBacklogLevel) {
const parentQueryResult: WitContracts.WorkItemQueryResult = yield call(_runParentWorkItemQuery, backlogLevelWorkItemIds, projectId, parentBacklogLevel);
parentLinks = parentQueryResult ? parentQueryResult.workItemRelations : [];
}
parentWorkItemIds = parentLinks
.filter(link => link.target && link.rel)
.map((link) => link.target.id);
workItemsToPage.push(...parentWorkItemIds);
const workItems: WitContracts.WorkItem[] = yield call(_pageWorkItemFields, workItemsToPage, [orderField]);
workItems.push(...pagedWorkItems);
workItems.sort((w1, w2) => w1.fields[orderField] - w2.fields[orderField]);
// Call action creators to update work items and links in the store
yield put(workItemsReceived(workItems, parentWorkItemIds, backlogLevelWorkItemIds, childWorkItemIds));
const linksReceived = childQueryResult ? childQueryResult.workItemRelations : [];
linksReceived.push(...parentLinks);
yield put(workItemLinksReceived(linksReceived));
if (overriddenWorkItemIterations) {
const currentValueTypes: IDictionaryNumberTo<IOverriddenIterationDuration> = JSON.parse(overriddenWorkItemIterations);
for (const key in currentValueTypes) {
if (currentValueTypes.hasOwnProperty(key)) {
const workItemId = Number(key);
yield put(setOverrideIteration(
workItemId,
currentValueTypes[workItemId].startIterationId,
currentValueTypes[workItemId].endIterationId,
currentValueTypes[workItemId].user));
}
}
}
}
} catch (error) {
yield put(genericError(error));
}
}
function _escape(value: string): string {
return value.replace("'", "''");
}
function _isInProgress(workItem: WitContracts.WorkItem, backlogConfig: Contracts.BacklogConfiguration) {
return (backlogConfig.workItemTypeMappedStates.find((t) => t.workItemTypeName == workItem.fields["System.WorkItemType"]).states[workItem.fields["System.State"]] === "InProgress");
}
function _findChildBacklogLevel(
backlogLevel: Contracts.BacklogLevelConfiguration,
backlogConfig: Contracts.BacklogConfiguration):
Contracts.BacklogLevelConfiguration {
let childBacklogLevel = backlogConfig.portfolioBacklogs.find((level) => level.rank < backlogLevel.rank);
if (childBacklogLevel) {
return childBacklogLevel;
}
return backlogConfig.requirementBacklog;
}
function _findParentBacklogLevel(
backlogLevel: Contracts.BacklogLevelConfiguration,
backlogConfig: Contracts.BacklogConfiguration):
Contracts.BacklogLevelConfiguration {
let parentBacklogLevel = backlogConfig
.portfolioBacklogs
.filter((level) => level.rank > backlogLevel.rank)
.sort((b1, b2) => b1.rank - b2.rank)[0];
if (parentBacklogLevel) {
return parentBacklogLevel;
}
return null;
}
async function _runChildWorkItemQuery(
ids: number[],
project: string,
backlogLevel: Contracts.BacklogLevelConfiguration):
Promise<WitContracts.WorkItemQueryResult> {
if (!ids || ids.length === 0) {
return Promise.resolve(null);
}
const idClause = ids.join(",");
const witClause = backlogLevel.workItemTypes.map(wit => "'" + wit.name + "'").join(",");
const wiql =
`SELECT [System.Id]
FROM WorkItemLinks
WHERE (Source.[System.TeamProject] = @project and Source.[System.Id] in (${idClause}))
AND ([System.Links.LinkType] = 'System.LinkTypes.Hierarchy-Forward')
AND (Target.[System.TeamProject] = @project and Target.[System.WorkItemType] in (${witClause}))
MODE (MayContain)`;
const witHttpClient = VSS_Service.getClient(WorkItemTrackingHttpClient);
return witHttpClient.queryByWiql({ query: wiql }, project);
}
async function _runParentWorkItemQuery(
ids: number[],
project: string,
backlogLevel: Contracts.BacklogLevelConfiguration):
Promise<WitContracts.WorkItemQueryResult> {
if (!ids || ids.length === 0) {
return Promise.resolve(null);
}
const idClause = ids.join(",");
const witClause = backlogLevel.workItemTypes.map(wit => "'" + wit.name + "'").join(",");
const wiql =
`SELECT [System.Id]
FROM WorkItemLinks
WHERE (Source.[System.TeamProject] = @project and Source.[System.Id] in (${idClause}))
AND ([System.Links.LinkType] = 'System.LinkTypes.Hierarchy-Reverse')
AND (Target.[System.TeamProject] = @project and Target.[System.WorkItemType] in (${witClause}))
MODE (MayContain)`;
const witHttpClient = VSS_Service.getClient(WorkItemTrackingHttpClient);
return witHttpClient.queryByWiql({ query: wiql }, project);
}
async function _pageWorkItemFields(
ids: number[],
fields: string[]): Promise<WitContracts.WorkItem[]> {
if (!ids || ids.length === 0) {
return Promise.resolve([]);
}
const commonFields = [
"System.Id",
"System.Title",
"System.State",
"System.WorkItemType",
"System.IterationPath"
];
commonFields.push(...fields);
const witHttpClient = VSS_Service.getClient(WorkItemTrackingHttpClient);
return witHttpClient.getWorkItems(ids, commonFields);
}

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

@ -0,0 +1,21 @@
import { LaunchWorkItemFormAction } from "../store/workItems/actions";
import { put, call } from "redux-saga/effects";
import { WorkItemFormNavigationService, IWorkItemFormNavigationService } from "TFS/WorkItemTracking/Services";
import { createInitialize } from "../store/common/actioncreators";
import { getProjectId, getTeamId, getBacklogLevel } from "../selectors";
export function* launchWorkItemFormSaga(action: LaunchWorkItemFormAction) {
const workItemNavSvc: IWorkItemFormNavigationService = yield call(WorkItemFormNavigationService.getService);
yield call(workItemNavSvc.openWorkItem.bind(workItemNavSvc), action.payload.workItemId);
// TODO: At this point the workitem returned after the update does not have
// updated links so we do not have a way to identify if any of the links changed
// our best bet is to update the workitems and relations by reinitializing
const projectId = getProjectId();
const teamId = getTeamId();
const backlogLevel = getBacklogLevel();
const initializeAction = createInitialize(projectId, teamId, backlogLevel);
yield put(initializeAction);
}

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

@ -0,0 +1,11 @@
import { Middleware } from "redux";
export const trackActions: Middleware = api => next => action => {
//if (action["track"])
{
// TODO: Publish telemetry
// console.log("TELEMETRY: ", action);
}
return next(action);
};

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

@ -0,0 +1,46 @@
import * as VSS_Service from 'VSS/Service';
import { StartUpdateWorkitemIterationAction } from "../store/workItems/actions";
import { put, call } from "redux-saga/effects";
import { WorkItemTrackingHttpClient3_2 } from 'TFS/WorkItemTracking/RestClient';
import { JsonPatchDocument } from 'VSS/WebApi/Contracts';
import { workItemSaved, workItemSaveFailed, clearOverrideIteration } from '../store/workitems/actionCreators';
import { saveOverrideIteration } from '../store/overrideIterationProgress/actionCreators';
import { IWorkItemOverrideIteration } from '../store';
export function* updateWorkItemIteration(action: StartUpdateWorkitemIterationAction) {
const witHttpClient = VSS_Service.getClient(WorkItemTrackingHttpClient3_2);
const {
payload
} = action;
try {
const doc: JsonPatchDocument = [{
"op": "add",
"path": "/fields/System.IterationPath",
"value": payload.teamIteration.path
}];
if (payload.override) {
const overridePayload: IWorkItemOverrideIteration = {
workItemId: payload.workItem,
iterationDuration: {
startIterationId: payload.teamIteration.id,
endIterationId: payload.teamIteration.id,
user: VSS.getWebContext().user.uniqueName
},
changingStart: false
};
yield put(saveOverrideIteration(overridePayload));
} else {
// Clear override iteration if any
yield put(clearOverrideIteration(payload.workItem));
}
// Update work item Iteration path
yield call(witHttpClient.updateWorkItem.bind(witHttpClient), doc, action.payload.workItem);
yield put(workItemSaved([action.payload.workItem]));
}
catch (error) {
yield put(workItemSaveFailed([action.payload.workItem], error));
}
}

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

@ -0,0 +1,53 @@
import { ClearOverrideIterationAction } from "../store/workItems/actions";
import { put, call, select } from "redux-saga/effects";
import { workItemOverrideIterationSelector } from "../selectors";
import { IWorkItemOverrideIteration } from "../store";
import { setOverrideIteration } from "../store/workitems/actionCreators";
import { cleanupOverrideIteration, saveOverrideIteration } from "../store/overrideIterationProgress/actionCreators";
import { OverrideIterationEndAction, SaveOverrideIterationAction } from "../store/overrideIterationProgress/actions";
export function* launchOverrideWorkItemIteration(action: OverrideIterationEndAction) {
const overrideIterationState: IWorkItemOverrideIteration = yield select(workItemOverrideIterationSelector());
if (!overrideIterationState) {
return;
}
yield put(cleanupOverrideIteration());
yield put(saveOverrideIteration(overrideIterationState));
}
export function* launchSaveOverrideIteration(action: SaveOverrideIterationAction) {
const overrideIterationState = action.payload;
yield put(setOverrideIteration(overrideIterationState.workItemId, overrideIterationState.iterationDuration.startIterationId, overrideIterationState.iterationDuration.endIterationId, overrideIterationState.iterationDuration.user));
const dataService = yield call(VSS.getService, VSS.ServiceIds.ExtensionData);
let currentValues = yield call(dataService.getValue.bind(dataService), "overriddenWorkItemIterations");
if (currentValues) {
currentValues = JSON.parse(currentValues);
} else {
currentValues = {};
}
currentValues[overrideIterationState.workItemId] = {
startIterationId: overrideIterationState.iterationDuration.startIterationId,
endIterationId: overrideIterationState.iterationDuration.endIterationId,
user: overrideIterationState.iterationDuration.user
};
yield call(dataService.setValue.bind(dataService), "overriddenWorkItemIterations", JSON.stringify(currentValues));
}
export function* launchClearOverrideIteration(action: ClearOverrideIterationAction) {
const dataService = yield call(VSS.getService, VSS.ServiceIds.ExtensionData);
let currentValues = yield call(dataService.getValue.bind(dataService), "overriddenWorkItemIterations");
if (currentValues) {
currentValues = JSON.parse(currentValues);
} else {
currentValues = {};
}
delete currentValues[action.payload];
yield call(dataService.setValue.bind(dataService), "overriddenWorkItemIterations", JSON.stringify(currentValues));
}

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

@ -0,0 +1,298 @@
import { IWorkItemOverrideIteration } from "../store";
import { IWorkItemHierarchy } from "./workItemHierarchySelector";
import { TeamSettingsIteration } from "TFS/Work/Contracts";
import { UIStatus, IDimension, CropWorkItem } from "../types";
import { compareIteration } from "../helpers/iterationComparer";
import { IIterationDisplayOptions } from "../store/teamiterations/types";
export interface IGridIteration {
teamIteration: TeamSettingsIteration;
dimension: IDimension;
}
export interface IGridWorkItem {
dimension: IDimension;
workItem: IWorkItemHierarchy;
isGap?: boolean;
crop: CropWorkItem;
gapColor?: string;
}
export interface IGridView {
emptyHeaderRow: IDimension[]; //Set of empty elements to place items on top of iteration header
iterationHeader: IGridIteration[];
iterationShadow: IGridIteration[];
workItems: IGridWorkItem[];
isSubGrid: boolean;
workItemShadow: number;
hideParents: boolean;
iterationDisplayOptions: IIterationDisplayOptions;
}
export function getGridView(
uiStatus: UIStatus,
teamIterations: TeamSettingsIteration[],
workItems: IWorkItemHierarchy[],
workItemOverrideIteration: IWorkItemOverrideIteration,
iterationDisplayOptions: IIterationDisplayOptions = null,
isSubGrid: boolean = false
): IGridView {
if (uiStatus !== UIStatus.Default) {
return {
emptyHeaderRow: [],
iterationHeader: [],
iterationShadow: [],
workItems: [],
isSubGrid,
workItemShadow: 0,
hideParents: false,
iterationDisplayOptions: null
}
}
const hideParents = isSubGrid || (workItems.length === 1 && workItems[0].id === 0);
const displayIterations = getDisplayIterations(teamIterations, workItems, iterationDisplayOptions);
const gridWorkItems = getGridWorkItems(
teamIterations,
displayIterations,
iterationDisplayOptions,
workItems,
/* startRow */ 3,
/* startCol */ 1,
hideParents);
let workItemShadow = 0;
if (workItemOverrideIteration && workItemOverrideIteration.workItemId) {
workItemShadow = workItemOverrideIteration.workItemId;
}
const view: IGridView = {
emptyHeaderRow: [],
iterationHeader: [],
iterationShadow: [],
workItems: gridWorkItems,
isSubGrid,
workItemShadow,
hideParents,
iterationDisplayOptions
};
// Calculate shadow and header
const startRow = 1;
const endRow = 2;
const lastWorkItemRow = gridWorkItems.length > 0 ? gridWorkItems[gridWorkItems.length - 1].dimension.endRow + 1 : endRow + 1;
let startCol = hideParents ? 1 : 2; // First column is for the epic
displayIterations.forEach(teamIteration => {
const endCol = startCol + 1;
const emptyRowDimension: IDimension = {
startCol,
startRow,
endRow,
endCol
};
view.emptyHeaderRow.push(emptyRowDimension);
const dimension: IDimension = {
startCol,
startRow: startRow + 1,
endRow,
endCol
};
const gridIteration: IGridIteration = {
teamIteration,
dimension
};
view.iterationHeader.push(gridIteration);
const shadowDimension: IDimension = {
startRow: startRow + 2,
startCol,
endCol,
endRow: lastWorkItemRow
};
const shadowIteration: IGridIteration = {
teamIteration,
dimension: shadowDimension
}
view.iterationShadow.push(shadowIteration);
startCol++;
});
return view;
}
export function getDisplayIterations(
teamIterations: TeamSettingsIteration[],
workItems: IWorkItemHierarchy[],
iterationDisplayOptions?: IIterationDisplayOptions): TeamSettingsIteration[] {
// Sort the input iteration
teamIterations = teamIterations.slice().sort(compareIteration);
if (iterationDisplayOptions) {
return teamIterations.slice(iterationDisplayOptions.startIndex, iterationDisplayOptions.endIndex + 1);
}
let firstIteration: TeamSettingsIteration = null;
let lastIteration: TeamSettingsIteration = null;
// Get all iterations that come in the range of the workItems
const calcFirstLastIteration = (workItem: IWorkItemHierarchy) => {
if (firstIteration === null) {
firstIteration = workItem.iterationDuration.startIteration;
lastIteration = workItem.iterationDuration.endIteration;
} else {
if (compareIteration(workItem.iterationDuration.startIteration, firstIteration) < 0) {
firstIteration = workItem.iterationDuration.startIteration;
}
if (compareIteration(workItem.iterationDuration.endIteration, lastIteration) > 0) {
lastIteration = workItem.iterationDuration.endIteration;
}
}
workItem.children.forEach(child => calcFirstLastIteration(child));
};
workItems.forEach(child => calcFirstLastIteration(child));
// Get two to the left and two to the right iterations from teamIterations
let startIndex = teamIterations.findIndex(i => i.id === firstIteration.id) - 2;
let endIndex = teamIterations.findIndex(i => i.id === lastIteration.id) + 2;
startIndex = startIndex < 0 ? 0 : startIndex;
endIndex = endIndex >= teamIterations.length ? teamIterations.length - 1 : endIndex;
return teamIterations.slice(startIndex, endIndex + 1);
}
function workItemCompare(w1: IWorkItemHierarchy, w2: IWorkItemHierarchy) {
if (w1.order === w2.order) {
return w1.id - w2.id;
}
return w1.order - w2.order;
}
export function getGridWorkItems(
teamIterations: TeamSettingsIteration[],
displayIterations: TeamSettingsIteration[],
iterationDisplayOptions: IIterationDisplayOptions,
workItems: IWorkItemHierarchy[],
startRow: number,
startColumn: number,
hideParents: boolean): IGridWorkItem[] {
const output: IGridWorkItem[] = [];
workItems = workItems.sort(workItemCompare);
let lastColumn = displayIterations.length + 1;
if (!hideParents) {
lastColumn++;
}
workItems.forEach((parent, index) => {
const parentStartRow = startRow;
const parentStartColumn = startColumn;
let parentEndColumn = parentStartColumn;
const children = parent.children.sort(workItemCompare);
const parentEndRow = parentStartRow + parent.children.length + (children.length > 0 ? 1 : 0); // Add additional row for just empty workitem to show gap between
if (!hideParents) {
parentEndColumn = parentStartColumn + 1;
const dimension: IDimension = {
startRow: parentStartRow,
startCol: parentStartColumn,
endRow: parentEndRow,
endCol: parentEndColumn
};
const gridItem: IGridWorkItem = { workItem: parent, dimension, crop: CropWorkItem.None };
output.push(gridItem);
}
let childStartRow = parentStartRow;
const allIterations = iterationDisplayOptions ? teamIterations : displayIterations;
children.forEach(child => {
const childEndRow = childStartRow + 1;
let startIterationIndex = allIterations.findIndex(gi => gi.id === child.iterationDuration.startIteration.id);
let endIterationIndex = allIterations.findIndex(gi => gi.id === child.iterationDuration.endIteration.id);
let crop: CropWorkItem = CropWorkItem.None;
let outofScope = false;
// Either drop of set out of scope if the child item iteration is out of scope
if (iterationDisplayOptions) {
if (startIterationIndex > iterationDisplayOptions.endIndex || endIterationIndex < iterationDisplayOptions.startIndex) {
outofScope = true;
}
if (iterationDisplayOptions.startIndex > startIterationIndex) {
startIterationIndex = 0;
crop = CropWorkItem.Left;
} else {
startIterationIndex = displayIterations.findIndex(gi => gi.id === child.iterationDuration.startIteration.id);
}
if (endIterationIndex > iterationDisplayOptions.endIndex) {
endIterationIndex = displayIterations.length - 1;
crop = crop === CropWorkItem.Left ? CropWorkItem.Both : CropWorkItem.Right;
} else {
endIterationIndex = displayIterations.findIndex(gi => gi.id === child.iterationDuration.endIteration.id);
}
}
if (!outofScope) {
const childStartColumn = parentEndColumn + startIterationIndex;
const childEndColumn = parentEndColumn + endIterationIndex + 1;
const dimension: IDimension = {
startRow: childStartRow,
startCol: childStartColumn,
endRow: childEndRow,
endCol: childEndColumn
};
const gridItem: IGridWorkItem = { workItem: child, dimension, crop };
output.push(gridItem);
childStartRow++;
}
});
if (children.length > 0 && index < (workItems.length - 1)) {
output.push({
workItem: <IWorkItemHierarchy>{
id: -1,
title: "",
children: [],
shouldShowDetails: false
},
dimension: {
startRow: parentEndRow - 1,
endRow: parentEndRow,
startCol: hideParents ? 1 : 2,
endCol: lastColumn
},
isGap: true,
gapColor: parent.color,
crop: CropWorkItem.None
});
}
startRow = parentEndRow;
});
return output;
}

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

@ -0,0 +1,78 @@
import { IContributionContext } from "../store/common/types";
import { createSelector } from "reselect";
import { getWorkItemsForLevel } from "./workItemsForLevel";
import { getUIStatus } from "./uistatus";
import { IFeatureTimelineRawState } from "../store";
import { WorkItemLevel } from "../store/workitems/types";
import { getWorkItemHierarchy } from "./workItemHierarchySelector";
import { getGridView } from "./gridViewSelector";
import { getTeamIterations } from "./teamIterations";
export const getRawState = (state: IFeatureTimelineRawState) => state;
export const getProjectId = () => {
const webContext = VSS.getWebContext();
return webContext.project.id;
}
export const getTeamId = () => {
const contributionContext: IContributionContext = VSS.getConfiguration();
if (contributionContext.team) {
return contributionContext.team.id;
}
const webContext = VSS.getWebContext();
return webContext.team.id;
};
export const getBacklogLevel = () => {
const contributionContext: IContributionContext = VSS.getConfiguration();
return contributionContext.level;
};
export const iterationDisplayOptionsSelector = () => {
return createSelector(
[getRawState],
(state) => {
if (!state || !state.iterationState) {
return null;
}
return state.iterationState.iterationDisplayOptions;
});
}
export const workItemIdsSelector = (level: WorkItemLevel) => {
return createSelector(
[getProjectId, getTeamId, getRawState],
(projectId, teamId, state) => {
if (!state || !state.workItemsState || !state.workItemsState.workItemInfos) {
return [];
}
return getWorkItemsForLevel(state.workItemsState.workItemInfos, level);
});
}
export const workItemOverrideIterationSelector = () => {
return createSelector([getRawState], (state) => state.workItemOverrideIteration);
}
export const uiStatusSelector = () => {
return createSelector([getProjectId, getTeamId, getRawState], getUIStatus);
}
export const workItemHierarchySelector = () => {
return createSelector([getProjectId, getTeamId, uiStatusSelector(), getRawState], getWorkItemHierarchy);
};
export const teamIterationsSelector = () => {
return createSelector([getProjectId, getTeamId, uiStatusSelector(), getRawState], getTeamIterations);
}
export const gridViewSelector = () => {
return createSelector([
uiStatusSelector(),
teamIterationsSelector(),
workItemHierarchySelector(),
workItemOverrideIterationSelector(),
iterationDisplayOptionsSelector()
],
getGridView)
}

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

@ -0,0 +1,17 @@
import { IFeatureTimelineRawState } from "../store";
import { TeamSettingsIteration } from "TFS/Work/Contracts";
import { UIStatus } from "../types";
export function getTeamIterations(
projectId: string,
teamId: string,
uiStatus: UIStatus,
rawState: IFeatureTimelineRawState): TeamSettingsIteration[] {
if (uiStatus !== UIStatus.Default) {
return [];
}
return rawState.iterationState.teamSettingsIterations[projectId][teamId];
}

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

@ -0,0 +1,39 @@
import { IFeatureTimelineRawState } from "../store";
import { UIStatus } from "../types";
import { compareIteration } from "../helpers/iterationComparer";
export function getUIStatus(
projectId: string,
teamId: string,
rawState: IFeatureTimelineRawState): UIStatus {
const {
iterationState
} = rawState;
if (rawState.error) {
return UIStatus.Error;
}
if (rawState.loading) {
return UIStatus.Loading;
}
if (!iterationState ||
!iterationState.teamSettingsIterations ||
!iterationState.teamSettingsIterations[projectId] ||
!iterationState.teamSettingsIterations[projectId][teamId] ||
iterationState.teamSettingsIterations[projectId][teamId].length === 0) {
return UIStatus.NoTeamIterations;
}
const iterations = iterationState.teamSettingsIterations[projectId][teamId].slice();
iterations.sort(compareIteration);
if (Object.keys(rawState.workItemsState.workItemInfos).length === 0) {
return UIStatus.NoWorkItems;
}
return UIStatus.Default;
}

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

@ -0,0 +1,178 @@
import { getWorkItemsForLevel } from './workItemsForLevel';
import { IFeatureTimelineRawState, IIterationDuration, IterationDurationKind } from '../store';
import { IWorkItemInfo, WorkItemLevel } from '../store/workitems/types';
import { TeamSettingsIteration, TimeFrame } from 'TFS/Work/Contracts';
import { WorkItem } from 'TFS/WorkItemTracking/Contracts';
import { UIStatus } from '../types';
import { compareIteration } from '../helpers/iterationComparer';
export interface IWorkItemHierarchy {
id: number;
title: string;
color: string;
isRoot: boolean;
workItem: WorkItem;
order: number;
iterationDuration: IIterationDuration;
children: IWorkItemHierarchy[];
shouldShowDetails: boolean;
}
export function getWorkItemHierarchy(
projectId: string,
teamId: string,
uiStatus: UIStatus,
input: IFeatureTimelineRawState): IWorkItemHierarchy[] {
if (uiStatus !== UIStatus.Default) {
return [];
}
const {
workItemsState
} = input;
// Fetch work items at parent level
const epicIds = getWorkItemsForLevel(workItemsState.workItemInfos, WorkItemLevel.Parent);
// Add unparent level as parent
epicIds.unshift(0);
return getWorkItemsDetails(projectId, teamId, epicIds, input, /* isRoot */ true);
}
function getWorkItemsDetails(projectId: string, teamId: string, ids: number[], input: IFeatureTimelineRawState, isRoot: boolean): IWorkItemHierarchy[] {
return ids.map(id => getWorkItemDetails(projectId, teamId, id, input, isRoot));
}
function getWorkItemDetails(projectId: string, teamId: string, id: number, input: IFeatureTimelineRawState, isRoot: boolean): IWorkItemHierarchy {
const {
workItemsState,
workItemMetadata
} = input;
const workItem = id && workItemsState.workItemInfos[id].workItem;
let workItemType = null;
if (workItem) {
const workItemTypeName = workItem.fields["System.WorkItemType"];
workItemType = workItemMetadata.metadata[projectId].workItemTypes.filter((wit) => wit.name.toLowerCase() === workItemTypeName.toLowerCase())[0];
}
const children = getWorkItemsDetails(projectId, teamId, getChildrenId(workItemsState.workItemInfos, id), input, /* isRoot */ false);
// try getting start/end iteration from children
let iterationDuration = getIterationDurationFromChildren(children);
const iterations = getIterations(projectId, teamId, input);
// TODO: We will use the dropdown option value when available to toggle use overridden value or not
// if the start/end iteration is overridden use that value
if (input.savedOverriddenWorkItemIterations &&
input.savedOverriddenWorkItemIterations[id]) {
const si = input.savedOverriddenWorkItemIterations[id].startIterationId;
const ei = input.savedOverriddenWorkItemIterations[id].endIterationId;
const overridedBy = input.savedOverriddenWorkItemIterations[id].user;
const startIteration = iterations.find(i => i.id === si);
const endIteration = iterations.find(i => i.id === ei);
if (startIteration && endIteration) {
iterationDuration = { startIteration, endIteration, kind: IterationDurationKind.UserOverridden, overridedBy };
}
}
// if null use workItems start/end iteration
if (workItem && (!iterationDuration.startIteration || !iterationDuration.endIteration)) {
const iterationPath = workItem.fields["System.IterationPath"];
const iteration = iterations.find((i) => i.path === iterationPath);
iterationDuration.startIteration = iteration;
iterationDuration.endIteration = iteration;
}
// If still null take currentIteration
const currentIteration = getCurrentIteration(projectId, teamId, input);
if (!iterationDuration.startIteration || !iterationDuration.endIteration) {
iterationDuration.startIteration = currentIteration;
iterationDuration.endIteration = currentIteration;
iterationDuration.kind = IterationDurationKind.CurrentIteration;
}
const orderFieldName = input.backlogConfiguration.backlogConfigurations[projectId][teamId].backlogFields.typeFields["Order"];
const color = workItemType ? "#" + (workItemType.color.length > 6 ? workItemType.color.substr(2) : workItemType.color) : "#c2c8d1";
const workItemDetails = {
id,
title: workItem ? workItem.fields["System.Title"] : "Unparented",
color,
order: workItem ? workItem.fields[orderFieldName] : 0,
workItem,
iterationDuration,
children,
isRoot,
shouldShowDetails: !isRoot && (iterationDuration.kind === IterationDurationKind.ChildRollup || iterationDuration.kind === IterationDurationKind.UserOverridden)
};
return workItemDetails;
}
function getIterationDurationFromChildren(children: IWorkItemHierarchy[]): IIterationDuration {
return children.reduce((prev, child) => {
let {
startIteration,
endIteration
} = prev;
if (!startIteration || !endIteration) {
startIteration = child.iterationDuration.startIteration;
endIteration = child.iterationDuration.endIteration;
} else {
if (compareIteration(child.iterationDuration.startIteration , startIteration) < 0) {
startIteration = child.iterationDuration.startIteration;
}
if (compareIteration(child.iterationDuration.endIteration, endIteration) > 0) {
endIteration = child.iterationDuration.endIteration;
}
}
return {
startIteration,
endIteration,
kind: IterationDurationKind.ChildRollup
}
}, { startIteration: null, endIteration: null, kind: IterationDurationKind.None });
}
function getCurrentIteration(projectId: string, teamId: string, input: IFeatureTimelineRawState): TeamSettingsIteration {
const allIterations = getIterations(projectId, teamId, input);
const currentIterations = allIterations.filter((itr) => TimeFrame && itr.attributes.timeFrame === TimeFrame.Current);
if (currentIterations.length === 0) {
return allIterations[0];
}
return currentIterations[0];
}
function getIterations(projectId: string, teamId: string, input: IFeatureTimelineRawState) {
if (!input ||
!input.iterationState ||
!input.iterationState.teamSettingsIterations ||
!input.iterationState.teamSettingsIterations[projectId] ||
!input.iterationState.teamSettingsIterations[projectId][teamId]) {
return [];
}
return input.iterationState.teamSettingsIterations[projectId][teamId];
}
function getChildrenId(workItemInfos: IDictionaryNumberTo<IWorkItemInfo>, parentId: number): number[] {
const childIds = [];
for (const key in workItemInfos) {
const workItem = workItemInfos[key];
if (workItem.parent === parentId && workItem.level !== WorkItemLevel.Parent) {
childIds.push(workItem.workItem.id);
}
}
return childIds;
}

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

@ -0,0 +1,12 @@
import { IWorkItemInfo, WorkItemLevel } from '../store/workitems/types';
// returns all work items for given level
export function getWorkItemsForLevel(
workItemInfos: IDictionaryNumberTo<IWorkItemInfo>,
level: WorkItemLevel): number[] {
const allIds: string[] = Object.keys(workItemInfos);
const ids = allIds.filter((id) => workItemInfos[Number(id)].level === level);
return ids.map(i => Number(i));
}

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

@ -0,0 +1,14 @@
import { ActionCreator } from 'redux';
import { BacklogConfigurationReceivedAction, BacklogConfigurationReceivedType } from './actions';
import { BacklogConfiguration } from 'TFS/Work/Contracts';
export const backlogConfigurationReceived: ActionCreator<BacklogConfigurationReceivedAction> =
(projectId: string, teamId: string, backlogConfiguration: BacklogConfiguration) => ({
type: BacklogConfigurationReceivedType,
payload: {
projectId,
teamId,
backlogConfiguration
}
});

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

@ -0,0 +1,15 @@
import { Action } from "redux";
import { BacklogConfiguration } from "TFS/Work/Contracts";
export const BacklogConfigurationReceivedType = "@@backlogconfiguration/BacklogConfigurationReceived";
export interface BacklogConfigurationReceivedAction extends Action {
type: "@@backlogconfiguration/BacklogConfigurationReceived";
payload: {
projectId: string;
teamId: string;
backlogConfiguration: BacklogConfiguration;
}
}
export type BacklogConfigurationActions = BacklogConfigurationReceivedAction;

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

@ -0,0 +1,34 @@
import { Reducer } from 'redux';
import { IBacklogConfigurationState } from './types';
import { BacklogConfigurationActions, BacklogConfigurationReceivedType, BacklogConfigurationReceivedAction } from './actions';
// Type-safe initialState!
export const initialState: IBacklogConfigurationState = {
backlogConfigurations: {}
};
const reducer: Reducer<IBacklogConfigurationState> = (state: IBacklogConfigurationState = initialState, action: BacklogConfigurationActions) => {
switch (action.type) {
case BacklogConfigurationReceivedType:
return handleBacklogConfigurationReceived(state, action as BacklogConfigurationReceivedAction);
default:
return state;
}
};
function handleBacklogConfigurationReceived(state: IBacklogConfigurationState, action: BacklogConfigurationReceivedAction): IBacklogConfigurationState {
let newState = { ...state };
const {
projectId,
teamId,
backlogConfiguration
} = action.payload;
const projectData = newState.backlogConfigurations[projectId] ? { ...newState.backlogConfigurations[projectId] } : {};
projectData[teamId] = backlogConfiguration;
newState.backlogConfigurations[projectId] = projectData;
return newState;
}
export default reducer;

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

@ -0,0 +1,6 @@
import { BacklogConfiguration } from "TFS/Work/Contracts";
export interface IBacklogConfigurationState {
// project -> team -> Backlog Configuration
backlogConfigurations: IDictionaryStringTo<IDictionaryStringTo<BacklogConfiguration>>;
}

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

@ -0,0 +1,25 @@
import { ActionCreator } from 'redux';
import { InitializeAction, InitializeType, ShowDetailsAction, ShowDetailsType, CloseDetailsAction, CloseDetailsType } from './actions';
export const createInitialize: ActionCreator<InitializeAction> =
(projectId: string, teamId: string, backlogLevelName: string) => ({
type: InitializeType,
payload: {
projectId,
teamId,
backlogLevelName
}
});
export const showDetails: ActionCreator<ShowDetailsAction> =
(id: number) => ({
type: ShowDetailsType,
payload: { id }
});
export const closeDetails: ActionCreator<CloseDetailsAction> =
(id: number) => ({
type: CloseDetailsType,
payload: { id }
});

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

@ -0,0 +1,31 @@
import { Action } from "redux";
export const InitializeType = "@@common/initialize";
export const ShowDetailsType = "@@common/showdetails";
export const CloseDetailsType = "@@common/closedetails";
export interface InitializeAction extends Action {
type: "@@common/initialize";
payload: {
projectId: string;
teamId: string;
backlogLevelName: string;
}
}
export interface ShowDetailsAction extends Action {
type: "@@common/showdetails";
payload: {
id: number;
}
}
export interface CloseDetailsAction extends Action {
type: "@@common/closedetails";
payload: {
id: number;
}
}
export type CommonActions = InitializeAction | ShowDetailsAction | CloseDetailsAction;

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

@ -0,0 +1,14 @@
import { Reducer } from 'redux';
import { CommonActions, ShowDetailsType, CloseDetailsType } from './actions';
const reducer: Reducer<number[]> = (state: number[] = [], action: CommonActions) => {
switch (action.type) {
case ShowDetailsType:
return [...state, action.payload.id];
case CloseDetailsType:
return state.filter(id => id !== action.payload.id);
default:
return state;
}
};
export default reducer;

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

@ -0,0 +1,11 @@
export interface IContributionContext {
level: string;
team: {
id: string;
name: string;
};
workItemTypes: string[];
host: {
background?: boolean;
};
}

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

@ -0,0 +1,9 @@
import { ActionCreator } from 'redux';
import { GenericErrorAction, GenericErrorType } from './actions';
export const genericError: ActionCreator<GenericErrorAction> =
(error: string) => ({
type: GenericErrorType,
payload: error
});

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

@ -0,0 +1,10 @@
import { Action } from "redux";
export const GenericErrorType = "@@error/GenericError";
export interface GenericErrorAction extends Action {
type: "@@error/GenericError";
payload: string;
}
export type ErrorActions = GenericErrorAction;

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

@ -0,0 +1,15 @@
import { Reducer } from 'redux';
import { ErrorActions, GenericErrorType } from './actions';
const reducer: Reducer<string> = (state: string = "", action: ErrorActions) => {
switch (action.type) {
case GenericErrorType:
if (typeof action.payload === "string") {
return action.payload;
}
return action.payload["message"];
default:
return state;
}
};
export default reducer;

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

73
src/redux/store/index.ts Normal file
Просмотреть файл

@ -0,0 +1,73 @@
import { combineReducers, Reducer } from 'redux';
import { IWorkItemsState } from './workitems/types';
import { IWorkItemMetadataState } from './workitemmetadata/types';
import { ITeamSettingsIterationState } from './teamiterations/types';
import { IBacklogConfigurationState } from './backlogconfiguration/types';
import workItemReducer from './workItems/reducer';
import metadataReducer from './workitemmetadata/reducer';
import teamIterationsReducer from './teamiterations/reducer';
import errorReducer from './error/reducer';
import loadingReducer from './loading/reducer';
import backlogConfigurationReducer from './backlogconfiguration/reducer';
import showHideDetailsReducer from "./common/reducer";
import { TeamSettingsIteration } from 'TFS/Work/Contracts';
import savedOverriddenWorkItemIterationsReducer from "./workitems/overrideWorkItemIterationReducer";
import overrideIterationReducer from "./overrideIterationProgress/reducer";
export interface IIterationDuration {
startIteration: TeamSettingsIteration;
endIteration: TeamSettingsIteration;
kind: IterationDurationKind;
overridedBy?: string; // User name for the case when kind is UserOverridden
}
export enum IterationDurationKind {
None,
CurrentIteration,
ChildRollup,
UserOverridden
}
export interface IOverriddenIterationDuration {
startIterationId: string;
endIterationId: string;
user: string;
}
export interface IWorkItemOverrideIteration {
workItemId: number;
iterationDuration: IOverriddenIterationDuration;
changingStart: boolean; // Weather we are changing start iteration or end iteration
}
export interface IFeatureTimelineRawState {
workItemsState: IWorkItemsState;
workItemMetadata: IWorkItemMetadataState;
iterationState: ITeamSettingsIterationState;
error: string;
backlogConfiguration: IBacklogConfigurationState;
loading: boolean;
// This will contain any overridden iterations by the UI in extension storage
savedOverriddenWorkItemIterations: IDictionaryNumberTo<IOverriddenIterationDuration>;
// list of work item ids for which the details window is shown
workItemDetails: number[];
workItemOverrideIteration?: IWorkItemOverrideIteration;
}
// setup reducers
export const reducers: Reducer<IFeatureTimelineRawState> =
combineReducers<IFeatureTimelineRawState>({
workItemsState: workItemReducer,
workItemMetadata: metadataReducer,
iterationState: teamIterationsReducer,
error: errorReducer,
backlogConfiguration: backlogConfigurationReducer,
loading: loadingReducer,
workItemDetails: showHideDetailsReducer,
workItemOverrideIteration: overrideIterationReducer,
savedOverriddenWorkItemIterations: savedOverriddenWorkItemIterationsReducer
});

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

@ -0,0 +1,9 @@
import { ActionCreator } from 'redux';
import { LoadingAction, LoadingType } from './actions';
export const loading: ActionCreator<LoadingAction> =
(status: boolean) => ({
type: LoadingType,
payload: status
});

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

@ -0,0 +1,10 @@
import { Action } from "redux";
export const LoadingType = "@@loading/loading";
export interface LoadingAction extends Action {
type: "@@loading/loading";
payload: boolean;
}
export type LoadingActions = LoadingAction;

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

@ -0,0 +1,12 @@
import { Reducer } from 'redux';
import { LoadingActions, LoadingType } from './actions';
const reducer: Reducer<boolean> = (state: boolean = false, action: LoadingActions) => {
switch (action.type) {
case LoadingType:
return action.payload;
default:
return state;
}
};
export default reducer;

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

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

@ -0,0 +1,38 @@
import { ActionCreator } from 'redux';
import { OverrideIterationStartAction, OverrideIterationStartType, OverrideIterationEndAction, OverrideIterationEndType, OverrideIterationHoverOverIterationAction, OverrideIterationHoverOverIterationType, OverrideIterationCleanupAction, OverrideIterationCleanupType, SaveOverrideIterationAction, SaveOverrideIterationActionType } from './actions';
import { IWorkItemOverrideIteration } from '..';
export const startOverrideIteration: ActionCreator<OverrideIterationStartAction> =
(payload: IWorkItemOverrideIteration) => ({
type: OverrideIterationStartType,
payload
});
export const endOverrideIteration: ActionCreator<OverrideIterationEndAction> =
(payload: void) => ({
type: OverrideIterationEndType,
payload
});
export const cleanupOverrideIteration: ActionCreator<OverrideIterationCleanupAction> =
(payload: void) => ({
type: OverrideIterationCleanupType,
payload
});
export const overrideHoverOverIteration: ActionCreator<OverrideIterationHoverOverIterationAction> =
(payload: string) => ({
type: OverrideIterationHoverOverIterationType,
payload
});
export const saveOverrideIteration: ActionCreator<SaveOverrideIterationAction> =
(payload: IWorkItemOverrideIteration) => ({
type: SaveOverrideIterationActionType,
payload
});

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

@ -0,0 +1,36 @@
import { Action } from "redux";
import { IWorkItemOverrideIteration } from "..";
export const OverrideIterationStartType = "@@overrideIteration/start";
export const OverrideIterationEndType = "@@overrideIteration/end";
export const SaveOverrideIterationActionType = "@@overrideIteration/save";
export const OverrideIterationCleanupType = "@@overrideIteration/cleanup";
export const OverrideIterationHoverOverIterationType = "@@overrideIteration/hoveroveriteration";
export interface OverrideIterationStartAction extends Action {
type: "@@overrideIteration/start",
payload: IWorkItemOverrideIteration
}
export interface OverrideIterationEndAction extends Action {
type: "@@overrideIteration/end",
payload: void
}
export interface SaveOverrideIterationAction extends Action {
type: "@@overrideIteration/save",
payload: IWorkItemOverrideIteration
}
export interface OverrideIterationCleanupAction extends Action {
type: "@@overrideIteration/cleanup",
payload: void
}
export interface OverrideIterationHoverOverIterationAction extends Action {
type: "@@overrideIteration/hoveroveriteration",
payload: string
}
export type OverrideIterationActions = OverrideIterationStartAction | OverrideIterationEndAction | OverrideIterationHoverOverIterationAction | OverrideIterationCleanupAction | SaveOverrideIterationAction;

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

@ -0,0 +1,29 @@
import { Reducer } from 'redux';
import { OverrideIterationActions, OverrideIterationHoverOverIterationType, OverrideIterationStartType, OverrideIterationCleanupType } from './actions';
import { IWorkItemOverrideIteration } from '..';
const reducer: Reducer<IWorkItemOverrideIteration> = (state: IWorkItemOverrideIteration = null, action: OverrideIterationActions) => {
switch (action.type) {
case OverrideIterationStartType:
return { ...action.payload };
case OverrideIterationCleanupType:
return null;
case OverrideIterationHoverOverIterationType: {
if (!state) {
return state;
}
const newState = { ...state };
newState.iterationDuration = { ...state.iterationDuration }
if (newState.changingStart) {
newState.iterationDuration.startIterationId = action.payload;
} else {
newState.iterationDuration.endIterationId = action.payload;
}
return newState;
}
default:
return state;
}
};
export default reducer;

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

@ -0,0 +1,52 @@
import { ActionCreator } from 'redux';
import { TeamSettingsIterationReceivedAction, TeamSettingsIterationReceivedType, DisplayAllIterationsAction, DisplayAllIterationsActionType, ChangeDisplayIterationCountAction, ChangeDisplayIterationCountActionType, ShiftDisplayIterationLeftActionType, ShiftDisplayIterationLeftAction, ShiftDisplayIterationRightActionType, ShiftDisplayIterationRightAction, RestoreDisplayIterationCountAction, RestoreDisplayIterationCountActionType } from './actions';
import { TeamSettingsIteration } from 'TFS/Work/Contracts';
import { IIterationDisplayOptions } from './types';
export const teamSettingsIterationReceived: ActionCreator<TeamSettingsIterationReceivedAction> =
(projectId: string, teamId: string, TeamSettingsIterations: TeamSettingsIteration[]) => ({
type: TeamSettingsIterationReceivedType,
payload: {
projectId,
teamId,
TeamSettingsIterations
}
});
export const displayAllIterations: ActionCreator<DisplayAllIterationsAction> =
() => ({
type: DisplayAllIterationsActionType,
payload: null
});
export const changeDisplayIterationCount: ActionCreator<ChangeDisplayIterationCountAction> =
(count: number, projectId: string, teamId: string) => ({
type: ChangeDisplayIterationCountActionType,
payload: {
count,
projectId,
teamId
}
});
export const restoreDisplayIterationCount: ActionCreator<RestoreDisplayIterationCountAction> =
(payload: IIterationDisplayOptions) => ({
type: RestoreDisplayIterationCountActionType,
payload
});
export const shiftDisplayIterationLeft: ActionCreator<ShiftDisplayIterationLeftAction> =
(count: number) => ({
type: ShiftDisplayIterationLeftActionType,
payload: {
count,
}
});
export const shiftDisplayIterationRight: ActionCreator<ShiftDisplayIterationRightAction> =
(count: number) => ({
type: ShiftDisplayIterationRightActionType,
payload: {
count,
}
});

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

@ -0,0 +1,54 @@
import { Action } from "redux";
import { TeamSettingsIteration } from "TFS/Work/Contracts";
import { IIterationDisplayOptions } from "./types";
export const TeamSettingsIterationReceivedType = "@@TeamSettingsIteration/TeamSettingsIterationReceived";
export const ChangeDisplayIterationCountActionType = "@@TeamSettingsIteration/ChangeDisplayIterationCountAction";
export const RestoreDisplayIterationCountActionType = "@@TeamSettingsIteration/RestoreDisplayIterationCountAction";
export const ShiftDisplayIterationLeftActionType = "@@TeamSettingsIteration/ShiftDisplayIterationLeftAction";
export const ShiftDisplayIterationRightActionType = "@@TeamSettingsIteration/ShiftDisplayIterationRightAction";
export const DisplayAllIterationsActionType = "@@TeamSettingsIteration/DisplayAllIterationsAction";
export interface TeamSettingsIterationReceivedAction extends Action {
type: "@@TeamSettingsIteration/TeamSettingsIterationReceived";
payload: {
projectId: string;
teamId: string;
TeamSettingsIterations: TeamSettingsIteration[];
}
}
export interface DisplayAllIterationsAction extends Action {
type: "@@TeamSettingsIteration/DisplayAllIterationsAction";
payload: void;
}
export interface ChangeDisplayIterationCountAction extends Action {
type: "@@TeamSettingsIteration/ChangeDisplayIterationCountAction";
payload: {
count: number,
teamId: string,
projectId: string
}
}
export interface RestoreDisplayIterationCountAction extends Action {
type: "@@TeamSettingsIteration/RestoreDisplayIterationCountAction";
payload: IIterationDisplayOptions
}
export interface ShiftDisplayIterationLeftAction extends Action {
type: "@@TeamSettingsIteration/ShiftDisplayIterationLeftAction";
payload: {
count: number
}
}
export interface ShiftDisplayIterationRightAction extends Action {
type: "@@TeamSettingsIteration/ShiftDisplayIterationRightAction";
payload: {
count: number
}
}
export type TeamSettingsIterationActions = TeamSettingsIterationReceivedAction | DisplayAllIterationsAction | ChangeDisplayIterationCountAction | ShiftDisplayIterationLeftAction | ShiftDisplayIterationRightAction | RestoreDisplayIterationCountAction;

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

@ -0,0 +1,158 @@
import { Reducer } from 'redux';
import { ITeamSettingsIterationState } from './types';
import { TeamSettingsIterationActions, TeamSettingsIterationReceivedType, TeamSettingsIterationReceivedAction, DisplayAllIterationsActionType, ShiftDisplayIterationLeftActionType, ShiftDisplayIterationRightActionType, ChangeDisplayIterationCountActionType, ShiftDisplayIterationLeftAction, ShiftDisplayIterationRightAction, ChangeDisplayIterationCountAction, RestoreDisplayIterationCountActionType, RestoreDisplayIterationCountAction } from './actions';
import { TeamSettingsIteration, TimeFrame } from 'TFS/Work/Contracts';
// Type-safe initialState!
export const initialState: ITeamSettingsIterationState = {
// project -> team -> teamsettingsiterations
teamSettingsIterations: {},
iterationDisplayOptions: null
};
const reducer: Reducer<ITeamSettingsIterationState> = (state: ITeamSettingsIterationState = initialState,
action: TeamSettingsIterationActions) => {
switch (action.type) {
case TeamSettingsIterationReceivedType:
return handleTeamSettingsIterationReceived(state, action as TeamSettingsIterationReceivedAction);
case DisplayAllIterationsActionType:
return handleDisplayAllIterations(state);
case ShiftDisplayIterationLeftActionType:
return handleShiftDisplayIterationLeft(state, action);
case ShiftDisplayIterationRightActionType:
return handleShiftDisplayIterationRight(state, action);
case ChangeDisplayIterationCountActionType:
return handleChangeDisplayIterationCountAction(state, action);
case RestoreDisplayIterationCountActionType:
return handleRestoreDisplayIterationCountAction(state, action);
default:
return state;
}
};
function handleRestoreDisplayIterationCountAction(state: ITeamSettingsIterationState, action: RestoreDisplayIterationCountAction) {
const newState = { ...state };
newState.iterationDisplayOptions = action.payload;
let { count } = action.payload;
const iterations = state.teamSettingsIterations[action.payload.projectId][action.payload.teamId] || [];
// Handle incase if the team iterations changed before restore
if (count > iterations.length) {
const currentIterationIndex = (iterations && getCurrentIterationIndex(iterations)) || 0;
count = iterations.length;
let startIndex = currentIterationIndex - Math.floor((count / 2));
if (startIndex < 0) {
startIndex = 0;
}
const endIndex = startIndex + (count - 1);
newState.iterationDisplayOptions.count = count;
newState.iterationDisplayOptions.startIndex = startIndex;
newState.iterationDisplayOptions.endIndex = endIndex;
}
return newState;
}
function handleChangeDisplayIterationCountAction(state: ITeamSettingsIterationState, action: ChangeDisplayIterationCountAction) {
let {
count,
teamId,
projectId
} = action.payload;
const originalCount = count;
const iterations = state.teamSettingsIterations[projectId][teamId];
const currentIterationIndex = getCurrentIterationIndex(iterations);
if (count > iterations.length) {
count = iterations.length;
}
const displayOptions = {
count,
originalCount,
teamId,
projectId,
startIndex: 0,
endIndex: 0,
totalIterations: iterations.length
};
let startIndex = currentIterationIndex - Math.floor((count / 2));
if (startIndex < 0) {
startIndex = 0;
}
const endIndex = startIndex + (count - 1);
displayOptions.startIndex = startIndex;
displayOptions.endIndex = endIndex;
const newState = { ...state };
newState.iterationDisplayOptions = displayOptions;
return newState;
}
function getCurrentIterationIndex(iterations: TeamSettingsIteration[]): number {
if (TimeFrame) {
return iterations.findIndex(i => i.attributes.timeFrame === TimeFrame.Current);
}
return 0;
}
function handleShiftDisplayIterationLeft(state: ITeamSettingsIterationState, action: ShiftDisplayIterationLeftAction) {
if (state.iterationDisplayOptions) {
const newState = { ...state };
const displayOptions = { ...newState.iterationDisplayOptions };
if ((displayOptions.startIndex - action.payload.count) >= 0) {
displayOptions.startIndex -= action.payload.count;
displayOptions.endIndex = displayOptions.startIndex + state.iterationDisplayOptions.count - 1;
}
newState.iterationDisplayOptions = displayOptions
return newState;
}
return state;
}
function handleShiftDisplayIterationRight(state: ITeamSettingsIterationState, action: ShiftDisplayIterationRightAction) {
if (state.iterationDisplayOptions) {
const newState = { ...state };
const iterationCount = state.teamSettingsIterations[state.iterationDisplayOptions.projectId][state.iterationDisplayOptions.teamId].length;
const displayOptions = { ...newState.iterationDisplayOptions };
if ((displayOptions.endIndex + action.payload.count) < iterationCount) {
displayOptions.endIndex += action.payload.count;
displayOptions.startIndex = displayOptions.endIndex - state.iterationDisplayOptions.count + 1;
}
newState.iterationDisplayOptions = displayOptions;
return newState;
}
return state;
}
function handleDisplayAllIterations(state: ITeamSettingsIterationState) {
const newState = { ...state };
newState.iterationDisplayOptions = null;
return newState;
}
function handleTeamSettingsIterationReceived(state: ITeamSettingsIterationState, action: TeamSettingsIterationReceivedAction): ITeamSettingsIterationState {
let newState = { ...state };
const {
projectId,
teamId,
TeamSettingsIterations
} = action.payload;
const projectData = newState.teamSettingsIterations[projectId] ? { ...newState.teamSettingsIterations[projectId] } : {};
projectData[teamId] = TeamSettingsIterations;
newState.teamSettingsIterations[projectId] = projectData;
return newState;
}
export default reducer;

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

@ -0,0 +1,17 @@
import { TeamSettingsIteration } from "TFS/Work/Contracts";
export interface IIterationDisplayOptions {
totalIterations: number
originalCount: number;
count: number;
startIndex: number;
endIndex: number;
teamId: string;
projectId: string;
}
export interface ITeamSettingsIterationState {
// project -> team -> Backlog Configuration
teamSettingsIterations: IDictionaryStringTo<IDictionaryStringTo<TeamSettingsIteration[]>>;
iterationDisplayOptions: IIterationDisplayOptions;
}

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

@ -0,0 +1,22 @@
import { ActionCreator } from 'redux';
import { WorkItemTypesReceivedAction, WorkItemTypesReceivedActionType, WorkItemStateColorsReceivedAction, WorkItemStateColorsReceivedActionType } from './actions';
import { WorkItemType, WorkItemStateColor } from 'TFS/WorkItemTracking/Contracts';
export const workItemTypesReceived: ActionCreator<WorkItemTypesReceivedAction> =
(projectId: string, workItemTypes: WorkItemType[]) => ({
type: WorkItemTypesReceivedActionType,
payload: {
projectId,
workItemTypes
}
});
export const workItemStateColorsReceived: ActionCreator<WorkItemStateColorsReceivedAction> =
(projectId: string, workItemTypeStateColors: IDictionaryStringTo<WorkItemStateColor[]>) => ({
type: WorkItemStateColorsReceivedActionType,
payload: {
projectId,
workItemTypeStateColors
}
});

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

@ -0,0 +1,23 @@
import { Action } from 'redux';
import { WorkItemType, WorkItemStateColor } from 'TFS/WorkItemTracking/Contracts';
export const WorkItemTypesReceivedActionType = '@@workitemmetadata/WorkItemTypesReceived';
export const WorkItemStateColorsReceivedActionType = '@@workitemmetadata/WorkItemStateColorsReceived';
export interface WorkItemTypesReceivedAction extends Action {
type: '@@workitemmetadata/WorkItemTypesReceived';
payload: {
projectId: string;
workItemTypes: WorkItemType[];
}
}
export interface WorkItemStateColorsReceivedAction extends Action {
type: '@@workitemmetadata/WorkItemStateColorsReceived';
payload: {
projectId: string;
workItemTypeStateColors: IDictionaryStringTo<WorkItemStateColor[]>;
}
}
export type MetaDataActions = WorkItemTypesReceivedAction | WorkItemStateColorsReceivedAction;

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

@ -0,0 +1,48 @@
import { Reducer } from 'redux';
import { IWorkItemMetadataState, IWorkItemMetadata } from './types';
import { WorkItemTypesReceivedActionType, MetaDataActions, WorkItemTypesReceivedAction, WorkItemStateColorsReceivedAction, WorkItemStateColorsReceivedActionType } from './actions';
// Type-safe initialState!
export const initialState: IWorkItemMetadataState = {
// project -> metadata
metadata: {}
};
const reducer: Reducer<IWorkItemMetadataState> = (state: IWorkItemMetadataState = initialState, action: MetaDataActions) => {
switch (action.type) {
case WorkItemTypesReceivedActionType:
return handleWorkItemTypesReceived(state, action as WorkItemTypesReceivedAction);
case WorkItemStateColorsReceivedActionType:
return handleWorkItemStateColorsReceived(state, action as WorkItemStateColorsReceivedAction);
default:
return state;
}
};
function handleWorkItemTypesReceived(state: IWorkItemMetadataState, action: WorkItemTypesReceivedAction): IWorkItemMetadataState {
let newState = { ...state };
const {
projectId,
workItemTypes
} = action.payload;
const projectData: IWorkItemMetadata = newState.metadata[projectId] ? { ...newState.metadata[projectId] } : {} as IWorkItemMetadata;
projectData.workItemTypes = workItemTypes;
newState.metadata[projectId] = projectData;
return newState;
}
function handleWorkItemStateColorsReceived(state: IWorkItemMetadataState, action: WorkItemStateColorsReceivedAction): IWorkItemMetadataState {
let newState = { ...state };
const {
projectId,
workItemTypeStateColors
} = action.payload;
const projectData: IWorkItemMetadata = newState.metadata[projectId] ? { ...newState.metadata[projectId] } : {} as IWorkItemMetadata;
projectData.workItemStateColors = workItemTypeStateColors;
newState.metadata[projectId] = projectData;
return newState;
}
export default reducer;

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

@ -0,0 +1,11 @@
import { WorkItemType, WorkItemStateColor } from "TFS/WorkItemTracking/Contracts";
export interface IWorkItemMetadata {
workItemTypes: WorkItemType[];
workItemStateColors: IDictionaryStringTo<WorkItemStateColor[]>;
}
export interface IWorkItemMetadataState {
// project -> WorkItemMetadata
metadata: IDictionaryStringTo<IWorkItemMetadata>;
}

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

@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`test work item received 1`] = `
Object {
"workItemInfos": Object {
"1": Object {
"children": Array [],
"level": 1,
"parent": 0,
"workItem": Object {
"id": 1,
},
},
},
}
`;
exports[`work item updated 1`] = `
Object {
"workItemInfos": Object {
"1": Object {
"children": Array [],
"level": 2,
"parent": 0,
"workItem": Object {
"fields": Object {
"System.Title": "hello",
},
"id": 1,
},
},
},
}
`;
exports[`work item updated 2`] = `
Object {
"workItemInfos": Object {
"1": Object {
"children": Array [],
"level": 2,
"parent": 0,
"workItem": Object {
"fields": Object {
"System.Title": "hi",
},
"id": 1,
},
},
},
}
`;

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

@ -0,0 +1,36 @@
declare var it, expect;
import reducer from '../reducer';
import { workItemsReceived, replaceWorkItems } from '../actionCreators';
debugger;
it('test work item received', () => {
debugger;
const action = workItemsReceived([{
id: 1
}], [], [1], [])
const state = reducer({workItemInfos: {}}, action);
expect(state).toMatchSnapshot();
});
it('work item updated', () => {
const action = workItemsReceived([{
id: 1,
fields: {
"System.Title": "hello"
}
}], [], [2], []);
let state = reducer({workItemInfos: {}}, action);
expect(state).toMatchSnapshot();
const replaceAction = replaceWorkItems([{
id: 1,
fields: {
"System.Title": "hi"
}
}])
state = reducer(state, replaceAction);
expect(state).toMatchSnapshot();
});

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

@ -0,0 +1,107 @@
import { ActionCreator } from 'redux';
import {
StartUpdateWorkitemIterationAction, WorkItemLinksReceivedActionType,
StartUpdateWorkitemIterationActionType, ChangeParentAction,
ChangeParentActionType, WorkItemsReceivedAction,
WorkItemsReceivedActionType, WorkItemLinksReceivedAction,
ReplaceWorkItemsAction, ReplaceWorkItemsActionType, LaunchWorkItemFormAction,
LaunchWorkItemFormActionType, SetOverrideIterationAction, SetOverrideIterationType, ClearOverrideIterationAction, ClearOverrideIterationType, WorkItemSavedAction, WorkItemSavedActionType, WorkItemSaveFailedActionType, WorkItemSaveFailedAction
} from './actions';
import { WorkItem, WorkItemLink } from 'TFS/WorkItemTracking/Contracts';
import { TeamSettingsIteration } from 'TFS/Work/Contracts';
export const startUpdateWorkItemIteration: ActionCreator<StartUpdateWorkitemIterationAction> =
(workItem: number, teamIteration: TeamSettingsIteration, override: boolean) => ({
type: StartUpdateWorkitemIterationActionType,
payload: {
workItem,
teamIteration,
override
}
});
export const workItemSaved: ActionCreator<WorkItemSavedAction> =
(workItems: number[]) => ({
type: WorkItemSavedActionType,
payload: {
workItems
}
});
export const workItemSaveFailed: ActionCreator<WorkItemSaveFailedAction> =
(workItems: number[], error: string) => ({
type: WorkItemSaveFailedActionType,
payload: {
workItems,
error
}
});
export const changParent: ActionCreator<ChangeParentAction> =
(workItems: number[], oldParent?: number, newParentId?: number) => ({
type: ChangeParentActionType,
payload: {
workItems,
oldParent,
newParentId
}
});
export const workItemsReceived: ActionCreator<WorkItemsReceivedAction> =
(workItems: WorkItem[],
parentWorkItemIds: number[],
currentLevelWorkItemIds: number[],
childLevelWorkItemIds: number[]) => ({
type: WorkItemsReceivedActionType,
payload: {
workItems,
parentWorkItemIds,
currentLevelWorkItemIds,
childLevelWorkItemIds,
}
});
export const workItemLinksReceived: ActionCreator<WorkItemLinksReceivedAction> =
(workItemLinks: WorkItemLink[]) => ({
type: WorkItemLinksReceivedActionType,
payload: {
workItemLinks
}
});
export const replaceWorkItems: ActionCreator<ReplaceWorkItemsAction> =
(workItems: WorkItem[]) => ({
type: ReplaceWorkItemsActionType,
payload: {
workItems
}
});
export const launchWorkItemForm: ActionCreator<LaunchWorkItemFormAction> =
(workItemId: number) => ({
type: LaunchWorkItemFormActionType,
track: true,
payload: {
workItemId
}
});
export const setOverrideIteration: ActionCreator<SetOverrideIterationAction> =
(workItemId: number, startIterationId: string, endIterationId: string, user: string) => ({
type: SetOverrideIterationType,
track: true,
payload: {
workItemId,
startIterationId,
endIterationId,
user
}
});
export const clearOverrideIteration: ActionCreator<ClearOverrideIterationAction> =
(id: number) => ({
type: ClearOverrideIterationType,
payload: id
});

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

@ -0,0 +1,97 @@
import { Action } from "redux";
import { WorkItem, WorkItemLink } from "TFS/WorkItemTracking/Contracts";
import { TrackableAction } from "./types";
import { TeamSettingsIteration } from "TFS/Work/Contracts";
export const StartUpdateWorkitemIterationActionType = "@@workitems/StartUpdateWorkitemIterationAction";
export const WorkItemSavedActionType = "@@workitems/WorkItemSavedAction";
export const WorkItemSaveFailedActionType = "@@workitems/WorkItemSaveFailedAction";
export const ChangeParentActionType = "@@workitems/ChangeParentAction";
export const WorkItemsReceivedActionType = "@@workitems/WorkItemsReceived";
export const WorkItemLinksReceivedActionType = "@@workitems/WorkItemLinksReceived"
export const ReplaceWorkItemsActionType = "@@workitems/ReplaceWorkItems";
export const LaunchWorkItemFormActionType = "@@workitems/LaunchWorkItemForm";
export const SetOverrideIterationType = "@@workitems/setoverrideiteration";
export const ClearOverrideIterationType = "@@overrideIteration/cleareoverrideiteration";
export interface StartUpdateWorkitemIterationAction extends Action {
type: "@@workitems/StartUpdateWorkitemIterationAction";
payload: {
workItem: number;
teamIteration: TeamSettingsIteration;
override: boolean;
}
}
export interface WorkItemSavedAction extends Action {
type: "@@workitems/WorkItemSavedAction";
payload: {
workItems: number[];
}
}
export interface WorkItemSaveFailedAction extends Action {
type: "@@workitems/WorkItemSaveFailedAction";
payload: {
workItems: number[];
error: string;
}
}
export interface ChangeParentAction extends Action {
type: "@@workitems/ChangeParentAction";
payload: {
workItems: number[];
newParentId?: number;
}
}
export interface WorkItemsReceivedAction extends Action {
type: "@@workitems/WorkItemsReceived";
payload: {
workItems: WorkItem[];
parentWorkItemIds: number[];
currentLevelWorkItemIds: number[];
childLevelWorkItemIds: number[];
}
}
export interface WorkItemLinksReceivedAction extends Action {
type: "@@workitems/WorkItemLinksReceived";
payload: {
workItemLinks: WorkItemLink[];
}
}
export interface ReplaceWorkItemsAction extends Action {
type: "@@workitems/ReplaceWorkItems";
payload: {
workItems: WorkItem[];
}
}
export interface LaunchWorkItemFormAction extends TrackableAction {
type: "@@workitems/LaunchWorkItemForm";
payload: {
workItemId: number;
}
}
export interface SetOverrideIterationAction extends TrackableAction {
type: "@@workitems/setoverrideiteration";
payload: {
workItemId: number;
startIterationId: string;
endIterationId: string;
user: string;
}
}
export interface ClearOverrideIterationAction extends Action {
type: "@@overrideIteration/cleareoverrideiteration",
payload: number
}
export type WorkItemActions = StartUpdateWorkitemIterationAction | ChangeParentAction | WorkItemsReceivedAction | WorkItemLinksReceivedAction | ReplaceWorkItemsAction | LaunchWorkItemFormAction;
export type OverrideIterationActions = SetOverrideIterationAction | ClearOverrideIterationAction;

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

@ -0,0 +1,29 @@
import { Reducer } from 'redux';
import { IOverriddenIterationDuration } from '..';
import { SetOverrideIterationType, ClearOverrideIterationType, OverrideIterationActions } from './actions';
const reducer: Reducer<IDictionaryNumberTo<IOverriddenIterationDuration>> =
(state: IDictionaryNumberTo<IOverriddenIterationDuration> = {}, action: OverrideIterationActions) => {
switch (action.type) {
case SetOverrideIterationType:
const newState = { ...state };
newState[Number(action.payload.workItemId)] = {
startIterationId: action.payload.startIterationId,
endIterationId: action.payload.endIterationId,
user: action.payload.user
};
return newState;
case ClearOverrideIterationType: {
if (!state) {
return state;
}
const newState = { ...state };
delete newState[action.payload]
return newState;
}
default:
return state;
}
};
export default reducer;

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

@ -0,0 +1,165 @@
import {
ChangeParentAction,
ChangeParentActionType,
ReplaceWorkItemsAction,
ReplaceWorkItemsActionType,
WorkItemActions,
WorkItemsReceivedAction,
WorkItemsReceivedActionType,
WorkItemLinksReceivedAction,
WorkItemLinksReceivedActionType,
StartUpdateWorkitemIterationAction,
StartUpdateWorkitemIterationActionType
} from './actions';
import { IWorkItemsState, WorkItemLevel } from './types';
import { Reducer } from 'redux';
// Type-safe initialState!
export const initialState: IWorkItemsState = {
workItemInfos: {}
};
const reducer: Reducer<IWorkItemsState> = (state: IWorkItemsState = initialState, action: WorkItemActions) => {
switch (action.type) {
case ChangeParentActionType:
return handleChangeParent(state, action as ChangeParentAction);
case WorkItemsReceivedActionType:
return handleWorkItemsReceived(state, action as WorkItemsReceivedAction);
case ReplaceWorkItemsActionType:
return handleReplaceWorkItems(state, action as ReplaceWorkItemsAction);
case WorkItemLinksReceivedActionType:
return handleWorkItemLinksReceived(state, action as WorkItemLinksReceivedAction);
case StartUpdateWorkitemIterationActionType:
return handleStartUpdateWorkItemIteration(state, action as StartUpdateWorkitemIterationAction);
default:
return state;
}
};
function handleStartUpdateWorkItemIteration(state: IWorkItemsState, action: StartUpdateWorkitemIterationAction): IWorkItemsState {
const {
workItem,
teamIteration
} = action.payload;
const newState = { ...state };
const workItemObject = newState.workItemInfos[workItem];
if (workItemObject) {
workItemObject.workItem.fields = { ...workItemObject.workItem.fields };
workItemObject.workItem.fields["System.IterationPath"] = teamIteration.path;
}
return newState;
}
function handleChangeParent(state: IWorkItemsState, action: ChangeParentAction): IWorkItemsState {
const {
workItems,
newParentId
} = action.payload;
const newState = { ...state };
for (const childId of workItems) {
changeParent(newState, childId, newParentId);
}
return newState;
}
function changeParent(newState: IWorkItemsState, childId: number, parentId: number) {
const info = newState.workItemInfos[childId];
const oldParentId = info.parent;
if (parentId === oldParentId) {
return;
}
const newParentInfo = newState.workItemInfos[parentId];
// Remove work item from old parent
if (oldParentId) {
const oldParentInfo = newState[oldParentId];
newState.workItemInfos[oldParentId] = {
...oldParentInfo
};
newState.workItemInfos[oldParentId].children = oldParentInfo.children.filter((id) => id !== childId);
}
if (parentId) {
//Add workItem as child of new parent
newState.workItemInfos[parentId] = {
...newParentInfo
};
newState.workItemInfos[parentId].children = [...newParentInfo.children, childId];
};
//Set parent id
newState.workItemInfos[childId] = {
...newState.workItemInfos[childId],
parent: parentId
};
}
function handleWorkItemsReceived(state: IWorkItemsState, action: WorkItemsReceivedAction): IWorkItemsState {
const newState = { ...state };
const {
workItems,
parentWorkItemIds,
currentLevelWorkItemIds
} = action.payload;
for (const workItem of workItems) {
let level = WorkItemLevel.Child;
if (parentWorkItemIds.some(parentId => parentId === workItem.id)) {
level = WorkItemLevel.Parent;
} else if (currentLevelWorkItemIds.some(currentId => currentId === workItem.id)) {
level = WorkItemLevel.Current;
}
newState.workItemInfos[workItem.id] = {
workItem,
children: [],
parent: 0,
level
};
}
return newState;
}
function handleReplaceWorkItems(state: IWorkItemsState, action: ReplaceWorkItemsAction): IWorkItemsState {
const newState = { ...state };
const {
workItems
} = action.payload;
for (const workItem of workItems) {
newState.workItemInfos[workItem.id] = {
...newState.workItemInfos[workItem.id],
workItem: workItem
};
}
return newState;
}
function handleWorkItemLinksReceived(state: IWorkItemsState, action: WorkItemLinksReceivedAction): IWorkItemsState {
const newState = { ...state };
const children = action.payload.workItemLinks.filter((link) => link.source);
for (const relation of children) {
let parentId = 0; relation.source.id;
let childId = 0; relation.target.id;
if (relation.rel === "System.LinkTypes.Hierarchy-Forward") {
parentId = relation.source.id;
childId = relation.target.id;
} else if (relation.rel === "System.LinkTypes.Hierarchy-Reverse") {
parentId = relation.target.id;
childId = relation.source.id;
}
changeParent(newState, childId, parentId);
}
return newState;
}
export default reducer;

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

@ -0,0 +1,23 @@
import { WorkItem } from "TFS/WorkItemTracking/Contracts";
import { Action } from "redux";
export enum WorkItemLevel {
Parent,
Current,
Child
}
export interface IWorkItemInfo {
workItem: WorkItem;
children: number[];
parent: number;
level: WorkItemLevel;
}
export interface IWorkItemsState {
workItemInfos: IDictionaryNumberTo<IWorkItemInfo>;
}
export interface TrackableAction extends Action {
track: boolean;
}

22
src/redux/types.ts Normal file
Просмотреть файл

@ -0,0 +1,22 @@
export enum UIStatus {
Default,
Loading,
Error,
NoWorkItems,
NoTeamIterations,
}
export enum CropWorkItem {
None,
Left,
Right,
Both
}
export interface IDimension {
startRow: number;
startCol: number;
endRow: number;
endCol: number;
}

31
tsconfig.json Normal file
Просмотреть файл

@ -0,0 +1,31 @@
{
"compilerOptions": {
"module": "amd",
"sourceMap": false,
"noUnusedLocals": true,
"jsx": "react",
"experimentalDecorators": true,
"lib": [
"es2015",
"es2015.promise",
"dom"
],
"target":"es5",
"moduleResolution": "node",
"types": [
"requirejs",
"react",
"react-dom",
"vss-web-extension-sdk"
],
"typeRoots": [
"../node_modules/@types"
]
},
"include": [
"./src/**/*"
],
"exclude": [
"node_modules"
]
}

204
tslint.json Normal file
Просмотреть файл

@ -0,0 +1,204 @@
{
"extends": [
"tslint:latest",
"tslint:recommended",
"tslint-react",
"tslint-microsoft-contrib",
"../tslint.json"
],
"rules": {
"no-unused-variable": true,
"align": false,
"array-type": [
true,
"array"
],
"arrow-parens": false,
"class-name": true,
"comment-format": [
false,
"check-space"
],
"completed-docs": false,
"await-promise": false,
"curly": true,
"eofline": false,
"export-name": false,
"forin": false,
"function-name": false,
"indent": [
true,
"spaces",
4
],
"interface-name": [
true,
"always-prefix"
],
"jsdoc-format": true,
"jsx-no-lambda": true,
"jsx-no-bind": true,
"jsx-no-multiline-js": false,
"jsx-no-string-ref": true,
"jsx-self-close": true,
"jsx-wrap-multiline": true,
"label-position": true,
"max-line-length": [
false,
140
],
"member-access": [
true,
"check-accessor"
],
"max-classes-per-file": false,
"max-func-body-length": false,
"member-ordering": [
true,
{
"order": [
"public-static-field",
"protected-static-field",
"private-static-field",
"public-instance-field",
"protected-instance-field",
"public-static-method",
"protected-static-method",
"private-static-method",
"public-instance-method",
"instance-method"
]
}
],
"missing-jsdoc": false,
"newline-before-return": false,
"no-any": false,
"no-arg": true,
"no-bitwise": false,
"no-consecutive-blank-lines": [
true
],
"no-console": [
true,
"debug",
"info",
"time",
"timeEnd",
"trace"
],
"no-construct": true,
"no-debugger": true,
"no-duplicate-variable": true,
"no-empty": true,
"no-eval": true,
"no-for-in": false,
"no-implicit-dependencies": false,
"no-import-side-effect": false,
"no-namespace": false,
"no-null-keyword": false,
"no-object-literal-type-assertion": false,
"no-parameter-properties": false,
"no-relative-imports": true,
"no-reserved-keywords": false,
"no-shadowed-variable": false,
"no-single-line-block-comment": false,
"no-string-literal": false,
"no-submodule-imports": false,
"no-switch-case-fall-through": true,
"no-trailing-whitespace": true,
"no-unused-expression": false,
"no-unused-variable": true,
"no-unused-method": true,
"no-use-before-declare": false,
"no-var-requires": false,
"object-literal-shorthand": false,
"object-literal-sort-keys": false,
"one-line": [
true,
"check-open-brace",
"check-catch",
"check-finally",
"check-else",
"check-whitespace"
],
"one-variable-per-declaration": [
true,
"ignore-for-loop"
],
"ordered-imports": false,
"prefer-const": true,
"prefer-method-signature": false,
"prefer-type-cast": false,
"quotemark": [
true,
"double"
],
"radix": true,
"react-a11y-anchors": false,
"react-a11y-aria-unsupported-elements": false,
"react-a11y-event-has-role": false,
"react-a11y-image-button-has-alt": false,
"react-a11y-img-has-alt": false,
"react-a11y-lang": false,
"react-a11y-meta": false,
"react-a11y-props": false,
"react-a11y-proptypes": false,
"react-a11y-role": false,
"react-a11y-role-has-required-aria-props": false,
"react-a11y-role-supports-aria-props": false,
"react-a11y-tabindex-no-positive": false,
"react-a11y-titles": false,
"react-anchor-blank-noopener": false,
"react-iframe-missing-sandbox": false,
"react-no-dangerous-html": false,
"react-this-binding-issue": false,
"react-tsx-curly-spacing": false,
"react-unused-props-and-state": false,
"semicolon": [
true,
"always"
],
"switch-default": false,
"trailing-comma": [
true,
{
"multiline": "never",
"singleline": "never"
}
],
"triple-equals": [
true,
"allow-null-check"
],
"typedef": [
false,
"variable-declaration"
],
"typedef-whitespace": [
true,
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
},
{
"call-signature": "onespace",
"index-signature": "onespace",
"parameter": "onespace",
"property-declaration": "onespace",
"variable-declaration": "onespace"
}
],
"variable-name": false,
"whitespace": [
true,
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type"
]
}
}

8
typings.json Normal file
Просмотреть файл

@ -0,0 +1,8 @@
{
"name": "rollup-extension",
"globalDependencies": {
"tfs": "npm:vss-web-extension-sdk/typings/tfs.d.ts",
"vss": "npm:vss-web-extension-sdk/typings/vss.d.ts"
}
}

53
vss-extension.json Normal file
Просмотреть файл

@ -0,0 +1,53 @@
{
"manifestVersion": 1,
"id": "workitem-feature-timeline-extension",
"version": "0.0.162",
"name": "Feature timeline",
"description": "Feature timeline of your in-progress features.",
"publisher": "ms-devlabs",
"targets": [{
"id": "Microsoft.VisualStudio.Services.Cloud"
},
{
"id": "Microsoft.TeamFoundation.Server",
"version": "[15.2,)"
}],
"icons": {
"default": "dist/images/icon.png"
},
"tags": ["work item", "timeline", "feature"],
"content": {
"details": {
"path": "dist/details.md"
}
},
"repository": {
"type": "git",
"uri": "https://github.com/microsoft/featuretimeline"
},
"scopes": [
"vso.work",
"vso.work_write"
],
"files": [
{
"path": "dist",
"addressable": true
}
],
"categories": ["Plan and track"],
"contributions": [{
"id": "workitem-feature-timeline",
"type": "ms.vss-web.tab",
"description": "Feature timeline of your in-progress features.",
"targets": [
"ms.vss-work-web.product-backlog-tabs"
],
"properties": {
"name": "Feature Timeline",
"order": 99,
"uri": "dist/index.html",
"dynamic": true
}
}]
}

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше