mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-31 19:42:15 +00:00
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:
co-authored by
Livio Amstutz
Max Peintner
parent
9e32740eb8
commit
92a294f5c8
@@ -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;
|
||||
}
|
||||
}
|
||||
+26
@@ -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 { }
|
||||
+28
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
+26
@@ -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();
|
||||
});
|
||||
});
|
||||
+108
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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 { }
|
||||
+27
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
+27
@@ -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();
|
||||
});
|
||||
});
|
||||
+117
@@ -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 { }
|
||||
+54
@@ -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>
|
||||
+36
@@ -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;
|
||||
}
|
||||
+25
@@ -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();
|
||||
});
|
||||
});
|
||||
+146
@@ -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 { }
|
||||
Reference in New Issue
Block a user