FE-737: Monaco editor (#36)

This commit is contained in:
Ildar Galeev 2019-01-16 20:29:08 +03:00 committed by GitHub
parent 24c7d26f2d
commit 835ce8d41a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 469 additions and 156 deletions

View File

@ -1,129 +1,118 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"control-center": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.css"
],
"scripts": [
"./node_modules/keycloak-js/dist/keycloak.js"
]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"control-center": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "./node_modules/monaco-editor/min/vs",
"output": "libs/vs"
},
"src/favicon.ico",
"src/assets"
],
"styles": ["src/styles.css"],
"scripts": ["./node_modules/keycloak-js/dist/keycloak.js"]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "control-center:build"
},
"configurations": {
"production": {
"browserTarget": "control-center:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "control-center:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"styles": ["src/styles.css"],
"scripts": [],
"assets": ["src/favicon.ico", "src/assets"]
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"],
"exclude": ["**/node_modules/**"]
}
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "control-center:build"
},
"configurations": {
"production": {
"browserTarget": "control-center:build:production"
"control-center-e2e": {
"root": "e2e/",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "control-center:serve"
},
"configurations": {
"production": {
"devServerTarget": "control-center:serve:production"
}
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": "e2e/tsconfig.e2e.json",
"exclude": ["**/node_modules/**"]
}
}
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "control-center:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"styles": [
"src/styles.css"
],
"scripts": [],
"assets": [
"src/favicon.ico",
"src/assets"
]
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"control-center-e2e": {
"root": "e2e/",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "control-center:serve"
},
"configurations": {
"production": {
"devServerTarget": "control-center:serve:production"
}
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": "e2e/tsconfig.e2e.json",
"exclude": [
"**/node_modules/**"
]
}
}
}
}
},
"defaultProject": "control-center"
}
"defaultProject": "control-center"
}

5
package-lock.json generated
View File

