src/component/panel/GanttDetailsPanel.tsx (279 lines of code) (raw):
import * as React from 'react';
import { useEffect } from 'react';
import * as SDK from 'azure-devops-extension-sdk';
import {
CommonServiceIds,
getClient,
IProjectPageService,
} from 'azure-devops-extension-api';
import { CoreRestClient, WebApiTeam } from 'azure-devops-extension-api/Core';
import { ContentSize } from "azure-devops-ui/Callout";
import { CustomHeader, HeaderTitle, HeaderTitleArea, HeaderTitleRow, TitleSize } from "azure-devops-ui/Header";
import { CustomPanel, PanelCloseButton, PanelContent, PanelFooter } from "azure-devops-ui/Panel";
import { TextField, TextFieldWidth } from "azure-devops-ui/TextField";
import { FormItem } from "azure-devops-ui/FormItem";
import { Button } from "azure-devops-ui/Button";
import { ButtonGroup } from "azure-devops-ui/ButtonGroup";
import { Dropdown, DropdownExpandableButton } from "azure-devops-ui/Dropdown";
import { Observer } from "azure-devops-ui/Observer";
import { ObservableArray, ObservableValue } from "azure-devops-ui/Core/Observable";
import { IListBoxItem } from "azure-devops-ui/ListBox";
import { DropdownMultiSelection } from "azure-devops-ui/Utilities/DropdownSelection";
import { BacklogLevelConfiguration, WorkRestClient } from "azure-devops-extension-api/Work";
import { BacklogItem, ExtensionManagementUtil, GanttHubDocument, TeamItem } from "../../service/helper";
export interface GanttPanelProps {
itemToEdit?: GanttHubDocument;
isChecked: boolean;
onDismiss: (isChecked: boolean) => void;
}
export const GanttDetailsPanel: React.FC<GanttPanelProps> = ({
itemToEdit,
isChecked,
onDismiss,
}) => {
const teamSelection = new DropdownMultiSelection();
const team = new ObservableArray<IListBoxItem<TeamItem>>();
const teamMap = new Map<string, TeamItem>();
const backlogSelection = new DropdownMultiSelection();
const backlog = new ObservableArray<IListBoxItem<BacklogItem>>();
const backlogMap = new Map<string, BacklogItem>();
const nameObservable = new ObservableValue<string | undefined>(itemToEdit ? itemToEdit.name : "");
const descriptionObservable = new ObservableValue<string | undefined>(itemToEdit ? itemToEdit?.description : "");
const ganttNameHasError = new ObservableValue<boolean | undefined>(itemToEdit == undefined);
const teamSelectHasError = new ObservableValue<boolean | undefined>(true);
const backlogSelectHasError = new ObservableValue<boolean | undefined>(true);
useEffect(() => {
(async () => {
await SDK.ready();
const projectService = await SDK.getService<IProjectPageService>(
CommonServiceIds.ProjectPageService
);
const project = await projectService.getProject();
if (project) {
const workRestClient = getClient(WorkRestClient);
const coreClient = getClient(CoreRestClient);
const teams = await coreClient.getTeams(project.name);
team.push(...teams.map(({ id, name }) => ({ id, text: name, data: { id, name } } as IListBoxItem<TeamItem>)));
itemToEdit ? markSelectedTeams(teams) : selectAllTeams(teams)
teamSelectHasError.value = teamSelection.selectedCount === 0;
const backlogConfigurations = await workRestClient.getBacklogConfigurations({
project: project.name,
projectId: project.id,
team: teams[0].name, // todo: assumption backlog config will be the same for all teams - maybe wrong
teamId: teams[0].id,
}).then(({ portfolioBacklogs = [] as BacklogLevelConfiguration[], requirementBacklog = {} as BacklogLevelConfiguration, taskBacklog = {} as BacklogLevelConfiguration }) => [...portfolioBacklogs, requirementBacklog, taskBacklog]
.reduce((acc, next) => {
const { rank = 0, workItemTypes = [] } = next;
return [...acc, ...workItemTypes.map(({ name }) => ({ rank, name }))]
}, [] as BacklogItem[]));
backlog.push(...backlogConfigurations
.sort(({ rank: r1 = 0 }, { rank: r2 = 0 }) => r2 - r1)
.map(t => ({ id: t.name, data: t, text: t.name })));
itemToEdit ? markSelectedBacklogs(backlogConfigurations) : selectAllBacklogs(backlogConfigurations)
backlogSelectHasError.value = backlogSelection.selectedCount === 0;
}
})();
}, [])
const selectAllTeams = (teams: WebApiTeam[]): void => {
teams.forEach(team => teamMap.set(team.id, team));
teamSelection.select(0, teams.length, true, true);
}
const markSelectedTeams = (teams: WebApiTeam[]): void => {
const teamIds = teams.map(t => t.id);
itemToEdit?.options.teams
.forEach(team => {
teamMap.set(team.id, team);
teamIds.some(t => t === team.id) && teamSelection?.select(teamIds.indexOf(team.id), 1, true, true);
})
}
const selectAllBacklogs = (backlogConfigurations: BacklogItem[]): void => {
backlogConfigurations.forEach(backlog => backlogMap.set(backlog.name, backlog));
backlogSelection.select(0, backlogConfigurations.length, true, true);
}
const markSelectedBacklogs = (backlogConfigurations: BacklogItem[]): void => {
backlogConfigurations.forEach((backlog, index) => {
if (itemToEdit?.options.backlog.some(b => b.name == backlog.name)) {
backlogMap.set(backlog.name, backlog)
backlogSelection.select(index, 1, true, true);
}
})
}
const onSave = React.useCallback(async (name: string, description: string, teams: TeamItem[], backlog: BacklogItem[]) => {
const createdBy = SDK.getUser().displayName;
const now = new Date().toISOString();
const itemDetails = {
name,
description,
createdBy,
lastModifiedBy: createdBy,
createdDate: itemToEdit?.createdDate || now,
lastModifiedDate: now,
options: {
teams,
backlog
},
__etag: (itemToEdit ? itemToEdit.__etag : undefined),
id: (itemToEdit ? itemToEdit.id : undefined)
};
const item = itemToEdit
? await ExtensionManagementUtil.updateItem(itemDetails).handle({} as GanttHubDocument, "Unable to update Gantt board.", true)
: await ExtensionManagementUtil.createItem(itemDetails).handle({} as GanttHubDocument, "Unable to create new Gantt board.", true)
if (Object.keys(item).length !== 0) {
onDismiss(isChecked);
}
}, []);
return (
<CustomPanel
onDismiss={() => onDismiss(isChecked)}
size={ContentSize.Large}
>
<CustomHeader className="bolt-header-with-commandbar">
<HeaderTitleArea>
<HeaderTitleRow>
<HeaderTitle titleSize={TitleSize.Large} children={itemToEdit ? `Edit ${ itemToEdit.name}` : "New delivery gantt"} />
</HeaderTitleRow>
</HeaderTitleArea>
<PanelCloseButton
onDismiss={() => onDismiss(isChecked)}
/>
</CustomHeader>
<PanelContent>
<div className="flex-column flex-grow rhythm-vertical-24">
<div className="padding-horizontal-20 rhythm-vertical-16">
A delivery gantt shows you when work will be delivered across your teams.
</div>
<div className="padding-horizontal-20 rhythm-vertical-16">
<FormItem
label="Name*"
message="Custom Gantt name"
>
<TextField
value={nameObservable}
onChange={(e, newValue) => {
nameObservable.value = newValue;
ganttNameHasError.value = (nameObservable.value?.trim().length === 0);
}}
placeholder={itemToEdit ? itemToEdit.name : "Name"}
width={TextFieldWidth.auto}
maxLength={64}
required
/>
</FormItem>
</div>
<div className="padding-horizontal-20 rhythm-vertical-16">
<FormItem
label="Description"
message="Gantt description"
>
<TextField
ariaLabel="Aria label"
value={descriptionObservable}
onChange={(e, newValue) => (descriptionObservable.value = newValue)}
multiline
rows={4}
maxLength={255}
placeholder={itemToEdit ? itemToEdit.description : "Description"}
width={TextFieldWidth.auto}
/>
</FormItem>
</div>
<div className="padding-horizontal-20 rhythm-vertical-16">
<FormItem
label="Team*"
message="Team selection"
>
<Observer selection={teamSelection}>
{() => (
<Dropdown
className="scale-dropdown"
actions={[
{
className: "bolt-dropdown-action-right-button",
disabled: teamSelection.selectedCount === 0,
iconProps: { iconName: "Clear" },
text: "Clear",
onClick: () => {
teamSelection.clear();
teamMap.clear();
teamSelectHasError.value = true;
}
}
]}
items={team}
minCalloutWidth={300}
showFilterBox
renderExpandable={props => <DropdownExpandableButton style={{ width: 140 }} {...props} />}
onSelect={(_, { data }) => {
!teamMap.has(data!.id) && teamMap.set(data!.id, data!) || teamMap.delete(data!.id);
teamSelectHasError.value = teamSelection.selectedCount === 0;
}}
selection={teamSelection}
/>)}
</Observer>
</FormItem>
</div>
<div className="padding-horizontal-20 rhythm-vertical-16">
<FormItem
label="Backlog*"
message="Backlog selection"
>
<Observer selection={backlogSelection}>
{() => (
<Dropdown
className="scale-dropdown"
actions={[
{
className: "bolt-dropdown-action-right-button",
disabled: backlogSelection.selectedCount === 0,
iconProps: { iconName: "Clear" },
text: "Clear",
onClick: () => {
backlogSelection.clear();
backlogMap.clear();
backlogSelectHasError.value = true;
}
}
]}
items={backlog}
minCalloutWidth={300}
showFilterBox
renderExpandable={props => <DropdownExpandableButton style={{ width: 140 }} {...props} />}
onSelect={(_, { data }) => {
!backlogMap.has(data!.name) && backlogMap.set(data!.name, data!) || backlogMap.delete(data!.name);
backlogSelectHasError.value = backlogSelection.selectedCount === 0;
}}
selection={backlogSelection}
/>)}
</Observer>
</FormItem>
</div>
</div>
</PanelContent>
<PanelFooter>
<ButtonGroup className="bolt-panel-footer-buttons">
<Button
text="Cancel"
onClick={() => onDismiss(isChecked)}
/>
<Observer name={ganttNameHasError} teamSelect={teamSelectHasError} taskSelect={backlogSelectHasError}>
{() => (
<Button
text={itemToEdit ? "Update" : "Create"}
primary
disabled={ganttNameHasError.value || teamSelectHasError.value || backlogSelectHasError.value}
onClick={() => {
onSave(nameObservable.value!,
descriptionObservable.value!,
[...teamMap.values()],
[...backlogMap.values()],
);
}
}
/>
)}
</Observer>
</ButtonGroup>
</PanelFooter>
</CustomPanel>
);
}