| @@ -297,4 +297,69 @@ export class RulesService implements RulesServiceInterface { | |||
| ); | |||
| } | |||
| /** | |||
| * @param id | |||
| * @param body | |||
| * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. | |||
| * @param reportProgress flag to report request and response progress. | |||
| */ | |||
| public update(id: string, body: object, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json'}): Observable<object>; | |||
| public update(id: string, body: object, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json'}): Observable<HttpResponse<object>>; | |||
| public update(id: string, body: object, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json'}): Observable<HttpEvent<object>>; | |||
| public update(id: string, body: object, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json'}): Observable<any> { | |||
| if (id === null || id === undefined) { | |||
| throw new Error('Required parameter id was null or undefined when calling update.'); | |||
| } | |||
| if (body === null || body === undefined) { | |||
| throw new Error('Required parameter body was null or undefined when calling update.'); | |||
| } | |||
| let headers = this.defaultHeaders; | |||
| let credential: string | undefined; | |||
| // authentication (basic) required | |||
| credential = this.configuration.lookupCredential('basic'); | |||
| if (credential) { | |||
| headers = headers.set('Authorization', 'Basic ' + credential); | |||
| } | |||
| let httpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; | |||
| if (httpHeaderAcceptSelected === undefined) { | |||
| // to determine the Accept header | |||
| const httpHeaderAccepts: string[] = [ | |||
| 'application/json' | |||
| ]; | |||
| httpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); | |||
| } | |||
| if (httpHeaderAcceptSelected !== undefined) { | |||
| headers = headers.set('Accept', httpHeaderAcceptSelected); | |||
| } | |||
| // to determine the Content-Type header | |||
| const consumes: string[] = [ | |||
| 'application/json' | |||
| ]; | |||
| const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes); | |||
| if (httpContentTypeSelected !== undefined) { | |||
| headers = headers.set('Content-Type', httpContentTypeSelected); | |||
| } | |||
| let responseType_: 'text' | 'json' = 'json'; | |||
| if(httpHeaderAcceptSelected && httpHeaderAcceptSelected.startsWith('text')) { | |||
| responseType_ = 'text'; | |||
| } | |||
| return this.httpClient.patch<object>(`${this.configuration.basePath}/rules/${encodeURIComponent(String(id))}`, | |||
| body, | |||
| { | |||
| responseType: <any>responseType_, | |||
| withCredentials: this.configuration.withCredentials, | |||
| headers: headers, | |||
| observe: observe, | |||
| reportProgress: reportProgress | |||
| } | |||
| ); | |||
| } | |||
| } | |||
| @@ -52,4 +52,12 @@ export interface RulesServiceInterface { | |||
| */ | |||
| remove(id: string, extraHttpRequestParams?: any): Observable<{}>; | |||
| /** | |||
| * | |||
| * | |||
| * @param id | |||
| * @param body | |||
| */ | |||
| update(id: string, body: object, extraHttpRequestParams?: any): Observable<object>; | |||
| } | |||
| @@ -21,5 +21,6 @@ export interface CreateRuleDto { | |||
| */ | |||
| text: string; | |||
| subRuleIds: Array<string>; | |||
| parentId: string; | |||
| } | |||
| @@ -18,6 +18,8 @@ | |||
| "@angular/platform-browser-dynamic": "~11.2.11", | |||
| "@angular/router": "~11.2.11", | |||
| "@openapitools/openapi-generator-cli": "^2.2.6", | |||
| "@types/base-64": "^1.0.0", | |||
| "base-64": "^1.0.0", | |||
| "rxjs": "~6.6.0", | |||
| "tslib": "^2.0.0", | |||
| "zone.js": "~0.11.3" | |||
| @@ -2668,6 +2670,11 @@ | |||
| "node": ">= 10.0.0" | |||
| } | |||
| }, | |||
| "node_modules/@types/base-64": { | |||
| "version": "1.0.0", | |||
| "resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.0.tgz", | |||
| "integrity": "sha512-AvCJx/HrfYHmOQRFdVvgKMplXfzTUizmh0tz9GFTpDePWgCY4uoKll84zKlaRoeiYiCr7c9ZnqSTzkl0BUVD6g==" | |||
| }, | |||
| "node_modules/@types/component-emitter": { | |||
| "version": "1.2.10", | |||
| "dev": true, | |||
| @@ -3487,6 +3494,11 @@ | |||
| "node": ">=0.10.0" | |||
| } | |||
| }, | |||
| "node_modules/base-64": { | |||
| "version": "1.0.0", | |||
| "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", | |||
| "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" | |||
| }, | |||
| "node_modules/base/node_modules/define-property": { | |||
| "version": "1.0.0", | |||
| "dev": true, | |||
| @@ -20132,6 +20144,11 @@ | |||
| } | |||
| } | |||
| }, | |||
| "@types/base-64": { | |||
| "version": "1.0.0", | |||
| "resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.0.tgz", | |||
| "integrity": "sha512-AvCJx/HrfYHmOQRFdVvgKMplXfzTUizmh0tz9GFTpDePWgCY4uoKll84zKlaRoeiYiCr7c9ZnqSTzkl0BUVD6g==" | |||
| }, | |||
| "@types/component-emitter": { | |||
| "version": "1.2.10", | |||
| "dev": true | |||
| @@ -20741,6 +20758,11 @@ | |||
| } | |||
| } | |||
| }, | |||
| "base-64": { | |||
| "version": "1.0.0", | |||
| "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", | |||
| "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" | |||
| }, | |||
| "base64-arraybuffer": { | |||
| "version": "0.1.4", | |||
| "dev": true | |||
| @@ -25,6 +25,8 @@ | |||
| "@angular/platform-browser-dynamic": "~11.2.11", | |||
| "@angular/router": "~11.2.11", | |||
| "@openapitools/openapi-generator-cli": "^2.2.6", | |||
| "@types/base-64": "^1.0.0", | |||
| "base-64": "^1.0.0", | |||
| "rxjs": "~6.6.0", | |||
| "tslib": "^2.0.0", | |||
| "zone.js": "~0.11.3" | |||
| @@ -1,7 +1,14 @@ | |||
| import { NgModule } from '@angular/core'; | |||
| import { CommonModule } from '@angular/common'; | |||
| import { CreateRuleComponent } from './create-rule/create-rule.component'; | |||
| import { MatCardModule } from '@angular/material/card'; | |||
| import { MatFormFieldModule } from '@angular/material/form-field'; | |||
| import { MatTreeModule } from '@angular/material/tree'; | |||
| import { MatButtonModule } from '@angular/material/button'; | |||
| import { MatIconModule } from '@angular/material/icon'; | |||
| import { MatCommonModule } from '@angular/material/core'; | |||
| import { MatInputModule } from '@angular/material/input'; | |||
| import { DynamicDatabase } from './create-rule/class/dynamic-database'; | |||
| @NgModule({ | |||
| @@ -9,7 +16,18 @@ import { CreateRuleComponent } from './create-rule/create-rule.component'; | |||
| CreateRuleComponent | |||
| ], | |||
| imports: [ | |||
| CommonModule | |||
| CommonModule, | |||
| MatCardModule, | |||
| MatFormFieldModule, | |||
| MatTreeModule, | |||
| MatButtonModule, | |||
| MatIconModule, | |||
| MatCommonModule, | |||
| MatFormFieldModule, | |||
| MatInputModule, | |||
| ], | |||
| providers: [ | |||
| DynamicDatabase, | |||
| ] | |||
| }) | |||
| export class AdminModule { } | |||
| @@ -0,0 +1,77 @@ | |||
| import { CollectionViewer, DataSource, SelectionChange } from "@angular/cdk/collections"; | |||
| import { FlatTreeControl } from "@angular/cdk/tree"; | |||
| import { BehaviorSubject, merge, Observable } from "rxjs"; | |||
| import { map } from "rxjs/operators"; | |||
| import { DynamicDatabase } from "./dynamic-database"; | |||
| import { RuleFlatNode } from "./rule-flat-node"; | |||
| export class DynamicDataSource implements DataSource<RuleFlatNode> { | |||
| dataChange = new BehaviorSubject<RuleFlatNode[]>([]); | |||
| get data(): RuleFlatNode[] { return this.dataChange.value; } | |||
| set data(value: RuleFlatNode[]) { | |||
| this._treeControl.dataNodes = value; | |||
| this.dataChange.next(value); | |||
| } | |||
| constructor(private _treeControl: FlatTreeControl<RuleFlatNode>, | |||
| private _database: DynamicDatabase) { | |||
| } | |||
| connect(collectionViewer: CollectionViewer): Observable<RuleFlatNode[]> { | |||
| this._treeControl.expansionModel.changed.subscribe(change => { | |||
| if ((change as SelectionChange<RuleFlatNode>).added || | |||
| (change as SelectionChange<RuleFlatNode>).removed) { | |||
| this.handleTreeControl(change as SelectionChange<RuleFlatNode>); | |||
| } | |||
| }); | |||
| return merge(collectionViewer.viewChange, this.dataChange).pipe(map(() => this.data)); | |||
| } | |||
| disconnect(collectionViewer: CollectionViewer): void { } | |||
| /** Handle expand/collapse behaviors */ | |||
| handleTreeControl(change: SelectionChange<RuleFlatNode>) { | |||
| if (change.added) { | |||
| change.added.forEach(node => this.toggleNode(node, true)); | |||
| } | |||
| if (change.removed) { | |||
| change.removed.slice().reverse().forEach(node => this.toggleNode(node, false)); | |||
| } | |||
| } | |||
| /** | |||
| * Toggle the node, remove from display list | |||
| */ | |||
| toggleNode(node: RuleFlatNode, expand: boolean) { | |||
| const children = node.item.subRule; | |||
| const index = this.data.indexOf(node); | |||
| if (!children || index < 0) { // If no children, or cannot find the node, no op | |||
| return; | |||
| } | |||
| setTimeout(() => { | |||
| if (expand) { | |||
| const nodes = children.map(name => | |||
| new RuleFlatNode( | |||
| name, | |||
| node.level + 1, | |||
| !!node.item.subRule ? node.item.subRule.length > 0 : false) | |||
| ); | |||
| this.data.splice(index + 1, 0, ...nodes); | |||
| } else { | |||
| let count = 0; | |||
| for (let i = index + 1; i < this.data.length | |||
| && this.data[i].level > node.level; i++, count++) { } | |||
| this.data.splice(index + 1, count); | |||
| } | |||
| // notify the change | |||
| this.dataChange.next(this.data); | |||
| node.isLoading = false; | |||
| }, 1000); | |||
| } | |||
| } | |||
| @@ -0,0 +1,175 @@ | |||
| import { Injectable } from "@angular/core" | |||
| import { RippleRef } from "@angular/material/core"; | |||
| import { BehaviorSubject, Subject } from "rxjs"; | |||
| import { map } from "rxjs/operators"; | |||
| import { CreateRuleDto, Rule, RulesService } from "../../../../../api"; | |||
| import { environment } from "../../../../environments/environment"; | |||
| import { RuleFlatNode } from "./rule-flat-node"; | |||
| @Injectable({ providedIn: 'root' }) | |||
| export class DynamicDatabase { | |||
| dataChange = new BehaviorSubject<Rule[]>([]); | |||
| childAdded = new Subject<Rule>(); | |||
| get data(): Rule[] { return this.dataChange.value; } | |||
| get count(): number { return this.dataChange.value.length }; | |||
| protected addData(rule: Rule) { this.dataChange.next([...this.dataChange.value, rule]) } | |||
| constructor(protected rulesService: RulesService) { | |||
| this.rulesService.configuration.basePath = environment.apiUrl; | |||
| this.rulesService.findAll() | |||
| .subscribe( | |||
| (rules: Rule[]) => this.dataChange.next( | |||
| rules | |||
| .filter(this.filterRuleWithParentFromRootLayer) | |||
| .sort(this.compareRulesByParagraph) | |||
| ) | |||
| ), | |||
| (err) => { | |||
| //TODO add toaster | |||
| } | |||
| } | |||
| getParagraphOfNextChild = (parentRule): string => `${parentRule.paragraph}.${parentRule.subRule.length + 1}`; | |||
| filterRuleWithParentFromRootLayer = (rootLayerRule: Rule) => typeof rootLayerRule.parentRule !== 'number'; | |||
| compareRulesByParagraph = (ruleA: Rule, ruleB: Rule) => this.compareParahraph(ruleA.paragraph, ruleB.paragraph); | |||
| compareParahraph(paragraphA: string, paragraphB: string): 0 | 1 | -1 { | |||
| if (paragraphA === paragraphB) { | |||
| return 0; | |||
| } | |||
| const paragrapASections = paragraphA.split('.'); | |||
| const paragrapBSections = paragraphB.split('.'); | |||
| if (+paragrapASections[0] > +paragrapBSections[0]) { | |||
| return 1; | |||
| } | |||
| if (+paragrapASections[0] < +paragrapBSections[0]) { | |||
| return -1; | |||
| } | |||
| if (+paragrapASections[0] === +paragrapBSections[0]) { | |||
| let shiftedParagraphASections = paragrapASections; | |||
| shiftedParagraphASections.shift(); | |||
| let shiftedParagraphBSections = paragrapBSections; | |||
| shiftedParagraphBSections.shift(); | |||
| return this.compareParahraph( | |||
| shiftedParagraphASections.join('.'), | |||
| shiftedParagraphBSections.join('.'), | |||
| ) | |||
| } | |||
| } | |||
| getParentById = (ruleId: string) => this.data.find(rule => rule.id === ruleId) | |||
| insertEmptyChild(parent: Rule) { | |||
| parent.subRule.push( | |||
| { | |||
| text: '', | |||
| id: '0', | |||
| parentRule: parent, | |||
| paragraph: this.getParagraphOfNextChild(parent) | |||
| } | |||
| ) | |||
| this.dataChange.next(this.data); | |||
| this.childAdded.next(parent); | |||
| } | |||
| insertEmptyParent() { | |||
| this.addData({ | |||
| text: '', | |||
| id: '0', | |||
| parentRule: null, | |||
| paragraph: `${this.count + 2}` | |||
| }) | |||
| } | |||
| insertChild(parent: Rule, subRule: Rule) { | |||
| const createSubRule: CreateRuleDto = { | |||
| paragraph: subRule.paragraph, | |||
| text: subRule.text, | |||
| subRuleIds: null, | |||
| parentId: parent.id | |||
| } | |||
| this.rulesService.create(createSubRule).subscribe( | |||
| newSubRule => { | |||
| if (typeof newSubRule.id !== 'undefined' && typeof newSubRule.parentRule?.id !== 'undefined') { | |||
| const updateParentRule: CreateRuleDto = { | |||
| paragraph: parent.paragraph, | |||
| text: parent.text, | |||
| subRuleIds: [...parent.subRule ? parent.subRule.map(rule => rule.id) : [], newSubRule.id] | |||
| } as CreateRuleDto; | |||
| this.rulesService.update(parent.id, updateParentRule).subscribe((updated) => { | |||
| //TODO Push updated message | |||
| this.update(parent.id, { | |||
| paragraph: parent.paragraph, | |||
| subRuleIds: [ | |||
| ...parent.subRule | |||
| .filter(rule => rule.id === "0") | |||
| .map(rule => rule.id) ?? [], | |||
| subRule.id, | |||
| ], | |||
| text: parent.text | |||
| }).subscribe( | |||
| (updated) => { | |||
| //todo add update message | |||
| }, | |||
| (err) => { | |||
| //TODO Push error message | |||
| console.error(err); | |||
| } | |||
| ) | |||
| }, | |||
| (err) => { | |||
| //TODO Push error message | |||
| console.error(err); | |||
| } | |||
| ) | |||
| return; | |||
| } | |||
| //TODO Push error message | |||
| } | |||
| ) | |||
| if (!parent.subRule) { | |||
| parent.subRule = []; | |||
| } | |||
| const lastIndex = parent.subRule.indexOf(subRule); | |||
| parent.subRule[lastIndex] = subRule; | |||
| this.dataChange.next(this.data); | |||
| this.childAdded.next(parent); | |||
| } | |||
| update(id: string, changedData: Partial<CreateRuleDto>) { | |||
| return this.rulesService.update(id, changedData); | |||
| } | |||
| insertParent(newRule: Rule) { | |||
| const createNewRule: CreateRuleDto = { | |||
| paragraph: `${this.count + 1}`, | |||
| text: newRule.text, | |||
| subRuleIds: null, | |||
| parentId: null | |||
| } | |||
| this.rulesService.create(createNewRule).subscribe( | |||
| () => { | |||
| //TODO created message | |||
| }, | |||
| (err) => { | |||
| //TODO Error message | |||
| } | |||
| ) | |||
| this.dataChange.next(this.data); | |||
| } | |||
| removeChild(parent: Rule, subRule: Rule) { | |||
| if (parent.subRule) { | |||
| const subRuleToRemove = this.findSubRuleFromParent(parent, subRule); | |||
| const indexIfSubRule = parent.subRule.indexOf(subRuleToRemove); | |||
| if (indexIfSubRule > -1) { | |||
| parent.subRule.splice(indexIfSubRule, 1); | |||
| } | |||
| this.dataChange.next(this.data); | |||
| } | |||
| } | |||
| findSubRuleFromParent = (parent: Rule, subRule: Rule): Rule | null => | |||
| parent.subRule.find(rules => rules.paragraph === subRule.paragraph && rules.id === subRule.id); | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| import { Rule } from "../../../../../api"; | |||
| export class RuleFlatNode { | |||
| constructor(public item: Rule, public level = 1, public expandable = false, | |||
| public isLoading = false,public root = true) { } | |||
| } | |||
| @@ -1 +1,34 @@ | |||
| <p>create-rule works!</p> | |||
| <mat-card class="wood-card"> | |||
| <mat-tree [dataSource]="dataSource" [treeControl]="treeControl"> | |||
| <mat-tree-node *matTreeNodeDef="let node" matTreeNodePadding> | |||
| <div> | |||
| <button mat-icon-button disabled></button> §{{ node.item.paragraph }} {{ node.item.text }} | |||
| <button *ngIf="node.root" mat-icon-button (click)="addEmptyRule(node)"> | |||
| <mat-icon>add</mat-icon> | |||
| </button> | |||
| </div> | |||
| </mat-tree-node> | |||
| <mat-tree-node *matTreeNodeDef="let node; when: hasChild" matTreeNodePadding> | |||
| <button mat-icon-button matTreeNodeToggle [attr.aria-label]="'Toggle ' + node.text"> | |||
| <mat-icon class="mat-icon-rtl-mirror"> | |||
| {{ treeControl.isExpanded(node) ? "expand_more" : "chevron_right" }} | |||
| </mat-icon> | |||
| </button> §{{ node.item.paragraph }} {{ node.item.text }} | |||
| <button *ngIf="node.root" mat-icon-button (click)="addEmptyRule(node)"> | |||
| <mat-icon>add</mat-icon> | |||
| </button> | |||
| </mat-tree-node> | |||
| <mat-tree-node *matTreeNodeDef="let node; when: hasNoContent" matTreeNodePadding> | |||
| <button mat-icon-button disabled></button> | |||
| <span>§{{ node.item.paragraph }}</span> | |||
| <mat-form-field class="new-node-input"> | |||
| <mat-label>New Rule</mat-label> | |||
| <input [formControl]="text" matInput #itemValue placeholder="text" /> | |||
| </mat-form-field> | |||
| <button mat-button (click)="addNode(node, itemValue.value)">Save</button> | |||
| </mat-tree-node> | |||
| </mat-tree> | |||
| <button mat-icon-button (click)="addEmptyRule()"> | |||
| <mat-icon>add</mat-icon> | |||
| </button> | |||
| </mat-card> | |||
| @@ -0,0 +1,20 @@ | |||
| mat-card { | |||
| margin: 15px; | |||
| color: white; | |||
| } | |||
| mat-tree-node { | |||
| color: white; | |||
| } | |||
| mat-tree { | |||
| background-color: transparent; | |||
| } | |||
| mat-label { | |||
| color: white; | |||
| } | |||
| .new-node-input { | |||
| padding-left: 2vw; | |||
| } | |||
| @@ -1,4 +1,11 @@ | |||
| import { FlatTreeControl } from '@angular/cdk/tree'; | |||
| import { Component, OnInit } from '@angular/core'; | |||
| import { FormControl } from '@angular/forms'; | |||
| import { MatTreeFlatDataSource, MatTreeFlattener } from '@angular/material/tree'; | |||
| import { Rule } from '../../../../api/model/rule'; | |||
| import { DynamicDatabase } from './class/dynamic-database'; | |||
| import { RuleFlatNode } from './class/rule-flat-node'; | |||
| @Component({ | |||
| selector: 'app-create-rule', | |||
| @@ -7,9 +14,93 @@ import { Component, OnInit } from '@angular/core'; | |||
| }) | |||
| export class CreateRuleComponent implements OnInit { | |||
| constructor() { } | |||
| flatNodeMap = new Map<RuleFlatNode, Rule>(); | |||
| nestedNodeMap = new Map<Rule, RuleFlatNode>(); | |||
| text = new FormControl(''); | |||
| private _transformer = (rule: Rule, level: number) => { | |||
| const existingNode = this.nestedNodeMap.get(rule); | |||
| const flatNode = existingNode && existingNode.item.id === rule.id | |||
| ? existingNode | |||
| : new RuleFlatNode(rule, level, !!rule.subRule?.length, false, level === 0) | |||
| this.flatNodeMap.set(flatNode, rule); | |||
| // dirty hack to expand node with new child node | |||
| const lastChildNode = !!rule.subRule ? rule.subRule[rule.subRule.length - 1] : undefined; | |||
| if (typeof lastChildNode !== 'undefined') { | |||
| if (flatNode.expandable) { | |||
| this.treeControl.expand(flatNode); | |||
| } | |||
| } | |||
| return flatNode; | |||
| } | |||
| treeControl: FlatTreeControl<RuleFlatNode>; | |||
| treeFlattener: MatTreeFlattener<Rule, RuleFlatNode>; | |||
| dataSource: MatTreeFlatDataSource<Rule, RuleFlatNode>; | |||
| _database: DynamicDatabase; | |||
| constructor(database: DynamicDatabase) { | |||
| this._database = database; | |||
| this.treeControl = new FlatTreeControl<RuleFlatNode>(this.getLevel, this.isExpandable); | |||
| this.treeFlattener = new MatTreeFlattener<Rule, RuleFlatNode, RuleFlatNode>( | |||
| this._transformer, this.getLevel, | |||
| this.isExpandable, this.getChildren | |||
| ); | |||
| this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); | |||
| this._database.dataChange.subscribe(data => { | |||
| this.dataSource.data = data; | |||
| }); | |||
| this._database.childAdded.subscribe(parent => this.treeControl.expand(this.nestedNodeMap.get(parent))) | |||
| } | |||
| ngOnInit(): void { | |||
| } | |||
| getLevel = (node: RuleFlatNode) => node.level; | |||
| isExpandable = (node: RuleFlatNode) => node.expandable; | |||
| getChildren = (node: Rule): Rule[] => node.subRule; | |||
| hasChild = (_: number, node: RuleFlatNode) => node.expandable; | |||
| hasNoContent = (_: number, _nodeData: RuleFlatNode) => _nodeData.item.text === ''; | |||
| hasContent = (_nodeData: RuleFlatNode) => _nodeData.item.text !== ""; | |||
| addEmptyRule(parentRuleNode?: RuleFlatNode) { | |||
| const parentRule = this.flatNodeMap.get(parentRuleNode); | |||
| if (typeof parentRule === 'undefined') { | |||
| this._database.insertEmptyParent(); | |||
| return; | |||
| } | |||
| this._database.insertEmptyChild(parentRule); | |||
| } | |||
| getParentNode(node: RuleFlatNode): RuleFlatNode | null { | |||
| const currentLevel = this.getLevel(node); | |||
| if (currentLevel < 1) { | |||
| return null; | |||
| } | |||
| const startIndex = this.treeControl.dataNodes.indexOf(node) - 1; | |||
| for (let i = startIndex; i >= 0; i--) { | |||
| const currentNode = this.treeControl.dataNodes[i]; | |||
| if (this.getLevel(currentNode) < currentLevel) { | |||
| return currentNode; | |||
| } | |||
| } | |||
| return null; | |||
| } | |||
| async addNode(newNode: RuleFlatNode, text: string) { | |||
| const newRule = this.flatNodeMap.get(newNode); | |||
| newRule.text = text; | |||
| if (!!newRule.parentRule) { | |||
| const parentRule = this._database.getParentById(newRule.parentRule.id) | |||
| this._database.insertChild(parentRule, newRule) | |||
| } | |||
| this._database.insertParent(newRule) | |||
| } | |||
| } | |||
| @@ -1,16 +1,34 @@ | |||
| import { NgModule } from '@angular/core'; | |||
| import { RouterModule, Routes } from '@angular/router'; | |||
| import { AuthGuard } from './auth/guard/auth.guard'; | |||
| import { CreateRuleComponent } from './admin/create-rule/create-rule.component'; | |||
| import { LoginComponent } from './auth/login/login.component'; | |||
| import { GridComponent } from './grid/grid.component'; | |||
| import { RulesComponent } from './rules/rules/rules.component'; | |||
| const routes: Routes = [ | |||
| { | |||
| path: '', | |||
| component: RulesComponent | |||
| }, | |||
| { | |||
| path: 'login', | |||
| component: LoginComponent | |||
| }, | |||
| { | |||
| path: 'admin', | |||
| canActivate: [AuthGuard], | |||
| children: [ | |||
| { | |||
| path: 'create', | |||
| component: CreateRuleComponent | |||
| } | |||
| ] | |||
| } | |||
| ]; | |||
| @NgModule({ | |||
| imports: [RouterModule.forRoot(routes)], | |||
| imports: [RouterModule.forRoot(routes, { enableTracing: true })], | |||
| exports: [RouterModule] | |||
| }) | |||
| export class AppRoutingModule { } | |||
| @@ -17,6 +17,11 @@ import { MatToolbarModule } from '@angular/material/toolbar'; | |||
| import { MatSidenavModule } from '@angular/material/sidenav'; | |||
| import { MatListModule } from '@angular/material/list'; | |||
| import { RuleListComponent } from './rules/rule-list/rule-list.component'; | |||
| import { AdminModule } from './admin/admin.module'; | |||
| import { AuthModule } from './auth/auth.module'; | |||
| import { HTTP_INTERCEPTORS } from '@angular/common/http'; | |||
| import { AuthInterceptor } from './auth/interceptor/auth.interceptor'; | |||
| import { ErrorInterceptor } from './auth/interceptor/error.interceptor'; | |||
| @NgModule({ | |||
| declarations: [ | |||
| DashboardComponent, | |||
| @@ -37,9 +42,14 @@ import { RuleListComponent } from './rules/rule-list/rule-list.component'; | |||
| FooterModule, | |||
| MatToolbarModule, | |||
| MatSidenavModule, | |||
| MatListModule | |||
| MatListModule, | |||
| AdminModule, | |||
| AuthModule, | |||
| ], | |||
| providers: [ | |||
| { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, | |||
| { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true } | |||
| ], | |||
| providers: [], | |||
| bootstrap: [MainComponent] | |||
| }) | |||
| export class AppModule { } | |||
| @@ -0,0 +1,36 @@ | |||
| import { NgModule } from '@angular/core'; | |||
| import { CommonModule } from '@angular/common'; | |||
| import { AuthGuard } from './guard/auth.guard'; | |||
| import { AuthService } from './service/auth.service'; | |||
| import { HttpClientModule } from '@angular/common/http'; | |||
| import { LoginComponent } from './login/login.component'; | |||
| import { MatCardModule } from '@angular/material/card'; | |||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | |||
| import { MatIconModule } from '@angular/material/icon'; | |||
| import { MatButtonModule } from '@angular/material/button'; | |||
| import {MatFormFieldModule} from '@angular/material/form-field'; | |||
| import {MatInputModule} from '@angular/material/input'; | |||
| @NgModule({ | |||
| declarations: [ | |||
| LoginComponent | |||
| ], | |||
| imports: [ | |||
| CommonModule, | |||
| MatCardModule, | |||
| MatButtonModule, | |||
| HttpClientModule, | |||
| MatFormFieldModule, | |||
| MatInputModule, | |||
| MatIconModule, | |||
| FormsModule, | |||
| ReactiveFormsModule, | |||
| ], | |||
| providers: [ | |||
| AuthGuard, | |||
| AuthService, | |||
| ], | |||
| }) | |||
| export class AuthModule { } | |||
| @@ -0,0 +1,16 @@ | |||
| import { TestBed } from '@angular/core/testing'; | |||
| import { AuthGuard } from './auth.guard'; | |||
| describe('AuthGuard', () => { | |||
| let guard: AuthGuard; | |||
| beforeEach(() => { | |||
| TestBed.configureTestingModule({}); | |||
| guard = TestBed.inject(AuthGuard); | |||
| }); | |||
| it('should be created', () => { | |||
| expect(guard).toBeTruthy(); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,27 @@ | |||
| import { Injectable } from '@angular/core'; | |||
| import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router'; | |||
| import { Observable } from 'rxjs'; | |||
| import { AuthService } from '../service/auth.service'; | |||
| @Injectable({ | |||
| providedIn: 'root' | |||
| }) | |||
| export class AuthGuard implements CanActivate { | |||
| constructor( | |||
| private router: Router, | |||
| private authenticationService: AuthService | |||
| ) { } | |||
| canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { | |||
| const user = this.authenticationService.user; | |||
| if (user) { | |||
| // logged in so return true | |||
| return true; | |||
| } | |||
| // not logged in so redirect to login page with the return url | |||
| this.router.navigate(['/login'], { queryParams: { returnUrl: state.url } }); | |||
| return false; | |||
| } | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| import { TestBed } from '@angular/core/testing'; | |||
| import { AuthInterceptor } from './auth.interceptor'; | |||
| describe('AuthInterceptor', () => { | |||
| beforeEach(() => TestBed.configureTestingModule({ | |||
| providers: [ | |||
| AuthInterceptor | |||
| ] | |||
| })); | |||
| it('should be created', () => { | |||
| const interceptor: AuthInterceptor = TestBed.inject(AuthInterceptor); | |||
| expect(interceptor).toBeTruthy(); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,31 @@ | |||
| import { Injectable } from '@angular/core'; | |||
| import { | |||
| HttpRequest, | |||
| HttpHandler, | |||
| HttpEvent, | |||
| HttpInterceptor | |||
| } from '@angular/common/http'; | |||
| import { Observable } from 'rxjs'; | |||
| import { AuthService } from '../service/auth.service'; | |||
| import { environment } from '../../../environments/environment'; | |||
| @Injectable() | |||
| export class AuthInterceptor implements HttpInterceptor { | |||
| constructor(private authenticationService: AuthService) { } | |||
| intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { | |||
| // add header with basic auth credentials if user is logged in and request is to the api url | |||
| const user = this.authenticationService.user; | |||
| const isLoggedIn = user && user.username && user.password; | |||
| const isAdminUrl = request.url.startsWith(environment.apiUrl); | |||
| if (isLoggedIn && isAdminUrl) { | |||
| request = request.clone({ | |||
| setHeaders: { | |||
| Authorization: this.authenticationService.getUserToken() | |||
| } | |||
| }); | |||
| } | |||
| return next.handle(request); | |||
| } | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| import { TestBed } from '@angular/core/testing'; | |||
| import { ErrorInterceptor } from './error.interceptor'; | |||
| describe('ErrorInterceptor', () => { | |||
| beforeEach(() => TestBed.configureTestingModule({ | |||
| providers: [ | |||
| ErrorInterceptor | |||
| ] | |||
| })); | |||
| it('should be created', () => { | |||
| const interceptor: ErrorInterceptor = TestBed.inject(ErrorInterceptor); | |||
| expect(interceptor).toBeTruthy(); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,29 @@ | |||
| import { Injectable } from '@angular/core'; | |||
| import { | |||
| HttpRequest, | |||
| HttpHandler, | |||
| HttpEvent, | |||
| HttpInterceptor | |||
| } from '@angular/common/http'; | |||
| import { Observable } from 'rxjs'; | |||
| import { AuthService } from '../service/auth.service'; | |||
| import { catchError } from 'rxjs/operators'; | |||
| import { throwError } from 'rxjs'; | |||
| @Injectable() | |||
| export class ErrorInterceptor implements HttpInterceptor { | |||
| constructor(private authenticationService: AuthService) { } | |||
| intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { | |||
| return next.handle(request).pipe(catchError(err => { | |||
| if (err.status === 401) { | |||
| // auto logout if 401 response returned from api | |||
| this.authenticationService.logout(); | |||
| } | |||
| const error = err.error.message || err.statusText; | |||
| return throwError(error); | |||
| })) | |||
| } | |||
| } | |||
| @@ -0,0 +1,20 @@ | |||
| <mat-card class="wood-card"> | |||
| <mat-card-title> Login </mat-card-title> | |||
| <div> | |||
| <p> | |||
| <mat-form-field appearance="standard"> | |||
| <mat-label>Username</mat-label> | |||
| <input [formControl]="username" matInput type="text" /> | |||
| </mat-form-field> | |||
| </p> | |||
| <p> | |||
| <mat-form-field appearance="standard"> | |||
| <mat-label>Password</mat-label> | |||
| <input [formControl]="password" matInput type="password" /> | |||
| </mat-form-field> | |||
| </p> | |||
| <p> | |||
| <button (click)="onSubmit()" mat-button>Bestätigen</button> | |||
| </p> | |||
| </div> | |||
| </mat-card> | |||
| @@ -0,0 +1,11 @@ | |||
| mat-card { | |||
| color: white; | |||
| margin: 10vw; | |||
| padding-left: 10vw; | |||
| padding-right: 10vw; | |||
| padding-top: 10vh; | |||
| padding-bottom: 10vh; | |||
| } | |||
| mat-label { | |||
| color: white; | |||
| } | |||
| @@ -0,0 +1,25 @@ | |||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | |||
| import { LoginComponent } from './login.component'; | |||
| describe('LoginComponent', () => { | |||
| let component: LoginComponent; | |||
| let fixture: ComponentFixture<LoginComponent>; | |||
| beforeEach(async () => { | |||
| await TestBed.configureTestingModule({ | |||
| declarations: [ LoginComponent ] | |||
| }) | |||
| .compileComponents(); | |||
| }); | |||
| beforeEach(() => { | |||
| fixture = TestBed.createComponent(LoginComponent); | |||
| component = fixture.componentInstance; | |||
| fixture.detectChanges(); | |||
| }); | |||
| it('should create', () => { | |||
| expect(component).toBeTruthy(); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,35 @@ | |||
| import { Component, OnInit } from '@angular/core'; | |||
| import { FormControl } from '@angular/forms'; | |||
| import { ActivatedRoute, Router } from '@angular/router'; | |||
| import { User } from '../../class/user'; | |||
| import { AuthService } from '../service/auth.service'; | |||
| @Component({ | |||
| selector: 'app-login', | |||
| templateUrl: './login.component.html', | |||
| styleUrls: ['./login.component.scss'] | |||
| }) | |||
| export class LoginComponent implements OnInit { | |||
| username = new FormControl(''); | |||
| password = new FormControl(''); | |||
| constructor(protected authService: AuthService, private route: ActivatedRoute, protected router: Router) { } | |||
| onSubmit() { | |||
| let user = new User(); | |||
| user.username = this.username.value; | |||
| user.password = this.password.value; | |||
| this.authService.user = user; | |||
| const redirectUrl = this.route.queryParams | |||
| .subscribe(value => { | |||
| if (value?.returnUrl) { | |||
| this.router.navigate([value.returnUrl]); | |||
| return; | |||
| } | |||
| this.router.navigate(['']); | |||
| }) | |||
| } | |||
| ngOnInit(): void { | |||
| } | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| import { TestBed } from '@angular/core/testing'; | |||
| import { AuthService } from './auth.service'; | |||
| describe('AuthService', () => { | |||
| let service: AuthService; | |||
| beforeEach(() => { | |||
| TestBed.configureTestingModule({}); | |||
| service = TestBed.inject(AuthService); | |||
| }); | |||
| it('should be created', () => { | |||
| expect(service).toBeTruthy(); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,35 @@ | |||
| import { Injectable } from '@angular/core'; | |||
| import { User } from '../../class/user'; | |||
| import {encode, decode} from 'base-64'; | |||
| @Injectable({ | |||
| providedIn: 'root' | |||
| }) | |||
| export class AuthService { | |||
| set user (val: User) { | |||
| localStorage.setItem("user", encode(`${val.username}:${val.password}`)); | |||
| } | |||
| get user () { | |||
| if (localStorage.getItem("user")) { | |||
| const userValues = decode(localStorage.getItem("user")).split(':'); | |||
| const user: User = {username: userValues[0], password: userValues[1]} | |||
| return user; | |||
| } | |||
| return null; | |||
| } | |||
| getUserToken () { | |||
| if (localStorage.getItem("user")) { | |||
| return `Basic ${localStorage.getItem("user")}`; | |||
| } | |||
| return '' | |||
| } | |||
| logout() { | |||
| if (localStorage.getItem("user")) { | |||
| localStorage.removeItem("user"); | |||
| } | |||
| } | |||
| constructor() { } | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| import { User } from './user'; | |||
| describe('User', () => { | |||
| it('should create an instance', () => { | |||
| expect(new User()).toBeTruthy(); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,4 @@ | |||
| export class User { | |||
| username: string; | |||
| password: string; | |||
| } | |||
| @@ -6,7 +6,7 @@ | |||
| > | |||
| </mat-toolbar> | |||
| <main class="content"> | |||
| <app-grid></app-grid> | |||
| <router-outlet></router-outlet> | |||
| </main> | |||
| </mat-sidenav-content> | |||
| </mat-sidenav-container> | |||
| @@ -0,0 +1,3 @@ | |||
| div { | |||
| color: white; | |||
| } | |||
| @@ -1,4 +1,4 @@ | |||
| <mat-card class="dashboard-card rule-card"> | |||
| <mat-card class="dashboard-card wood-card"> | |||
| <mat-card-header> | |||
| <mat-card-title class="title mat-title"> | |||
| <mat-icon mat-list-icon class="logo">gavel</mat-icon> Regeln | |||
| @@ -1,21 +1,5 @@ | |||
| :host ::ng-deep .mat-list-base .mat-list-item .mat-list-item-content { | |||
| justify-content: left; | |||
| text-align: left; | |||
| } | |||
| div { | |||
| color: white; | |||
| } | |||
| .rule-card { | |||
| background-image: url('/assets/wood.jpeg'); | |||
| background-repeat: no-repeat; | |||
| background-size: cover; | |||
| mat-card { | |||
| margin: 15px; | |||
| width: 90%; | |||
| color: white; | |||
| position: absolute; | |||
| top: 15px; | |||
| left: 15px; | |||
| right: 15px; | |||
| bottom: 15px; | |||
| } | |||
| @@ -1,3 +1,4 @@ | |||
| export const environment = { | |||
| production: true | |||
| production: true, | |||
| apiUrl: 'api.hoppe.ziermach.de', | |||
| }; | |||
| @@ -3,7 +3,8 @@ | |||
| // The list of file replacements can be found in `angular.json`. | |||
| export const environment = { | |||
| production: false | |||
| production: false, | |||
| apiUrl: 'http://localhost:3000' | |||
| }; | |||
| /* | |||
| @@ -1,5 +1,7 @@ | |||
| /* You can add global styles to this file, and also import other style files */ | |||
| @import '~@angular/material/prebuilt-themes/deeppurple-amber.css'; | |||
| html, | |||
| body { | |||
| height: 100vh !important; | |||
| @@ -13,7 +15,6 @@ body { | |||
| } | |||
| .logo { | |||
| padding-right: 1vw !important; | |||
| } | |||
| @@ -21,3 +22,21 @@ body { | |||
| display: flex !important; | |||
| align-items: center !important; | |||
| } | |||
| :host ::ng-deep .mat-list-base .mat-list-item .mat-list-item-content { | |||
| justify-content: left; | |||
| text-align: left; | |||
| } | |||
| .wood-card { | |||
| background-image: url('/assets/wood.jpeg'); | |||
| background-repeat: no-repeat; | |||
| background-size: cover; | |||
| color: white; | |||
| position: absolute; | |||
| top: 15px; | |||
| left: 15px; | |||
| right: 15px; | |||
| bottom: 15px; | |||
| } | |||