src/app/issue-detail/timeline/timeline.component.ts
This component shows the full timeline with all its timeline events for a given issue.
selector | app-timeline |
styleUrls | ./timeline.component.scss |
templateUrl | ./timeline.component.html |
Properties |
|
Methods |
|
Inputs |
constructor(dataService: DataService)
|
||||||||
Service for handling API connection is required
Parameters :
|
issueId | |
Type : NodeId
|
|
The id of the corresponding issue for which the timeline is shown |
projectID | |
Type : string
|
|
The id of the project in which the issue is listed |
Private addSingleCoalesceItem | ||||||||||||||||
addSingleCoalesceItem(timelineItem: IssueTimelineItem, filter: ItemFilterFunction | undefined, coalesced: CoalescedTimelineItem[])
|
||||||||||||||||
Adds a single item to a list containing all coalesced timeline items.
Parameters :
Returns :
void
|
Private finishCoalescing | ||||||||||||
finishCoalescing(coalesceList: IssueTimelineItem[], coalesced: CoalescedTimelineItem[])
|
||||||||||||
Turns a list of timeline items into a coalesced timeline item, and adds them to a list of coalesced timeline items.
Parameters :
Returns :
void
|
makeCommentId | ||||
makeCommentId(node)
|
||||
Handles the id for a given node...
Parameters :
Returns :
NodeId
|
prepareTimelineItems | ||||||||
prepareTimelineItems(items: Map
|
||||||||
Prepares the timeline items (aka. the timeline events).
Parameters :
Returns :
void
|
requestTimelineItems |
requestTimelineItems()
|
Retrieves all timeline items (aka. timeline events) for the current issue. Use in ngAfterViewInit() lifecycle hook
Returns :
void
|
Private Static shouldStopCoalescing | ||||||||||||
shouldStopCoalescing(previousItem: IssueTimelineItem, nextItem: IssueTimelineItem)
|
||||||||||||
Check if a timeline item can be coalesced with another timeline item. This is the case if a) the user is the same and b) both items were created within the span of a minute
Parameters :
Returns :
boolean
True if both items can be coalesced |
Private userName | ||||||||
userName(item: IssueTimelineItem)
|
||||||||
Returns the name of the user that created a given timeline item (aka. timeline event) or just "Deleted User" in case the user no longer exists.
Parameters :
Returns :
any
Name of the timeline item creator. |
Static Readonly COALESCABLE_EVENTS |
Type : Map<string | ItemFilterFunction>
|
Default value : new Map([
[
'LabelledEvent',
(item) => {
return !!item.label;
}
],
[
'UnlabelledEvent',
(item) => {
return !!item.removedLabel;
}
],
[
'AddedToComponentEvent',
(item) => {
return !!item.component;
}
],
[
'RemovedFromComponentEvent',
(item) => {
return !!item.removedComponent;
}
],
[
'AddedToLocationEvent',
(item) => {
return !!item.location;
}
],
[
'RemovedFromLocationEvent',
(item) => {
return !!item.removedLocation;
}
],
[
'LinkEvent',
(item) => {
return !!item.linkedIssue;
}
],
[
'UnlinkEvent',
(item) => {
return !!item.removedLinkedIssue;
}
]
])
|
Events which need to be coalesced |
query |
Type : QueryComponent
|
Decorators :
@ViewChild(QueryComponent)
|
Component which is handling the query to the server |
Public timeFormatter |
Default value : new TimeFormatter()
|
provides functionality for time formatting for correct representation |
timelineItems |
Type : Array<CoalescedTimelineItem>
|
Default value : []
|
Already coalesced items for timeline representation |
Public timelineItems$ |
Type : DataList<IssueTimelineItem | >
|
Subscription for timelineitems |
import {AfterViewInit, Component, Input, ViewChild} from '@angular/core';
import {TimeFormatter} from '@app/issue-detail/time-formatter';
import {IssueTimelineItem} from '../../../generated/graphql-dgql';
import {DataList} from '@app/data-dgql/query';
import DataService from '@app/data-dgql';
import {ListType, NodeId, NodeType} from '@app/data-dgql/id';
import {QueryComponent} from '@app/utils/query-component/query.component';
/**
* This interface may contain in contrast to a normal timeline item several events in one item.
* Timeline items are coalesced because they are performed in close succession.
* @example
* label 1 and label 2 are added to an issue at the same time.
* Instead of representing them as to individual items/events, they will be represented as one item in the timeline.
*/
export interface CoalescedTimelineItem {
user: string;
type: string;
isCoalesced: boolean;
item: Array<IssueTimelineItem> | IssueTimelineItem;
time: string;
}
type ItemFilterFunction = (IssueTimelineItem) => boolean;
/**
* This component shows the full timeline
* with all its timeline events for a given issue.
*/
@Component({
selector: 'app-timeline',
templateUrl: './timeline.component.html',
styleUrls: ['./timeline.component.scss']
})
export class TimelineComponent implements AfterViewInit {
/**
* Events which need to be coalesced
*/
static readonly COALESCABLE_EVENTS: Map<string, ItemFilterFunction> = new Map([
[
'LabelledEvent',
(item) => {
return !!item.label;
}
],
[
'UnlabelledEvent',
(item) => {
return !!item.removedLabel;
}
],
[
'AddedToComponentEvent',
(item) => {
return !!item.component;
}
],
[
'RemovedFromComponentEvent',
(item) => {
return !!item.removedComponent;
}
],
[
'AddedToLocationEvent',
(item) => {
return !!item.location;
}
],
[
'RemovedFromLocationEvent',
(item) => {
return !!item.removedLocation;
}
],
[
'LinkEvent',
(item) => {
return !!item.linkedIssue;
}
],
[
'UnlinkEvent',
(item) => {
return !!item.removedLinkedIssue;
}
]
]);
/**
* provides functionality for time formatting for correct representation
*/
public timeFormatter = new TimeFormatter();
/**
* Already coalesced items for timeline representation
*/
timelineItems: Array<CoalescedTimelineItem> = [];
/**
* Subscription for timelineitems
*/
public timelineItems$: DataList<IssueTimelineItem, unknown>;
/**
* The id of the corresponding issue for which the timeline is shown
*/
@Input() issueId: NodeId;
/**
* The id of the project in which the issue is listed
*/
@Input() projectID: string;
/**
* Component which is handling the query to the server
*/
@ViewChild(QueryComponent) query: QueryComponent;
/**
* Check if a timeline item can be coalesced with another timeline item. This is the case if
* a) the user is the same and
* b) both items were created within the span of a minute
* @param previousItem The previous item
* @param nextItem The item to be coalesced with the previous item
* @returns True if both items can be coalesced
*/
private static shouldStopCoalescing(previousItem: IssueTimelineItem, nextItem: IssueTimelineItem): boolean {
return (
previousItem.createdBy.id !== previousItem.createdBy.id ||
Math.abs(Date.parse(nextItem.createdAt) - Date.parse(nextItem.createdAt)) > 60000
);
}
/**
* Service for handling API connection is required
* @param dataService handling api connection
*/
constructor(private dataService: DataService) {}
ngAfterViewInit(): void {
this.requestTimelineItems();
}
/**
* Retrieves all timeline items (aka. timeline events) for the current issue.
* Use in ngAfterViewInit() lifecycle hook
*/
requestTimelineItems(): void {
// gets an observable with all timeline items for thecurrent issue
this.timelineItems$ = this.dataService.getList({
node: this.issueId,
type: ListType.TimelineItems
});
// FIXME: decide on the count
this.timelineItems$.count = 99999;
this.query.listenTo(this.timelineItems$, (value) => {
this.prepareTimelineItems(value);
});
}
/**
* Prepares the timeline items (aka. the timeline events).
* @param items Timeline items to prepare.
*/
prepareTimelineItems(items: Map<string, IssueTimelineItem>): void {
let coalescingType: string = null;
let coalesceList = new Array<IssueTimelineItem>();
const coalesced: Array<CoalescedTimelineItem> = [];
for (const timelineItem of items.values()) {
const itemType: string = (timelineItem as any).__typename;
const filter = TimelineComponent.COALESCABLE_EVENTS.get(itemType);
let stopCoalescing = false;
// decides whether to stop coalescing
if (coalescingType) {
stopCoalescing = TimelineComponent.shouldStopCoalescing(coalesceList[0], timelineItem);
}
// case: the coalescing type equals the current item type
// or coalescing should stop
if (coalescingType !== itemType || stopCoalescing) {
// adds remaining items
this.finishCoalescing(coalesceList, coalesced);
coalesceList = [];
if (filter && filter(timelineItem)) {
coalescingType = itemType;
coalesceList.push(timelineItem);
} else {
coalescingType = null;
this.addSingleCoalesceItem(timelineItem, filter, coalesced);
}
continue;
} else if (coalescingType === null) {
this.addSingleCoalesceItem(timelineItem, filter, coalesced);
continue;
}
if (filter(timelineItem)) {
coalesceList.push(timelineItem);
}
}
// adds remaining items
this.finishCoalescing(coalesceList, coalesced);
this.timelineItems = coalesced;
}
/**
* Returns the name of the user
* that created a given timeline item (aka. timeline event)
* or just "Deleted User" in case the user no longer exists.
* @param item The given timeline item.
* @returns Name of the timeline item creator.
*/
private userName(item: IssueTimelineItem) {
// case: the timeline item's creator's name can be retrieved
if (item.createdBy) {
return item.createdBy.displayName;
}
return 'Deleted User';
}
/**
* Turns a list of timeline items into a coalesced timeline item, and adds them to a list of coalesced timeline items.
*
* @param coalesceList The list of timeline items
* @param coalesced The list of all coalesced timeline items
*/
private finishCoalescing(coalesceList: IssueTimelineItem[], coalesced: CoalescedTimelineItem[]): void {
if (coalesceList.length === 0) {
return;
}
const firstItem: any = coalesceList[0];
const itemType = firstItem.__typename;
const createdBy = this.userName(firstItem);
if (coalesceList.length > 1) {
// Combine multiple timeline items into one
coalesced.push({
type: itemType,
isCoalesced: true,
item: coalesceList,
user: createdBy,
time: coalesceList[0].createdAt
});
} else if (coalesceList.length === 1) {
// Wrap a single timeline item into a coalesced timeline item
coalesced.push({
type: itemType,
isCoalesced: false,
item: coalesceList[0],
user: createdBy,
time: coalesceList[0].createdAt
});
}
}
/**
* Adds a single item to a list containing all coalesced timeline items.
* @param timelineItem The given item.
* @param filter Filter used on the given item.
* @param coalesced The list of coalesced timeline items
*/
private addSingleCoalesceItem(
timelineItem: IssueTimelineItem,
filter: ItemFilterFunction | undefined,
coalesced: CoalescedTimelineItem[]
): void {
if (!filter || filter(timelineItem)) {
coalesced.push({
type: (timelineItem as any).__typename,
isCoalesced: false,
item: timelineItem,
user: this.userName(timelineItem),
time: timelineItem.createdAt
});
}
}
/**
* Handles the id for a given node...
*/
makeCommentId(node): NodeId {
return {type: NodeType.IssueComment, id: node.id};
}
}
<!--Timeline of a given issue showing all its events-->
<app-query-component errorMessage="Failed to load timeline items">
<ng-template appQueryBody>
<div class="container">
<div class="page-header"></div>
<ul class="timeline">
<li *ngFor="let timelineItem of timelineItems" [ngSwitch]="timelineItem.type" class="timeline-inverted">
<!--Icons used in the timeline-->
<div class="timeline-badge" *ngSwitchCase="'AddedToComponentEvent'">
<mat-icon>add</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'RemovedFromComponentEvent'">
<mat-icon>remove</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'AddedToLocationEvent'">
<mat-icon>add</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'RemovedFromLocationEvent'">
<mat-icon>remove</mat-icon>
</div>
<div class="timeline-badge closed" *ngSwitchCase="'ClosedEvent'">
<mat-icon>task_alt</mat-icon>
</div>
<div class="timeline-badge reopened" *ngSwitchCase="'ReopenedEvent'">
<mat-icon>add_task</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'AssignedEvent'">
<mat-icon>person_add</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'UnassignedEvent'">
<mat-icon>person_remove</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'CategoryChangedEvent'">
<mat-icon>category</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'DueDateChangedEvent'">
<mat-icon>event</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'EstimatedTimeChangedEvent'">
<mat-icon>date_range</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'LabelledEvent'">
<mat-icon>new_label</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'UnlabelledEvent'">
<mat-icon>label</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'DeletedIssueComment'">
<mat-icon>speaker_notes_off</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'MarkedAsDuplicateEvent'">
<mat-icon>plagiarism</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'UnmarkedAsDuplicateEvent'">
<mat-icon>file_copy</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'LinkEvent'">
<mat-icon>add_link</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'UnlinkEvent'">
<mat-icon>link_off</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'PinnedEvent'">
<mat-icon>push_pin</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'UnpinnedEvent'">
<mat-icon>push_pin</mat-icon>
</div>
<div class="timeline-badge warning" *ngSwitchCase="'ReferencedByIssueEvent'">
<mat-icon>switch_access_shortcut_add</mat-icon>
</div>
<div class="timeline-badge warning" *ngSwitchCase="'ReferencedByOtherEvent'">
<mat-icon>switch_access_shortcut</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'StartDateChangedEvent'">
<mat-icon>event</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'PriorityChangedEvent'">
<mat-icon>reorder</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'RenamedTitleEvent'">
<mat-icon>drive_file_rename_outline</mat-icon>
</div>
<div class="timeline-badge warning" *ngSwitchCase="'WasLinkedEvent'">
<mat-icon>add_link</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'WasUnlinkedEvent'">
<mat-icon>link_off</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'AddedArtifactEvent'">
<mat-icon>data_object</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'RemovedArtifactEvent'">
<mat-icon>data_object</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'AddedNonFunctionalConstraintEvent'">
<mat-icon>border_style</mat-icon>
</div>
<div class="timeline-badge" *ngSwitchCase="'RemovedNonFunctionalConstraintEvent'">
<mat-icon>border_style</mat-icon>
</div>
<!--Timeline events-->
<div class="commentContainer" *ngSwitchCase="'IssueComment'">
<app-comment [commentId]="makeCommentId(timelineItem.item)" [issueId]="issueId" [isIssueBody]="false"></app-comment>
</div>
<app-timeline-event-closed class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'ClosedEvent'">
</app-timeline-event-closed>
<app-timeline-event-assigned class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'AssignedEvent'">
</app-timeline-event-assigned>
<app-timeline-event-due-date-changed class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'DueDateChangedEvent'">
</app-timeline-event-due-date-changed>
<app-timeline-event-estimated-time-changed
class="fill-width"
[timelineItem]="timelineItem"
*ngSwitchCase="'EstimatedTimeChangedEvent'"
>
</app-timeline-event-estimated-time-changed>
<app-timeline-event-labelled class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'LabelledEvent'">
</app-timeline-event-labelled>
<app-timeline-event-deleted-issue-comment class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'DeletedIssueComment'">
</app-timeline-event-deleted-issue-comment>
<app-timeline-event-marked-duplicate
class="fill-width"
[timelineItem]="timelineItem"
[projectID]="projectID"
*ngSwitchCase="'MarkedAsDuplicateEvent'"
>
</app-timeline-event-marked-duplicate>
<app-timeline-event-link class="fill-width" [timelineItem]="timelineItem" [projectID]="projectID" *ngSwitchCase="'LinkEvent'">
</app-timeline-event-link>
<app-timeline-event-pinned class="fill-width" [timelineItem]="timelineItem" [projectID]="projectID" *ngSwitchCase="'PinnedEvent'">
</app-timeline-event-pinned>
<app-timeline-event-referenced-by-issue
class="fill-width"
[timelineItem]="timelineItem"
[projectID]="projectID"
*ngSwitchCase="'ReferencedByIssueEvent'"
>
</app-timeline-event-referenced-by-issue>
<app-timeline-event-priority-changed class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'PriorityChangedEvent'">
</app-timeline-event-priority-changed>
<app-timeline-event-removed-from-component
class="fill-width"
[timelineItem]="timelineItem"
[projectID]="projectID"
*ngSwitchCase="'RemovedFromComponentEvent'"
>
</app-timeline-event-removed-from-component>
<app-timeline-event-referenced-by-other
class="fill-width"
[timelineItem]="timelineItem"
[projectID]="projectID"
*ngSwitchCase="'ReferencedByOtherEvent'"
>
</app-timeline-event-referenced-by-other>
<app-timeline-event-removed-from-location
class="fill-width"
[timelineItem]="timelineItem"
[projectID]="projectID"
*ngSwitchCase="'RemovedFromLocationEvent'"
>
</app-timeline-event-removed-from-location>
<app-timeline-event-reopened class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'ReopenedEvent'">
</app-timeline-event-reopened>
<app-timeline-event-start-date-changed class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'StartDateChangedEvent'">
</app-timeline-event-start-date-changed>
<app-timeline-event-renamed class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'RenamedTitleEvent'">
</app-timeline-event-renamed>
<app-timeline-event-unassigned class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'UnassignedEvent'">
</app-timeline-event-unassigned>
<app-timeline-event-unlabelled class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'UnlabelledEvent'">
</app-timeline-event-unlabelled>
<app-timeline-event-was-linked
class="fill-width"
[timelineItem]="timelineItem"
[projectID]="projectID"
*ngSwitchCase="'WasLinkedEvent'"
>
</app-timeline-event-was-linked>
<app-timeline-event-unmarked-duplicate
class="fill-width"
[timelineItem]="timelineItem"
*ngSwitchCase="'UnmarkedAsDuplicateEvent'"
>
</app-timeline-event-unmarked-duplicate>
<app-timeline-event-was-unlinked
class="fill-width"
[timelineItem]="timelineItem"
[projectID]="projectID"
*ngSwitchCase="'WasUnlinkedEvent'"
>
</app-timeline-event-was-unlinked>
<app-timeline-event-unpinned
class="fill-width"
[timelineItem]="timelineItem"
[projectID]="projectID"
*ngSwitchCase="'UnpinnedEvent'"
>
</app-timeline-event-unpinned>
<app-timeline-event-unlink class="fill-width" [timelineItem]="timelineItem" [projectID]="projectID" *ngSwitchCase="'UnlinkEvent'">
</app-timeline-event-unlink>
<app-timeline-event-added-artifact class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'AddedArtifactEvent'">
</app-timeline-event-added-artifact>
<app-timeline-event-removed-artifact class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'RemovedArtifactEvent'">
</app-timeline-event-removed-artifact>
<app-timeline-event-added-nfc
class="fill-width"
[timelineItem]="timelineItem"
*ngSwitchCase="'AddedNonFunctionalConstraintEvent'"
>
</app-timeline-event-added-nfc>
<app-timeline-event-removed-nfc
class="fill-width"
[timelineItem]="timelineItem"
*ngSwitchCase="'RemovedNonFunctionalConstraintEvent'"
>
</app-timeline-event-removed-nfc>
<app-timeline-event-added-to-component
class="fill-width"
[timelineItem]="timelineItem"
[projectID]="projectID"
*ngSwitchCase="'AddedToComponentEvent'"
>
</app-timeline-event-added-to-component>
<app-timeline-event-category-changed class="fill-width" [timelineItem]="timelineItem" *ngSwitchCase="'CategoryChangedEvent'">
</app-timeline-event-category-changed>
<app-timeline-event-added-to-location
class="fill-width"
[timelineItem]="timelineItem"
[projectID]="projectID"
*ngSwitchCase="'AddedToLocationEvent'"
>
</app-timeline-event-added-to-location>
</li>
</ul>
</div>
</ng-template>
</app-query-component>
./timeline.component.scss
.timeline {
list-style: none;
margin: 0;
padding: 20px 0 20px;
position: relative;
&::before {
top: 0;
bottom: 0;
position: absolute;
content: " ";
width: 2px;
background: rgba(0, 0, 0, 0.12);
left: 35px;
}
& > li {
margin-bottom: 20px;
position: relative;
display: flex;
align-items: flex-start;
& > .timeline-badge {
color: #fff;
$size: 50px;
width: $size;
height: $size;
line-height: 50px;
font-size: 2.5em;
text-align: center;
position: relative;
top: 16px;
margin-left: calc(36px - (#{$size} / 2));
margin-right: 12px;
background-color: #999999;
z-index: 100;
border-radius: 50%;
}
& > .commentContainer {
flex: 1;
left: -36px;
z-index: 100;
::ng-deep .timeline-item::before {
display: none; // hide duplicate line
}
}
}
}
.timeline-badge.warning {
background-color: #f0ad4e !important;
}
.timeline-badge.closed {
background-color: #ff0036 !important;
}
.timeline-badge.reopened {
background-color: #00ba39 !important;
}
.timeline-title {
margin-top: 0;
color: inherit;
}
.fill-width {
width: 100%;
}
.timeline-body > p,
.timeline-body > ul {
margin-bottom: 0;
}
.timeline-body > p + p {
margin-top: 5px;
}
.timeline-panel {
flex: 1;
border: 1px solid #d4d4d4;
border-radius: 2px;
padding: 20px;
position: relative;
-webkit-box-shadow: 0 1px 6px rgba(0, 0, 0, 0.175);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.175);
&::before {
position: absolute;
top: 26px;
display: inline-block;
border-top: 15px solid transparent;
border-left: 15px solid #ccc;
border-right: 0 solid #ccc;
border-bottom: 15px solid transparent;
content: " ";
border-left-width: 0;
border-right-width: 15px;
left: -15px;
right: auto;
}
&::after {
position: absolute;
top: 27px;
display: inline-block;
border-top: 14px solid transparent;
border-left: 14px solid #fafafa;
border-right: 0 solid #fafafa;
border-bottom: 14px solid transparent;
content: " ";
border-left-width: 0;
border-right-width: 14px;
left: -14px;
right: auto;
}
}