feat(console): integrate frontend (#95)

* feat: console frontend

* chore(dependabot): cycle and npm

* chore: rename citadel to zitadel, remove generated files

* chore: delete go files

* chore(frontend): ci steps

* chore: remove docker and envoy files

* chore: remove docker file

* chore: working dir

* chore: run proto build

* add console start

* chore: restructure folders

* chore: remove gui build

* statikFs

* generate proto for console

* add statik import

* import

* chore: try statik

* chore: path

* chore: path

* chore: script in root

* chore: order build steps

* chore: go get

* chore: folder traversal

* chore: non empty test file

* chore: gitignore

* chore: gitignore

* chore: statik path

* chore: switch to failing FE build

* fix: build

* fix: project-grant-test

* fix: rm test

* add statik.go

* go mod tidy

* chore: place test, seperate test from build

* chore: lint all the world

* chore: ci the world instead

* chore: tune docker

* chore: undo container test

* chore: fix run

* chore: docker build

* chore: test docker build

* chore: go build flags

* finaly

* fix caos_local

* go mod

Co-authored-by: Livio Amstutz <[email protected]>
Co-authored-by: Max Peintner <[email protected]>
This commit is contained in:
Florian Forster
2020-05-13 14:41:43 +02:00
committed by GitHub
co-authored by Livio Amstutz Max Peintner
parent 9e32740eb8
commit 92a294f5c8
375 changed files with 97826 additions and 52 deletions
@@ -0,0 +1,32 @@
<div class="card" appOutsideClick (clickOutside)="closeCard($event)">
<mat-icon class="avatar">account_circle</mat-icon>
<span class="u-name">{{profile?.firstName}} {{profile?.lastName}}</span>
<span class="u-email">{{profile?.userName}}</span>
<span class="iamuser" *ngIf="iamuser">IAM USER</span>
<button color="accent" (click)="editUserProfile()" mat-stroked-button>{{'USER.EDITACCOUNT' | translate}}</button>
<div class="l-accounts">
<mat-progress-bar *ngIf="loadingUsers" color="accent" mode="indeterminate"></mat-progress-bar>
<a class="row" *ngFor="let user of users" (click)="selectAccount(user.userName)">
<mat-icon class="small-avatar" svgIcon="mdi_account_circle_outline"></mat-icon>
<div class="col">
<span class="title">{{user.userName}}</span>
<span class="email">{{'USER.STATE.'+user.authState | translate}}</span>
</div>
<span class="fill-space"></span>
<mat-icon>keyboard_arrow_right</mat-icon>
</a>
<a class="row" (click)="selectAccount()">
<div class="icon-wrapper">
<mat-icon>add</mat-icon>
</div>
<span class="col">
<span class="title">{{'USER.ADDACCOUNT' | translate}}</span>
</span>
<span class="fill-space"></span>
<mat-icon>keyboard_arrow_right</mat-icon>
</a>
</div>
<button color="accent" (click)="logout()" mat-stroked-button>logout everywhere</button>
</div>
@@ -0,0 +1,112 @@
.card {
border-radius: .5rem;
z-index: 200;
border: 1px solid #ffffff30;
width: 350px;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
.avatar {
height: 80px;
width: 80px;
border-radius: 50%;
line-height: 80px;
font-size: 80px;
margin-bottom: 1rem;
}
.u-name {
font-size: 1rem;
line-height: 1rem;
}
.u-email {
font-size: .8rem;
margin-top: 0;
}
.iamuser {
font-size: 1rem;
background: -webkit-linear-gradient(rgb(240,140,53), rgb(233, 60, 231));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
button {
border-radius: 1rem;
margin: .5rem;
.mat-button-wrapper {
font-size: .8rem;
}
}
.l-accounts {
display: flex;
flex-direction: column;
width: 100%;
border-top: 1px solid #ffffff30;
border-bottom: 1px solid #ffffff30;
padding: .5rem 0;
.row {
padding: .5rem;
display: flex;
align-items: center;
color: inherit;
text-decoration: none;
&:hover {
cursor: pointer;
background-color: #ffffff10;
}
.small-avatar {
height: 35px;
width: 35px;
line-height: 35px;
font-size: 35px;
border-radius: 50%;
margin: 0 1rem;
}
.icon-wrapper {
height: 35px;
width: 35px;
border-radius: 50%;
margin: 0 1rem;
text-align: center;
mat-icon {
margin: auto;
vertical-align: middle;
}
}
.col {
flex: 1;
display: flex;
flex-direction: column;
.title {
font-weight: 500;
font-size: .9rem;
line-height: 1rem;
}
.email {
color: #81868a;
font-size: .8rem;
line-height: 1rem;
}
}
.fill-space {
flex: 1;
}
}
}
}
@@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AccountsCardComponent } from './accounts-card.component';
describe('AccountsCardComponent', () => {
let component: AccountsCardComponent;
let fixture: ComponentFixture<AccountsCardComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AccountsCardComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AccountsCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,66 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Router } from '@angular/router';
import { AuthConfig } from 'angular-oauth2-oidc';
import { UserProfile, UserSessionView } from 'src/app/proto/generated/auth_pb';
import { AuthUserService } from 'src/app/services/auth-user.service';
import { AuthService } from 'src/app/services/auth.service';
@Component({
selector: 'app-accounts-card',
templateUrl: './accounts-card.component.html',
styleUrls: ['./accounts-card.component.scss'],
})
export class AccountsCardComponent implements OnInit {
@Input() public profile!: UserProfile.AsObject;
@Input() public iamuser: boolean = false;
@Output() public close: EventEmitter<void> = new EventEmitter();
public users: UserSessionView.AsObject[] = [];
public loadingUsers: boolean = false;
constructor(public authService: AuthService, private router: Router, private userService: AuthUserService) { }
public ngOnInit(): void {
this.loadingUsers = true;
this.userService.getMyUserSessions().then(sessions => {
this.users = sessions.toObject().userSessionsList;
const index = this.users.findIndex(user => user.userName === this.profile.userName);
this.users.splice(index, 1);
this.loadingUsers = false;
}).catch(() => {
this.loadingUsers = false;
});
}
public editUserProfile(): void {
this.router.navigate(['user/me']);
this.close.emit();
}
public closeCard(element: HTMLElement): void {
if (!element.classList.contains('dontcloseonclick')) {
this.close.emit();
}
}
public selectAccount(loginHint?: string, idToken?: string): void {
const configWithPrompt: Partial<AuthConfig> = {
customQueryParams: {
prompt: 'select_account',
} as any,
};
if (loginHint) {
(configWithPrompt as any).customQueryParams['login_hint'] = loginHint;
}
if (idToken) {
(configWithPrompt as any).customQueryParams['id_token_hint'] = idToken;
}
this.authService.authenticate(configWithPrompt);
}
public logout(): void {
this.router.navigate(['/']);
this.close.emit();
}
}
@@ -0,0 +1,27 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { TranslateModule } from '@ngx-translate/core';
import { OutsideClickModule } from 'src/app/directives/outside-click/outside-click.module';
import { AccountsCardComponent } from './accounts-card.component';
@NgModule({
declarations: [
AccountsCardComponent,
],
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatProgressBarModule,
OutsideClickModule,
TranslateModule,
],
exports: [
AccountsCardComponent,
],
})
export class AccountsCardModule { }
@@ -0,0 +1,33 @@
<h1 mat-dialog-title>
<span class="title">{{'MEMBER.ADD' | translate}}</span>
</h1>
<p class="desc"> {{'ORG_DETAIL.MEMBER.ADDDESCRIPTION' | translate}}</p>
<div mat-dialog-content>
<app-search-user-autocomplete (selectionChanged)="users = $event"></app-search-user-autocomplete>
<mat-form-field class="full-width" appearance="outline"
*ngIf="creationType === CreationType.PROJECT_OWNED || creationType === CreationType.PROJECT_GRANTED">
<mat-label>{{ 'PROJECT.GRANT.TITLE' | translate }}</mat-label>
<mat-select [(ngModel)]="roles" multiple>
<mat-option *ngFor="let role of memberRoleOptions" [value]="role">
{{ 'ROLES.'+role | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<ng-container *ngIf="creationType === CreationType.ORG">
<app-org-member-roles-autocomplete (selectionChanged)="setOrgMemberRoles($event)">
</app-org-member-roles-autocomplete>
</ng-container>
</div>
<div mat-dialog-actions class="action">
<button mat-button (click)="closeDialog()">
cancel
</button>
<button [disabled]="users.length == 0 || roles.length == 0" color="primary" mat-raised-button class="ok-button"
(click)="closeDialogWithSuccess()">
Add
</button>
</div>
@@ -0,0 +1,25 @@
.title {
font-size: 1.2rem;
}
.desc {
color: #81868a;
font-size: .9rem;
}
.full-width {
width: 100%;
}
.action {
display: flex;
justify-content: flex-end;
.ok-button {
margin-left: 0.5rem;
}
button {
border-radius: 0.5rem;
}
}
@@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ProjectMemberCreateDialogComponent } from './project-member-create-dialog.component';
describe('AddMemberDialogComponent', () => {
let component: ProjectMemberCreateDialogComponent;
let fixture: ComponentFixture<ProjectMemberCreateDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProjectMemberCreateDialogComponent],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProjectMemberCreateDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,60 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ProjectRole, User } from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
export enum CreationType {
PROJECT_OWNED = 0,
PROJECT_GRANTED = 1,
ORG = 2,
}
@Component({
selector: 'app-project-member-create-dialog',
templateUrl: './project-member-create-dialog.component.html',
styleUrls: ['./project-member-create-dialog.component.scss'],
})
export class ProjectMemberCreateDialogComponent {
public projectId: string = '';
public creationType!: CreationType;
public users: Array<User.AsObject> = [];
public roles: Array<ProjectRole.AsObject> | string[] = [];
public CreationType: any = CreationType;
public memberRoleOptions: string[] = [];
constructor(
private projectService: ProjectService,
public dialogRef: MatDialogRef<ProjectMemberCreateDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
toastService: ToastService,
) {
this.creationType = data.creationType;
this.projectId = data.projectId;
if (this.creationType === CreationType.PROJECT_GRANTED) {
this.projectService.GetProjectGrantMemberRoles().then(resp => {
this.memberRoleOptions = resp.toObject().rolesList;
}).catch(error => {
toastService.showError(error.message);
});
} else if (this.creationType === CreationType.PROJECT_OWNED) {
this.projectService.GetProjectMemberRoles().then(resp => {
this.memberRoleOptions = resp.toObject().rolesList;
console.log(this.memberRoleOptions);
}).catch(error => {
toastService.showError(error.message);
});
}
}
public closeDialog(): void {
this.dialogRef.close(false);
}
public closeDialogWithSuccess(): void {
this.dialogRef.close({ users: this.users, roles: this.roles });
}
public setOrgMemberRoles(roles: string[]): void {
this.roles = roles;
}
}
@@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import { SearchUserAutocompleteModule } from 'src/app/modules/search-user-autocomplete/search-user-autocomplete.module';
import {
OrgMemberRolesAutocompleteModule,
} from '../../pages/orgs/org-member-roles-autocomplete/org-member-roles-autocomplete.module';
import { SearchRolesAutocompleteModule } from '../search-roles-autocomplete/search-roles-autocomplete.module';
import { ProjectMemberCreateDialogComponent } from './project-member-create-dialog.component';
@NgModule({
declarations: [ProjectMemberCreateDialogComponent],
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
TranslateModule,
MatFormFieldModule,
MatSelectModule,
FormsModule,
SearchUserAutocompleteModule,
SearchRolesAutocompleteModule,
OrgMemberRolesAutocompleteModule,
],
entryComponents: [
ProjectMemberCreateDialogComponent,
],
})
export class ProjectMemberCreateDialogModule { }
@@ -0,0 +1,17 @@
<div class="card">
<div *ngIf="title || description" class="header" [ngClass]="{'bottom-margin': expanded}">
<div *ngIf="title" class="row">
<h2 class="title">{{title}}</h2>
<span class="fill-space"></span>
<ng-content select="card-actions"></ng-content>
<button class="button" matTooltip="Expand or collapse" mat-icon-button (click)="expanded = !expanded">
<mat-icon *ngIf="!expanded">keyboard_arrow_down</mat-icon>
<mat-icon *ngIf="expanded">keyboard_arrow_up</mat-icon>
</button>
</div>
<p *ngIf="description" class="desc">{{description}}</p>
</div>
<div class="card-content" *ngIf="expanded" [@openClose]="animate">
<ng-content></ng-content>
</div>
</div>
@@ -0,0 +1,46 @@
.card {
margin: 1rem 0;
padding: 1.5rem;
border-radius: .5rem;
padding-top: 1rem;
.header {
&.bottom-margin {
margin-bottom: 1rem;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
.title {
margin: 0;
font-weight: 400;
font-family: 'Rubik';
font-size: 1.2rem;
// margin-top: .3rem;
}
.fill-space {
flex: 1;
}
.button {
margin-right: -.5rem;
}
}
.desc {
font-size: .9rem;
color: #81868a;
}
}
.card-content {
display: flex;
flex-direction: column;
width: 100%;
}
}
@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CardComponent } from './card.component';
describe('CardComponent', () => {
let component: CardComponent;
let fixture: ComponentFixture<CardComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [CardComponent],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,25 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-card',
templateUrl: './card.component.html',
styleUrls: ['./card.component.scss'],
animations: [
trigger('openClose', [
transition(':enter', [
style({ height: '0', opacity: 0 }),
animate('150ms ease-in-out', style({ height: '*', opacity: 1 })),
]),
transition(':leave', [
animate('150ms ease-in-out', style({ height: '0', opacity: 0 })),
]),
]),
],
})
export class CardComponent {
public expanded: boolean = true;
@Input() public title: string = '';
@Input() public description: string = '';
@Input() public animate: boolean = false;
}
@@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { CardComponent } from './card.component';
@NgModule({
declarations: [CardComponent],
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatTooltipModule,
],
exports: [
CardComponent,
],
})
export class CardModule { }
@@ -0,0 +1,13 @@
<span class="header">{{ 'CHANGES.LISTTITLE' | translate }}</span>
<div class="scroll-container" appScrollable (scrollPosition)="scrollHandler($event)">
<li class="item change-item-back" *ngFor="let event of data | async">
<span class="seq">
{{dateFromTimestamp(event.changeDate) | localizedDate: 'EEE dd. MMM, HH:mm'}}
</span>
<span class="desc">{{'CHANGES.EVENTS.'+event.eventType | translate}}</span>
</li>
<div class="sp-wrapper">
<mat-spinner *ngIf="loading | async" diameter="25"></mat-spinner>
</div>
</div>
@@ -0,0 +1,40 @@
.header {
display: block;
margin-bottom: 1rem;
font-weight: 400;
color: #81868a;
margin-top: 1rem;
}
.scroll-container {
max-height: 540px;
overflow-y: scroll;
.item {
box-sizing: border-box;
height: 60px;
padding: .5rem;
margin: .25rem 0;
border-radius: .5rem;
display: flex;
flex-direction: column;
.seq {
color: #81868a;
font-size: 12px;
align-self: flex-end;
}
.desc {
overflow-x: auto;
font-size: .9rem;
margin: 4px 0;
}
}
.sp-wrapper {
padding: .5rem;
display: flex;
justify-content: center;
}
}
@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangesComponent } from './changes.component';
describe('ChangesComponent', () => {
let component: ChangesComponent;
let fixture: ComponentFixture<ChangesComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ChangesComponent],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ChangesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,124 @@
import { Component, Input, OnInit } from '@angular/core';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
import { BehaviorSubject, from, Observable } from 'rxjs';
import { scan, take, tap } from 'rxjs/operators';
import { Change, Changes } from 'src/app/proto/generated/management_pb';
import { MgmtUserService } from 'src/app/services/mgmt-user.service';
export enum ChangeType {
USER = 'user',
ORG = 'org',
PROJECT = 'project',
}
@Component({
selector: 'app-changes',
templateUrl: './changes.component.html',
styleUrls: ['./changes.component.scss'],
})
export class ChangesComponent implements OnInit {
@Input() public changeType: ChangeType = ChangeType.USER;
@Input() public id: string = '';
// Source data
private _done: BehaviorSubject<any> = new BehaviorSubject(false);
private _loading: BehaviorSubject<any> = new BehaviorSubject(false);
private _data: BehaviorSubject<any> = new BehaviorSubject([]);
// Observable data
loading: Observable<boolean> = this._loading.asObservable();
public data!: Observable<Change.AsObject[]>;
public changes!: Changes.AsObject;
constructor(private mgmtUserService: MgmtUserService) { }
ngOnInit(): void {
this.init();
}
public scrollHandler(e: any): void {
if (e === 'bottom') {
this.more();
}
}
private init(): void {
let first: Promise<Changes>;
switch (this.changeType) {
case ChangeType.USER: first = this.mgmtUserService.UserChanges(this.id, 10, 0);
break;
case ChangeType.PROJECT: first = this.mgmtUserService.ProjectChanges(this.id, 20, 0);
break;
case ChangeType.ORG: first = this.mgmtUserService.OrgChanges(this.id, 10, 0);
break;
}
this.mapAndUpdate(first);
// Create the observable array for consumption in components
this.data = this._data.asObservable().pipe(
scan((acc, val) => {
return false ? val.concat(acc) : acc.concat(val);
}));
}
private more(): void {
const cursor = this.getCursor();
let more: Promise<Changes>;
switch (this.changeType) {
case ChangeType.USER: more = this.mgmtUserService.UserChanges(this.id, 10, cursor);
break;
case ChangeType.PROJECT: more = this.mgmtUserService.ProjectChanges(this.id, 10, cursor);
break;
case ChangeType.ORG: more = this.mgmtUserService.OrgChanges(this.id, 10, cursor);
break;
}
this.mapAndUpdate(more);
}
// Determines the snapshot to paginate query
private getCursor(): number {
const current = this._data.value;
if (current.length) {
// return true ? current[0].sequence :
return current[current.length - 1].sequence;
}
return 0;
}
// Maps the snapshot to usable format the updates source
private mapAndUpdate(col: Promise<Changes>): any {
if (this._done.value || this._loading.value) { return; }
// loading
this._loading.next(true);
// Map snapshot with doc ref (needed for cursor)
return from(col).pipe(
tap((res: Changes) => {
let values = res.toObject().changesList;
// If prepending, reverse the batch order
values = false ? values.reverse() : values;
// update source with new values, done loading
this._data.next(values);
// console.log(values);
this._loading.next(false);
// no more values, mark done
if (!values.length) {
this._done.next(true);
}
}),
take(1)).subscribe();
}
public dateFromTimestamp(date: Timestamp.AsObject): any {
const ts: Date = new Date(date.seconds * 1000 + date.nanos / 1000);
return ts;
}
}
@@ -0,0 +1,30 @@
import { ScrollingModule } from '@angular/cdk/scrolling';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { ScrollableModule } from 'src/app/directives/scrollable/scrollable.module';
import { PipesModule } from 'src/app/pipes/pipes.module';
import { ChangesComponent } from './changes.component';
@NgModule({
declarations: [
ChangesComponent,
],
imports: [
CommonModule,
ScrollableModule,
MatProgressSpinnerModule,
TranslateModule,
PipesModule,
ScrollingModule,
],
exports: [
ChangesComponent,
ScrollableModule,
],
})
export class ChangesModule { }
@@ -0,0 +1,8 @@
<div class="meta-wrapper">
<div class="main-content">
<ng-content></ng-content>
</div>
<div class="meta">
<ng-content class="meta-content" select="metainfo"></ng-content>
</div>
</div>
@@ -0,0 +1,33 @@
.meta-wrapper {
display: flex;
height: 100%;
.main-content {
display: relative;
width: 100%;
overflow-y: auto;
}
.meta {
flex: 1 0 300px;
@media only screen and (min-width: 1500px) {
flex-basis: 400px;
}
// overflow-y: auto;
padding: 1rem;
.meta-content {
max-height: calc(100vh - 60px);
display: flex;
flex-direction: column;
}
}
@media only screen and (max-width: 700px) {
flex-direction: column;
.main-content, .meta {
overflow-y: visible;
}
}
}
@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MetaLayoutComponent } from './meta-layout.component';
describe('MetaLayoutComponent', () => {
let component: MetaLayoutComponent;
let fixture: ComponentFixture<MetaLayoutComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MetaLayoutComponent],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MetaLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-meta-layout',
templateUrl: './meta-layout.component.html',
styleUrls: ['./meta-layout.component.scss'],
})
export class MetaLayoutComponent {
}
@@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MetaLayoutComponent } from './meta-layout.component';
@NgModule({
declarations: [MetaLayoutComponent],
imports: [
CommonModule,
],
exports: [MetaLayoutComponent],
})
export class MetaLayoutModule { }
@@ -0,0 +1,56 @@
import { DataSource } from '@angular/cdk/collections';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
import { ProjectRole } from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
/**
* Data source for the ProjectMembers view. This class should
* encapsulate all logic for fetching and manipulating the displayed data
* (including sorting, pagination, and filtering).
*/
export class ProjectRolesDataSource extends DataSource<ProjectRole.AsObject> {
public totalResult: number = 0;
public rolesSubject: BehaviorSubject<ProjectRole.AsObject[]> = new BehaviorSubject<ProjectRole.AsObject[]>([]);
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
constructor(private projectService: ProjectService) {
super();
}
public loadRoles(projectId: string, pageIndex: number, pageSize: number, sortDirection?: string): void {
const offset = pageIndex * pageSize;
this.loadingSubject.next(true);
from(this.projectService.SearchProjectRoles(projectId, pageSize, offset)).pipe(
map(resp => {
this.totalResult = resp.toObject().totalResult;
return resp.toObject().resultList;
}),
catchError(() => of([])),
finalize(() => this.loadingSubject.next(false)),
).subscribe(roles => {
this.rolesSubject.next(roles);
});
}
/**
* Connect this data source to the table. The table will only update when
* the returned stream emits new items.
* @returns A stream of the items to be rendered.
*/
public connect(): Observable<ProjectRole.AsObject[]> {
return this.rolesSubject.asObservable();
}
/**
* Called when the table is being destroyed. Use this function, to clean up
* any open connections or free any held resources that were set up during connect.
*/
public disconnect(): void {
this.rolesSubject.complete();
this.loadingSubject.complete();
}
}
@@ -0,0 +1,72 @@
<div class="table-header-row" *ngIf="projectId">
<div class="col">
<ng-container *ngIf="!selection.hasValue()">
<span class="desc">{{'ORG_DETAIL.TABLE.TOTAL' | translate}}</span>
<span class="count">{{dataSource?.rolesSubject.value.length}}</span>
</ng-container>
<ng-container *ngIf="selection.hasValue()">
<span class="desc">{{'ORG_DETAIL.TABLE.SELECTION' | translate}}</span>
<span class="count">{{selection?.selected?.length}}</span>
</ng-container>
</div>
<span class="fill-space"></span>
<ng-template appHasRole [appHasRole]="['project.role.delete']">
<button [disabled]="disabled" matTooltip="{{'PROJECT.ROLE.DELETE' | translate}}" class="icon-button"
(click)="deleteSelectedRoles()" mat-icon-button *ngIf="selection.hasValue() && actionsVisible">
<mat-icon>delete_outline</mat-icon>
</button>
</ng-template>
<ng-template appHasRole [appHasRole]="['project.role.write:' + projectId, 'project.role.write']">
<a *ngIf="actionsVisible" [disabled]="disabled" class="add-button"
[routerLink]="[ '/projects', projectId, 'roles', 'create']" color="primary" mat-raised-button>
<mat-icon class="icon">add</mat-icon>{{ 'ACTIONS.NEW' | translate }}
</a>
</ng-template>
</div>
<div class="table-wrapper">
<div class="spinner-container" *ngIf="dataSource.loading$ | async">
<mat-spinner diameter="50"></mat-spinner>
</div>
<table [dataSource]="dataSource" mat-table class="full-width-table" matSort aria-label="Elements">
<ng-container matColumnDef="select">
<th class="selection" mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
</th>
<td class="selection" mat-cell *matCellDef="let row">
<mat-checkbox (click)="$event.stopPropagation()" (change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)">
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef> {{ 'PROJECT.ROLE.NAME' | translate }} </th>
<td mat-cell *matCellDef="let role"> {{role.name}} </td>
</ng-container>
<ng-container matColumnDef="displayname">
<th mat-header-cell *matHeaderCellDef> {{ 'PROJECT.ROLE.DISPLAY_NAME' | translate }} </th>
<td mat-cell *matCellDef="let role"> {{role.displayName}} </td>
</ng-container>
<ng-container matColumnDef="group">
<th mat-header-cell *matHeaderCellDef> {{ 'PROJECT.ROLE.GROUP' | translate }} </th>
<td mat-cell *matCellDef="let role">
<span class="role app-label" *ngIf="role.group"
matTooltip="Add all of group {{role.group}} to selection"
(click)="selectAllOfGroup(role.group)">{{role.group}}</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<mat-paginator #paginator [length]="dataSource.totalResult" [pageSize]="50" [pageSizeOptions]="[25, 50, 100, 250]">
</mat-paginator>
</div>
@@ -0,0 +1,84 @@
.table-header-row {
display: flex;
align-items: center;
.col {
display: flex;
flex-direction: column;
.desc {
font-size: .8rem;
color: #81868a;
}
.count {
font-size: 2rem;
}
}
.fill-space {
flex: 1;
}
.icon-button {
margin-right: .5rem;
}
.add-button {
border-radius: .5rem;
}
}
.table-wrapper {
overflow: auto;
.spinner-container {
display: flex;
align-items: center;
justify-content: center;
}
table, mat-paginator {
width: 100%;
background-color: #2d2e30;
td, th {
&:first-child {
padding-left: 0;
padding-right: 1rem;
}
&:last-child {
padding-right: 0;
}
}
.action {
width: 40px;
}
.data-row {
&:hover {
background-color: #ffffff05;
}
}
.selection {
width: 50px;
max-width: 50px;
}
.margin-neg {
margin-left: -1rem;
}
.role {
display: inline-block;
margin: .25rem;
}
}
}
.pointer {
outline: none;
cursor: pointer;
}
@@ -0,0 +1,34 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ProjectRolesComponent } from './project-roles.component';
describe('ProjectRolesComponent', () => {
let component: ProjectRolesComponent;
let fixture: ComponentFixture<ProjectRolesComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProjectRolesComponent],
imports: [
NoopAnimationsModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProjectRolesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should compile', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,110 @@
import { SelectionModel } from '@angular/cdk/collections';
import { AfterViewInit, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatTable } from '@angular/material/table';
import { tap } from 'rxjs/operators';
import { ProjectRole } from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
import { ProjectRolesDataSource } from './project-roles-datasource';
@Component({
selector: 'app-project-roles',
templateUrl: './project-roles.component.html',
styleUrls: ['./project-roles.component.scss'],
})
export class ProjectRolesComponent implements AfterViewInit, OnInit {
@Input() public projectId: string = '';
@Input() public disabled: boolean = false;
@Input() public actionsVisible: boolean = false;
@ViewChild(MatPaginator) public paginator!: MatPaginator;
@ViewChild(MatTable) public table!: MatTable<ProjectRole.AsObject>;
public dataSource!: ProjectRolesDataSource;
public selection: SelectionModel<ProjectRole.AsObject> = new SelectionModel<ProjectRole.AsObject>(true, []);
@Output() public changedSelection: EventEmitter<Array<ProjectRole.AsObject>> = new EventEmitter();
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
public displayedColumns: string[] = ['select', 'name', 'displayname', 'group'];
constructor(private projectService: ProjectService, private toast: ToastService) { }
public ngOnInit(): void {
this.dataSource = new ProjectRolesDataSource(this.projectService);
this.dataSource.loadRoles(this.projectId, 0, 25, 'asc');
this.selection.changed.subscribe(() => {
this.changedSelection.emit(this.selection.selected);
});
}
public ngAfterViewInit(): void {
this.paginator.page
.pipe(
tap(() => this.loadRolesPage()),
)
.subscribe();
}
public selectAllOfGroup(group: string): void {
const groupRoles: ProjectRole.AsObject[] = this.dataSource.rolesSubject.getValue()
.filter(role => role.group === group);
this.selection.select(...groupRoles);
}
private loadRolesPage(): void {
this.dataSource.loadRoles(
this.projectId,
this.paginator.pageIndex,
this.paginator.pageSize,
);
}
public isAllSelected(): boolean {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource.rolesSubject.value.length;
return numSelected === numRows;
}
public masterToggle(): void {
this.isAllSelected() ?
this.selection.clear() :
this.dataSource.rolesSubject.value.forEach((row: ProjectRole.AsObject) => this.selection.select(row));
}
public deleteSelectedRoles(): Promise<any> {
const oldState = this.dataSource.rolesSubject.value;
const indexes = this.selection.selected.map(sel => {
return oldState.findIndex(iter => iter.key === sel.key);
});
return Promise.all(this.selection.selected.map(role => {
return this.projectService.RemoveProjectRole(role.projectId, role.key);
})).then(() => {
this.toast.showInfo('Deleted');
indexes.forEach(index => {
if (index > -1) {
oldState.splice(index, 1);
this.dataSource.rolesSubject.next(this.dataSource.rolesSubject.value);
}
});
this.selection.clear();
}).catch(error => {
this.toast.showError(error?.message || 'Error');
});
}
public removeRole(role: ProjectRole.AsObject, index: number): void {
this.projectService
.RemoveProjectRole(role.projectId, role.key)
.then(() => {
this.toast.showInfo('Role removed');
this.dataSource.rolesSubject.value.splice(index, 1);
this.dataSource.rolesSubject.next(this.dataSource.rolesSubject.value);
})
.catch(data => {
this.toast.showError(data.message);
});
}
}
@@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { ProjectRolesComponent } from './project-roles.component';
@NgModule({
declarations: [ProjectRolesComponent],
imports: [
CommonModule,
MatButtonModule,
HasRoleModule,
MatTableModule,
MatPaginatorModule,
MatIconModule,
MatProgressSpinnerModule,
MatCheckboxModule,
RouterModule,
MatTooltipModule,
TranslateModule,
MatMenuModule,
],
exports: [
ProjectRolesComponent,
],
})
export class ProjectRolesModule { }
@@ -0,0 +1,28 @@
<form>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Project Name</mat-label>
<input matInput *ngIf="singleOutput" type="text" placeholder="Search for the project name" #nameInput
[formControl]="myControl" [matAutocomplete]="auto" />
<mat-chip-list *ngIf="!singleOutput" #chipList aria-label="name selection">
<mat-chip class="chip" *ngFor="let selectedProject of projects" [selectable]="selectable"
[removable]="removable" (removed)="remove(selectedProject)">
{{selectedProject.name}}
<mat-icon matChipRemove *ngIf="removable">cancel</mat-icon>
</mat-chip>
<input placeholder="{{'PROJECT.NAME' | translate}}" #nameInput [formControl]="myControl"
[matAutocomplete]="auto" [matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes" [matChipInputAddOnBlur]="addOnBlur"
(matChipInputTokenEnd)="add($event)" />
</mat-chip-list>
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" [displayWith]="displayFn">
<mat-option *ngIf="isLoading" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let project of filteredProjects" [value]="project">
{{project.name}}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</form>
@@ -0,0 +1,3 @@
.full-width {
width: 100%;
}
@@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchProjectAutocompleteComponent } from './search-project-autocomplete.component';
describe('SearchProjectComponent', () => {
let component: SearchProjectAutocompleteComponent;
let fixture: ComponentFixture<SearchProjectAutocompleteComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [SearchProjectAutocompleteComponent],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchProjectAutocompleteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,108 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { from } from 'rxjs';
import { debounceTime, switchMap, tap } from 'rxjs/operators';
import { Project, ProjectSearchKey, ProjectSearchQuery, SearchMethod } from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
@Component({
selector: 'app-search-project-autocomplete',
templateUrl: './search-project-autocomplete.component.html',
styleUrls: ['./search-project-autocomplete.component.scss'],
})
export class SearchProjectAutocompleteComponent {
public selectable: boolean = true;
public removable: boolean = true;
public addOnBlur: boolean = true;
public separatorKeysCodes: number[] = [ENTER, COMMA];
public myControl: FormControl = new FormControl();
public names: string[] = [];
public projects: Array<Project.AsObject> = [];
public filteredProjects: Array<Project.AsObject> = [];
public isLoading: boolean = false;
@ViewChild('nameInput') public nameInput!: ElementRef<HTMLInputElement>;
@ViewChild('auto') public matAutocomplete!: MatAutocomplete;
@Input() public singleOutput: boolean = false;
@Output() public selectionChanged: EventEmitter<Project.AsObject[] | Project.AsObject> = new EventEmitter();
constructor(private projectService: ProjectService, private toast: ToastService) {
this.myControl.valueChanges
.pipe(
debounceTime(200),
tap(() => this.isLoading = true),
switchMap(value => {
const query = new ProjectSearchQuery();
query.setKey(ProjectSearchKey.PROJECTSEARCHKEY_PROJECT_NAME);
query.setValue(value);
query.setMethod(SearchMethod.SEARCHMETHOD_CONTAINS);
return from(this.projectService.SearchProjects(10, 0, [query]));
}),
// finalize(() => this.isLoading = false),
).subscribe((projects) => {
console.log(projects.toObject().resultList);
this.isLoading = false;
this.filteredProjects = projects.toObject().resultList;
});
}
public displayFn(project?: Project.AsObject): string | undefined {
return project ? `${project.name}` : undefined;
}
public add(event: MatChipInputEvent): void {
if (!this.matAutocomplete.isOpen) {
const input = event.input;
const value = event.value;
if ((value || '').trim()) {
const index = this.filteredProjects.findIndex((project) => {
if (project.name) {
return project.name === value;
}
});
if (index > -1) {
if (this.projects && this.projects.length > 0) {
this.projects.push(this.filteredProjects[index]);
} else {
this.projects = [this.filteredProjects[index]];
}
}
}
if (input) {
input.value = '';
}
}
}
public remove(project: Project.AsObject): void {
const index = this.projects.indexOf(project);
if (index >= 0) {
this.projects.splice(index, 1);
}
}
public selected(event: MatAutocompleteSelectedEvent): void {
const index = this.filteredProjects.findIndex((project) => project === event.option.value);
if (index !== -1) {
if (this.singleOutput) {
this.selectionChanged.emit(this.filteredProjects[index]);
} else {
if (this.projects && this.projects.length > 0) {
this.projects.push(this.filteredProjects[index]);
} else {
this.projects = [this.filteredProjects[index]];
}
this.selectionChanged.emit(this.projects);
this.nameInput.nativeElement.value = '';
this.myControl.setValue(null);
}
}
}
}
@@ -0,0 +1,34 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SearchProjectAutocompleteComponent } from './search-project-autocomplete.component';
@NgModule({
declarations: [SearchProjectAutocompleteComponent],
imports: [
CommonModule,
MatAutocompleteModule,
MatChipsModule,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
FormsModule,
TranslateModule,
],
exports: [
SearchProjectAutocompleteComponent,
],
})
export class SearchProjectAutocompleteModule { }
@@ -0,0 +1,27 @@
<form>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Role Name</mat-label>
<input matInput *ngIf="singleOutput" type="text" placeholder="Search for the role name" #nameInput
[formControl]="myControl" [matAutocomplete]="auto" />
<mat-chip-list *ngIf="!singleOutput" #chipList aria-label="name selection">
<mat-chip class="chip" *ngFor="let selectedRole of roles" [selectable]="selectable" [removable]="removable"
(removed)="remove(selectedRole)">
{{selectedRole.displayName}}
<mat-icon matChipRemove *ngIf="removable">cancel</mat-icon>
</mat-chip>
<input placeholder="Role Name" #nameInput [formControl]="myControl" [matAutocomplete]="auto"
[matChipInputFor]="chipList" [matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[matChipInputAddOnBlur]="addOnBlur" (matChipInputTokenEnd)="add($event)" />
</mat-chip-list>
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" [displayWith]="displayFn">
<mat-option *ngIf="isLoading" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let role of filteredRoles" [value]="role.key">
{{role.displayName}}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</form>
@@ -0,0 +1,3 @@
.full-width {
width: 100%;
}
@@ -0,0 +1,27 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchRolesAutocompleteComponent } from './search-roles-autocomplete.component';
describe('SearchProjectComponent', () => {
let component: SearchRolesAutocompleteComponent;
let fixture: ComponentFixture<SearchRolesAutocompleteComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [SearchRolesAutocompleteComponent],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchRolesAutocompleteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,117 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { from } from 'rxjs';
import { debounceTime, switchMap, tap } from 'rxjs/operators';
import {
ProjectRole,
ProjectRoleSearchKey,
ProjectRoleSearchQuery,
SearchMethod,
} from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
@Component({
selector: 'app-search-roles-autocomplete',
templateUrl: './search-roles-autocomplete.component.html',
styleUrls: ['./search-roles-autocomplete.component.scss'],
})
export class SearchRolesAutocompleteComponent {
public selectable: boolean = true;
public removable: boolean = true;
public addOnBlur: boolean = true;
public separatorKeysCodes: number[] = [ENTER, COMMA];
public myControl: FormControl = new FormControl();
public names: string[] = [];
public roles: Array<ProjectRole.AsObject> = [];
public filteredRoles: Array<ProjectRole.AsObject> = [];
public isLoading: boolean = false;
@ViewChild('nameInput') public nameInput!: ElementRef<HTMLInputElement>;
@ViewChild('auto') public matAutocomplete!: MatAutocomplete;
@Input() public projectId: string = '';
@Input() public singleOutput: boolean = false;
@Output() public selectionChanged: EventEmitter<ProjectRole.AsObject[] | ProjectRole.AsObject> = new EventEmitter();
constructor(private projectService: ProjectService, private toast: ToastService) {
this.myControl.valueChanges
.pipe(
debounceTime(200),
tap(() => this.isLoading = true),
switchMap(value => {
const query = new ProjectRoleSearchQuery();
query.setKey(ProjectRoleSearchKey.PROJECTROLESEARCHKEY_DISPLAY_NAME);
query.setMethod(SearchMethod.SEARCHMETHOD_CONTAINS);
query.setValue(value);
return from(this.projectService.SearchProjectRoles(this.projectId, 10, 0, [query]));
}),
).subscribe((roles) => {
console.log(roles.toObject().resultList);
this.isLoading = false;
this.filteredRoles = roles.toObject().resultList;
}, error => {
console.log(error);
this.isLoading = false;
});
}
public displayFn(project?: ProjectRole.AsObject): string | undefined {
return project ? `${project.displayName}` : undefined;
}
public add(event: MatChipInputEvent): void {
if (!this.matAutocomplete.isOpen) {
const input = event.input;
const value = event.value;
if ((value || '').trim()) {
const index = this.filteredRoles.findIndex((role) => {
if (role.key) {
return role.key === value;
}
});
if (index > -1) {
if (this.roles && this.roles.length > 0) {
this.roles.push(this.filteredRoles[index]);
} else {
this.roles = [this.filteredRoles[index]];
}
}
}
if (input) {
input.value = '';
}
}
}
public remove(role: ProjectRole.AsObject): void {
const index = this.roles.indexOf(role);
if (index >= 0) {
this.roles.splice(index, 1);
}
}
public selected(event: MatAutocompleteSelectedEvent): void {
const index = this.filteredRoles.findIndex((role) => role.key === event.option.value);
if (index !== -1) {
if (this.singleOutput) {
this.selectionChanged.emit(this.filteredRoles[index]);
} else {
if (this.roles && this.roles.length > 0) {
this.roles.push(this.filteredRoles[index]);
} else {
this.roles = [this.filteredRoles[index]];
}
this.selectionChanged.emit(this.roles);
this.nameInput.nativeElement.value = '';
this.myControl.setValue(null);
}
}
}
}
@@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SearchRolesAutocompleteComponent } from './search-roles-autocomplete.component';
@NgModule({
declarations: [SearchRolesAutocompleteComponent],
imports: [
CommonModule,
MatAutocompleteModule,
MatChipsModule,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
FormsModule,
TranslateModule,
],
exports: [
SearchRolesAutocompleteComponent,
],
})
export class SearchRolesAutocompleteModule { }
@@ -0,0 +1,54 @@
<form>
<mat-form-field *ngIf="target == UserTarget.SELF" appearance="outline" class="full-width">
<mat-label>Organizations User Email</mat-label>
<input matInput *ngIf="singleOutput" type="text" placeholder="Search for the user email" #usernameInput
[formControl]="myControl" [matAutocomplete]="auto" />
<mat-chip-list *ngIf="!singleOutput" #chipList aria-label="useremail selection">
<mat-chip class="chip" *ngFor="let selecteduser of users" [selectable]="selectable" [removable]="removable"
(removed)="remove(selecteduser)">
{{ selecteduser?.firstName }} {{selecteduser.lastName}} | <small> {{selecteduser.email}}</small>
<mat-icon matChipRemove *ngIf="removable">cancel</mat-icon>
</mat-chip>
<input placeholder="{{'ORG_DETAIL.MEMBER.EMAIL' | translate}}" #usernameInput [formControl]="myControl"
[matAutocomplete]="auto" [matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes" [matChipInputAddOnBlur]="addOnBlur"
(matChipInputTokenEnd)="add($event)" />
</mat-chip-list>
<!-- <mat-hint *ngIf="hint">
{{hint}}
<a (click)=" changeTarget()">{{'USER.TARGET.CLICKHERE' | translate}}</a>
</mat-hint> -->
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" [displayWith]="displayFn">
<mat-option *ngIf="isLoading" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let user of filteredUsers" [value]="user">
{{user.firstName}} {{user.lastName}}
<small>{{user.email}}</small>
</mat-option>
</mat-autocomplete>
</mat-form-field>
<div *ngIf="target == UserTarget.EXTERNAL" class="line">
<mat-form-field appearance="outline">
<mat-label>Global User Email</mat-label>
<input matInput type="text" [formControl]="globalEmailControl" />
</mat-form-field>
<button mat-icon-button (click)="getGlobalUser()">
<mat-icon>search</mat-icon>
</button>
</div>
<p *ngIf="target == UserTarget.EXTERNAL && users.length > 0">{{'USER.SEARCH.FOUND' | translate}}: <span
*ngFor="let user of users; index as i">{{user.email}} <mat-icon class="sm-dlt" (click)="users.splice(i, 1)">
remove_circle</mat-icon></span></p>
<p class="target-desc">{{(target == UserTarget.SELF ? 'USER.TARGET.SELF' : 'USER.TARGET.EXTERNAL') | translate}}
<a (click)="changeTarget()">{{'USER.TARGET.CLICKHERE' | translate}}</a>
</p>
</form>
@@ -0,0 +1,36 @@
.full-width {
width: 100%;
}
.target-desc {
color: #81868a;
font-size: .8rem;
margin: 0;
margin-bottom: 1rem;
a {
color: white;
&:hover {
cursor: pointer;
color: white;
text-decoration: underline;
}
}
}
.line {
width: 100%;
display: flex;
mat-form-field {
flex: 1;
}
button {
margin-top: 1rem;
}
}
.sm-dlt {
cursor: pointer;
font-size: .8rem;
}
@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchUserAutocompleteComponent } from './search-user-autocomplete.component';
describe('SearchUserAutocompleteComponent', () => {
let component: SearchUserAutocompleteComponent;
let fixture: ComponentFixture<SearchUserAutocompleteComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [SearchUserAutocompleteComponent],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchUserAutocompleteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,146 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { from, of, Subject } from 'rxjs';
import { debounceTime, switchMap, takeUntil, tap } from 'rxjs/operators';
import { SearchMethod, User, UserSearchKey, UserSearchQuery } from 'src/app/proto/generated/management_pb';
import { MgmtUserService } from 'src/app/services/mgmt-user.service';
import { ToastService } from 'src/app/services/toast.service';
export enum UserTarget {
SELF = 'self',
EXTERNAL = 'external',
}
@Component({
selector: 'app-search-user-autocomplete',
templateUrl: './search-user-autocomplete.component.html',
styleUrls: ['./search-user-autocomplete.component.scss'],
})
export class SearchUserAutocompleteComponent {
public selectable: boolean = true;
public removable: boolean = true;
public addOnBlur: boolean = true;
public separatorKeysCodes: number[] = [ENTER, COMMA];
public myControl: FormControl = new FormControl();
public globalEmailControl: FormControl = new FormControl();
public emails: string[] = [];
public users: Array<User.AsObject> = [];
public filteredUsers: Array<User.AsObject> = [];
public isLoading: boolean = false;
public target: UserTarget = UserTarget.SELF;
public hint: string = '';
public UserTarget: any = UserTarget;
@ViewChild('usernameInput') public usernameInput!: ElementRef<HTMLInputElement>;
@ViewChild('auto') public matAutocomplete!: MatAutocomplete;
@Output() public selectionChanged: EventEmitter<User.AsObject | User.AsObject[]> = new EventEmitter();
@Input() public singleOutput: boolean = false;
private unsubscribed$: Subject<void> = new Subject();
constructor(private userService: MgmtUserService, private toast: ToastService) {
this.getFilteredResults();
}
private getFilteredResults(): void {
this.myControl.valueChanges.pipe(debounceTime(200),
takeUntil(this.unsubscribed$),
tap(() => this.isLoading = true),
switchMap(value => {
const query = new UserSearchQuery();
query.setKey(UserSearchKey.USERSEARCHKEY_EMAIL);
query.setValue(value);
query.setMethod(SearchMethod.SEARCHMETHOD_CONTAINS);
if (this.target === UserTarget.SELF) {
return from(this.userService.SearchUsers(10, 0, [query]));
} else {
return of(); // from(this.userService.GetUserByEmailGlobal(value));
}
}),
).subscribe((userresp: any) => {
this.isLoading = false;
if (this.target === UserTarget.SELF && userresp) {
this.filteredUsers = userresp.toObject().resultList;
}
});
}
public displayFn(user?: User.AsObject): string | undefined {
return user ? `${user.email}` : undefined;
}
public add(event: MatChipInputEvent): void {
if (!this.matAutocomplete.isOpen) {
const input = event.input;
const value = event.value;
if ((value || '').trim()) {
const index = this.filteredUsers.findIndex((user) => {
if (user.email) {
return user.email === value;
}
});
if (index > -1) {
if (this.users && this.users.length > 0) {
this.users.push(this.filteredUsers[index]);
} else {
this.users = [this.filteredUsers[index]];
}
}
}
if (input) {
input.value = '';
}
}
}
public remove(user: User.AsObject): void {
const index = this.users.indexOf(user);
if (index >= 0) {
this.users.splice(index, 1);
}
}
public selected(event: MatAutocompleteSelectedEvent): void {
const index = this.filteredUsers.findIndex((user) => user === event.option.value);
if (index !== -1) {
if (this.singleOutput) {
this.selectionChanged.emit(this.filteredUsers[index]);
} else {
if (this.users && this.users.length > 0) {
this.users.push(this.filteredUsers[index]);
} else {
this.users = [this.filteredUsers[index]];
}
this.selectionChanged.emit(this.users);
}
}
this.usernameInput.nativeElement.value = '';
this.myControl.setValue(null);
}
public changeTarget(): void {
if (this.target === UserTarget.SELF) {
this.target = UserTarget.EXTERNAL;
this.filteredUsers = [];
this.unsubscribed$.next(); // clear old subscription
} else if (this.target === UserTarget.EXTERNAL) {
this.target = UserTarget.SELF;
this.getFilteredResults(); // new subscription
}
}
public getGlobalUser(): void {
this.userService.GetUserByEmailGlobal(this.globalEmailControl.value).then(user => {
this.users = [user.toObject()];
this.selectionChanged.emit(this.users);
}).catch(error => {
this.toast.showError(error.message);
});
}
}
@@ -0,0 +1,33 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SearchUserAutocompleteComponent } from './search-user-autocomplete.component';
@NgModule({
declarations: [SearchUserAutocompleteComponent],
imports: [
CommonModule,
MatAutocompleteModule,
MatChipsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
FormsModule,
TranslateModule,
],
exports: [SearchUserAutocompleteComponent],
})
export class SearchUserAutocompleteModule { }