diff --git a/src/PortfolioPlanning/Common/Services/UserSettingsDataService.ts b/src/PortfolioPlanning/Common/Services/UserSettingsDataService.ts new file mode 100644 index 0000000..c648de1 --- /dev/null +++ b/src/PortfolioPlanning/Common/Services/UserSettingsDataService.ts @@ -0,0 +1,132 @@ +import { StorageConstants, UserSettings } from "../../Models/UserSettingsDataModels"; +import { PortfolioTelemetry } from "../Utilities/Telemetry"; +import { ExtensionStorageError, IQueryResultError } from "../../Models/PortfolioPlanningQueryModels"; +import { ProgressTrackingUserSetting, RollupHierachyUserSetting } from "../../Contracts"; + +export class UserSettingsDataService { + private static _instance: UserSettingsDataService; + private _vsid: string = null; + private _telemetry: PortfolioTelemetry; + + private constructor() { + try { + const webContext = VSS.getWebContext(); + this._vsid = webContext.user.id; + this._telemetry = PortfolioTelemetry.getInstance(); + } catch (error) { + PortfolioTelemetry.getInstance().TrackException(error); + } finally { + // set default values. + this._vsid = null; + } + } + + public static getInstance(): UserSettingsDataService { + if (!UserSettingsDataService._instance) { + UserSettingsDataService._instance = new UserSettingsDataService(); + } + return UserSettingsDataService._instance; + } + + public async getUserSettings(): Promise { + if (!this._vsid) { + this._telemetry.TrackAction("UserSettingsDataService/NullVSID"); + return Promise.resolve(this.getDefaultUserSettings()); + } + + const client = await this.getStorageClient(); + let settings: UserSettings = this.getDefaultUserSettings(); + let document: any = null; + + try { + document = await client.getDocument(StorageConstants.COLLECTION_NAME, this._vsid); + settings = this.parseUserSettingsDocument(document); + } catch (error) { + this._telemetry.TrackException(error); + const parsedError = this.ParseStorageError(error); + + if (parsedError.status === 404) { + // Collection/document has not been created, initialize it. + try { + await client.setDocument(StorageConstants.COLLECTION_NAME, settings); + } catch (error) { + this._telemetry.TrackException(error); + } + } + } + + return settings; + } + + public async updateUserSettings(latestSettings: UserSettings): Promise { + if (!this._vsid) { + this._telemetry.TrackAction("UserSettingsDataService/updateUserSettings/NullVSID"); + return Promise.resolve(); + } + + const client = await this.getStorageClient(); + latestSettings.id = this._vsid; + + try { + await client.updateDocument(StorageConstants.COLLECTION_NAME, latestSettings); + } catch (error) { + this._telemetry.TrackException(error); + } + } + + public getDefaultUserSettings(): UserSettings { + return { + Schema: StorageConstants.CURRENT_USER_SETTINGS_SCHEMA_VERSION, + id: this._vsid, + ProgressTrackingOption: this.getDefaultProgressTrackingOption(), + TimelineItemRollup: this.getDefaultTimelineItemRollup() + }; + } + + private async getStorageClient(): Promise { + return VSS.getService(VSS.ServiceIds.ExtensionData); + } + + private parseUserSettingsDocument(doc: any): UserSettings { + if (!doc) { + this._telemetry.TrackAction("UserSettingsDataService/ParseUserSettings/NoDoc"); + return this.getDefaultUserSettings(); + } + + const settings: UserSettings = doc; + + // Set default values if not present. + if (!settings.ProgressTrackingOption) { + settings.ProgressTrackingOption = this.getDefaultProgressTrackingOption(); + } + + if (!settings.TimelineItemRollup) { + settings.TimelineItemRollup = this.getDefaultTimelineItemRollup(); + } + + return settings; + } + + private ParseStorageError(error: any): IQueryResultError { + if (!error) { + return { + exceptionMessage: "no error information" + }; + } + + const parsedError: ExtensionStorageError = error; + + return { + exceptionMessage: parsedError.message, + status: parsedError.status + }; + } + + private getDefaultProgressTrackingOption(): ProgressTrackingUserSetting.Options { + return ProgressTrackingUserSetting.Options.CompletedCount; + } + + private getDefaultTimelineItemRollup(): RollupHierachyUserSetting.Options { + return RollupHierachyUserSetting.Options.Descendants; + } +} diff --git a/src/PortfolioPlanning/Common/Utilities/Telemetry.ts b/src/PortfolioPlanning/Common/Utilities/Telemetry.ts index cc8cc0c..84d8296 100644 --- a/src/PortfolioPlanning/Common/Utilities/Telemetry.ts +++ b/src/PortfolioPlanning/Common/Utilities/Telemetry.ts @@ -138,6 +138,7 @@ export class PortfolioTelemetry { public TrackException(exception: Error) { try { + console.log(JSON.stringify(exception, null, " ")); AppInsightsClient.getAppInsightsInstance().trackException({ error: exception, properties: { diff --git a/src/PortfolioPlanning/Components/Plan/DependencyPanel.tsx b/src/PortfolioPlanning/Components/Plan/DependencyPanel.tsx index 679d4c2..0d98b16 100644 --- a/src/PortfolioPlanning/Components/Plan/DependencyPanel.tsx +++ b/src/PortfolioPlanning/Components/Plan/DependencyPanel.tsx @@ -4,7 +4,7 @@ import "./DependencyPanel.scss"; import { ITimelineItem, LoadingStatus, - ProgressTrackingCriteria, + ProgressTrackingUserSetting, IWorkItemIcon, IProject, IProjectConfiguration @@ -27,13 +27,14 @@ import { MessageCard, MessageCardSeverity } from "azure-devops-ui/MessageCard"; import { connect } from "react-redux"; import { Icon } from "azure-devops-ui/Icon"; import moment = require("moment"); +import { UserSettings } from "../../Models/UserSettingsDataModels"; type WorkItemIconMap = { [projectIdKey: string]: { [workItemType: string]: IWorkItemIcon } }; type WorkItemInProgressStatesMap = { [projectIdKey: string]: { [WorkItemType: string]: string[] } }; export interface IDependencyPanelProps { workItem: ITimelineItem; projectInfo: IProject; - progressTrackingCriteria: ProgressTrackingCriteria; + userSettings: UserSettings; onDismiss: () => void; } @@ -279,11 +280,13 @@ export class DependencyPanel extends React.Component ({ workItem: ownProps.workItem, - progressTrackingCriteria: ownProps.progressTrackingCriteria, + userSettings: ownProps.userSettings, onDismiss: ownProps.onDismiss }); diff --git a/src/PortfolioPlanning/Components/Plan/PlanPage.tsx b/src/PortfolioPlanning/Components/Plan/PlanPage.tsx index 959cb3d..6178ab7 100644 --- a/src/PortfolioPlanning/Components/Plan/PlanPage.tsx +++ b/src/PortfolioPlanning/Components/Plan/PlanPage.tsx @@ -17,7 +17,7 @@ import { PlanDirectoryActions } from "../../Redux/Actions/PlanDirectoryActions"; import { EpicTimelineActions } from "../../Redux/Actions/EpicTimelineActions"; import { PortfolioPlanningMetadata } from "../../Models/PortfolioPlanningQueryModels"; import { PlanSettingsPanel } from "./PlanSettingsPanel"; -import { ProgressTrackingCriteria, ITimelineItem, LoadingStatus } from "../../Contracts"; +import { ITimelineItem, LoadingStatus } from "../../Contracts"; import { AddItemPanel } from "./AddItemPanel"; import { Spinner, SpinnerSize } from "azure-devops-ui/Spinner"; import { Link } from "azure-devops-ui/Link"; @@ -25,6 +25,7 @@ import { DeletePlanDialog } from "./DeletePlanDialog"; import { MessageCard, MessageCardSeverity } from "azure-devops-ui/MessageCard"; import { PortfolioTelemetry } from "../../Common/Utilities/Telemetry"; import { ExtendedSinglePlanTelemetry } from "../../Models/TelemetryModels"; +import { UserSettings } from "../../Models/UserSettingsDataModels"; interface IPlanPageMappedProps { plan: PortfolioPlanningMetadata; @@ -32,7 +33,6 @@ interface IPlanPageMappedProps { teamNames: string[]; epicIds: { [epicId: number]: number }; selectedItem: ITimelineItem; - progressTrackingCriteria: ProgressTrackingCriteria; addItemPanelOpen: boolean; planSettingsPanelOpen: boolean; exceptionMessage: string; @@ -40,6 +40,7 @@ interface IPlanPageMappedProps { isNewPlanExperience: boolean; deletePlanDialogHidden: boolean; planTelemetry: ExtendedSinglePlanTelemetry; + userSettings: UserSettings; } export type IPlanPageProps = IPlanPageMappedProps & typeof Actions; @@ -143,8 +144,9 @@ export default class PlanPage extends React.Component { this.props.onTogglePlanSettingsPanelOpen(false); }} @@ -186,15 +188,15 @@ function mapStateToProps(state: IPortfolioPlanningState): IPlanPageMappedProps { projectNames: getProjectNames(state), teamNames: getTeamNames(state), epicIds: getEpicIds(state.epicTimelineState), - selectedItem: getSelectedItem(state.epicTimelineState), - progressTrackingCriteria: state.epicTimelineState.progressTrackingCriteria, + selectedItem: getSelectedItem(state.epicTimelineState, state.planDirectoryState.userSettings), addItemPanelOpen: state.epicTimelineState.addItemsPanelOpen, planSettingsPanelOpen: state.epicTimelineState.planSettingsPanelOpen, exceptionMessage: state.epicTimelineState.exceptionMessage, planLoadingStatus: state.epicTimelineState.planLoadingStatus, isNewPlanExperience: state.epicTimelineState.isNewPlanExperience, deletePlanDialogHidden: state.epicTimelineState.deletePlanDialogHidden, - planTelemetry: getPlanExtendedTelemetry(state.epicTimelineState) + planTelemetry: getPlanExtendedTelemetry(state.epicTimelineState), + userSettings: state.planDirectoryState.userSettings }; } @@ -204,9 +206,13 @@ const Actions = { resetPlanState: EpicTimelineActions.resetPlanState, onOpenAddItemPanel: EpicTimelineActions.openAddItemPanel, onToggleProgressTrackingCriteria: EpicTimelineActions.toggleProgressTrackingCriteria, + onToggleTimelineItemRollupCriteria: EpicTimelineActions.toggleTimelineRollupCriteria, onCloseAddItemPanel: EpicTimelineActions.closeAddItemPanel, onAddItems: EpicTimelineActions.addItems, onTogglePlanSettingsPanelOpen: EpicTimelineActions.togglePlanSettingsPanelOpen, + + // TODO Need another action when the panel is closed. + // Check if settings changed, and reload timeline if needed. toggleDeletePlanDialogHidden: EpicTimelineActions.toggleDeletePlanDialogHidden, dismissErrorMessageCard: EpicTimelineActions.dismissErrorMessageCard }; diff --git a/src/PortfolioPlanning/Components/Plan/PlanSettingsPanel.tsx b/src/PortfolioPlanning/Components/Plan/PlanSettingsPanel.tsx index 3847a03..06f2091 100644 --- a/src/PortfolioPlanning/Components/Plan/PlanSettingsPanel.tsx +++ b/src/PortfolioPlanning/Components/Plan/PlanSettingsPanel.tsx @@ -2,21 +2,17 @@ import * as React from "react"; import "./PlanSettingsPanel.scss"; import { Panel } from "azure-devops-ui/Panel"; import { ComboBox } from "office-ui-fabric-react/lib/ComboBox"; -import { ProgressTrackingCriteria } from "../../Contracts"; +import { UserSettings } from "../../Models/UserSettingsDataModels"; +import { ProgressTrackingUserSetting, RollupHierachyUserSetting } from "../../Contracts"; export interface IPlanSettingsProps { - progressTrackingCriteria: ProgressTrackingCriteria; - onProgressTrackingCriteriaChanged: (criteria: ProgressTrackingCriteria) => void; + userSettings: UserSettings; + onProgressTrackingCriteriaChanged: (criteria: ProgressTrackingUserSetting.Options) => void; + onTimelineItemRollupChanged: (criteria: RollupHierachyUserSetting.Options) => void; onClosePlanSettingsPanel: () => void; } export const PlanSettingsPanel = (props: IPlanSettingsProps) => { - const completedCountKey = "completedCount"; - const effortKey = "effort"; - - const selectedProgressCriteriaKey = - props.progressTrackingCriteria === ProgressTrackingCriteria.CompletedCount ? completedCountKey : effortKey; - return (
@@ -24,28 +20,43 @@ export const PlanSettingsPanel = (props: IPlanSettingsProps) => {
Track Progress Using:
{ - switch (item.key) { - case completedCountKey: - props.onProgressTrackingCriteriaChanged(ProgressTrackingCriteria.CompletedCount); - break; - case effortKey: - props.onProgressTrackingCriteriaChanged(ProgressTrackingCriteria.Effort); - break; + props.onProgressTrackingCriteriaChanged(item.key as ProgressTrackingUserSetting.Options); + }} + /> +
+
+
Track Progress Using:
+ { + props.onTimelineItemRollupChanged(item.key as RollupHierachyUserSetting.Options); }} />
diff --git a/src/PortfolioPlanning/Components/Plan/PlanTimeline.tsx b/src/PortfolioPlanning/Components/Plan/PlanTimeline.tsx index b8472d7..4ec49e3 100644 --- a/src/PortfolioPlanning/Components/Plan/PlanTimeline.tsx +++ b/src/PortfolioPlanning/Components/Plan/PlanTimeline.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as moment from "moment"; -import { ITimelineGroup, ITimelineItem, ITeam, ProgressTrackingCriteria, IProject } from "../../Contracts"; +import { ITimelineGroup, ITimelineItem, ITeam, IProject } from "../../Contracts"; import Timeline, { TimelineHeaders, DateHeader } from "react-calendar-timeline"; import "./PlanTimeline.scss"; import { IPortfolioPlanningState } from "../../Redux/Contracts"; @@ -26,6 +26,7 @@ import { MenuButton } from "azure-devops-ui/Menu"; import { IconSize } from "azure-devops-ui/Icon"; import { DetailsDialog } from "./DetailsDialog"; import { ConnectedDependencyPanel } from "./DependencyPanel"; +import { UserSettings } from "../../Models/UserSettingsDataModels"; const day = 60 * 60 * 24 * 1000; const week = day * 7; @@ -52,8 +53,8 @@ interface IPlanTimelineMappedProps { planOwner: IdentityRef; exceptionMessage: string; setDatesDialogHidden: boolean; - progressTrackingCriteria: ProgressTrackingCriteria; projects: { [projectIdKey: string]: IProject }; + userSettings: UserSettings; } interface IPlanTimelineState { @@ -133,7 +134,7 @@ export class PlanTimeline extends React.Component this.setState({ dependencyPanelOpen: false })} /> ); @@ -565,13 +566,13 @@ function mapStateToProps(state: IPortfolioPlanningState): IPlanTimelineMappedPro projectNames: getProjectNames(state), teamNames: getTeamNames(state), teams: state.epicTimelineState.teams, - items: getTimelineItems(state.epicTimelineState), + items: getTimelineItems(state.epicTimelineState, state.planDirectoryState.userSettings), selectedItemId: state.epicTimelineState.selectedItemId, planOwner: getSelectedPlanOwner(state), exceptionMessage: state.epicTimelineState.exceptionMessage, setDatesDialogHidden: state.epicTimelineState.setDatesDialogHidden, - progressTrackingCriteria: state.epicTimelineState.progressTrackingCriteria, - projects: getIndexedProjects(state.epicTimelineState) + projects: getIndexedProjects(state.epicTimelineState), + userSettings: state.planDirectoryState.userSettings }; } diff --git a/src/PortfolioPlanning/Contracts.ts b/src/PortfolioPlanning/Contracts.ts index 49047f5..ef67a93 100644 --- a/src/PortfolioPlanning/Contracts.ts +++ b/src/PortfolioPlanning/Contracts.ts @@ -112,9 +112,43 @@ export interface ITimelineItem { canMove: boolean; } -export enum ProgressTrackingCriteria { - CompletedCount = "Completed Count", - Effort = "Effort" +export interface IUseSettingKeyTextPair { + Key: ProgressTrackingUserSetting.Options | RollupHierachyUserSetting.Options; + Text: string; +} + +export namespace ProgressTrackingUserSetting { + export enum Options { + CompletedCount = "completedcount", + Effort = "effort" + } + + export const CompletedCount: IUseSettingKeyTextPair = { + Key: Options.CompletedCount, + Text: "Completed Count" + }; + + export const Effort: IUseSettingKeyTextPair = { + Key: Options.Effort, + Text: "Effort" + }; +} + +export namespace RollupHierachyUserSetting { + export enum Options { + Children = "children", + Descendants = "descendants" + } + + export const Children: IUseSettingKeyTextPair = { + Key: Options.Descendants, + Text: "Children" + }; + + export const Descendants: IUseSettingKeyTextPair = { + Key: Options.Descendants, + Text: "Descendants" + }; } export enum LoadingStatus { diff --git a/src/PortfolioPlanning/Models/UserSettingsDataModels.ts b/src/PortfolioPlanning/Models/UserSettingsDataModels.ts new file mode 100644 index 0000000..0a1e082 --- /dev/null +++ b/src/PortfolioPlanning/Models/UserSettingsDataModels.ts @@ -0,0 +1,13 @@ +import { RollupHierachyUserSetting, ProgressTrackingUserSetting } from "../Contracts"; + +export class StorageConstants { + public static COLLECTION_NAME: string = "UserSettings"; + public static CURRENT_USER_SETTINGS_SCHEMA_VERSION: number = 1; +} + +export interface UserSettings { + Schema: number; + id: string; + ProgressTrackingOption: ProgressTrackingUserSetting.Options; + TimelineItemRollup: RollupHierachyUserSetting.Options; +} diff --git a/src/PortfolioPlanning/Redux/Actions/EpicTimelineActions.ts b/src/PortfolioPlanning/Redux/Actions/EpicTimelineActions.ts index a3603a1..e250149 100644 --- a/src/PortfolioPlanning/Redux/Actions/EpicTimelineActions.ts +++ b/src/PortfolioPlanning/Redux/Actions/EpicTimelineActions.ts @@ -1,10 +1,11 @@ import { createAction, ActionsUnion } from "../Helpers"; import { - ProgressTrackingCriteria, + ProgressTrackingUserSetting, IAddItems, IRemoveItem, LoadingStatus, - IProjectConfiguration + IProjectConfiguration, + RollupHierachyUserSetting } from "../../Contracts"; import moment = require("moment"); import { PortfolioPlanningFullContentQueryResult } from "../../Models/PortfolioPlanningQueryModels"; @@ -24,6 +25,7 @@ export const enum EpicTimelineActionTypes { AddItems = "EpicTimeline/AddItems", RemoveItems = "EpicTimeline/RemoveItems", ToggleProgressTrackingCriteria = "EpicTimeline/ToggleProgressTrackingCriteria", + ToggleTimelineRollupCriteria = "EpicTimeline/ToggleTimelineRollupCriteria", ToggleLoadingStatus = "EpicTimeline/ToggleLoadingStatus", ResetPlanState = "EpicTimeline/ResetPlanState", TogglePlanSettingsPanelOpen = "EpicTimeline/TogglePlanSettingsPanelOpen", @@ -74,10 +76,22 @@ export const EpicTimelineActions = { PortfolioTelemetry.getInstance().TrackAction(EpicTimelineActionTypes.RemoveItems); return createAction(EpicTimelineActionTypes.RemoveItems, itemToRemove); }, - toggleProgressTrackingCriteria: (criteria: ProgressTrackingCriteria) => - createAction(EpicTimelineActionTypes.ToggleProgressTrackingCriteria, { + toggleProgressTrackingCriteria: (criteria: ProgressTrackingUserSetting.Options) => { + PortfolioTelemetry.getInstance().TrackAction(EpicTimelineActionTypes.ToggleProgressTrackingCriteria, { + ["Value"]: criteria + }); + return createAction(EpicTimelineActionTypes.ToggleProgressTrackingCriteria, { criteria - }), + }); + }, + toggleTimelineRollupCriteria: (criteria: RollupHierachyUserSetting.Options) => { + PortfolioTelemetry.getInstance().TrackAction(EpicTimelineActionTypes.ToggleTimelineRollupCriteria, { + ["Value"]: criteria + }); + return createAction(EpicTimelineActionTypes.ToggleTimelineRollupCriteria, { + criteria + }); + }, toggleLoadingStatus: (status: LoadingStatus) => createAction(EpicTimelineActionTypes.ToggleLoadingStatus, { status }), resetPlanState: () => createAction(EpicTimelineActionTypes.ResetPlanState), diff --git a/src/PortfolioPlanning/Redux/Actions/PlanDirectoryActions.ts b/src/PortfolioPlanning/Redux/Actions/PlanDirectoryActions.ts index b7b2756..e51ef66 100644 --- a/src/PortfolioPlanning/Redux/Actions/PlanDirectoryActions.ts +++ b/src/PortfolioPlanning/Redux/Actions/PlanDirectoryActions.ts @@ -1,6 +1,7 @@ import { createAction, ActionsUnion } from "../Helpers"; import { PortfolioPlanningDirectory, PortfolioPlanning } from "../../Models/PortfolioPlanningQueryModels"; import { PortfolioTelemetry } from "../../Common/Utilities/Telemetry"; +import { UserSettings } from "../../Models/UserSettingsDataModels"; export const enum PlanDirectoryActionTypes { Initialize = "PlanDirectory/Initialize", @@ -16,8 +17,8 @@ export const enum PlanDirectoryActionTypes { } export const PlanDirectoryActions = { - initialize: (directoryData: PortfolioPlanningDirectory) => - createAction(PlanDirectoryActionTypes.Initialize, { directoryData }), + initialize: (directoryData: PortfolioPlanningDirectory, userSettings: UserSettings) => + createAction(PlanDirectoryActionTypes.Initialize, { directoryData, userSettings }), createPlan: (name: string, description: string) => { PortfolioTelemetry.getInstance().TrackAction(PlanDirectoryActionTypes.CreatePlan); return createAction(PlanDirectoryActionTypes.CreatePlan, { @@ -43,7 +44,7 @@ export const PlanDirectoryActions = { PortfolioTelemetry.getInstance().TrackAction(PlanDirectoryActionTypes.DeletePlan); return createAction(PlanDirectoryActionTypes.DeletePlan, { id }); }, - updateProjectsAndTeamsMetadata: (planId:string, projectNames: string[], teamNames: string[]) => + updateProjectsAndTeamsMetadata: (planId: string, projectNames: string[], teamNames: string[]) => createAction(PlanDirectoryActionTypes.UpdateProjectsAndTeamsMetadata, { planId, projectNames, teamNames }), toggleSelectedPlanId: (id: string) => { PortfolioTelemetry.getInstance().TrackAction(PlanDirectoryActionTypes.ToggleSelectedPlanId); diff --git a/src/PortfolioPlanning/Redux/Contracts.ts b/src/PortfolioPlanning/Redux/Contracts.ts index de12d9c..48ff14c 100644 --- a/src/PortfolioPlanning/Redux/Contracts.ts +++ b/src/PortfolioPlanning/Redux/Contracts.ts @@ -1,6 +1,7 @@ -import { IProject, IWorkItem, ProgressTrackingCriteria, ITeam, LoadingStatus } from "../Contracts"; +import { IProject, IWorkItem, ITeam, LoadingStatus } from "../Contracts"; import { PortfolioPlanningMetadata } from "../Models/PortfolioPlanningQueryModels"; import { ExtendedSinglePlanTelemetry } from "../Models/TelemetryModels"; +import { UserSettings } from "../Models/UserSettingsDataModels"; export interface IPortfolioPlanningState { planDirectoryState: IPlanDirectoryState; @@ -18,10 +19,10 @@ export interface IEpicTimelineState { setDatesDialogHidden: boolean; planSettingsPanelOpen: boolean; selectedItemId: number; - progressTrackingCriteria: ProgressTrackingCriteria; isNewPlanExperience: boolean; deletePlanDialogHidden: boolean; planTelemetry: ExtendedSinglePlanTelemetry; + userSettings: UserSettings; } export interface IPlanDirectoryState { @@ -30,4 +31,5 @@ export interface IPlanDirectoryState { selectedPlanId: string; newPlanDialogVisible: boolean; plans: PortfolioPlanningMetadata[]; + userSettings: UserSettings; } diff --git a/src/PortfolioPlanning/Redux/Reducers/EpicTimelineReducer.ts b/src/PortfolioPlanning/Redux/Reducers/EpicTimelineReducer.ts index 4d4fe3a..082ccd8 100644 --- a/src/PortfolioPlanning/Redux/Reducers/EpicTimelineReducer.ts +++ b/src/PortfolioPlanning/Redux/Reducers/EpicTimelineReducer.ts @@ -6,9 +6,10 @@ import { PortfolioItemDeletedAction } from "../Actions/EpicTimelineActions"; import produce from "immer"; -import { ProgressTrackingCriteria, LoadingStatus, IWorkItemIcon } from "../../Contracts"; +import { LoadingStatus, IWorkItemIcon } from "../../Contracts"; import { MergeType } from "../../Models/PortfolioPlanningQueryModels"; import { defaultIProjectComparer, defaultIWorkItemComparer } from "../../Common/Utilities/Comparers"; +import { UserSettingsDataService } from "../../Common/Services/UserSettingsDataService"; export function epicTimelineReducer(state: IEpicTimelineState, action: EpicTimelineActions): IEpicTimelineState { return produce(state || getDefaultState(), (draft: IEpicTimelineState) => { @@ -79,7 +80,7 @@ export function epicTimelineReducer(state: IEpicTimelineState, action: EpicTimel return handlePortfolioItemDeleted(state, action as PortfolioItemDeletedAction); } case EpicTimelineActionTypes.ToggleProgressTrackingCriteria: { - draft.progressTrackingCriteria = action.payload.criteria; + draft.userSettings.ProgressTrackingOption = action.payload.criteria; break; } case EpicTimelineActionTypes.ToggleLoadingStatus: { @@ -142,10 +143,10 @@ export function getDefaultState(): IEpicTimelineState { setDatesDialogHidden: true, planSettingsPanelOpen: false, selectedItemId: null, - progressTrackingCriteria: ProgressTrackingCriteria.CompletedCount, isNewPlanExperience: false, deletePlanDialogHidden: true, - planTelemetry: null + planTelemetry: null, + userSettings: UserSettingsDataService.getInstance().getDefaultUserSettings() }; } diff --git a/src/PortfolioPlanning/Redux/Reducers/PlanDirectoryReducer.ts b/src/PortfolioPlanning/Redux/Reducers/PlanDirectoryReducer.ts index 53e2a67..20123f8 100644 --- a/src/PortfolioPlanning/Redux/Reducers/PlanDirectoryReducer.ts +++ b/src/PortfolioPlanning/Redux/Reducers/PlanDirectoryReducer.ts @@ -3,17 +3,19 @@ import produce from "immer"; import { PlanDirectoryActions, PlanDirectoryActionTypes } from "../Actions/PlanDirectoryActions"; import { LoadingStatus } from "../../Contracts"; import { caseInsensitiveComparer } from "../../Common/Utilities/String"; +import { UserSettingsDataService } from "../../Common/Services/UserSettingsDataService"; export function planDirectoryReducer(state: IPlanDirectoryState, action: PlanDirectoryActions): IPlanDirectoryState { return produce(state || getDefaultState(), (draft: IPlanDirectoryState) => { switch (action.type) { case PlanDirectoryActionTypes.Initialize: { - const { directoryData } = action.payload; + const { directoryData, userSettings } = action.payload; draft.directoryLoadingStatus = LoadingStatus.Loaded; draft.exceptionMessage = directoryData.exceptionMessage; draft.plans = directoryData.entries; draft.plans.sort((a, b) => caseInsensitiveComparer(a.name, b.name)); + draft.userSettings = userSettings; break; } @@ -83,6 +85,7 @@ export function getDefaultState(): IPlanDirectoryState { exceptionMessage: "", selectedPlanId: undefined, plans: [], - newPlanDialogVisible: false + newPlanDialogVisible: false, + userSettings: UserSettingsDataService.getInstance().getDefaultUserSettings() }; } diff --git a/src/PortfolioPlanning/Redux/Sagas/EpicTimelineSaga.ts b/src/PortfolioPlanning/Redux/Sagas/EpicTimelineSaga.ts index 88c27a7..20b5ac0 100644 --- a/src/PortfolioPlanning/Redux/Sagas/EpicTimelineSaga.ts +++ b/src/PortfolioPlanning/Redux/Sagas/EpicTimelineSaga.ts @@ -17,6 +17,9 @@ import { PlanDirectoryActionTypes, PlanDirectoryActions } from "../Actions/PlanD import { LoadPortfolio } from "./LoadPortfolio"; import { ActionsOfType } from "../Helpers"; import { SetDefaultDatesForWorkItems, saveDatesToServer } from "./DefaultDateUtil"; +import { UserSettingsDataService } from "../../Common/Services/UserSettingsDataService"; +import { UserSettings } from "../../Models/UserSettingsDataModels"; +import { getUserSettings } from "../Selectors/PlanDirectorySelectors"; export function* epicTimelineSaga(): SagaIterator { yield takeEvery(EpicTimelineActionTypes.UpdateDates, onUpdateDates); @@ -24,11 +27,45 @@ export function* epicTimelineSaga(): SagaIterator { yield takeEvery(EpicTimelineActionTypes.AddItems, onAddEpics); yield takeEvery(PlanDirectoryActionTypes.ToggleSelectedPlanId, onToggleSelectedPlanId); yield takeEvery(EpicTimelineActionTypes.RemoveItems, onRemoveEpic); + yield takeEvery(EpicTimelineActionTypes.ToggleProgressTrackingCriteria, onProgressTrackingCriteria); + yield takeEvery(EpicTimelineActionTypes.ToggleTimelineRollupCriteria, onTimelineRollupCriteria); } -function* onUpdateDates( - action: ActionsOfType +function* onProgressTrackingCriteria( + action: ActionsOfType ): SagaIterator { + const newOption = action.payload.criteria; + try { + const dataService = UserSettingsDataService.getInstance(); + const userSettings: UserSettings = yield effects.select(getUserSettings); + + userSettings.ProgressTrackingOption = newOption; + + yield effects.call([dataService, dataService.updateUserSettings], userSettings); + } catch (exception) { + console.error(exception); + yield effects.put(EpicTimelineActions.handleGeneralException(exception)); + } +} + +function* onTimelineRollupCriteria( + action: ActionsOfType +): SagaIterator { + const newOption = action.payload.criteria; + try { + const dataService = UserSettingsDataService.getInstance(); + const userSettings: UserSettings = yield effects.select(getUserSettings); + + userSettings.TimelineItemRollup = newOption; + + yield effects.call([dataService, dataService.updateUserSettings], userSettings); + } catch (exception) { + console.error(exception); + yield effects.put(EpicTimelineActions.handleGeneralException(exception)); + } +} + +function* onUpdateDates(action: ActionsOfType): SagaIterator { const epicId = action.payload.epicId; try { yield effects.call(saveDatesToServer, epicId); @@ -51,6 +88,7 @@ function* onShiftEpic(action: ActionsOfType): SagaIterator { try { yield effects.put(EpicTimelineActions.toggleLoadingStatus(LoadingStatus.NotLoaded)); diff --git a/src/PortfolioPlanning/Redux/Sagas/PlanDirectorySaga.ts b/src/PortfolioPlanning/Redux/Sagas/PlanDirectorySaga.ts index cf702c1..0de272d 100644 --- a/src/PortfolioPlanning/Redux/Sagas/PlanDirectorySaga.ts +++ b/src/PortfolioPlanning/Redux/Sagas/PlanDirectorySaga.ts @@ -14,6 +14,8 @@ import { getProjectNames, getTeamNames, getExceptionMessage } from "../Selectors import { PortfolioTelemetry } from "../../Common/Utilities/Telemetry"; import { LaunchWorkItemFormActionType } from "../../../Common/redux/actions/launchWorkItemForm"; import { launchWorkItemFormSaga } from "./launchWorkItemFromSaga"; +import { UserSettings } from "../../Models/UserSettingsDataModels"; +import { UserSettingsDataService } from "../../Common/Services/UserSettingsDataService"; export function* planDirectorySaga(): SagaIterator { yield effects.call(initializePlanDirectory); @@ -21,16 +23,18 @@ export function* planDirectorySaga(): SagaIterator { yield effects.takeEvery(PlanDirectoryActionTypes.DeletePlan, deletePlan); yield effects.takeEvery(EpicTimelineActionTypes.PortfolioItemsReceived, updateProjectsAndTeamsMetadata); yield effects.takeEvery(EpicTimelineActionTypes.PortfolioItemDeleted, updateProjectsAndTeamsMetadata); - yield effects.takeEvery(LaunchWorkItemFormActionType, launchWorkItemFormSaga); + yield effects.takeEvery(LaunchWorkItemFormActionType, launchWorkItemFormSaga); } export function* initializePlanDirectory(): SagaIterator { try { const service = PortfolioPlanningDataService.getInstance(); + const settingsService = UserSettingsDataService.getInstance(); const allPlans: PortfolioPlanningDirectory = yield effects.call([service, service.GetAllPortfolioPlans]); + const userSettings: UserSettings = yield effects.call([settingsService, settingsService.getUserSettings]); - yield effects.put(PlanDirectoryActions.initialize(allPlans)); + yield effects.put(PlanDirectoryActions.initialize(allPlans, userSettings)); } catch (exception) { console.error(exception); yield effects.put(PlanDirectoryActions.handleGeneralException(exception)); diff --git a/src/PortfolioPlanning/Redux/Selectors/EpicTimelineSelectors.ts b/src/PortfolioPlanning/Redux/Selectors/EpicTimelineSelectors.ts index 1e43f45..99bc35e 100644 --- a/src/PortfolioPlanning/Redux/Selectors/EpicTimelineSelectors.ts +++ b/src/PortfolioPlanning/Redux/Selectors/EpicTimelineSelectors.ts @@ -1,7 +1,8 @@ import { IEpicTimelineState, IPortfolioPlanningState } from "../Contracts"; -import { IProject, IWorkItem, ITimelineGroup, ITimelineItem, ProgressTrackingCriteria } from "../../Contracts"; +import { IProject, IWorkItem, ITimelineGroup, ITimelineItem, ProgressTrackingUserSetting } from "../../Contracts"; import moment = require("moment"); import { ExtendedSinglePlanTelemetry } from "../../Models/TelemetryModels"; +import { UserSettings } from "../../Models/UserSettingsDataModels"; export function getProjects(state: IEpicTimelineState): IProject[] { return state.projects; @@ -53,13 +54,13 @@ export function getEpicIds(state: IEpicTimelineState): { [epicId: number]: numbe return result; } -export function getTimelineItems(state: IEpicTimelineState): ITimelineItem[] { +export function getTimelineItems(state: IEpicTimelineState, userSettings: UserSettings): ITimelineItem[] { return state.epics.map(epic => { let completed: number; let total: number; let progress: number; - if (state.progressTrackingCriteria === ProgressTrackingCriteria.CompletedCount) { + if (userSettings.ProgressTrackingOption === ProgressTrackingUserSetting.CompletedCount.Key) { completed = epic.completedCount; total = epic.totalCount; progress = epic.countProgress; @@ -90,8 +91,8 @@ export function getTimelineItems(state: IEpicTimelineState): ITimelineItem[] { }); } -export function getSelectedItem(state: IEpicTimelineState): ITimelineItem { - return getTimelineItems(state).find(item => item.id === state.selectedItemId); +export function getSelectedItem(state: IEpicTimelineState, userSettings: UserSettings): ITimelineItem { + return getTimelineItems(state, userSettings).find(item => item.id === state.selectedItemId); } export function getMessage(state: IEpicTimelineState): string { @@ -117,10 +118,6 @@ export function getAddEpicPanelOpen(state: IEpicTimelineState): boolean { return state.addItemsPanelOpen; } -export function getProgressTrackingCriteria(state: IEpicTimelineState): ProgressTrackingCriteria { - return state.progressTrackingCriteria; -} - export function getExceptionMessage(state: IPortfolioPlanningState): string { return state.epicTimelineState.exceptionMessage; } diff --git a/src/PortfolioPlanning/Redux/Selectors/PlanDirectorySelectors.ts b/src/PortfolioPlanning/Redux/Selectors/PlanDirectorySelectors.ts index b9f4ab3..75e0206 100644 --- a/src/PortfolioPlanning/Redux/Selectors/PlanDirectorySelectors.ts +++ b/src/PortfolioPlanning/Redux/Selectors/PlanDirectorySelectors.ts @@ -2,6 +2,7 @@ import { IPortfolioPlanningState, IPlanDirectoryState } from "../Contracts"; import { IdentityRef } from "VSS/WebApi/Contracts"; import { PortfolioPlanningMetadata } from "../../Models/PortfolioPlanningQueryModels"; import { SinglePlanTelemetry } from "../../Models/TelemetryModels"; +import { UserSettings } from "../../Models/UserSettingsDataModels"; export function getSelectedPlanId(state: IPortfolioPlanningState): string { return state.planDirectoryState.selectedPlanId; @@ -27,3 +28,7 @@ export function getPlansTelemetry(state: IPlanDirectoryState): SinglePlanTelemet }; }); } + +export function getUserSettings(state: IPlanDirectoryState): UserSettings { + return state.userSettings; +}