src/app/issue-detail/issue-sidebar.component.ts
selector | app-issue-sidebar |
styleUrls | ./issue-sidebar.component.scss |
templateUrl | ./issue-sidebar.component.html |
Properties |
|
Methods |
Inputs |
Outputs |
constructor(dataService: DataService, dialogService: MatDialog, notify: UserNotifyService)
|
||||||||||||
Parameters :
|
issue$ | |
Type : DataNode<Issue>
|
|
issueId | |
Type : string
|
|
localIssue | |
Type : LocalIssueData
|
|
Use this input to edit a local issue object. Used for creation. |
projectId | |
Type : string
|
|
localIssueChange | |
Type : EventEmitter
|
|
applyLocalChangeset |
applyLocalChangeset(key, add: NodeId[], remove: NodeId[])
|
Returns :
void
|
makeComponentFilter | ||||
makeComponentFilter(search)
|
||||
Parameters :
Returns :
ComponentFilter
|
makeIssueFilter | ||||
makeIssueFilter(search)
|
||||
Parameters :
Returns :
IssueFilter
|
makeLabelFilter | ||||
makeLabelFilter(search)
|
||||
Parameters :
Returns :
LabelFilter
|
makeLocationFilter | ||||
makeLocationFilter(search)
|
||||
Parameters :
Returns :
IssueLocationFilter
|
makeUserFilter | ||||
makeUserFilter(search)
|
||||
Parameters :
Returns :
UserFilter
|
onDeleteLabel | |||
onDeleteLabel(undefined)
|
|||
Parameters :
Returns :
void
|
onEditLabel | |||
onEditLabel(undefined)
|
|||
Parameters :
Returns :
void
|
Public allAssigneeCandidatesList |
Type : SetMultiSource
|
Public allComponentsList |
Type : ListId
|
Public allLabelsList |
Type : SetMultiSource
|
Public allLinkedIssuesList |
Type : MaybeLocalList<string>
|
Public allLocationsList |
Type : SetMultiSource
|
applyAssigneeChangeset |
Default value : () => {...}
|
applyComponentChangeset |
Default value : () => {...}
|
applyLabelChangeset |
Default value : () => {...}
|
applyLinkedIssueChangeset |
Default value : () => {...}
|
applyLocationChangeset |
Default value : () => {...}
|
Public assigneeList |
Type : MaybeLocalList<NodeId>
|
Public Optional assigneeListPromise |
Type : Promise<HydrateList<User>>
|
Public componentList |
Type : MaybeLocalList<NodeId>
|
Public Optional componentListPromise |
Type : Promise<HydrateList<QComponent>>
|
componentSetEditor |
Decorators :
@ViewChild('componentSet')
|
Public labelList |
Type : MaybeLocalList<NodeId>
|
Public Optional labelListPromise |
Type : Promise<HydrateList | LabelPage>
|
Public linkedByIssueList |
Type : MaybeLocalList<NodeId>
|
Public Optional linkedByIssueListPromise |
Type : Promise<HydrateList<Issue>>
|
Public linkedIssueList |
Type : MaybeLocalList<NodeId>
|
Public Optional linkedIssueListPromise |
Type : Promise<HydrateList<Issue>>
|
Public locationList |
Type : MaybeLocalList<NodeId>
|
Public Optional locationListPromise |
Type : Promise<HydrateList<IssueLocation>>
|
onCreateLabel |
Default value : () => {...}
|
import {Component, EventEmitter, Input, OnInit, Output, ViewChild} from '@angular/core';
import {DataNode, HydrateList} from '@app/data-dgql/query';
import {
Component as QComponent,
ComponentFilter,
Issue,
IssueFilter,
IssueLocation,
IssueLocationFilter,
LabelFilter,
User,
UserFilter
} from '../../generated/graphql-dgql';
import {encodeNodeId, ListId, ListType, NodeId, NodeType, ROOT_NODE} from '@app/data-dgql/id';
import {SetMultiSource} from '@app/components/set-editor/set-editor-dialog.component';
import DataService from '@app/data-dgql';
import {MatDialog} from '@angular/material/dialog';
import {CreateEditLabelDialogComponent} from '@app/dialogs/create-label-dialog/create-edit-label-dialog.component';
import {RemoveDialogComponent} from '@app/dialogs/remove-dialog/remove-dialog.component';
import {UserNotifyService} from '@app/user-notify/user-notify.service';
import {LabelPage} from 'src/generated/graphql';
type MaybeLocalList<T> = ListId | T[];
export type LocalIssueData = {
components: NodeId[];
locations: NodeId[];
labels: NodeId[];
assignees: NodeId[];
linksToIssues: NodeId[];
};
@Component({
selector: 'app-issue-sidebar',
templateUrl: './issue-sidebar.component.html',
styleUrls: ['./issue-sidebar.component.scss']
})
export class IssueSidebarComponent implements OnInit {
@Input() issue$?: DataNode<Issue>;
@Input() issueId?: string;
@Input() projectId: string;
/** Use this input to edit a local issue object. Used for creation. */
@Input() localIssue: LocalIssueData;
@Output() localIssueChange = new EventEmitter<LocalIssueData>();
@ViewChild('componentSet') componentSetEditor;
constructor(private dataService: DataService, private dialogService: MatDialog, private notify: UserNotifyService) {}
public componentList: MaybeLocalList<NodeId>;
public locationList: MaybeLocalList<NodeId>;
public labelList: MaybeLocalList<NodeId>;
public linkedIssueList: MaybeLocalList<NodeId>;
public linkedByIssueList: MaybeLocalList<NodeId>;
public assigneeList: MaybeLocalList<NodeId>;
public allComponentsList: ListId;
public allLocationsList: SetMultiSource;
public allLabelsList: SetMultiSource;
public allLinkedIssuesList: MaybeLocalList<string>;
public allAssigneeCandidatesList: SetMultiSource;
public componentListPromise?: Promise<HydrateList<QComponent>>;
public locationListPromise?: Promise<HydrateList<IssueLocation>>;
public labelListPromise?: Promise<HydrateList<Issue> | LabelPage>;
public linkedIssueListPromise?: Promise<HydrateList<Issue>>;
public linkedByIssueListPromise?: Promise<HydrateList<Issue>>;
public assigneeListPromise?: Promise<HydrateList<User>>;
ngOnInit() {
if (this.issueId) {
this.componentList = {
node: {type: NodeType.Issue, id: this.issueId},
type: ListType.Components
};
this.locationList = {
node: {type: NodeType.Issue, id: this.issueId},
type: ListType.IssueLocations
};
this.labelList = {
node: {type: NodeType.Issue, id: this.issueId},
type: ListType.Labels
};
this.assigneeList = {
node: {type: NodeType.Issue, id: this.issueId},
type: ListType.Assignees
};
this.linkedIssueList = {
node: {type: NodeType.Issue, id: this.issueId},
type: ListType.LinkedIssues
};
this.linkedByIssueList = {
node: {type: NodeType.Issue, id: this.issueId},
type: ListType.LinkedByIssues
};
} else if (this.localIssue) {
this.componentList = this.localIssue.components;
this.locationList = this.localIssue.locations;
this.labelList = this.localIssue.labels;
this.assigneeList = this.localIssue.assignees;
this.linkedIssueList = this.localIssue.linksToIssues;
this.linkedByIssueList = [];
}
const projectComponents = {
node: {type: NodeType.Project, id: this.projectId},
type: ListType.Components
};
const projectInterfaces = {
node: {type: NodeType.Project, id: this.projectId},
type: ListType.ComponentInterfaces
};
this.allComponentsList = projectComponents;
this.allLocationsList = {
staticSources: [projectComponents, projectInterfaces]
};
this.allLabelsList = {
staticSources: this.issueId
? [
{
node: {type: NodeType.Issue, id: this.issueId},
type: ListType.Labels
}
]
: [],
// source labels from labels of issue components
sourceNodes: this.issueId
? {
node: {type: NodeType.Issue, id: this.issueId},
type: ListType.Components
}
: this.localIssue.components || [],
listFromNode: (node) => ({
node,
type: ListType.Labels
})
};
this.allAssigneeCandidatesList = {
staticSources: [
this.issueId && {
node: {type: NodeType.Issue, id: this.issueId},
type: ListType.Assignees
},
{
node: ROOT_NODE,
type: ListType.SearchUsers
}
].filter((x) => x)
};
this.allLinkedIssuesList = {
node: {type: NodeType.Project, id: this.projectId},
type: ListType.Issues
};
this.componentListPromise = this.issue$?.dataAsPromise().then((data) => data.components);
this.locationListPromise = this.issue$?.dataAsPromise().then((data) => data.locations);
this.labelListPromise = this.issue$?.dataAsPromise().then((data) => data.labels);
this.assigneeListPromise = this.issue$?.dataAsPromise().then((data) => data.assignees);
this.linkedIssueListPromise = this.issue$?.dataAsPromise().then((data) => data.linksToIssues);
this.linkedByIssueListPromise = this.issue$?.dataAsPromise().then((data) => data.linkedByIssues);
}
makeComponentFilter(search): ComponentFilter {
return {name: search};
}
makeLocationFilter(search): IssueLocationFilter {
return {name: search};
}
makeLabelFilter(search): LabelFilter {
return {name: search};
}
makeIssueFilter(search): IssueFilter {
return {title: search};
}
makeUserFilter(search): UserFilter {
// FIXME: maybe you would want to search by display name?
return {username: search};
}
applyLocalChangeset(key: keyof LocalIssueData, add: NodeId[], remove: NodeId[]): void {
const set = this.localIssue[key];
const keySet = new Set([...set].map((id) => encodeNodeId(id)));
for (const id of add) {
const encId = encodeNodeId(id);
if (!keySet.has(encId)) {
set.push(id);
keySet.add(encId);
}
}
for (const id of remove) {
const encId = encodeNodeId(id);
if (keySet.has(encId)) {
set.splice(set.indexOf(id), 1);
keySet.delete(encId);
}
}
this.localIssueChange.emit(this.localIssue);
}
applyComponentChangeset = async (add: NodeId[], remove: NodeId[]): Promise<void> => {
if (this.localIssue) {
return this.applyLocalChangeset('components', add, remove);
}
const mutId = Math.random().toString();
const issue = {type: NodeType.Issue, id: this.issueId};
// FIXME: batch mutations?
for (const id of add) {
await this.dataService.mutations.addIssueComponent(mutId, issue, id);
}
for (const id of remove) {
await this.dataService.mutations.removeIssueComponent(mutId, issue, id);
}
};
applyLocationChangeset = async (add: NodeId[], remove: NodeId[]): Promise<void> => {
if (this.localIssue) {
return this.applyLocalChangeset('locations', add, remove);
}
const mutId = Math.random().toString();
const issue = {type: NodeType.Issue, id: this.issueId};
// FIXME: batch mutations?
for (const id of add) {
await this.dataService.mutations.addIssueLocation(mutId, issue, id);
}
for (const id of remove) {
await this.dataService.mutations.removeIssueLocation(mutId, issue, id);
}
};
applyLabelChangeset = async (add: NodeId[], remove: NodeId[]): Promise<void> => {
if (this.localIssue) {
return this.applyLocalChangeset('labels', add, remove);
}
const mutId = Math.random().toString();
const issue = {type: NodeType.Issue, id: this.issueId};
// FIXME: batch mutations?
for (const id of add) {
await this.dataService.mutations.addIssueLabel(mutId, issue, id);
}
for (const id of remove) {
await this.dataService.mutations.removeIssueLabel(mutId, issue, id);
}
};
applyAssigneeChangeset = async (add: NodeId[], remove: NodeId[]): Promise<void> => {
if (this.localIssue) {
return this.applyLocalChangeset('assignees', add, remove);
}
const mutId = Math.random().toString();
const issue = {type: NodeType.Issue, id: this.issueId};
// FIXME: batch mutations?
for (const id of add) {
await this.dataService.mutations.addIssueAssignee(mutId, issue, id);
}
for (const id of remove) {
await this.dataService.mutations.removeIssueAssignee(mutId, issue, id);
}
};
applyLinkedIssueChangeset = async (add: NodeId[], remove: NodeId[]): Promise<void> => {
if (this.localIssue) {
return this.applyLocalChangeset('linksToIssues', add, remove);
}
const mutId = Math.random().toString();
const issue = {type: NodeType.Issue, id: this.issueId};
// FIXME: batch mutations?
for (const id of add) {
await this.dataService.mutations.linkIssue(mutId, issue, id);
}
for (const id of remove) {
await this.dataService.mutations.unlinkIssue(mutId, issue, id);
}
};
onCreateLabel = (): Promise<NodeId | null> => {
return new Promise((resolve) => {
this.dialogService
.open(CreateEditLabelDialogComponent, {
width: '400px',
data: {
projectId: {type: NodeType.Project, id: this.projectId},
issueId: this.issue$?.current.components.nodes.map((c) => {
return {type: NodeType.Component, id: c.id};
})
}
})
.afterClosed()
.subscribe((created) => {
if (created) {
const labelComponents = created.components.nodes.map((c) => c.id);
let hasOverlap = false;
if (Array.isArray(this.componentList)) {
for (const componentId of this.componentList) {
if (labelComponents.includes(componentId.id)) {
hasOverlap = true;
break;
}
}
} else {
for (const item of this.componentSetEditor.listSet$.currentItems) {
if (labelComponents.includes(item.id)) {
hasOverlap = true;
break;
}
}
}
if (hasOverlap) {
resolve({type: NodeType.Label, id: created.id});
} else {
this.notify.notifyInfo('New label could not be added to issue because it does not appear to have any components in common.');
resolve(null);
}
} else {
resolve(null);
}
});
});
};
onEditLabel({id}): void {
this.dialogService.open(CreateEditLabelDialogComponent, {
width: '400px',
data: {
editExisting: id,
projectId: {type: NodeType.Project, id: this.projectId}
}
});
}
onDeleteLabel({id, preview}): void {
this.dialogService
.open(RemoveDialogComponent, {
data: {
title: 'Delete label',
messages: [`Are you sure you want to delete the label “${preview.name}”?`]
}
})
.afterClosed()
.subscribe((shouldDelete) => {
if (shouldDelete) {
this.dataService.mutations.deleteLabel(Math.random().toString(), id);
}
});
}
}
<!-- Components this issue is assigned to -->
<div class="issue-sidebar-row">
<app-set-editor
#componentSet
[makeFilter]="makeComponentFilter"
[scoreKeys]="['name']"
[listSet]="componentList"
[listAll]="allComponentsList"
[hydrate]="componentListPromise"
[applyChangeset]="applyComponentChangeset"
>
<span title>Components</span>
<span if-empty>No components assigned</span>
<ng-container *appItem="let item">
<div>{{ item.name }}</div>
</ng-container>
</app-set-editor>
</div>
<mat-divider></mat-divider>
<!-- Issue locations, interfaces as well as components -->
<div class="issue-sidebar-row">
<app-set-editor
[makeFilter]="makeLocationFilter"
[scoreKeys]="['name']"
[listSet]="locationList"
[listAll]="allLocationsList"
[hydrate]="locationListPromise"
[applyChangeset]="applyLocationChangeset"
>
<span title>Locations</span>
<span if-empty>No locations assigned</span>
<ng-container *appItem="let item">
<div *ngIf="item.__typename === 'Component'">{{ item.name }}</div>
<div *ngIf="item.__typename === 'ComponentInterface'">
<span class="location-containing-component-name" *ngIf="!!item.component"> {{ item.component.name }} › </span>
{{ item.name }}
</div>
</ng-container>
</app-set-editor>
</div>
<mat-divider></mat-divider>
<!-- Issue labels -->
<div class="issue-sidebar-row">
<app-set-editor
[makeFilter]="makeLabelFilter"
[scoreKeys]="['name']"
[listSet]="labelList"
[listAll]="allLabelsList"
[hydrate]="labelListPromise"
[applyChangeset]="applyLabelChangeset"
itemOps="create-edit-delete"
[createItem]="onCreateLabel"
(editItem)="onEditLabel($event)"
(deleteItem)="onDeleteLabel($event)"
>
<span title>Labels</span>
<span if-empty>No labels assigned</span>
<ng-container *appItem="let item">
<app-issue-label [label]="item"></app-issue-label>
</ng-container>
</app-set-editor>
</div>
<mat-divider></mat-divider>
<!-- Issue assignees -->
<div class="issue-sidebar-row">
<app-set-editor
[makeFilter]="makeUserFilter"
[scoreKeys]="['username', 'displayName']"
emptySuggestionsLabel="Search for a user to see results"
[listSet]="assigneeList"
[listAll]="allAssigneeCandidatesList"
[hydrate]="assigneeListPromise"
[applyChangeset]="applyAssigneeChangeset"
>
<span title>Assignees</span>
<span if-empty>No one</span>
<div *appItem="let item">
<app-user-item [user]="item"></app-user-item>
</div>
</app-set-editor>
</div>
<mat-divider></mat-divider>
<!-- Issues the current issue is linked to -->
<div class="issue-sidebar-row">
<app-set-editor
[makeFilter]="makeIssueFilter"
[scoreKeys]="['title']"
[listSet]="linkedIssueList"
[listAll]="allLinkedIssuesList"
[hydrate]="linkedIssueListPromise"
[applyChangeset]="applyLinkedIssueChangeset"
>
<span title>Linked Issues</span>
<span if-empty>No issues linked</span>
<div class="linked-issue-item" *appItem="let item; let interactive = interactive">
<app-issue-item [issue]="item" [extended]="!interactive" [interactive]="interactive" [projectId]="projectId"></app-issue-item>
</div>
</app-set-editor>
</div>
<mat-divider></mat-divider>
<!-- Issues the current issue is linked by -->
<div class="issue-sidebar-row" *ngIf="!localIssue">
<app-set-editor
[makeFilter]="makeIssueFilter"
[scoreKeys]="['title']"
[listSet]="linkedByIssueList"
[editable]="false"
[hydrate]="linkedByIssueListPromise"
[applyChangeset]="null"
>
<span title>Linked By Issues</span>
<span if-empty>No issues link to this issue</span>
<div class="linked-issue-item" *appItem="let item; let interactive = interactive">
<app-issue-item [issue]="item" [extended]="!interactive" [interactive]="interactive" [projectId]="projectId"></app-issue-item>
</div>
</app-set-editor>
</div>
<mat-divider></mat-divider>
<!-- Issue interfaces, artefacts and NFR -->
<div class="issue-sidebar-row">
<mat-label><strong>Interfaces, Artefacts & NFR</strong></mat-label>
unimplemented
<br />
</div>
./issue-sidebar.component.scss
.issue-sidebar-row {
margin: 8px 0;
&:first-child {
margin-top: 0;
}
}
.location-containing-component-name {
opacity: 0.7;
}