@ -7437,6 +7437,11 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz",
"integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y="
},
"monaco-editor": {
"version": "0.15.6",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.15.6.tgz",
"integrity": "sha512-JoU9V9k6KqT9R9Tiw1RTU8ohZ+Xnf9DMg6Ktqqw5hILumwmq7xqa/KLXw513uTUsWbhtnHoSJYYR++u3pkyxJg=="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",

View File

@ -34,6 +34,7 @@
"keycloak-js": "4.5.0",
"lodash-es": "^4.17.10",
"moment": "^2.22.2",
"monaco-editor": "^0.15.6",
"rxjs": "^6.3.3",
"thrift-ts": "git+ssh://git@github.com/rbkmoney/thrift-ts.git#17cea6fc8a57f899a3042917ab67761f2314aff1",
"uuid": "^3.3.2",

View File

@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import { MatSidenav } from '@angular/material';
import { Subject } from 'rxjs';
@Injectable()
export class DetailsContainerService {
opened$: Subject<boolean> = new Subject();
private detailsContainer: MatSidenav;
set container(sidenav: MatSidenav) {
this.detailsContainer = sidenav;
this.detailsContainer.openedChange.subscribe(opened => this.opened$.next(opened));
}
open() {
this.detailsContainer.open();
}
close() {
this.detailsContainer.close();
}
}

View File

@ -1,4 +1,11 @@
<form fxLayout fxLayoutGap="20px">
<form
fxLayout
fxLayout.sm="column"
fxLayout.xs="column"
fxLayoutGap="20px"
fxLayoutGap.sm="0"
fxLayoutGap.xs="0"
>
<cc-domain-objects-type-selector
fxFlex="50"
[group]="group"
@ -6,7 +13,15 @@
></cc-domain-objects-type-selector>
<mat-form-field fxFlex="50">
<span matPrefix>/</span>
<input matInput placeholder="RegExp pattern" (keyup)="patternChange($event.target.value)" />
<input
matInput
placeholder="RegExp pattern"
[value]="pattern"
(keyup)="patternChange($event.target.value)"
/>
<span matSuffix>/</span>
<button mat-button matSuffix mat-icon-button aria-label="Clear" (click)="clearPattern()">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
</form>

View File

@ -11,11 +11,19 @@ export class GroupControlComponent {
@Output() typeSelectionChange: EventEmitter<string[]> = new EventEmitter();
@Output() regExpPatternChange: EventEmitter<string> = new EventEmitter();
pattern = '';
selectionChange(selectedTypes: string[]) {
this.typeSelectionChange.emit(selectedTypes);
}
patternChange(pattern: string) {
this.regExpPatternChange.emit(pattern);
this.pattern = pattern;
this.regExpPatternChange.emit(this.pattern);
}
clearPattern() {
this.pattern = '';
this.regExpPatternChange.emit(this.pattern);
}
}

View File

@ -23,14 +23,18 @@
</ng-container>
<ng-container matColumnDef="details" stickyEnd>
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let object">
<button mat-icon-button (click)="openDetails(object.json)">
<td mat-cell *matCellDef="let object; let index = index">
<button mat-icon-button (click)="openDetails(object, index)">
<mat-icon>visibility</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="cols"></tr>
<tr mat-row *matRowDef="let object; columns: cols"></tr>
<tr
mat-row
*matRowDef="let object; columns: cols; let index = index"
[class.selected-row]="selectedIndex === index && detailsOpened"
></tr>
</table>
</div>
<mat-paginator [pageSizeOptions]="[10, 20, 50, 100]" showFirstLastButtons></mat-paginator>

View File

@ -11,7 +11,7 @@ table {
}
.mat-column-type {
padding-right: 8px
padding-right: 8px;
}
.mat-column-ref {
@ -39,3 +39,7 @@ table {
width: 100%;
overflow: auto;
}
.selected-row {
background: #f5f5f5;
}

View File

@ -1,12 +1,13 @@
import { Component, OnInit, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
import { MatTableDataSource, MatPaginator, MatSort } from '@angular/material';
import { AbstractDomainObject, DomainGroup } from '../domain-group';
import { DomainGroup } from '../domain-group';
import { DomainDetailsService } from '../../domain-details.service';
import { toTableGroup, toDataSource } from './table-group';
import { sortData } from './sort-table-data';
import { filterPredicate } from './filter-predicate';
import { TableDataSource, TableGroup } from './model';
import { DetailsContainerService } from '../../details-container.service';
@Component({
selector: 'cc-group-table',
@ -21,9 +22,14 @@ export class GroupTableComponent implements OnInit, OnChanges {
dataSource: MatTableDataSource<TableDataSource> = new MatTableDataSource();
cols = ['name', 'ref', 'data', 'details'];
selectedIndex: number;
detailsOpened: boolean;
private tableGroup: TableGroup[];
constructor(private detailsService: DomainDetailsService) {}
constructor(
private detailsService: DomainDetailsService,
private detailsContainerService: DetailsContainerService
) {}
ngOnChanges({ group }: SimpleChanges) {
if (group && group.currentValue) {
@ -36,10 +42,13 @@ export class GroupTableComponent implements OnInit, OnChanges {
this.dataSource.sort = this.sort;
this.dataSource.filterPredicate = filterPredicate;
this.dataSource.sortData = sortData;
this.detailsContainerService.opened$.subscribe(opened => (this.detailsOpened = opened));
}
openDetails(obj: AbstractDomainObject) {
this.detailsService.emit(obj);
openDetails({ json }: TableDataSource, index: number) {
console.log(index);
this.selectedIndex = index;
this.detailsService.emit(json);
}
applyFilter(filterValue: string) {

View File

@ -0,0 +1 @@
<cc-monaco-editor [file]="file$ | async" [options]="options"></cc-monaco-editor>

View File

@ -0,0 +1,4 @@
cc-monaco-editor {
display: block;
height: 100%;
}

View File

@ -0,0 +1,30 @@
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { DomainDetailsService } from '../domain-details.service';
import { MonacoFile, MonacoEditorOptions } from '../../monaco-editor';
@Component({
selector: 'cc-domain-obj-details',
templateUrl: './domain-obj-details.component.html',
styleUrls: ['./domain-obj-details.component.scss']
})
export class DomainObjDetailsComponent implements OnInit {
file$: Observable<MonacoFile>;
options: MonacoEditorOptions = {
readOnly: true
};
constructor(private detailsService: DomainDetailsService) {}
ngOnInit() {
this.file$ = this.detailsService.detailedObject$.pipe(
map(o => ({
uri: 'index.json',
language: 'json',
content: JSON.stringify(o, null, 2)
}))
);
}
}

View File

@ -0,0 +1 @@
export * from './domain-obj-details.component';

View File

@ -7,8 +7,8 @@
fixedTopGap="64"
>
<div class="details-container" fxLayout="column" fxLayoutGap="20px">
<cc-domain-obj-details fxFlex="95"></cc-domain-obj-details>
<button mat-stroked-button (click)="closeDetails()">CLOSE</button>
<cc-pretty-json [object]="detailedDomainObj$ | async"></cc-pretty-json>
</div>
</mat-sidenav>
<mat-sidenav-content>

View File

@ -4,4 +4,5 @@ mat-sidenav {
.details-container {
padding: 20px;
height: 100%;
}

View File

@ -1,37 +1,35 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { MatSnackBar, MatSidenav } from '@angular/material';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { DomainService } from './domain.service';
import { AbstractDomainObject } from './domain-group/domain-group';
import { DomainDetailsService } from './domain-details.service';
import { DetailsContainerService } from './details-container.service';
@Component({
templateUrl: './domain.component.html',
styleUrls: ['../shared/container.css', './domain.component.scss']
styleUrls: ['../shared/container.css', './domain.component.scss'],
providers: [DomainDetailsService, DetailsContainerService]
})
export class DomainComponent implements OnInit {
initialized = false;
isLoading: boolean;
detailedDomainObj$: Observable<AbstractDomainObject>;
@ViewChild('domainObjDetails') detailsContainer: MatSidenav;
constructor(
private domainService: DomainService,
private snackBar: MatSnackBar,
private detailsService: DomainDetailsService
private detailsService: DomainDetailsService,
private detailsContainerService: DetailsContainerService
) {}
ngOnInit() {
this.initialize();
this.detailedDomainObj$ = this.detailsService.detailedObject$.pipe(
tap(() => this.detailsContainer.open())
);
this.detailsContainerService.container = this.detailsContainer;
this.detailsService.detailedObject$.subscribe(() => this.detailsContainerService.open());
}
closeDetails() {
this.detailsContainer.close();
this.detailsContainerService.close();
}
private initialize() {

View File

@ -12,6 +12,7 @@ import {
import { FlexLayoutModule } from '@angular/flex-layout';
import { DomainComponent } from './domain.component';
import { DomainObjDetailsComponent } from './domain-obj-details';
import { DomainRoutingModule } from './domain-routing.module';
import { DomainService } from './domain.service';
import { ThriftModule } from '../thrift/thrift.module';
@ -19,9 +20,10 @@ import { DomainGroupModule } from './domain-group';
import { MetadataLoader } from './metadata-loader';
import { SharedModule } from '../shared/shared.module';
import { DomainDetailsService } from './domain-details.service';
import { MonacoEditorModule } from '../monaco-editor';
@NgModule({
declarations: [DomainComponent],
declarations: [DomainComponent, DomainObjDetailsComponent],
imports: [
CommonModule,
DomainRoutingModule,
@ -33,10 +35,11 @@ import { DomainDetailsService } from './domain-details.service';
MatButtonModule,
MatInputModule,
MatProgressSpinnerModule,
MonacoEditorModule,
ThriftModule,
DomainGroupModule,
SharedModule
],
providers: [DomainService, MetadataLoader, DomainDetailsService]
providers: [DomainService, MetadataLoader]
})
export class DamainModule {}

View File

@ -0,0 +1,17 @@
import { Observable } from 'rxjs';
import { shareReplay } from 'rxjs/operators';
declare const window: any;
export const bootstrap$ = Observable.create(observer => {
const script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'libs/vs/loader.js';
script.onload = () => {
window.require.config({ paths: { vs: 'libs/vs' } });
window.require(['vs/editor/editor.main'], () => {
observer.next();
});
};
document.body.appendChild(script);
}).pipe(shareReplay(1));

View File

@ -0,0 +1,14 @@
/// <reference path="../../../node_modules/monaco-editor/monaco.d.ts" />
import { Observable, Observer } from 'rxjs';
export function fromDisposable<T>(
source: (listener: (e: T) => void) => monaco.IDisposable
): Observable<T> {
return Observable.create((observer: Observer<T>) => {
const disposable = source(e => {
observer.next(e);
});
return () => disposable.dispose();
});
}

View File

@ -0,0 +1,2 @@
export * from './monaco-editor.module';
export * from './model';

View File

@ -0,0 +1,7 @@
export type MonacoEditorOptions = monaco.editor.IEditorOptions;
export interface MonacoFile {
uri: string;
content: string;
language?: string;
}

View File

@ -0,0 +1,69 @@
import {
OnInit,
Input,
ElementRef,
Output,
EventEmitter,
Directive,
OnChanges,
SimpleChanges,
HostListener,
OnDestroy
} from '@angular/core';
import { Subject } from 'rxjs';
import { filter, map, distinctUntilChanged, debounceTime, takeUntil } from 'rxjs/operators';
import { MonacoFile, MonacoEditorOptions } from './model';
import { MonacoEditorService } from './monaco-editor.service';
@Directive({
selector: 'cc-monaco-editor,[ccMonacoEditor]'
})
export class MonacoEditorDirective implements OnInit, OnChanges, OnDestroy {
@Input() file: MonacoFile;
@Input() options: MonacoEditorOptions;
@Output() ready = new EventEmitter();
private resize$ = new Subject();
private destroy$ = new Subject();
private initialized = false;
constructor(private monacoEditorService: MonacoEditorService, private editorRef: ElementRef) {}
@HostListener('window:resize') onResize() {
this.resize$.next();
}
ngOnChanges({ options, file }: SimpleChanges) {
if (options) {
this.monacoEditorService.updateOptions(options.currentValue);
}
if (file) {
this.monacoEditorService.open(file.currentValue);
}
}
ngOnInit() {
this.monacoEditorService.init(this.editorRef, this.options).subscribe(() => {
this.ready.emit();
this.initialized = true;
});
this.resize$
.pipe(
filter(() => this.initialized),
map(() => {
const { clientWidth, clientHeight } = this.editorRef.nativeElement;
return { width: clientWidth, height: clientHeight };
}),
distinctUntilChanged((a, b) => a.width === b.width && a.height === b.height),
debounceTime(50),
takeUntil(this.destroy$)
)
.subscribe(dimension => this.monacoEditorService.layout(dimension));
}
ngOnDestroy() {
this.destroy$.next();
}
}

View File

@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MonacoEditorDirective } from './monaco-editor.directive';
import { MonacoEditorService } from './monaco-editor.service';
@NgModule({
declarations: [MonacoEditorDirective],
imports: [CommonModule],
exports: [MonacoEditorDirective],
providers: [MonacoEditorService]
})
export class MonacoEditorModule {}

View File

@ -0,0 +1,93 @@
import { Injectable, ElementRef, NgZone } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { tap, map, takeUntil, take } from 'rxjs/operators';
import { MonacoEditorOptions, MonacoFile } from './model';
import { fromDisposable } from './from-disposable';
import { bootstrap$ } from './bootstrap';
declare const window: any;
@Injectable()
export class MonacoEditorService {
fileChange$ = new Subject<MonacoFile>();
private editor: monaco.editor.ICodeEditor;
private file: MonacoFile;
constructor(private zone: NgZone) {}
init({ nativeElement }: ElementRef, options: MonacoEditorOptions = {}): Observable<void> {
return bootstrap$.pipe(
tap(() => {
this.disposeModels();
this.editor = monaco.editor.create(nativeElement, {
...options
});
if (this.file) {
this.open(this.file);
}
})
);
}
updateOptions(options: MonacoEditorOptions) {
if (this.editor) {
this.editor.updateOptions(options);
}
}
open(file: MonacoFile) {
this.file = file;
if (!this.editor) {
return;
}
const uri = monaco.Uri.file(file.uri);
let model = monaco.editor.getModel(uri);
if (model) {
if (file.language && model.getModeId() !== file.language) {
model.dispose();
model = undefined;
} else {
model.setValue(file.content);
}
}
if (!model) {
model = monaco.editor.createModel(file.content, file.language, uri);
this.registerModelChangeListener(file, model);
}
this.editor.setModel(model);
}
layout(dimension?: monaco.editor.IDimension) {
if (this.editor) {
this.editor.layout(dimension);
}
}
private registerModelChangeListener(file: MonacoFile, model: monaco.editor.IModel) {
const destroy = fromDisposable(model.onWillDispose.bind(model)).pipe(take(1));
fromDisposable(model.onDidChangeContent.bind(model))
.pipe(
map(() => model.getValue()),
takeUntil(destroy)
)
.subscribe((content: string) =>
this.zone.run(() =>
this.fileChange$.next({
...file,
content
})
)
);
}
private disposeModels() {
if (!window.monaco) {
return;
}
for (const model of monaco.editor.getModels()) {
model.dispose();
}
}
}

View File

@ -5,29 +5,31 @@ import { Component, Input } from '@angular/core';
templateUrl: './pretty-json.component.html',
styles: [
`
:host /deep/ * {
font-family: Menlo, Monaco, 'Courier New', monospace;
font-weight: normal;
font-size: 12px;
line-height: 18px;
letter-spacing: 0px;
}
:host /deep/ .string {
color: #008000;
font-weight: bold;
color: #0451a5;
}
:host /deep/ .number {
color: #0000ff;
font-weight: bold;
color: #09885a;
}
:host /deep/ .boolean {
color: #000080;
font-weight: bold;
color: #0451a5;
}
:host /deep/ .null {
color: magenta;
font-weight: bold;
color: #0451a5;
}
:host /deep/ .key {
color: #660e7a;
font-weight: bold;
color: #a31515;
}
pre {