Adds admin ui

parent 10c68924
Pipeline #21426 failed with stages
in 6 minutes and 17 seconds
......@@ -27,10 +27,13 @@
"compress": "gzip dist/browser/** -r",
"http-startup": "http-server dist/browser/ -p 4711",
"prod-test": "npm run build:PROD && npm run purify && npm run compress && npm run http-startup",
"prod-test:SSR": "npm run build:SSR && npm run start:SSR",
"prod-test:SSR": "npm run build:SSR && npm run job && npm run start:SSR",
"job": "npm run job:images; npm run job:link; npm run job:manifest",
"job:images:logo": "cd dist/browser/assets/jobs/; node --experimental-modules GenerateImages.mjs --command=generateLogoImages",
"job:images:frontend": "cd dist/browser/assets/jobs/; node --experimental-modules GenerateImages.mjs --command=generateFrontendPreview --host=http://localhost:4210",
"job:images": "cd dist/browser/assets/jobs/; node --experimental-modules GenerateImages.mjs --command=all"
"job:images:frontend": "cd dist/browser/assets/jobs/; node --experimental-modules GenerateImages.mjs --command=generateFrontendPreview --host=http://localhost:4000",
"job:images": "cd dist/browser/assets/jobs/; node --experimental-modules GenerateImages.mjs --command=all --host=http://localhost:4000",
"job:link": "cd dist/browser/assets/jobs/; node GenerateMetaNodes.js --command=generateLinkImages --baseUrl=http://localhost:4000",
"job:manifest": "cd dist/browser/assets/jobs/; node GenerateMetaNodes.js --command=generateManifest --baseUrl=http://localhost:4000"
},
"private": true,
"dependencies": {
......@@ -45,6 +48,10 @@
"@angular/platform-server": "^7.1.1",
"@angular/router": "^7.1.1",
"@angular/service-worker": "^7.1.1",
"@auth0/angular-jwt": "2.0.0",
"@fortawesome/angular-fontawesome": "^0.3.0",
"@fortawesome/fontawesome-svg-core": "^1.2.8",
"@fortawesome/free-solid-svg-icons": "^5.5.0",
"@ng-bootstrap/ng-bootstrap": "^4.0.0",
"@ng-bootstrap/schematics": "^2.0.0-alpha.1",
"@nguniversal/express-engine": "^7.0.2",
......@@ -65,8 +72,7 @@
"ngx-translate-messageformat-compiler": "^4.4.0",
"rxjs": "^6.3.3",
"ts-loader": "^5.3.1",
"zone.js": "^0.8.26",
"@auth0/angular-jwt": "2.0.0"
"zone.js": "^0.8.26"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.11.0",
......
<div class="card mx-sm-auto mx-5 my-3 w-100"
[routerLink]="['/admin', 'quiz']">
<div class="card-body">
<p class="card-text">Quiz Admin</p>
<p class="card-subtitle">Administrate known quizzes</p>
</div>
</div>
<div class="card mx-sm-auto mx-5 my-3 w-100"
[routerLink]="['/admin', 'user']">
<div class="card-body">
<p class="card-text">User Admin</p>
<p class="card-subtitle">Administrate current users</p>
</div>
</div>
\ No newline at end of file
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminOverviewComponent } from './admin-overview.component';
describe('AdminOverviewComponent', () => {
let component: AdminOverviewComponent;
let fixture: ComponentFixture<AdminOverviewComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AdminOverviewComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AdminOverviewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import { Component, OnInit } from '@angular/core';
import { FooterBarService } from '../../service/footer-bar/footer-bar.service';
@Component({
selector: 'app-admin-overview',
templateUrl: './admin-overview.component.html',
styleUrls: ['./admin-overview.component.scss'],
})
export class AdminOverviewComponent implements OnInit {
constructor(private footerBarService: FooterBarService) {
this.updateFooterElements();
}
public ngOnInit(): void {
}
private updateFooterElements(): void {
const footerElements = [
this.footerBarService.footerElemBack,
];
this.footerBarService.replaceFooterElements(footerElements);
}
}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { SharedModule } from '../shared/shared.module';
import { AdminOverviewComponent } from './admin-overview/admin-overview.component';
import { QuizAdminComponent } from './quiz-admin/quiz-admin.component';
import { UserAdminComponent } from './user-admin/user-admin.component';
const routes: Routes = [
{
path: 'user',
component: UserAdminComponent,
}, {
path: 'quiz',
component: QuizAdminComponent,
}, {
path: '',
component: AdminOverviewComponent,
},
];
@NgModule({
declarations: [UserAdminComponent, QuizAdminComponent, AdminOverviewComponent],
imports: [
SharedModule, RouterModule.forChild(routes),
],
})
export class AdminModule {
}
<div *ngFor="let quiz of data; let i = index"
class="card mx-sm-auto mx-5 my-3 w-100">
<div class="card-body">
<div class="d-flex justify-content-end">
<fa-icon *ngIf="isDeletingElem(i)"
[icon]="'spinner'"
[spin]="true"></fa-icon>
<fa-icon *ngIf="!isDeletingElem(i)"
[icon]="'times'"
class="cursor-pointer"
(click)="deleteElem(i)"></fa-icon>
</div>
<p class="card-text">
<span>Name: </span>
<span *ngIf="quiz.name">{{quiz.name}}</span>
<span *ngIf="quiz.originalObject?.hashtag">{{quiz.originalObject.hashtag}}</span>
</p>
<p class="card-text">Is Active: {{isActiveQuiz(quiz)}}</p>
<ng-container *ngIf="isActiveQuiz(quiz)">
<p class="card-text">MemberEntity Groups:</p>
<ul>
<li *ngFor="let group of quiz.memberGroups">
<span>Name: {{group.name}}</span>
<ul>
<li *ngFor="let member of group.members">
<span>MemberEntity Name: {{member}}</span>
</li>
</ul>
</li>
</ul>
<ul>
<li *ngFor="let question of quiz.questionList">
<span>Type: {{question.TYPE}}</span>
<ul>
<li *ngFor="let answers of group.answerOptionList">
<span>Answer Text: {{answers.answerText}}</span><br/>
<span>Is Correct: {{answers.isCorrect}}</span><br/>
<span>Type: {{answers.TYPE}}</span><br/>
</li>
</ul>
<span>Display Answer Text: {{question.displayAnswerText}}</span>
<span>Multiple Selection Enabled: {{question.multipleSelectionEnabled}}</span>
<span>Show One Answer Per Row: {{question.showOneAnswerPerRow}}</span>
<span>Timer: {{question.timer}}</span>
<span>Question Text: {{question.questionText}}</span>
</li>
</ul>
<p class="card-text">Music Config</p>
<p class="card-text">Lobby</p>
<ul>
<li>Enabled: {{quiz.originalObject.sessionConfig.music.enabled.lobby}}</li>
<li>Title: {{quiz.originalObject.sessionConfig.music.titleConfig.lobby}}</li>
<li>Title: {{quiz.originalObject.sessionConfig.music.volumeConfig.lobby}}</li>
</ul>
<p class="card-text">Countdown Running</p>
<ul>
<li>Enabled: {{quiz.originalObject.sessionConfig.music.enabled.countdownRunning}}</li>
<li>Title: {{quiz.originalObject.sessionConfig.music.titleConfig.countdownRunning}}</li>
<li>Title: {{quiz.originalObject.sessionConfig.music.volumeConfig.countdownRunning}}</li>
</ul>
<p class="card-text">Countdown End</p>
<ul>
<li>Enabled: {{quiz.originalObject.sessionConfig.music.enabled.countdownEnd}}</li>
<li>Title: {{quiz.originalObject.sessionConfig.music.titleConfig.countdownEnd}}</li>
<li>Title: {{quiz.originalObject.sessionConfig.music.volumeConfig.countdownEnd}}</li>
</ul>
<p class="card-text">Nick Config</p>
<ul>
<li>MemberEntity Groups: {{quiz.originalObject.sessionConfig.nicks.memberGroups}}</li>
<li>Max Members Per Group: {{quiz.originalObject.sessionConfig.nicks.maxMembersPerGroup}}</li>
<li>Automatically join to Group: {{quiz.originalObject.sessionConfig.nicks.autoJoinToGroup}}</li>
<li>Selected Nicks: {{quiz.originalObject.sessionConfig.nicks.selectedNicks}}</li>
<li>blockIllegalNicks: {{quiz.originalObject.sessionConfig.nicks.blockIllegalNicks}}</li>
<li>restrictToCasLogin: {{quiz.originalObject.sessionConfig.nicks.restrictToCasLogin}}</li>
</ul>
<p class="card-text">Session Config</p>
<ul>
<li>readingConfirmationEnabled: {{quiz.originalObject.sessionConfig.readingConfirmationEnabled}}</li>
<li>confidenceSliderEnabled: {{quiz.originalObject.sessionConfig.confidenceSliderEnabled}}</li>
<li>showResponseProgress: {{quiz.originalObject.sessionConfig.showResponseProgress}}</li>
<li>theme: {{quiz.originalObject.sessionConfig.theme}}</li>
</ul>
</ng-container>
</div>
</div>
\ No newline at end of file
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { QuizAdminComponent } from './quiz-admin.component';
describe('QuizAdminComponent', () => {
let component: QuizAdminComponent;
let fixture: ComponentFixture<QuizAdminComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ QuizAdminComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(QuizAdminComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import { Component, OnInit } from '@angular/core';
import { AdminService } from '../../service/api/admin/admin.service';
import { FooterBarService } from '../../service/footer-bar/footer-bar.service';
@Component({
selector: 'app-quiz-admin',
templateUrl: './quiz-admin.component.html',
styleUrls: ['./quiz-admin.component.scss'],
})
export class QuizAdminComponent implements OnInit {
private _data: Array<object>;
get data(): Array<object> {
return this._data;
}
private _deletingElements: Array<number> = [];
constructor(private footerBarService: FooterBarService, private adminService: AdminService) {
this.updateFooterElements();
}
public ngOnInit(): void {
this.adminService.getAvailableQuizzes().subscribe(data => {
this._data = data;
});
}
public isActiveQuiz(quiz): boolean {
return quiz.hasOwnProperty('originalObject');
}
public isDeletingElem(index: number): boolean {
return this._deletingElements.indexOf(index) > -1;
}
public deleteElem(index: number): void {
this._deletingElements.push(index);
this.adminService.deleteQuiz((this._data[index] as any).name || (this._data[index] as any).originalObject.hashtag).subscribe(() => {
this._deletingElements.splice(this._deletingElements.indexOf(index), 1);
this._data.splice(index, 1);
}, () => {
this._deletingElements.splice(this._deletingElements.indexOf(index), 1);
});
}
private updateFooterElements(): void {
const footerElements = [
this.footerBarService.footerElemBack,
];
this.footerBarService.replaceFooterElements(footerElements);
}
}
<button class="btn btn-primary"
(click)="showAddUserModal()">
Add User
</button>
<div *ngFor="let user of data; let i = index"
class="card mx-sm-auto mx-5 my-3 w-100">
<div class="card-body">
<div class="d-flex justify-content-end">
<fa-icon *ngIf="!isDeletingElem(i)"
class="cursor-pointer mr-2"
(click)="editElem(i)"
[icon]="'edit'"></fa-icon>
<fa-icon *ngIf="isDeletingElem(i)"
[icon]="'spinner'"
[spin]="true"></fa-icon>
<fa-icon *ngIf="!isDeletingElem(i)"
[icon]="'times'"
class="cursor-pointer"
(click)="deleteElem(i)"></fa-icon>
</div>
<p class="card-text">Username: {{user.username}}</p>
<p class="card-text">Gitlab Token: {{user.gitlabToken}}</p>
<p class="card-text">
<span>Password-Hash:</span><br/>
<span>{{user.passwordHash}}</span>
</p>
<p class="card-text">User Authorizations:</p>
<ul>
<li *ngFor="let auth of user.userAuthorizations">{{auth}}</li>
</ul>
</div>
</div>
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { UserAdminComponent } from './user-admin.component';
describe('UserAdminComponent', () => {
let component: UserAdminComponent;
let fixture: ComponentFixture<UserAdminComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ UserAdminComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(UserAdminComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import { Component, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { AddUserComponent } from '../../modals/add-user/add-user.component';
import { AdminService } from '../../service/api/admin/admin.service';
import { FooterBarService } from '../../service/footer-bar/footer-bar.service';
import { UserService } from '../../service/user/user.service';
@Component({
selector: 'app-user-admin',
templateUrl: './user-admin.component.html',
styleUrls: ['./user-admin.component.scss'],
})
export class UserAdminComponent implements OnInit {
private _data: Array<object>;
get data(): Array<object> {
return this._data;
}
private _deletingElements: Array<number> = [];
constructor(private userService: UserService,
private footerBarService: FooterBarService,
private adminService: AdminService,
private ngbModal: NgbModal,
) {
this.updateFooterElements();
}
public ngOnInit(): void {
this.adminService.getAvailableUsers().subscribe(data => {
this._data = data;
});
}
public isDeletingElem(index: number): boolean {
return this._deletingElements.indexOf(index) > -1;
}
public deleteElem(index: number): void {
this._deletingElements.push(index);
this.adminService.deleteUser((this._data[index] as any).username).subscribe(() => {
this._deletingElements.splice(this._deletingElements.indexOf(index), 1);
this._data.splice(index, 1);
}, () => {
this._deletingElements.splice(this._deletingElements.indexOf(index), 1);
});
}
public showAddUserModal(): void {
this.ngbModal.open(AddUserComponent).result.then(value => {
value.passwordHash = this.userService.hashPassword(value.username, value.password);
delete value.password;
this.adminService.updateUser(value).subscribe(() => {
this._data.push(value);
});
}).catch(() => {});
}
public editElem(index: number): void {
const ref = this.ngbModal.open(AddUserComponent);
ref.componentInstance.username = (this._data[index] as any).username;
ref.componentInstance.gitlabToken = (this._data[index] as any).gitlabToken;
ref.componentInstance.userAuthorizations = (this._data[index] as any).userAuthorizations;
ref.result.then(value => {
value.originalUser = (this._data[index] as any).username;
this.adminService.updateUser(value).subscribe(() => {
value.passwordHash = this.userService.hashPassword(value.username, value.password);
delete value.password;
(this._data[index] as any).username = value.username;
(this._data[index] as any).passwordHash = value.passwordHash;
(this._data[index] as any).gitlabToken = value.gitlabToken;
(this._data[index] as any).userAuthorizations = value.userAuthorizations;
});
}).catch(() => {});
}
private updateFooterElements(): void {
const footerElements = [
this.footerBarService.footerElemBack,
];
this.footerBarService.replaceFooterElements(footerElements);
}
}
......@@ -19,8 +19,8 @@
[attr.data-intro]="elem.showIntro ? (('region.footer.footer_bar.description.' + elem.id) | translate) : null"
(click)="toggleSetting(elem)">
<p class="footerElemIcon mb-0">
<i [class]="elem.iconClass"
aria-hidden="true"></i>
<fa-icon [icon]="elem.iconClass"
aria-hidden="true"></fa-icon>
</p>
<p class="footerElemText text-truncate text-left mb-0 ml-2 d-md-block"><span [class]="elem.textClass"
[innerHTML]="elem.textName | translate"></span></p>
......@@ -31,11 +31,11 @@
id="footer-move-left"
[style.visibility]="footerElemIndex === 1 ? 'hidden' : 'visible'"
(click)="moveLeft()">
<i class="fas fa-caret-left"></i>
<fa-icon [icon]="'caret-left'"></fa-icon>
</span>
<span class="d-flex d-sm-none position-absolute justify-content-center align-items-center"
id="footer-move-right"
[style.visibility]="hideRight() ? 'hidden' : 'visible'"
(click)="moveRight()">
<i class="fas fa-caret-right"></i>
<fa-icon [icon]="'caret-right'"></fa-icon>
</span>
\ No newline at end of file
......@@ -62,13 +62,11 @@
<div class="modal-body">
<ul class="p-0 list-unstyled">
<li>
<i class="fas"
[class.fa-check-square]="connectionService.serverAvailable"
[class.text-success]="connectionService.serverAvailable"
[class.fa-times]="!connectionService.serverAvailable"
<fa-icon [icon]="connectionService.serverAvailable ? 'check-square' : 'times'"
[transform]="'shrink-5'"
[mask]="'square'"
[class.text-danger]="!connectionService.serverAvailable"
data-fa-transform="shrink-5"
data-fa-mask="fas fa-square"></i>
[class.text-success]="connectionService.serverAvailable"></fa-icon>
<p class="ml-2 mb-0 d-inline">
<span>Server: </span>
<span *ngIf="connectionService.serverAvailable">{{'region.header.connection_status.server_status.available' | translate}}</span>
......@@ -76,13 +74,11 @@
</p>
</li>
<li>
<i class="fas"
[class.fa-check-square]="connectionService.websocketAvailable"
[class.text-success]="connectionService.websocketAvailable"
[class.fa-times]="!connectionService.websocketAvailable"
<fa-icon [icon]="connectionService.websocketAvailable ? 'check-square' : 'times'"
[transform]="'shrink-5'"
[mask]="'square'"
[class.text-danger]="!connectionService.websocketAvailable"
data-fa-transform="shrink-5"
data-fa-mask="fas fa-square"></i>
[class.text-success]="connectionService.websocketAvailable"></fa-icon>
<p class="ml-2 mb-0 d-inline">
<span>Websocket: </span>
<span *ngIf="connectionService.websocketAvailable">{{'region.header.connection_status.websocket_status.connected' | translate}}</span>
......@@ -90,13 +86,11 @@
</p>
</li>
<li>
<i class="fas"
[class.fa-check-square]="indexedDbAvailable"
[class.text-success]="indexedDbAvailable"
[class.fa-times]="!indexedDbAvailable"
[class.text-danger]="!indexedDbAvailable"
data-fa-transform="shrink-5"
data-fa-mask="fas fa-square"></i>
<fa-icon [icon]="connectionService.indexedDbAvailable ? 'check-square' : 'times'"
[transform]="'shrink-5'"
[mask]="'square'"
[class.text-danger]="!connectionService.indexedDbAvailable"
[class.text-success]="connectionService.indexedDbAvailable"></fa-icon>
<p class="ml-2 mb-0 d-inline">
<span>IndexedDb: </span>
<span *ngIf="indexedDbAvailable">{{'region.header.connection_status.indexedDb_status.writable' | translate}}</span>
......@@ -104,13 +98,11 @@
</p>
</li>
<li>
<i class="fas"
[class.fa-check-square]="connectionService.rtt < 300 && connectionService.serverAvailable"
[class.text-success]="connectionService.rtt < 300 && connectionService.serverAvailable"
[class.fa-times]="connectionService.rtt > 300 || !connectionService.serverAvailable"
<fa-icon [icon]="connectionService.rtt <= 300 && connectionService.serverAvailable ? 'check-square' : 'times'"
[transform]="'shrink-5'"
[mask]="'square'"
[class.text-danger]="connectionService.rtt > 300 || !connectionService.serverAvailable"
data-fa-transform="shrink-5"
data-fa-mask="fas fa-square"></i>
[class.text-success]="connectionService.rtt <= 300 && connectionService.serverAvailable"></fa-icon>
<p class="ml-2 mb-0 d-inline">
<span>Round-Trip-Time: </span>
<span *ngIf="connectionService.serverAvailable">{{connectionService.rtt}} ms</span>
......
......@@ -14,14 +14,14 @@
(click)="selectKey(i)">
<span class="text-danger mr-2"
*ngIf="hasEmptyKeys(elem)">
<i class="fas fa-exclamation-triangle"></i>
<fa-icon [icon]="'exclamation-triangle'"></fa-icon>
</span>
<span class="text-truncate"
[title]="elem.key">{{elem.key}}</span>
<span class="ml-auto pointer"
*ngIf="selectedIndex === i"
(click)="removeKey(i)">
<i class="fas fa-trash"></i>
<fa-icon [icon]="'trash'"></fa-icon>
</span>
</div>
</div>
......
......@@ -9,8 +9,11 @@
[title]="elem.titleRef | translate"
data-animation="false"
(click)="connector(elem)">
<i [class]="elem.iconClass"
<i *ngIf="elem.customIcon"
[class]="elem.iconClass"
aria-hidden="true"></i>
<fa-icon *ngIf="!elem.customIcon"
[icon]="elem.iconClass"></fa-icon>
</li>
</ul>
</div>
......@@ -4,6 +4,12 @@ import { QuestiontextComponent } from '../../quiz/quiz-manager/quiz-manager-deta
import { TrackingService } from '../../service/tracking/tracking.service';
class MarkdownBarElement {
private _customIcon: boolean;
get customIcon(): boolean {
return this._customIcon;
}
get hiddenByDefault(): boolean {
return this._hiddenByDefault;
}
......@@ -40,10 +46,11 @@ class MarkdownBarElement {
private readonly _titleRef: string;
private readonly _hiddenByDefault: boolean;
constructor({ id, titleRef, iconClass, iconClassToggled = iconClass, hiddenByDefault = false }) {
constructor({ id, titleRef, iconClass, iconClassToggled = iconClass, hiddenByDefault = false, customIcon = false }) {
this._id = id;
this._titleRef = titleRef;
this._iconClass = iconClass;
this._customIcon = customIcon;
this._iconClassToggled = iconClassToggled;
this._hiddenByDefault = hiddenByDefault;
}
......@@ -53,37 +60,37 @@ class MarkdownBarElement {
const BoldMarkdownButton = new MarkdownBarElement({
id: 'boldMarkdownButton',
titleRef: 'plugins.markdown_bar.tooltip.bold',
iconClass: 'fas fa-bold',
iconClass: 'bold',
});
const HeaderMarkdownButton = new MarkdownBarElement({
id: 'headerMarkdownButton',
titleRef: 'plugins.markdown_bar.tooltip.heading',
iconClass: 'fas fa-heading',
iconClass: 'heading',
});
const HyperlinkMarkdownButton = new MarkdownBarElement({
id: 'hyperlinkMarkdownButton',
titleRef: 'plugins.markdown_bar.tooltip.hyperlink',
iconClass: 'fas fa-globe',
iconClass: 'globe',
});
const UlMarkdownButton = new MarkdownBarElement({
id: 'unsortedListMarkdownButton',
titleRef: 'plugins.markdown_bar.tooltip.unordered_list',
iconClass: 'fas fa-list-ul',
iconClass: 'list-ul',
});
const CodeMarkdownButton = new MarkdownBarElement({
id: 'codeMarkdownButton',
titleRef: 'plugins.markdown_bar.tooltip.code',
iconClass: 'fas fa-code',
iconClass: 'code',
});
const ImageMarkdownButton = new MarkdownBarElement({
id: 'imageMarkdownButton',
titleRef: 'plugins.markdown_bar.tooltip.image',
iconClass: 'fas fa-image',
iconClass: 'image',
});
const ShowMoreMarkdownButton = new MarkdownBarElement({
id: 'showMoreMarkdownButton',
titleRef: 'plugins.markdown_bar.tooltip.show_more',
iconClass: 'far fa-caret-square-down',
iconClass: 'caret-square-down',
iconClassToggled: 'far fa-caret-square-up',
});
......@@ -92,24 +99,25 @@ const LatexMarkdownButton = new MarkdownBarElement({
id: 'latexMarkdownButton',
titleRef: 'plugins.markdown_bar.tooltip.latex',
iconClass: 'latexIcon',
customIcon: true,
hiddenByDefault: true,
});
const UnderlineMarkdownButton = new MarkdownBarElement({
id: 'underlineMarkdownButton',
titleRef: 'plugins.markdown_bar.tooltip.underline',
iconClass: 'fas fa-underline',