@@ -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; | |||
} |