From e9d00df583f4acd77e548c6522822a5450072432 Mon Sep 17 00:00:00 2001 From: "GRUPOGMV\\ssis" Date: Fri, 9 Aug 2024 08:08:23 +0200 Subject: [PATCH 1/2] Rehecho crear policy. --- package-lock.json | 25 +++ package.json | 2 + .../new-policy-dialog.component.html | 52 +++--- .../new-policy-dialog.component.scss | 6 +- .../new-policy-dialog.component.ts | 79 ++++----- ...olicy-definition-create-page-form-model.ts | 9 + .../policy-definition-create-page-form.ts | 40 +++++ src/app/pages/policies/policies.module.ts | 12 +- .../participant-id-select/array-utils.ts | 21 +++ .../participant-id-select.component.html | 24 +++ .../participant-id-select.component.ts | 37 ++++ .../editor/expression-form-controls.ts | 94 ++++++++++ .../editor/expression-form-handler.ts | 149 ++++++++++++++++ .../editor/expression-form-value.ts | 11 ++ .../policy-form-add-menu.component.html | 32 ++++ .../policy-form-add-menu.component.ts | 60 +++++++ ...-form-expression-constraint.component.html | 48 ++++++ ...cy-form-expression-constraint.component.ts | 28 +++ ...olicy-form-expression-empty.component.html | 1 + .../policy-form-expression-empty.component.ts | 17 ++ ...olicy-form-expression-multi.component.html | 41 +++++ .../policy-form-expression-multi.component.ts | 23 +++ .../policy-form-expression.component.html | 14 ++ .../policy-form-expression.component.ts | 21 +++ .../policy-form-remove-button.component.html | 3 + .../policy-form-remove-button.component.ts | 19 ++ .../policy-operator-select.component.html | 11 ++ .../policy-operator-select.component.ts | 21 +++ .../policy-expression-recipe.service.ts | 38 ++++ ...timespan-restriction-dialog.component.html | 34 ++++ .../timespan-restriction-dialog.component.ts | 52 ++++++ .../timespan-restriction-expression.ts | 24 +++ .../policies/policy-editor/editor/tree.ts | 162 ++++++++++++++++++ .../model/policy-definition-create-dto.ts | 21 +++ .../model/policy-expression-mapped.ts | 18 ++ .../model/policy-form-adapter.ts | 148 ++++++++++++++++ .../model/policy-left-expressions.ts | 4 + .../policy-editor/model/policy-mapper.ts | 129 ++++++++++++++ .../model/policy-multi-expressions.ts | 22 +++ .../policy-editor/model/policy-operators.ts | 78 +++++++++ .../policy-editor/model/policy-verbs.ts | 55 ++++++ .../model/ui-policy-constraint.ts | 82 +++++++++ .../model/ui-policy-expression-type.ts | 15 ++ .../model/ui-policy-expression-utils.ts | 38 ++++ .../model/ui-policy-expression.ts | 28 +++ .../policy-editor/policy-editor.module.ts | 52 ++++++ .../policy-expression.component.html | 50 ++++++ .../policy-expression.component.ts | 16 ++ .../policy-renderer.component.html | 6 + .../policy-renderer.component.ts | 14 ++ .../policy-view/policy-view.component.ts | 5 +- .../pipes-and-directives.module.ts | 26 +++ .../pipes/compare-by-field.pipe.ts | 11 ++ .../pipes-and-directives/pipes/values.pipe.ts | 11 ++ src/app/shared/services/policy.service.ts | 13 ++ src/app/shared/shared.module.ts | 18 +- src/app/shared/utils/map-utils.ts | 31 ++++ src/app/shared/utils/mat-dialog-utils.ts | 28 +++ src/app/shared/utils/rxjs-utils.ts | 24 +++ src/app/shared/validators/json-validator.ts | 19 ++ .../no-whitespaces-or-colons-validator.ts | 8 + .../valid-date-range-optional-end.ts | 22 +++ .../shared/validators/validation-messages.ts | 14 ++ src/assets/config/app.config.json | 1 + src/assets/config/app.config.template.json | 1 + src/environments/environment.prod.ts | 1 + src/environments/environment.ts | 1 + src/styles.scss | 77 +++++++++ 68 files changed, 2214 insertions(+), 83 deletions(-) create mode 100644 src/app/pages/policies/new-policy-dialog/policy-definition-create-page-form-model.ts create mode 100644 src/app/pages/policies/new-policy-dialog/policy-definition-create-page-form.ts create mode 100644 src/app/pages/policies/policy-editor/editor/controls/participant-id-select/array-utils.ts create mode 100644 src/app/pages/policies/policy-editor/editor/controls/participant-id-select/participant-id-select.component.html create mode 100644 src/app/pages/policies/policy-editor/editor/controls/participant-id-select/participant-id-select.component.ts create mode 100644 src/app/pages/policies/policy-editor/editor/expression-form-controls.ts create mode 100644 src/app/pages/policies/policy-editor/editor/expression-form-handler.ts create mode 100644 src/app/pages/policies/policy-editor/editor/expression-form-value.ts create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-add-menu/policy-form-add-menu.component.html create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-add-menu/policy-form-add-menu.component.ts create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-expression-constraint/policy-form-expression-constraint.component.html create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-expression-constraint/policy-form-expression-constraint.component.ts create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-expression-empty/policy-form-expression-empty.component.html create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-expression-empty/policy-form-expression-empty.component.ts create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-expression-multi/policy-form-expression-multi.component.html create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-expression-multi/policy-form-expression-multi.component.ts create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-expression/policy-form-expression.component.html create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-expression/policy-form-expression.component.ts create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-remove-button/policy-form-remove-button.component.html create mode 100644 src/app/pages/policies/policy-editor/editor/policy-form-remove-button/policy-form-remove-button.component.ts create mode 100644 src/app/pages/policies/policy-editor/editor/policy-operator-select/policy-operator-select.component.html create mode 100644 src/app/pages/policies/policy-editor/editor/policy-operator-select/policy-operator-select.component.ts create mode 100644 src/app/pages/policies/policy-editor/editor/recipes/policy-expression-recipe.service.ts create mode 100644 src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-dialog.component.html create mode 100644 src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-dialog.component.ts create mode 100644 src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-expression.ts create mode 100644 src/app/pages/policies/policy-editor/editor/tree.ts create mode 100644 src/app/pages/policies/policy-editor/model/policy-definition-create-dto.ts create mode 100644 src/app/pages/policies/policy-editor/model/policy-expression-mapped.ts create mode 100644 src/app/pages/policies/policy-editor/model/policy-form-adapter.ts create mode 100644 src/app/pages/policies/policy-editor/model/policy-left-expressions.ts create mode 100644 src/app/pages/policies/policy-editor/model/policy-mapper.ts create mode 100644 src/app/pages/policies/policy-editor/model/policy-multi-expressions.ts create mode 100644 src/app/pages/policies/policy-editor/model/policy-operators.ts create mode 100644 src/app/pages/policies/policy-editor/model/policy-verbs.ts create mode 100644 src/app/pages/policies/policy-editor/model/ui-policy-constraint.ts create mode 100644 src/app/pages/policies/policy-editor/model/ui-policy-expression-type.ts create mode 100644 src/app/pages/policies/policy-editor/model/ui-policy-expression-utils.ts create mode 100644 src/app/pages/policies/policy-editor/model/ui-policy-expression.ts create mode 100644 src/app/pages/policies/policy-editor/policy-editor.module.ts create mode 100644 src/app/pages/policies/policy-editor/renderer/policy-expression/policy-expression.component.html create mode 100644 src/app/pages/policies/policy-editor/renderer/policy-expression/policy-expression.component.ts create mode 100644 src/app/pages/policies/policy-editor/renderer/policy-renderer/policy-renderer.component.html create mode 100644 src/app/pages/policies/policy-editor/renderer/policy-renderer/policy-renderer.component.ts create mode 100644 src/app/shared/pipes-and-directives/pipes-and-directives.module.ts create mode 100644 src/app/shared/pipes-and-directives/pipes/compare-by-field.pipe.ts create mode 100644 src/app/shared/pipes-and-directives/pipes/values.pipe.ts create mode 100644 src/app/shared/utils/map-utils.ts create mode 100644 src/app/shared/utils/mat-dialog-utils.ts create mode 100644 src/app/shared/utils/rxjs-utils.ts create mode 100644 src/app/shared/validators/json-validator.ts create mode 100644 src/app/shared/validators/no-whitespaces-or-colons-validator.ts create mode 100644 src/app/shared/validators/valid-date-range-optional-end.ts create mode 100644 src/app/shared/validators/validation-messages.ts diff --git a/package-lock.json b/package-lock.json index 128fc72..e9d8b41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,8 @@ "@jsonforms/core": "^3.2.1", "@think-it-labs/edc-connector-client": "0.5.0", "angular-oauth2-oidc": "^17.0.2", + "date-fns": "^2.30.0", + "date-fns-tz": "^2.0.1", "install": "^0.13.0", "jexl": "^2.3.0", "jsonld": "^8.3.2", @@ -7918,6 +7920,29 @@ "node": ">=10" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/date-fns-tz": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.1.tgz", + "integrity": "sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==", + "peerDependencies": { + "date-fns": "2.x" + } + }, "node_modules/date-format": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.9.tgz", diff --git a/package.json b/package.json index c9a48f2..d65bb94 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "@jsonforms/angular-material": "^3.2.1", "@jsonforms/core": "^3.2.1", "@think-it-labs/edc-connector-client": "0.5.0", + "date-fns": "^2.30.0", + "date-fns-tz": "^2.0.1", "angular-oauth2-oidc": "^17.0.2", "install": "^0.13.0", "jexl": "^2.3.0", diff --git a/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.html b/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.html index 519135c..2cbbe47 100644 --- a/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.html +++ b/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.html @@ -1,38 +1,32 @@ +
+
Policy Metadata
+ + + ID + + {{ + validationMessages.invalidWhitespacesOrColonsMessage + }} + + +
Policy Expression
+ + + + +
- - - ID - - - - - - Permissions (JSON) - - - - - - Prohibitions (JSON) - - - - - - Obligations (JSON) - -
- - - diff --git a/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.scss b/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.scss index bf7d93f..d92ba3d 100644 --- a/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.scss +++ b/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.scss @@ -1,5 +1,5 @@ mat-dialog-content { - width: 800px; + min-width: 1500px; } .form-field-half{ @@ -11,3 +11,7 @@ mat-dialog-content { box-sizing: border-box; width: 100%; } +.mat-mdc-dialog-content { + max-height: 85vh; + min-height: 65vh; +} diff --git a/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.ts b/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.ts index f8854fc..acf9bc7 100644 --- a/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.ts +++ b/src/app/pages/policies/new-policy-dialog/new-policy-dialog.component.ts @@ -1,69 +1,52 @@ -import { Component, OnInit } from '@angular/core'; -import { PolicyInput } from "../../../shared/models/edc-connector-entities"; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { MatDialogRef } from "@angular/material/dialog"; import { NotificationService } from 'src/app/shared/services/notification.service'; -import { PolicyBuilder } from '@think-it-labs/edc-connector-client'; +import { PolicyDefinitionCreatePageForm } from './policy-definition-create-page-form'; +import { ExpressionFormHandler } from '../policy-editor/editor/expression-form-handler'; +import { ValidationMessages } from 'src/app/shared/validators/validation-messages'; +import { Subject } from 'rxjs'; +import { PolicyDefinitionCreateDto } from '../policy-editor/model/policy-definition-create-dto'; @Component({ selector: 'app-new-policy-dialog', templateUrl: './new-policy-dialog.component.html', styleUrls: ['./new-policy-dialog.component.scss'] }) -export class NewPolicyDialogComponent implements OnInit { - editMode: boolean = false; - policy: PolicyInput = { - "@type": "Set" - }; +export class NewPolicyDialogComponent implements OnInit,OnDestroy { - policyId: string = ''; - permissionsJson: string = ''; - prohibitionsJson: string = ''; - obligationsJson: string = ''; - constructor(private dialogRef: MatDialogRef, - private notificationService: NotificationService) { - } + constructor( + public form: PolicyDefinitionCreatePageForm, + public expressionFormHandler: ExpressionFormHandler, + public validationMessages: ValidationMessages, + private dialogRef: MatDialogRef, + private notificationService: NotificationService + ) {} ngOnInit(): void { - this.editMode = true; + + this.expressionFormHandler.reset() + this.form.reset() } onSave() { - try { - this.policy.permission = this.parseAndVerifyJson(this.permissionsJson); - this.policy.prohibition = this.parseAndVerifyJson(this.prohibitionsJson); - this.policy.obligation = this.parseAndVerifyJson(this.obligationsJson); - - this.dialogRef.close({ - '@id': this.policyId, - policy: new PolicyBuilder() - .raw({ - ...this.policy - }) - .build() - }); - - } catch (error) { - if (error instanceof SyntaxError) { - this.notificationService.showError("Error parsing JSON: " + error.message); - console.error(error); - } + const createDto = this.buildPolicyDefinitionCreateDto(); + if(this.form.group.valid){ + this.dialogRef.close(createDto); } } - /** - * Parse and verify a JSON from the policy - * - * @param json JSON to parse and verify - * @returns the parsed JSON or null if it is empty - */ - private parseAndVerifyJson(json: string): any { - if (json.trim() != '') { - const parsedJson = JSON.parse(json); - return parsedJson; - } - - return null; + buildPolicyDefinitionCreateDto(): PolicyDefinitionCreateDto { + return { + policyDefinitionId: this.form.group.controls.id.value, + expression: this.expressionFormHandler.toUiPolicyExpression(), + }; } + ngOnDestroy$ = new Subject(); + + ngOnDestroy(): void { + this.ngOnDestroy$.next(null); + this.ngOnDestroy$.complete(); + } } diff --git a/src/app/pages/policies/new-policy-dialog/policy-definition-create-page-form-model.ts b/src/app/pages/policies/new-policy-dialog/policy-definition-create-page-form-model.ts new file mode 100644 index 0000000..3d1eae0 --- /dev/null +++ b/src/app/pages/policies/new-policy-dialog/policy-definition-create-page-form-model.ts @@ -0,0 +1,9 @@ +import {FormControl, UntypedFormGroup, ɵFormGroupValue} from '@angular/forms'; + +export type PolicyDefinitionCreatePageFormValue = + ɵFormGroupValue; + +export interface PolicyDefinitionCreatePageFormModel { + id: FormControl; + treeControls: UntypedFormGroup; +} diff --git a/src/app/pages/policies/new-policy-dialog/policy-definition-create-page-form.ts b/src/app/pages/policies/new-policy-dialog/policy-definition-create-page-form.ts new file mode 100644 index 0000000..dee5af5 --- /dev/null +++ b/src/app/pages/policies/new-policy-dialog/policy-definition-create-page-form.ts @@ -0,0 +1,40 @@ +import {Injectable} from '@angular/core'; +import {FormBuilder, FormGroup, Validators} from '@angular/forms'; +import { + PolicyDefinitionCreatePageFormModel, + PolicyDefinitionCreatePageFormValue, +} from './policy-definition-create-page-form-model'; +import { ExpressionFormControls } from '../policy-editor/editor/expression-form-controls'; +import { noWhitespacesOrColonsValidator } from 'src/app/shared/validators/no-whitespaces-or-colons-validator'; + +/** + * Handles AngularForms for NewPolicyDialog + */ +@Injectable() +export class PolicyDefinitionCreatePageForm { + group = this.buildFormGroup(); + + /** + * Quick access to full value + */ + get value(): PolicyDefinitionCreatePageFormValue { + return this.group.value; + } + + constructor( + private formBuilder: FormBuilder, + private expressionFormControls: ExpressionFormControls, + ) {} + + buildFormGroup(): FormGroup { + return this.formBuilder.nonNullable.group({ + id: ['', [Validators.required, noWhitespacesOrColonsValidator]], + treeControls: this.expressionFormControls.formGroup, + }); + } + + reset(){ + this.expressionFormControls.reset() + this.group = this.buildFormGroup() + } +} diff --git a/src/app/pages/policies/policies.module.ts b/src/app/pages/policies/policies.module.ts index e779022..ca173ad 100644 --- a/src/app/pages/policies/policies.module.ts +++ b/src/app/pages/policies/policies.module.ts @@ -5,6 +5,10 @@ import { PolicyRuleViewerComponent } from './policy-rule-viewer/policy-rule-view import { PolicyViewComponent } from './policy-view/policy-view.component'; import { NewPolicyDialogComponent } from './new-policy-dialog/new-policy-dialog.component'; import { SharedModule } from 'src/app/shared/shared.module'; +import { PolicyEditorModule } from './policy-editor/policy-editor.module'; +import { PolicyDefinitionCreatePageForm } from './new-policy-dialog/policy-definition-create-page-form'; +import { ExpressionFormControls } from './policy-editor/editor/expression-form-controls'; +import { ExpressionFormHandler } from './policy-editor/editor/expression-form-handler'; @NgModule({ declarations: [ @@ -12,9 +16,15 @@ import { SharedModule } from 'src/app/shared/shared.module'; PolicyViewComponent, NewPolicyDialogComponent ], + providers: [ + PolicyDefinitionCreatePageForm, + ExpressionFormControls, + ExpressionFormHandler + ], imports: [ PoliciesRoutingModule, - SharedModule + SharedModule, + PolicyEditorModule ] }) export class PoliciesModule { } diff --git a/src/app/pages/policies/policy-editor/editor/controls/participant-id-select/array-utils.ts b/src/app/pages/policies/policy-editor/editor/controls/participant-id-select/array-utils.ts new file mode 100644 index 0000000..6cd7b33 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/controls/participant-id-select/array-utils.ts @@ -0,0 +1,21 @@ +/** + * Remove item once from list. + * + * Use this over .filter(...) to remove items on user interactions + * to prevent one click from removing many items. + * + * Returns copy. + */ +export function removeOnce(list: T[], item: T): T[] { + const index = list.indexOf(item); + if (index >= 0) { + const copy = [...list]; + copy.splice(index, 1); + return copy; + } + return list; +} + +export function filterNonNull(array: (T | null | undefined)[]): T[] { + return array.filter((it) => it != null) as T[]; +} diff --git a/src/app/pages/policies/policy-editor/editor/controls/participant-id-select/participant-id-select.component.html b/src/app/pages/policies/policy-editor/editor/controls/participant-id-select/participant-id-select.component.html new file mode 100644 index 0000000..b7b6cf7 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/controls/participant-id-select/participant-id-select.component.html @@ -0,0 +1,24 @@ + + Participant ID + + + {{ participantId }} + + + + + + The Participant ID of your Connector can be + found on the dashboard of your Connector. + + diff --git a/src/app/pages/policies/policy-editor/editor/controls/participant-id-select/participant-id-select.component.ts b/src/app/pages/policies/policy-editor/editor/controls/participant-id-select/participant-id-select.component.ts new file mode 100644 index 0000000..5746253 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/controls/participant-id-select/participant-id-select.component.ts @@ -0,0 +1,37 @@ +import {COMMA, ENTER, SEMICOLON} from '@angular/cdk/keycodes'; +import {Component, HostBinding, Input} from '@angular/core'; +import {FormControl} from '@angular/forms'; +import {MatChipInputEvent} from '@angular/material/chips'; +import { removeOnce } from './array-utils'; + +@Component({ + selector: 'participant-id-select', + templateUrl: 'participant-id-select.component.html', +}) +export class ParticipantIdSelectComponent { + separatorKeysCodes: number[] = [ENTER, COMMA, SEMICOLON]; + + @Input() + control!: FormControl; + + @HostBinding('class.flex') + @HostBinding('class.flex-row') + cls = true; + + constructor() {} + + remove(participantId: string) { + this.control.setValue(removeOnce(this.control.value, participantId)); + } + + add(event: MatChipInputEvent): void { + const participantIds = (event.value || '') + .split(/[,;]/) + .map((it) => it.trim()) + .filter((it) => it); + if (participantIds.length) { + this.control.setValue([...this.control.value, ...participantIds]); + } + event.chipInput.clear(); + } +} diff --git a/src/app/pages/policies/policy-editor/editor/expression-form-controls.ts b/src/app/pages/policies/policy-editor/editor/expression-form-controls.ts new file mode 100644 index 0000000..32e1d56 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/expression-form-controls.ts @@ -0,0 +1,94 @@ +import {Injectable} from '@angular/core'; +import { + FormControl, + UntypedFormControl, + UntypedFormGroup, +} from '@angular/forms'; +import {PolicyOperatorConfig} from '../model/policy-operators'; +import {ExpressionFormValue} from './expression-form-value'; +import {TreeNode} from './tree'; +import { OperatorDto, UiPolicyLiteral } from '../model/ui-policy-constraint'; + +/** + * Manages the FormGroup across the expression tree + * + * Controls are needed for both constraint operators and values + * + * Must be provided at the component level as viewProvider. + */ +@Injectable() +export class ExpressionFormControls { + formGroup = new UntypedFormGroup({}); + + getValue(node: TreeNode): UiPolicyLiteral { + const formValue = this.getValueFormControl(node).value; + return node.value.verb!.adapter.buildValueFn( + formValue, + this.getOperator(node), + ); + } + + getOperator(node: TreeNode): PolicyOperatorConfig { + return this.getOperatorFormControl(node).value; + } + + registerControls( + nodeId: string, + expr: ExpressionFormValue, + operator: OperatorDto, + value: UiPolicyLiteral, + ) { + if (expr.type !== 'CONSTRAINT') { + return; + } + + const supportedOperators = expr.supportedOperators ?? []; + const operatorConfig = + supportedOperators.find((it) => it.id === operator) ?? + supportedOperators[0]; + + const operatorControl = new UntypedFormControl(operatorConfig); + + const valueControl = expr.verb!.adapter.fromControlFactory(); + valueControl.reset(expr.verb!.adapter.buildFormValueFn(value)); + + this.formGroup.addControl(`${nodeId}-value`, valueControl); + this.formGroup.addControl(`${nodeId}-op`, operatorControl); + } + + unregisterControls(node: TreeNode) { + this.dfs(node, (node) => this.unregisterNodeControls(node)); + } + + getValueFormControl(treeNode: TreeNode): FormControl { + return this.formGroup.get(`${treeNode.id}-value`) as FormControl; + } + + getOperatorFormControl( + treeNode: TreeNode, + ): UntypedFormControl { + return this.formGroup.get(`${treeNode.id}-op`) as FormControl; + } + + private unregisterNodeControls(node: TreeNode) { + if (node.value.type !== 'CONSTRAINT') { + return; + } + + [`${node.id}-value`, `${node.id}-op`].forEach((it) => + this.formGroup.removeControl(it), + ); + } + + private dfs( + treeNode: TreeNode, + callback: (node: TreeNode) => void, + ) { + callback(treeNode); + treeNode.children.forEach((child) => this.dfs(child, callback)); + } + + reset(){ + this.formGroup = new UntypedFormGroup({}); + } +} diff --git a/src/app/pages/policies/policy-editor/editor/expression-form-handler.ts b/src/app/pages/policies/policy-editor/editor/expression-form-handler.ts new file mode 100644 index 0000000..9d1c4b7 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/expression-form-handler.ts @@ -0,0 +1,149 @@ +import {Injectable} from '@angular/core'; +import {PolicyExpressionMapped} from '../model/policy-expression-mapped'; +import {PolicyMapper} from '../model/policy-mapper'; +import {PolicyMultiExpressionConfig} from '../model/policy-multi-expressions'; +import {SUPPORTED_POLICY_OPERATORS} from '../model/policy-operators'; +import {PolicyVerbConfig} from '../model/policy-verbs'; +import {ExpressionFormControls} from './expression-form-controls'; +import {ExpressionFormValue} from './expression-form-value'; +import {Tree, TreeGeneratorFn, TreeNode} from './tree'; +import { UiPolicyExpression } from '../model/ui-policy-expression'; + +/** + * Central service for interacting with the policy expression form. + * + * Must be provided at the component level as viewProvider. + */ +@Injectable() +export class ExpressionFormHandler { + tree: Tree = this.buildTree({type: 'EMPTY'}); + + constructor( + public controls: ExpressionFormControls, + public policyMapper: PolicyMapper, + ) {} + + private buildTree( + expression: PolicyExpressionMapped, + ): Tree { + return Tree.ofTreeLikeStructure< + PolicyExpressionMapped, + ExpressionFormValue + >({ + root: expression, + generatorFn: this.treeGenerator(), + }); + } + + addConstraint(path: string[], verb: PolicyVerbConfig) { + const expression: UiPolicyExpression = { + type: 'CONSTRAINT', + constraint: { + left: verb.operandLeftId, + ...verb.adapter.emptyConstraintValue(), + }, + }; + + this.addExpression(path, expression); + } + + addMultiExpression( + path: string[], + multiExpression: PolicyMultiExpressionConfig, + ) { + const expression: UiPolicyExpression = { + type: multiExpression.expressionType, + expressions: [], + }; + this.addExpression(path, expression); + } + + addExpression(path: string[], expression: UiPolicyExpression) { + const mapped = this.policyMapper.buildPolicy(expression); + this.addTree(path, mapped); + } + + removeNode(node: TreeNode) { + this.controls.unregisterControls(node); + if (node.path.length === 1) { + this.tree.replaceTree(node.path, {type: 'EMPTY'}, this.treeGenerator()); + } else { + this.tree.remove(node.path); + } + } + + private addTree( + path: string[], + expression: PolicyExpressionMapped, + ): TreeNode { + if (path.length === 1 && this.tree.root.value.type === 'EMPTY') { + this.tree.replaceTree(path, expression, this.treeGenerator()); + return this.tree.root; + } else { + return this.tree.pushTree(path, expression, this.treeGenerator()); + } + } + + private treeGenerator(): TreeGeneratorFn< + PolicyExpressionMapped, + ExpressionFormValue + > { + // Function returning a function for it to be available in the constructor + return (expr, nodeId) => { + const value = this.buildExpressionFormValue(expr); + + // Also create form controls as necessary + this.controls.registerControls( + nodeId, + value, + expr.operator?.id!, + expr.valueRaw!, + ); + + return {value, children: expr.expressions ?? []}; + }; + } + + private buildExpressionFormValue( + original: PolicyExpressionMapped, + ): ExpressionFormValue { + const supportedOperators = SUPPORTED_POLICY_OPERATORS.filter((it) => + original.verb?.supportedOperators.includes(it.id), + ); + return { + type: original.type, + multiExpression: original.multiExpression, + verb: original.verb, + supportedOperators, + }; + } + + toUiPolicyExpression() { + const visit = (node: TreeNode): UiPolicyExpression => { + const value = node.value; + if (value.type === 'EMPTY') { + return {type: 'EMPTY'}; + } else if (value.type === 'MULTI') { + return { + type: value.multiExpression!.expressionType, + expressions: node.children.map((it) => visit(it)), + }; + } else { + return { + type: 'CONSTRAINT', + constraint: { + left: value.verb!.operandLeftId, + operator: this.controls.getOperator(node).id, + right: this.controls.getValue(node), + }, + }; + } + }; + + return visit(this.tree.root); + } + + reset(){ + this.tree = this.buildTree({type: 'EMPTY'}); + } +} diff --git a/src/app/pages/policies/policy-editor/editor/expression-form-value.ts b/src/app/pages/policies/policy-editor/editor/expression-form-value.ts new file mode 100644 index 0000000..395d9c9 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/expression-form-value.ts @@ -0,0 +1,11 @@ +import {PolicyMultiExpressionConfig} from '../model/policy-multi-expressions'; +import {PolicyOperatorConfig} from '../model/policy-operators'; +import {PolicyVerbConfig} from '../model/policy-verbs'; + +export interface ExpressionFormValue { + type: 'CONSTRAINT' | 'MULTI' | 'EMPTY'; + + multiExpression?: PolicyMultiExpressionConfig; + verb?: PolicyVerbConfig; + supportedOperators?: PolicyOperatorConfig[]; +} diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-add-menu/policy-form-add-menu.component.html b/src/app/pages/policies/policy-editor/editor/policy-form-add-menu/policy-form-add-menu.component.html new file mode 100644 index 0000000..1008f65 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-add-menu/policy-form-add-menu.component.html @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-add-menu/policy-form-add-menu.component.ts b/src/app/pages/policies/policy-editor/editor/policy-form-add-menu/policy-form-add-menu.component.ts new file mode 100644 index 0000000..1307c27 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-add-menu/policy-form-add-menu.component.ts @@ -0,0 +1,60 @@ +import {Component, Input, OnDestroy} from '@angular/core'; +import {Subject} from 'rxjs'; +import { + PolicyMultiExpressionConfig, + SUPPORTED_MULTI_EXPRESSIONS, +} from '../../model/policy-multi-expressions'; +import { + PolicyVerbConfig, + SUPPORTED_POLICY_VERBS, +} from '../../model/policy-verbs'; +import {ExpressionFormHandler} from '../expression-form-handler'; +import {ExpressionFormValue} from '../expression-form-value'; +import { + PolicyExpressionRecipe, + PolicyExpressionRecipeService, +} from '../recipes/policy-expression-recipe.service'; +import {TreeNode} from '../tree'; + +@Component({ + selector: 'policy-form-add-menu', + templateUrl: './policy-form-add-menu.component.html', +}) +export class PolicyFormAddMenuComponent implements OnDestroy { + multiExpressions = SUPPORTED_MULTI_EXPRESSIONS; + constraints = SUPPORTED_POLICY_VERBS; + + @Input() + treeNode!: TreeNode; + + constructor( + public expressionFormHandler: ExpressionFormHandler, + public policyExpressionRecipeService: PolicyExpressionRecipeService, + ) {} + + onAddConstraint(constraint: PolicyVerbConfig) { + const path = this.treeNode.path; + this.expressionFormHandler.addConstraint(path, constraint); + } + + onAddMultiExpression(multi: PolicyMultiExpressionConfig) { + const path = this.treeNode.path; + this.expressionFormHandler.addMultiExpression(path, multi); + } + + onAddRecipe(recipe: PolicyExpressionRecipe) { + const path = this.treeNode.path; + recipe + .onclick(this.ngOnDestroy$) + .subscribe((expression) => + this.expressionFormHandler.addExpression(path, expression), + ); + } + + ngOnDestroy$ = new Subject(); + + ngOnDestroy(): void { + this.ngOnDestroy$.next(null); + this.ngOnDestroy$.complete(); + } +} diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-expression-constraint/policy-form-expression-constraint.component.html b/src/app/pages/policies/policy-editor/editor/policy-form-expression-constraint/policy-form-expression-constraint.component.html new file mode 100644 index 0000000..bf5f414 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-expression-constraint/policy-form-expression-constraint.component.html @@ -0,0 +1,48 @@ +
+ {{ verb.operandLeftTitle }} +
+ +
+ +
+ + + {{ verb.operandLeftTitle }} + + {{ verb.operandRightHint }} + + + + {{ verb.operandLeftTitle }} + + {{ verb.operandRightHint }} + + + + + +
+ +
diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-expression-constraint/policy-form-expression-constraint.component.ts b/src/app/pages/policies/policy-editor/editor/policy-form-expression-constraint/policy-form-expression-constraint.component.ts new file mode 100644 index 0000000..c6c8985 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-expression-constraint/policy-form-expression-constraint.component.ts @@ -0,0 +1,28 @@ +import {Component, HostBinding, Input} from '@angular/core'; +import {PolicyVerbConfig} from '../../model/policy-verbs'; +import {ExpressionFormHandler} from '../expression-form-handler'; +import {ExpressionFormValue} from '../expression-form-value'; +import {TreeNode} from '../tree'; + +@Component({ + selector: 'policy-form-expression-constraint', + templateUrl: './policy-form-expression-constraint.component.html', +}) +export class PolicyFormExpressionConstraintComponent { + @HostBinding('class.flex') + @HostBinding('class.gap-4') + cls = true; + + @Input() + treeNode!: TreeNode; + + get expr(): ExpressionFormValue { + return this.treeNode.value; + } + + get verb(): PolicyVerbConfig { + return this.expr.verb!; + } + + constructor(public expressionFormHandler: ExpressionFormHandler) {} +} diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-expression-empty/policy-form-expression-empty.component.html b/src/app/pages/policies/policy-editor/editor/policy-form-expression-empty/policy-form-expression-empty.component.html new file mode 100644 index 0000000..073d65f --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-expression-empty/policy-form-expression-empty.component.html @@ -0,0 +1 @@ + diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-expression-empty/policy-form-expression-empty.component.ts b/src/app/pages/policies/policy-editor/editor/policy-form-expression-empty/policy-form-expression-empty.component.ts new file mode 100644 index 0000000..00bc5b0 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-expression-empty/policy-form-expression-empty.component.ts @@ -0,0 +1,17 @@ +import {Component, HostBinding, Input} from '@angular/core'; +import {ExpressionFormValue} from '../expression-form-value'; +import {TreeNode} from '../tree'; + +@Component({ + selector: 'policy-form-expression-empty', + templateUrl: './policy-form-expression-empty.component.html', +}) +export class PolicyFormExpressionEmptyComponent { + @HostBinding('class.flex') + @HostBinding('class.h-[4rem]') + @HostBinding('class.items-center') + cls = true; + + @Input() + treeNode!: TreeNode; +} diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-expression-multi/policy-form-expression-multi.component.html b/src/app/pages/policies/policy-editor/editor/policy-form-expression-multi/policy-form-expression-multi.component.html new file mode 100644 index 0000000..dc5543e --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-expression-multi/policy-form-expression-multi.component.html @@ -0,0 +1,41 @@ +
+
+ {{ + expr.multiExpression!.title + }} +
+ +
+
+
+ + +
+
+ +
+ +
+
+
diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-expression-multi/policy-form-expression-multi.component.ts b/src/app/pages/policies/policy-editor/editor/policy-form-expression-multi/policy-form-expression-multi.component.ts new file mode 100644 index 0000000..8217993 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-expression-multi/policy-form-expression-multi.component.ts @@ -0,0 +1,23 @@ +import {Component, HostBinding, Input, TrackByFunction} from '@angular/core'; +import {ExpressionFormValue} from '../expression-form-value'; +import {TreeNode} from '../tree'; + +@Component({ + selector: 'policy-form-expression-multi', + templateUrl: './policy-form-expression-multi.component.html', +}) +export class PolicyFormExpressionMultiComponent { + @HostBinding('class.flex') + @HostBinding('class.flex-col') + @HostBinding('class.justify-stretch') + cls = true; + + @Input() + treeNode!: TreeNode; + + trackByFn: TrackByFunction> = (_, it) => it.id; + + get expr(): ExpressionFormValue { + return this.treeNode.value; + } +} diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-expression/policy-form-expression.component.html b/src/app/pages/policies/policy-editor/editor/policy-form-expression/policy-form-expression.component.html new file mode 100644 index 0000000..d56831a --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-expression/policy-form-expression.component.html @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-expression/policy-form-expression.component.ts b/src/app/pages/policies/policy-editor/editor/policy-form-expression/policy-form-expression.component.ts new file mode 100644 index 0000000..c4a21a4 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-expression/policy-form-expression.component.ts @@ -0,0 +1,21 @@ +import {Component, Input, TrackByFunction} from '@angular/core'; +import {ExpressionFormHandler} from '../expression-form-handler'; +import {ExpressionFormValue} from '../expression-form-value'; +import {TreeNode} from '../tree'; + +@Component({ + selector: 'policy-form-expression', + templateUrl: './policy-form-expression.component.html', +}) +export class PolicyFormExpressionComponent { + @Input() + treeNode!: TreeNode; + + trackByFn: TrackByFunction> = (_, it) => it.id; + + get expr(): ExpressionFormValue { + return this.treeNode.value; + } + + constructor(public expressionFormHandler: ExpressionFormHandler) {} +} diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-remove-button/policy-form-remove-button.component.html b/src/app/pages/policies/policy-editor/editor/policy-form-remove-button/policy-form-remove-button.component.html new file mode 100644 index 0000000..c3223c1 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-remove-button/policy-form-remove-button.component.html @@ -0,0 +1,3 @@ + diff --git a/src/app/pages/policies/policy-editor/editor/policy-form-remove-button/policy-form-remove-button.component.ts b/src/app/pages/policies/policy-editor/editor/policy-form-remove-button/policy-form-remove-button.component.ts new file mode 100644 index 0000000..e3469a0 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-form-remove-button/policy-form-remove-button.component.ts @@ -0,0 +1,19 @@ +import {Component, Input} from '@angular/core'; +import {ExpressionFormHandler} from '../expression-form-handler'; +import {ExpressionFormValue} from '../expression-form-value'; +import {TreeNode} from '../tree'; + +@Component({ + selector: 'policy-form-remove-button', + templateUrl: './policy-form-remove-button.component.html', +}) +export class PolicyFormRemoveButton { + @Input() + treeNode!: TreeNode; + + constructor(public expressionFormHandler: ExpressionFormHandler) {} + + onRemoveClick() { + this.expressionFormHandler.removeNode(this.treeNode); + } +} diff --git a/src/app/pages/policies/policy-editor/editor/policy-operator-select/policy-operator-select.component.html b/src/app/pages/policies/policy-editor/editor/policy-operator-select/policy-operator-select.component.html new file mode 100644 index 0000000..039f427 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-operator-select/policy-operator-select.component.html @@ -0,0 +1,11 @@ + + {{ label }} + + + {{ operator.title }} + + + diff --git a/src/app/pages/policies/policy-editor/editor/policy-operator-select/policy-operator-select.component.ts b/src/app/pages/policies/policy-editor/editor/policy-operator-select/policy-operator-select.component.ts new file mode 100644 index 0000000..4bcfa95 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/policy-operator-select/policy-operator-select.component.ts @@ -0,0 +1,21 @@ +import {Component, HostBinding, Input} from '@angular/core'; +import {UntypedFormControl} from '@angular/forms'; +import {PolicyOperatorConfig} from '../../model/policy-operators'; + +@Component({ + selector: 'policy-operator-select', + templateUrl: 'policy-operator-select.component.html', +}) +export class PolicyOperatorSelectComponent { + @Input() + operators: PolicyOperatorConfig[] = []; + + @Input() + control!: UntypedFormControl; + + @HostBinding('class.flex') + @HostBinding('class.flex-row') + cls = true; + + label = 'Operator'; +} diff --git a/src/app/pages/policies/policy-editor/editor/recipes/policy-expression-recipe.service.ts b/src/app/pages/policies/policy-editor/editor/recipes/policy-expression-recipe.service.ts new file mode 100644 index 0000000..0021cd5 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/recipes/policy-expression-recipe.service.ts @@ -0,0 +1,38 @@ +import {ComponentType} from '@angular/cdk/portal'; +import {Injectable} from '@angular/core'; +import {MatDialog} from '@angular/material/dialog'; +import {Observable} from 'rxjs'; +import {TimespanRestrictionDialogComponent} from './timespan-restriction-dialog/timespan-restriction-dialog.component'; +import { UiPolicyExpression } from '../../model/ui-policy-expression'; +import { showDialogUntil } from 'src/app/shared/utils/mat-dialog-utils'; +import { filterNotNull } from 'src/app/shared/utils/rxjs-utils'; + +export interface PolicyExpressionRecipe { + title: string; + onclick: (until$: Observable) => Observable; +} + +@Injectable() +export class PolicyExpressionRecipeService { + recipes: PolicyExpressionRecipe[] = [ + { + title: 'Timespan Restriction', + onclick: (until$: Observable) => + this.showRecipeDialog(TimespanRestrictionDialogComponent, until$), + }, + ]; + + constructor(private dialog: MatDialog) {} + + private showRecipeDialog( + cmp: ComponentType, + until$: Observable, + ): Observable { + return showDialogUntil( + this.dialog, + cmp, + {}, + until$, + ).pipe(filterNotNull()); + } +} diff --git a/src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-dialog.component.html b/src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-dialog.component.html new file mode 100644 index 0000000..5e6e8a9 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-dialog.component.html @@ -0,0 +1,34 @@ +

Timespan Restriction

+ +
+ + + Date Range + + + + + DD/MM/YYYY – DD/MM/YYYY + + + {{ + validationMessages.invalidDateRangeMessage + }} + +
+
+ + + + + + diff --git a/src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-dialog.component.ts b/src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-dialog.component.ts new file mode 100644 index 0000000..7642b8a --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-dialog.component.ts @@ -0,0 +1,52 @@ +import {Component, OnDestroy} from '@angular/core'; +import {FormBuilder} from '@angular/forms'; +import {MatDialogRef} from '@angular/material/dialog'; +import {Subject} from 'rxjs'; +import {buildTimespanRestriction} from './timespan-restriction-expression'; +import { UiPolicyExpression } from '../../../model/ui-policy-expression'; +import { ValidationMessages } from 'src/app/shared/validators/validation-messages'; +import { validDateRange } from 'src/app/shared/validators/valid-date-range-optional-end'; + +@Component({ + selector: 'timespan-restriction-dialog', + templateUrl: './timespan-restriction-dialog.component.html', +}) +export class TimespanRestrictionDialogComponent implements OnDestroy { + group = this.formBuilder.nonNullable.group({ + range: this.formBuilder.group( + { + start: null as Date | null, + end: null as Date | null, + }, + {validators: validDateRange}, + ), + }); + + constructor( + private formBuilder: FormBuilder, + private dialogRef: MatDialogRef, + public validationMessages: ValidationMessages, + ) {} + + onAdd() { + const formValue = this.group.value; + + const expression = buildTimespanRestriction( + formValue.range!.start!, + formValue.range!.end!, + ); + + this.close(expression); + } + + private close(params: UiPolicyExpression) { + this.dialogRef.close(params); + } + + ngOnDestroy$ = new Subject(); + + ngOnDestroy(): void { + this.ngOnDestroy$.next(null); + this.ngOnDestroy$.complete(); + } +} diff --git a/src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-expression.ts b/src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-expression.ts new file mode 100644 index 0000000..eb37232 --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-expression.ts @@ -0,0 +1,24 @@ + +import {addDays} from 'date-fns'; +import {policyLeftExpressions} from '../../../model/policy-left-expressions'; +import {constraint, multi} from '../../../model/ui-policy-expression-utils'; +import { UiPolicyExpression } from '../../../model/ui-policy-expression'; +import { OperatorDto } from '../../../model/ui-policy-constraint'; + +export const buildTimespanRestriction = ( + firstDay: Date, + lastDay: Date, +): UiPolicyExpression => { + const evaluationTimeConstraint = (operator: OperatorDto, value: Date) => + constraint( + policyLeftExpressions.policyEvaluationTime, + operator, + value.toISOString(), + ); + + return multi( + 'AND', + evaluationTimeConstraint('GEQ', firstDay), + evaluationTimeConstraint('LT', addDays(lastDay, 1)), + ); +}; diff --git a/src/app/pages/policies/policy-editor/editor/tree.ts b/src/app/pages/policies/policy-editor/editor/tree.ts new file mode 100644 index 0000000..e04e27e --- /dev/null +++ b/src/app/pages/policies/policy-editor/editor/tree.ts @@ -0,0 +1,162 @@ +/** + * Tree data structure with a generic value type. + * + * The tree is mutable, but the TreeNode structure inside is immutable for better + * change detection. + */ +export class Tree { + constructor(public root: TreeNode, private _nextId: number) {} + + remove(path: string[]): void { + this.transform((node) => { + if (this.isEqualPath(node, path)) { + return null; + } + return node; + }); + } + + replaceTree( + path: string[], + value: I, + generatorFn: TreeGeneratorFn, + ): TreeNode { + const parentPath = path.slice(0, -1); + + const newNode = Tree.recursiveFoldNodes({ + parentPath, + original: value, + generatorFn, + idFactory: () => this.nextId(), + }); + + this.transform((node) => { + if (!this.isEqualPath(node, path)) { + return node; + } + + return newNode; + }); + + return newNode; + } + + push(parentPath: string[], value: T): TreeNode { + const id = this.nextId(); + const newNode: TreeNode = { + id, + path: [...parentPath, id], + value: value, + children: [], + }; + this.pushNode(parentPath, newNode); + return newNode; + } + + pushTree( + parentPath: string[], + value: I, + generatorFn: TreeGeneratorFn, + ): TreeNode { + const newNode = Tree.recursiveFoldNodes({ + parentPath, + original: value, + generatorFn, + idFactory: () => this.nextId(), + }); + this.pushNode(parentPath, newNode); + return newNode; + } + + private pushNode(parentPath: string[], newNode: TreeNode): void { + this.transform((node) => { + if (!this.isEqualPath(node, parentPath)) { + return node; + } + + return { + ...node, + children: [...node.children, newNode], + }; + }); + } + + private isEqualPath(node: TreeNode, path: string[]) { + return node.path.join('.') === path.join('.'); + } + + private transform(fn: (node: TreeNode) => TreeNode | null): void { + const transformNode = (node: TreeNode): TreeNode | null => { + const transformed = fn(node); + if (!transformed) { + return null; + } + return { + ...transformed, + children: transformed.children + .map(transformNode) + .filter((it) => it != null) as TreeNode[], + }; + }; + this.root = transformNode(this.root)!; + } + + private nextId() { + return String(this._nextId++); + } + + static ofTreeLikeStructure(opts: { + root: T; + generatorFn: TreeGeneratorFn; + }): Tree { + let currentId = 0; + const nextId = () => String(currentId++); + const rootNode = Tree.recursiveFoldNodes({ + parentPath: [], + original: opts.root, + generatorFn: opts.generatorFn, + idFactory: nextId, + }); + return new Tree(rootNode, currentId); + } + + private static recursiveFoldNodes(opts: { + parentPath: string[]; + original: T; + generatorFn: TreeGeneratorFn; + idFactory: () => string; + }): TreeNode { + const id = opts.idFactory(); + const {value, children} = opts.generatorFn(opts.original, id); + const path = [...opts.parentPath, id]; + const childrenMapped = children.map((child) => + Tree.recursiveFoldNodes({ + parentPath: path, + original: child, + generatorFn: opts.generatorFn, + idFactory: opts.idFactory, + }), + ); + return { + id, + path, + value, + children: childrenMapped, + }; + } +} + +export interface TreeNode { + path: string[]; + id: string; + value: T; + children: TreeNode[]; +} + +/** + * Mapper between a tree-like structure a dedicated TreeNode value type + */ +export type TreeGeneratorFn = ( + value: T, + id: string, +) => {value: R; children: T[]}; diff --git a/src/app/pages/policies/policy-editor/model/policy-definition-create-dto.ts b/src/app/pages/policies/policy-editor/model/policy-definition-create-dto.ts new file mode 100644 index 0000000..cfe729e --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/policy-definition-create-dto.ts @@ -0,0 +1,21 @@ +import { UiPolicyExpression } from "./ui-policy-expression"; + +/** + * Create a Policy Definition + * @export + * @interface PolicyDefinitionCreateDto + */ +export declare interface PolicyDefinitionCreateDto { + /** + * Policy Definition ID + * @type {string} + * @memberof PolicyDefinitionCreateDto + */ + policyDefinitionId: string; + /** + * + * @type {UiPolicyExpression} + * @memberof PolicyDefinitionCreateDto + */ + expression: UiPolicyExpression; +} diff --git a/src/app/pages/policies/policy-editor/model/policy-expression-mapped.ts b/src/app/pages/policies/policy-editor/model/policy-expression-mapped.ts new file mode 100644 index 0000000..570e1a7 --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/policy-expression-mapped.ts @@ -0,0 +1,18 @@ + +import {PolicyMultiExpressionConfig} from './policy-multi-expressions'; +import {PolicyOperatorConfig} from './policy-operators'; +import {PolicyVerbConfig} from './policy-verbs'; +import { UiPolicyLiteral } from './ui-policy-constraint'; + +export interface PolicyExpressionMapped { + type: 'CONSTRAINT' | 'MULTI' | 'EMPTY'; + + multiExpression?: PolicyMultiExpressionConfig; + expressions?: PolicyExpressionMapped[]; + + verb?: PolicyVerbConfig; + operator?: PolicyOperatorConfig; + valueRaw?: UiPolicyLiteral; + valueJson?: string; + displayValue?: string; +} diff --git a/src/app/pages/policies/policy-editor/model/policy-form-adapter.ts b/src/app/pages/policies/policy-editor/model/policy-form-adapter.ts new file mode 100644 index 0000000..9c9a12f --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/policy-form-adapter.ts @@ -0,0 +1,148 @@ +import {UntypedFormControl, Validators} from '@angular/forms'; +import {format} from 'date-fns-tz'; +import {PolicyOperatorConfig} from './policy-operators'; +import { UiPolicyConstraint, UiPolicyLiteral } from './ui-policy-constraint'; +import { filterNonNull } from '../editor/controls/participant-id-select/array-utils'; +import { jsonValidator } from 'src/app/shared/validators/json-validator'; + +export interface PolicyFormAdapter { + displayText: (value: UiPolicyLiteral) => string | null; + fromControlFactory: () => UntypedFormControl; + buildFormValueFn: (literal: UiPolicyLiteral) => T; + buildValueFn: ( + formValue: T, + operator: PolicyOperatorConfig, + ) => UiPolicyLiteral; + emptyConstraintValue: () => Pick; +} + +const readSingleStringLiteral = (literal: UiPolicyLiteral): string | null => { + if (literal.type === 'STRING') { + return literal.value ?? null; + } else if (literal.type === 'STRING_LIST') { + return literal.valueList?.length ? literal.valueList[0] : null; + } + return null; +}; + +const readArrayLiteral = (literal: UiPolicyLiteral): string[] => { + if (literal.type === 'STRING') { + return filterNonNull([literal.value]); + } else if (literal.type === 'STRING_LIST') { + return literal.valueList ?? []; + } + return []; +}; + +const readJsonLiteral = (literal: UiPolicyLiteral): string => { + if (literal.type === 'STRING') { + return JSON.stringify(literal.value); + } else if (literal.type === 'STRING_LIST') { + return JSON.stringify(literal.valueList); + } + return literal.value ?? 'null'; +}; + +const stringLiteral = (value: string | null | undefined): UiPolicyLiteral => ({ + type: 'STRING', + value: value ?? undefined, +}); + +export const localDateAdapter: PolicyFormAdapter = { + displayText: (literal): string | null => { + const value = readSingleStringLiteral(literal); + try { + if (!value) { + return value; + } + return format(new Date(value), 'yyyy-MM-dd'); + } catch (e) { + return '' + value; + } + }, + fromControlFactory: () => new UntypedFormControl(null, Validators.required), + buildFormValueFn: (literal): Date | null => { + const value = readSingleStringLiteral(literal); + try { + if (!value) { + return null; + } + return new Date(value); + } catch (e) { + return null; + } + }, + buildValueFn: (value) => stringLiteral(value?.toISOString()), + emptyConstraintValue: () => ({ + operator: 'LT', + right: { + type: 'STRING', + }, + }), +}; + +export const stringAdapter: PolicyFormAdapter = { + displayText: (literal): string | null => + readSingleStringLiteral(literal) ?? '', + fromControlFactory: () => new UntypedFormControl('', Validators.required), + buildFormValueFn: (literal): string => readSingleStringLiteral(literal) ?? '', + buildValueFn: (value) => stringLiteral(value), + emptyConstraintValue: () => ({ + operator: 'EQ', + right: { + type: 'STRING', + value: '', + }, + }), +}; + +export const stringArrayOrCommaJoinedAdapter: PolicyFormAdapter = { + displayText: (literal): string | null => readArrayLiteral(literal).join(', '), + fromControlFactory: () => new UntypedFormControl([], Validators.required), + buildFormValueFn: (literal): string[] => { + if (literal.type === 'STRING') { + return literal.value?.split(',') ?? []; + } + + return readArrayLiteral(literal); + }, + buildValueFn: (value, operator) => { + const items = value as string[]; + if (operator.id === 'EQ') { + return { + type: 'STRING', + value: items.join(','), + }; + } + + return { + type: 'STRING_LIST', + valueList: items, + }; + }, + emptyConstraintValue: () => ({ + operator: 'IN', + right: { + type: 'STRING_LIST', + valueList: [], + }, + }), +}; + +export const jsonAdapter: PolicyFormAdapter = { + displayText: (literal) => readJsonLiteral(literal), + buildFormValueFn: (literal) => readJsonLiteral(literal), + buildValueFn: (formValue) => ({ + type: 'JSON', + value: formValue, + }), + fromControlFactory: () => + new UntypedFormControl('', [Validators.required, jsonValidator]), + emptyConstraintValue: () => ({ + operator: 'EQ', + right: { + type: 'JSON', + value: 'null', + }, + }), +}; diff --git a/src/app/pages/policies/policy-editor/model/policy-left-expressions.ts b/src/app/pages/policies/policy-editor/model/policy-left-expressions.ts new file mode 100644 index 0000000..e091010 --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/policy-left-expressions.ts @@ -0,0 +1,4 @@ +export const policyLeftExpressions = { + policyEvaluationTime: 'POLICY_EVALUATION_TIME', + referringConnector: 'REFERRING_CONNECTOR', +}; diff --git a/src/app/pages/policies/policy-editor/model/policy-mapper.ts b/src/app/pages/policies/policy-editor/model/policy-mapper.ts new file mode 100644 index 0000000..8874faf --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/policy-mapper.ts @@ -0,0 +1,129 @@ +import {Injectable} from '@angular/core'; +import {PolicyExpressionMapped} from './policy-expression-mapped'; +import { + PolicyMultiExpressionConfig, + SUPPORTED_MULTI_EXPRESSIONS, +} from './policy-multi-expressions'; +import { + PolicyOperatorConfig, + SUPPORTED_POLICY_OPERATORS, + defaultPolicyOperatorConfig, +} from './policy-operators'; +import { + PolicyVerbConfig, + SUPPORTED_POLICY_VERBS, + defaultPolicyVerbConfig, +} from './policy-verbs'; +import { UiPolicyExpression } from './ui-policy-expression'; +import { OperatorDto, UiPolicyLiteral } from './ui-policy-constraint'; +import { UiPolicyExpressionType } from './ui-policy-expression-type'; +import { associateBy } from 'src/app/shared/utils/map-utils'; + +@Injectable({providedIn: 'root'}) +export class PolicyMapper { + verbs = associateBy(SUPPORTED_POLICY_VERBS, (it) => it.operandLeftId); + operators = associateBy(SUPPORTED_POLICY_OPERATORS, (it) => it.id); + multiExpressionTypes = associateBy( + SUPPORTED_MULTI_EXPRESSIONS, + (it) => it.expressionType, + ); + + buildPolicy(expression: UiPolicyExpression): PolicyExpressionMapped { + if (expression.type === 'EMPTY') { + return {type: 'EMPTY'}; + } + + if (expression.type === 'CONSTRAINT') { + return this.mapConstraint(expression); + } + + return this.mapMultiExpression(expression); + } + + private mapConstraint( + expression: UiPolicyExpression, + ): PolicyExpressionMapped { + const verb = this.getVerbConfig(expression.constraint?.left!); + const operator = this.getOperatorConfig(expression.constraint?.operator!); + const value = expression.constraint?.right; + + return { + type: 'CONSTRAINT', + verb, + operator, + valueRaw: value, + valueJson: this.formatJson(value!), + displayValue: this.formatValue(value, verb) ?? 'null', + }; + } + + private mapMultiExpression( + expression: UiPolicyExpression, + ): PolicyExpressionMapped { + const multiExpression = this.getMultiExpressionConfig(expression.type); + const expressions = (expression.expressions ?? []).map((it) => + this.buildPolicy(it), + ); + return { + type: 'MULTI', + multiExpression, + expressions, + }; + } + + private getVerbConfig(verb: string): PolicyVerbConfig { + const verbConfig = this.verbs.get(verb); + if (verbConfig) { + return verbConfig; + } + + return defaultPolicyVerbConfig(verb); + } + + private getOperatorConfig(operator: OperatorDto): PolicyOperatorConfig { + const operatorConfig = this.operators.get(operator); + if (operatorConfig) { + return operatorConfig; + } + + return defaultPolicyOperatorConfig(operator); + } + + private getMultiExpressionConfig( + expressionType: UiPolicyExpressionType, + ): PolicyMultiExpressionConfig { + const multiExpressionType = this.multiExpressionTypes.get(expressionType); + if (multiExpressionType) { + return multiExpressionType; + } + + return { + expressionType, + title: expressionType, + description: '', + }; + } + + private formatValue( + value: UiPolicyLiteral | undefined, + verbConfig: PolicyVerbConfig, + ) { + if (value == null) { + return ''; + } + + return verbConfig.adapter.displayText(value); + } + + private formatJson(value: UiPolicyLiteral): string { + if (value.type === 'STRING_LIST') { + return JSON.stringify(value.valueList); + } + + if (value.type === 'JSON') { + return value.value ?? ''; + } + + return JSON.stringify(value.value); + } +} diff --git a/src/app/pages/policies/policy-editor/model/policy-multi-expressions.ts b/src/app/pages/policies/policy-editor/model/policy-multi-expressions.ts new file mode 100644 index 0000000..1bd21a3 --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/policy-multi-expressions.ts @@ -0,0 +1,22 @@ +import { UiPolicyExpressionType } from "./ui-policy-expression-type"; + +export interface PolicyMultiExpressionConfig { + expressionType: UiPolicyExpressionType; + title: string; + description: string; +} + +export const SUPPORTED_MULTI_EXPRESSIONS: PolicyMultiExpressionConfig[] = [ + { + expressionType: 'AND', + title: 'AND', + description: + 'Conjunction of several expressions. Evaluates to true if and only if all child expressions are true', + }, + { + expressionType: 'OR', + title: 'OR', + description: + 'Disjunction of several expressions. Evaluates to true if and only if at least one child expression is true', + } +]; diff --git a/src/app/pages/policies/policy-editor/model/policy-operators.ts b/src/app/pages/policies/policy-editor/model/policy-operators.ts new file mode 100644 index 0000000..8c7f4c5 --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/policy-operators.ts @@ -0,0 +1,78 @@ +import { OperatorDto } from "./ui-policy-constraint"; + + +export interface PolicyOperatorConfig { + id: OperatorDto; + title: string; + description: string; +} + +export const SUPPORTED_POLICY_OPERATORS: PolicyOperatorConfig[] = [ + { + id: 'EQ', + title: '=', + description: 'Equal to', + }, + { + id: 'NEQ', + title: '≠', + description: 'Not equal to', + }, + { + id: 'GEQ', + title: '≥', + description: 'Greater than or equal to', + }, + { + id: 'GT', + title: '>', + description: 'Greater than', + }, + { + id: 'LEQ', + title: '≤', + description: 'Less than or equal to', + }, + { + id: 'LT', + title: '<', + description: 'Less than', + }, + { + id: 'IN', + title: 'IN', + description: 'In', + }, + { + id: 'HAS_PART', + title: 'HAS PART', + description: 'Has Part', + }, + { + id: 'IS_A', + title: 'IS A', + description: 'Is a', + }, + { + id: 'IS_NONE_OF', + title: 'IS NONE OF', + description: 'Is none of', + }, + { + id: 'IS_ANY_OF', + title: 'IS ANY OF', + description: 'Is any of', + }, + { + id: 'IS_ALL_OF', + title: 'IS ALL OF', + description: 'Is all of', + }, +]; +export const defaultPolicyOperatorConfig = ( + operator: OperatorDto, +): PolicyOperatorConfig => ({ + id: operator, + title: operator, + description: '', +}); diff --git a/src/app/pages/policies/policy-editor/model/policy-verbs.ts b/src/app/pages/policies/policy-editor/model/policy-verbs.ts new file mode 100644 index 0000000..58a4985 --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/policy-verbs.ts @@ -0,0 +1,55 @@ + +import { + PolicyFormAdapter, + jsonAdapter, + localDateAdapter, + stringArrayOrCommaJoinedAdapter, +} from './policy-form-adapter'; +import {policyLeftExpressions} from './policy-left-expressions'; +import {SUPPORTED_POLICY_OPERATORS} from './policy-operators'; +import { OperatorDto } from './ui-policy-constraint'; + +export interface PolicyVerbConfig { + operandLeftId: string; + operandLeftTitle: string; + operandLeftDescription: string; + operandRightType: 'DATE' | 'TEXT' | 'PARTICIPANT_ID'; + operandRightHint?: string; + operandRightPlaceholder?: string; + supportedOperators: OperatorDto[]; + adapter: PolicyFormAdapter; +} + +export const SUPPORTED_POLICY_VERBS: PolicyVerbConfig[] = [ + { + operandLeftId: policyLeftExpressions.policyEvaluationTime, + operandLeftTitle: 'Evaluation Time', + operandLeftDescription: + 'Time at which the policy is evaluated. This can be used to restrict the data offer to certain time periods', + supportedOperators: ['GEQ', 'LEQ', 'GT', 'LT'], + operandRightType: 'DATE', + operandRightPlaceholder: 'MM/DD/YYYY', + operandRightHint: 'MM/DD/YYYY', + adapter: localDateAdapter, + }, + { + operandLeftId: policyLeftExpressions.referringConnector, + operandLeftTitle: 'Participant ID', + operandLeftDescription: + 'Participant ID, also called Connector ID, of the counter-party connector.', + operandRightType: 'PARTICIPANT_ID', + supportedOperators: ['EQ', 'IN'], + operandRightPlaceholder: 'MDSL1234XX.C1234YY', + operandRightHint: 'Multiple values can be joined by comma', + adapter: stringArrayOrCommaJoinedAdapter, + }, +]; + +export const defaultPolicyVerbConfig = (verb: string): PolicyVerbConfig => ({ + operandLeftId: verb, + operandLeftTitle: verb, + operandLeftDescription: '', + supportedOperators: SUPPORTED_POLICY_OPERATORS.map((it) => it.id), + operandRightType: 'TEXT', + adapter: jsonAdapter, +}); diff --git a/src/app/pages/policies/policy-editor/model/ui-policy-constraint.ts b/src/app/pages/policies/policy-editor/model/ui-policy-constraint.ts new file mode 100644 index 0000000..12f9790 --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/ui-policy-constraint.ts @@ -0,0 +1,82 @@ + +/** + * Type-Safe ODRL Policy Operator as supported by the sovity product landscape + * @export + */ +export declare const OperatorDto: { + readonly Eq: "EQ"; + readonly Neq: "NEQ"; + readonly Gt: "GT"; + readonly Geq: "GEQ"; + readonly Lt: "LT"; + readonly Leq: "LEQ"; + readonly In: "IN"; + readonly HasPart: "HAS_PART"; + readonly IsA: "IS_A"; + readonly IsAllOf: "IS_ALL_OF"; + readonly IsAnyOf: "IS_ANY_OF"; + readonly IsNoneOf: "IS_NONE_OF"; +}; +/** + * Supported Types of values for the right hand side of an expression + * @export + */ +export declare const UiPolicyLiteralType: { + readonly String: "STRING"; + readonly StringList: "STRING_LIST"; + readonly Json: "JSON"; +}; + +export declare type UiPolicyLiteralType = (typeof UiPolicyLiteralType)[keyof typeof UiPolicyLiteralType]; +/** + * Sum type: A String, a list of Strings or a generic JSON value. + * @export + * @interface UiPolicyLiteral + */ +export declare interface UiPolicyLiteral { + /** + * + * @type {UiPolicyLiteralType} + * @memberof UiPolicyLiteral + */ + type: UiPolicyLiteralType; + /** + * Only for types STRING and JSON + * @type {string} + * @memberof UiPolicyLiteral + */ + value?: string; + /** + * Only for type STRING_LIST + * @type {Array} + * @memberof UiPolicyLiteral + */ + valueList?: Array; +} + +export declare type OperatorDto = (typeof OperatorDto)[keyof typeof OperatorDto]; +/** + * ODRL AtomicConstraint as supported by the sovity product landscape. For example 'a EQ b', 'c IN [d, e, f]' + * @export + * @interface UiPolicyConstraint + */ +export declare interface UiPolicyConstraint { + /** + * Left side of the expression. + * @type {string} + * @memberof UiPolicyConstraint + */ + left: string; + /** + * + * @type {OperatorDto} + * @memberof UiPolicyConstraint + */ + operator: OperatorDto; + /** + * + * @type {UiPolicyLiteral} + * @memberof UiPolicyConstraint + */ + right: UiPolicyLiteral; +} diff --git a/src/app/pages/policies/policy-editor/model/ui-policy-expression-type.ts b/src/app/pages/policies/policy-editor/model/ui-policy-expression-type.ts new file mode 100644 index 0000000..7bbec6d --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/ui-policy-expression-type.ts @@ -0,0 +1,15 @@ +/** + * Ui Policy Expression types: + * * `CONSTRAINT` - Expression 'a=b' + * * `AND` - Conjunction of several expressions. Evaluates to true iff all child expressions are true. + * * `OR` - Disjunction of several expressions. Evaluates to true iff at least one child expression is true. + * @export + */ +export declare const UiPolicyExpressionType: { + readonly Empty: "EMPTY"; + readonly Constraint: "CONSTRAINT"; + readonly And: "AND"; + readonly Or: "OR"; +}; + +export declare type UiPolicyExpressionType = (typeof UiPolicyExpressionType)[keyof typeof UiPolicyExpressionType]; diff --git a/src/app/pages/policies/policy-editor/model/ui-policy-expression-utils.ts b/src/app/pages/policies/policy-editor/model/ui-policy-expression-utils.ts new file mode 100644 index 0000000..e9109ca --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/ui-policy-expression-utils.ts @@ -0,0 +1,38 @@ +import { OperatorDto } from "./ui-policy-constraint"; +import { UiPolicyExpression } from "./ui-policy-expression"; +import { UiPolicyExpressionType } from "./ui-policy-expression-type"; + + +export const constraint = ( + left: string, + operator: OperatorDto, + value: string, +): UiPolicyExpression => ({ + type: 'CONSTRAINT', + constraint: { + left, + operator, + right: {type: 'STRING', value}, + }, +}); + +export const constraintList = ( + left: string, + operator: OperatorDto, + valueList: string[], +): UiPolicyExpression => ({ + type: 'CONSTRAINT', + constraint: { + left, + operator, + right: {type: 'STRING_LIST', valueList}, + }, +}); + +export const multi = ( + type: Exclude, + ...expressions: UiPolicyExpression[] +): UiPolicyExpression => ({ + type, + expressions, +}); diff --git a/src/app/pages/policies/policy-editor/model/ui-policy-expression.ts b/src/app/pages/policies/policy-editor/model/ui-policy-expression.ts new file mode 100644 index 0000000..10a6b8d --- /dev/null +++ b/src/app/pages/policies/policy-editor/model/ui-policy-expression.ts @@ -0,0 +1,28 @@ +import { UiPolicyConstraint } from "./ui-policy-constraint"; +import { UiPolicyExpressionType } from "./ui-policy-expression-type"; + +/** + * ODRL constraint as supported by the sovity product landscape + * @export + * @interface UiPolicyExpression + */ +export declare interface UiPolicyExpression { + /** + * + * @type {UiPolicyExpressionType} + * @memberof UiPolicyExpression + */ + type: UiPolicyExpressionType; + /** + * Only for types AND, OR. List of sub-expressions to be evaluated according to the expressionType. + * @type {Array} + * @memberof UiPolicyExpression + */ + expressions?: Array; + /** + * + * @type {UiPolicyConstraint} + * @memberof UiPolicyExpression + */ + constraint?: UiPolicyConstraint; +} diff --git a/src/app/pages/policies/policy-editor/policy-editor.module.ts b/src/app/pages/policies/policy-editor/policy-editor.module.ts new file mode 100644 index 0000000..f798c93 --- /dev/null +++ b/src/app/pages/policies/policy-editor/policy-editor.module.ts @@ -0,0 +1,52 @@ +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {ParticipantIdSelectComponent} from './editor/controls/participant-id-select/participant-id-select.component'; +import {PolicyFormAddMenuComponent} from './editor/policy-form-add-menu/policy-form-add-menu.component'; +import {PolicyFormExpressionConstraintComponent} from './editor/policy-form-expression-constraint/policy-form-expression-constraint.component'; +import {PolicyFormExpressionEmptyComponent} from './editor/policy-form-expression-empty/policy-form-expression-empty.component'; +import {PolicyFormExpressionMultiComponent} from './editor/policy-form-expression-multi/policy-form-expression-multi.component'; +import {PolicyFormExpressionComponent} from './editor/policy-form-expression/policy-form-expression.component'; +import {PolicyFormRemoveButton} from './editor/policy-form-remove-button/policy-form-remove-button.component'; +import {PolicyOperatorSelectComponent} from './editor/policy-operator-select/policy-operator-select.component'; +import {PolicyExpressionRecipeService} from './editor/recipes/policy-expression-recipe.service'; +import {TimespanRestrictionDialogComponent} from './editor/recipes/timespan-restriction-dialog/timespan-restriction-dialog.component'; +import {PolicyExpressionComponent} from './renderer/policy-expression/policy-expression.component'; +import {PolicyRendererComponent} from './renderer/policy-renderer/policy-renderer.component'; +import { PipesAndDirectivesModule } from 'src/app/shared/pipes-and-directives/pipes-and-directives.module'; +import { SharedModule } from 'src/app/shared/shared.module'; + +@NgModule({ + imports: [ + // Angular + CommonModule, + FormsModule, + ReactiveFormsModule, + + SharedModule, + PipesAndDirectivesModule, + ], + declarations: [ + PolicyFormAddMenuComponent, + PolicyFormExpressionComponent, + PolicyFormExpressionEmptyComponent, + PolicyFormExpressionConstraintComponent, + PolicyFormExpressionMultiComponent, + PolicyFormRemoveButton, + PolicyOperatorSelectComponent, + + ParticipantIdSelectComponent, + + TimespanRestrictionDialogComponent, + + PolicyRendererComponent, + PolicyExpressionComponent, + ], + providers: [PolicyExpressionRecipeService], + exports: [ + PolicyRendererComponent, + PolicyFormExpressionComponent, + TimespanRestrictionDialogComponent, + ], +}) +export class PolicyEditorModule {} diff --git a/src/app/pages/policies/policy-editor/renderer/policy-expression/policy-expression.component.html b/src/app/pages/policies/policy-editor/renderer/policy-expression/policy-expression.component.html new file mode 100644 index 0000000..e1572b8 --- /dev/null +++ b/src/app/pages/policies/policy-editor/renderer/policy-expression/policy-expression.component.html @@ -0,0 +1,50 @@ + +
Unrestricted
+ + +
+
+ {{ expression.multiExpression!.title }} +
+
+
+ + +
+
+
+ + +
+ + {{ expression.verb!.operandLeftTitle }} + + + + {{ expression.operator!.title }} + + + {{ + expression.displayValue + }} +
diff --git a/src/app/pages/policies/policy-editor/renderer/policy-expression/policy-expression.component.ts b/src/app/pages/policies/policy-editor/renderer/policy-expression/policy-expression.component.ts new file mode 100644 index 0000000..c709af6 --- /dev/null +++ b/src/app/pages/policies/policy-editor/renderer/policy-expression/policy-expression.component.ts @@ -0,0 +1,16 @@ +import {Component, HostBinding, Input} from '@angular/core'; +import {PolicyExpressionMapped} from '../../model/policy-expression-mapped'; + +@Component({ + selector: 'policy-expression', + templateUrl: './policy-expression.component.html', +}) +export class PolicyExpressionComponent { + @HostBinding('class.flex') + @HostBinding('class.flex-col') + @HostBinding('class.justify-stretch') + cls = true; + + @Input() + expression!: PolicyExpressionMapped; +} diff --git a/src/app/pages/policies/policy-editor/renderer/policy-renderer/policy-renderer.component.html b/src/app/pages/policies/policy-editor/renderer/policy-renderer/policy-renderer.component.html new file mode 100644 index 0000000..7d3c0a1 --- /dev/null +++ b/src/app/pages/policies/policy-editor/renderer/policy-renderer/policy-renderer.component.html @@ -0,0 +1,6 @@ +
+
+ {{ error }} +
+
+ diff --git a/src/app/pages/policies/policy-editor/renderer/policy-renderer/policy-renderer.component.ts b/src/app/pages/policies/policy-editor/renderer/policy-renderer/policy-renderer.component.ts new file mode 100644 index 0000000..42b0929 --- /dev/null +++ b/src/app/pages/policies/policy-editor/renderer/policy-renderer/policy-renderer.component.ts @@ -0,0 +1,14 @@ +import {Component, Input} from '@angular/core'; +import {PolicyExpressionMapped} from '../../model/policy-expression-mapped'; + +@Component({ + selector: 'app-policy-renderer', + templateUrl: './policy-renderer.component.html', +}) +export class PolicyRendererComponent { + @Input() + expression!: PolicyExpressionMapped; + + @Input() + errors: string[] = []; +} diff --git a/src/app/pages/policies/policy-view/policy-view.component.ts b/src/app/pages/policies/policy-view/policy-view.component.ts index b138155..c0fbc95 100644 --- a/src/app/pages/policies/policy-view/policy-view.component.ts +++ b/src/app/pages/policies/policy-view/policy-view.component.ts @@ -9,6 +9,7 @@ import { ConfirmationDialogComponent, ConfirmDialogModel } from "../../../shared import { PolicyDefinition, PolicyDefinitionInput, IdResponse, QuerySpec } from "../../../shared/models/edc-connector-entities"; import { PolicyRuleViewerComponent } from '../policy-rule-viewer/policy-rule-viewer.component'; import { PageEvent } from '@angular/material/paginator'; +import { PolicyDefinitionCreateDto } from '../policy-editor/model/policy-definition-create-dto'; @Component({ selector: 'app-policy-view', @@ -48,9 +49,9 @@ export class PolicyViewComponent implements OnInit { onCreate() { const dialogRef = this.dialog.open(NewPolicyDialogComponent); dialogRef.afterClosed().pipe(first()).subscribe({ - next: (newPolicyDefinition: PolicyDefinitionInput) => { + next: (newPolicyDefinition: PolicyDefinitionCreateDto) => { if (newPolicyDefinition) { - this.policyService.createPolicy(newPolicyDefinition).subscribe( + this.policyService.createComplexPolicy(newPolicyDefinition).subscribe( { next: (response: IdResponse) => this.errorOrUpdateSubscriber.next(response), error: (error: Error) => this.showError(error, "An error occurred while creating the policy."), diff --git a/src/app/shared/pipes-and-directives/pipes-and-directives.module.ts b/src/app/shared/pipes-and-directives/pipes-and-directives.module.ts new file mode 100644 index 0000000..7f9635c --- /dev/null +++ b/src/app/shared/pipes-and-directives/pipes-and-directives.module.ts @@ -0,0 +1,26 @@ +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {CompareByFieldPipe} from './pipes/compare-by-field.pipe'; +import {ValuesPipe} from './pipes/values.pipe'; + +@NgModule({ + imports: [ + // Angular + CommonModule, + + // Angular Material + MatIconModule, + MatProgressSpinnerModule, + ], + declarations: [ + CompareByFieldPipe, + ValuesPipe, + ], + exports: [ + CompareByFieldPipe, + ValuesPipe, + ], +}) +export class PipesAndDirectivesModule {} diff --git a/src/app/shared/pipes-and-directives/pipes/compare-by-field.pipe.ts b/src/app/shared/pipes-and-directives/pipes/compare-by-field.pipe.ts new file mode 100644 index 0000000..59b6f4b --- /dev/null +++ b/src/app/shared/pipes-and-directives/pipes/compare-by-field.pipe.ts @@ -0,0 +1,11 @@ +import {Pipe, PipeTransform} from '@angular/core'; + +/** + * Creates Compare By Function for Angular Material compareWith parameters + */ +@Pipe({name: 'compareByField'}) +export class CompareByFieldPipe implements PipeTransform { + transform(key: string): (a: any, b: any) => boolean { + return (a, b) => a === b || (a != null && b != null && a[key] === b[key]); + } +} diff --git a/src/app/shared/pipes-and-directives/pipes/values.pipe.ts b/src/app/shared/pipes-and-directives/pipes/values.pipe.ts new file mode 100644 index 0000000..13165b9 --- /dev/null +++ b/src/app/shared/pipes-and-directives/pipes/values.pipe.ts @@ -0,0 +1,11 @@ +import {Pipe, PipeTransform} from '@angular/core'; + +/** + * `Object.values(...)` can't be used from angular templates. + */ +@Pipe({name: 'values'}) +export class ValuesPipe implements PipeTransform { + transform(obj: T): T[keyof T][] { + return Object.values(obj || {}); + } +} diff --git a/src/app/shared/services/policy.service.ts b/src/app/shared/services/policy.service.ts index 9949ef9..3bdddb0 100644 --- a/src/app/shared/services/policy.service.ts +++ b/src/app/shared/services/policy.service.ts @@ -18,6 +18,7 @@ import {expandArray, PolicyDefinition, QuerySpec, EDC_CONTEXT, JSON_LD_DEFAULT_C import {PolicyDefinitionInput} from "../models/edc-connector-entities" import { environment } from 'src/environments/environment'; import { CONTEXTS } from '../utils/app.constants'; +import { PolicyDefinitionCreateDto } from 'src/app/pages/policies/policy-editor/model/policy-definition-create-dto'; @Injectable({ @@ -26,6 +27,7 @@ import { CONTEXTS } from '../utils/app.constants'; export class PolicyService { private readonly BASE_URL = `${environment.runtime.managementApiUrl}${environment.runtime.service.policy.baseUrl}`; + private readonly COMPLEX_BASE_URL = `${environment.runtime.managementApiUrl}${environment.runtime.service.policy.complexBaseUrl}`; constructor(private http: HttpClient) { } @@ -49,6 +51,17 @@ export class PolicyService { ))); } + + /** + * Creates a new policy definition + * @param input + */ + public createComplexPolicy(input: PolicyDefinitionCreateDto): Observable { + return from(lastValueFrom(this.http.post( + `${this.COMPLEX_BASE_URL}`, input + ))); + } + /** * Removes a policy definition with the given ID if possible. Deleting a policy definition is only possible if that policy definition is not yet referenced by a contract definition, in which case an error is returned. DANGER ZONE: Note that deleting policy definitions can have unexpected results, do this at your own risk! * @param id diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 3707cab..0e9e1d5 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -27,6 +27,10 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTableModule } from '@angular/material/table'; import { UploaderFileComponent } from './components/uploader-file/uploader-file.component'; import { DndDirective } from './directives/dnd.directive'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; @NgModule({ imports: [ @@ -52,7 +56,12 @@ import { DndDirective } from './directives/dnd.directive'; MatTabsModule, MatDividerModule, MatProgressSpinnerModule, - MatTableModule + MatTableModule, + MatChipsModule, + MatTooltipModule, + MatDatepickerModule, + MatMenuModule, + MatTooltipModule ], declarations: [ NavigationComponent, @@ -87,7 +96,12 @@ import { DndDirective } from './directives/dnd.directive'; MatTabsModule, MatDividerModule, MatProgressSpinnerModule, - MatTableModule + MatTableModule, + MatChipsModule, + MatTooltipModule, + MatDatepickerModule, + MatMenuModule, + MatTooltipModule ] }) export class SharedModule {} diff --git a/src/app/shared/utils/map-utils.ts b/src/app/shared/utils/map-utils.ts new file mode 100644 index 0000000..b7c6fde --- /dev/null +++ b/src/app/shared/utils/map-utils.ts @@ -0,0 +1,31 @@ +/** + * Group items by key extractor + * @param array items + * @param keyExtractor key extractor + */ +export function groupedBy( + array: T[], + keyExtractor: (it: T) => K, +): Map { + const map = new Map(); + array.forEach((it) => { + const key = keyExtractor(it); + if (!map.has(key)) { + map.set(key, []); + } + map.get(key)!.push(it); + }); + return map; +} + +/** + * Create Map with entries [keyExtractor(it), it] + * @param array items + * @param keyExtractor key extractor + */ +export function associateBy( + array: T[], + keyExtractor: (it: T) => K, +): Map { + return new Map(array.map((it) => [keyExtractor(it), it])); +} diff --git a/src/app/shared/utils/mat-dialog-utils.ts b/src/app/shared/utils/mat-dialog-utils.ts new file mode 100644 index 0000000..d0451ac --- /dev/null +++ b/src/app/shared/utils/mat-dialog-utils.ts @@ -0,0 +1,28 @@ +import {ComponentType} from '@angular/cdk/portal'; +import {MatDialog, MatDialogConfig} from '@angular/material/dialog'; +import {Observable} from 'rxjs'; + +/** + * Method for launching Angular Material Dialogs with the lifetime of the dialog being handled by a until$ observable + * + * @param dialogService MatDialog + * @param dialog ComponentType + * @param config MatDialogConfig + * @param until$ Observable that controls the lifetime of the dialog + * @template T Type of the data passed to the dialog + * @template R Type of the data returned by the dialog + * @return afterClosed Observable + */ +export function showDialogUntil( + dialogService: MatDialog, + dialog: ComponentType, + config: MatDialogConfig, + until$: Observable, +): Observable { + const ref = dialogService.open(dialog, config); + until$.subscribe({ + next: () => ref.close(), + complete: () => ref.close(), + }); + return ref.afterClosed(); +} diff --git a/src/app/shared/utils/rxjs-utils.ts b/src/app/shared/utils/rxjs-utils.ts new file mode 100644 index 0000000..47d4733 --- /dev/null +++ b/src/app/shared/utils/rxjs-utils.ts @@ -0,0 +1,24 @@ +import {Observable, OperatorFunction, defer, from} from 'rxjs'; +import {filter, tap} from 'rxjs/operators'; + +/** + * Simple not null filtering RXJS Operator. + * + * The trick is that it removes the "null | undefined" from the resulting stream type signature. + */ +export function filterNotNull(): OperatorFunction { + return filter((it) => it != null) as any; +} + +export function throwIfNull( + msg: string, +): OperatorFunction { + return tap((it) => { + if (it == null) { + throw new Error(msg); + } + }) as OperatorFunction; +} + +export const toObservable = (fn: () => Promise): Observable => + defer(() => from(fn())); diff --git a/src/app/shared/validators/json-validator.ts b/src/app/shared/validators/json-validator.ts new file mode 100644 index 0000000..023d2ac --- /dev/null +++ b/src/app/shared/validators/json-validator.ts @@ -0,0 +1,19 @@ +import {AbstractControl, ValidationErrors, ValidatorFn} from '@angular/forms'; + +/** + * Validates whether control's value is valid JSON. + * @param control control + */ +export const jsonValidator: ValidatorFn = ( + control: AbstractControl, +): ValidationErrors | null => { + const value = control.value; + if (value) { + try { + JSON.parse(value); + } catch (e) { + return {jsonInvalid: true}; + } + } + return null; +}; diff --git a/src/app/shared/validators/no-whitespaces-or-colons-validator.ts b/src/app/shared/validators/no-whitespaces-or-colons-validator.ts new file mode 100644 index 0000000..24e814d --- /dev/null +++ b/src/app/shared/validators/no-whitespaces-or-colons-validator.ts @@ -0,0 +1,8 @@ +import {ValidatorFn, Validators} from '@angular/forms'; + +/** + * Validates whether value contains whitespaces + * @param control control + */ +export const noWhitespacesOrColonsValidator: ValidatorFn = + Validators.pattern(/^[^\s:]*$/); diff --git a/src/app/shared/validators/valid-date-range-optional-end.ts b/src/app/shared/validators/valid-date-range-optional-end.ts new file mode 100644 index 0000000..fb44c20 --- /dev/null +++ b/src/app/shared/validators/valid-date-range-optional-end.ts @@ -0,0 +1,22 @@ +import {AbstractControl, ValidationErrors, ValidatorFn} from '@angular/forms'; +import {DateRange} from '@angular/material/datepicker'; + +export const validDateRangeOptionalEnd: ValidatorFn = ( + control: AbstractControl, +): ValidationErrors | null => { + const value: DateRange = control.value; + if (!value?.start || (value?.end && value.start > value.end)) { + return {required: true}; + } + return null; +}; + +export const validDateRange: ValidatorFn = ( + control: AbstractControl, +): ValidationErrors | null => { + const value: DateRange = control.value; + if (!value?.start || !value?.end || value.start > value.end) { + return {required: true}; + } + return null; +}; diff --git a/src/app/shared/validators/validation-messages.ts b/src/app/shared/validators/validation-messages.ts new file mode 100644 index 0000000..1fa70a3 --- /dev/null +++ b/src/app/shared/validators/validation-messages.ts @@ -0,0 +1,14 @@ +import {Injectable} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +export class ValidationMessages { + invalidEmailMessage = 'Must be a valid E-Mail address.'; + invalidUrlMessage = 'Must be valid URL, e.g. https://example.com'; + invalidJsonMessage = 'Must be valid JSON'; + invalidWhitespacesOrColonsMessage = 'Must not contain whitespaces or colons.'; + invalidPrefix = (field: string, prefix: string): string => + `${field} must start with "${prefix}".`; + invalidDateRangeMessage = 'Need valid date range.'; + idExistsErrorMessage = 'ID already exists.'; + invalidQueryParam = "Must not contain '=' or '&' characters."; +} diff --git a/src/assets/config/app.config.json b/src/assets/config/app.config.json index 5ebbcba..4f375fa 100644 --- a/src/assets/config/app.config.json +++ b/src/assets/config/app.config.json @@ -23,6 +23,7 @@ }, "policy": { "baseUrl": "/v3/policydefinitions", + "complexBaseUrl": "/v3/complexpolicydefinitions", "get": "/", "getAll": "/request", "count": "/pagination/count?type=policyDefinition" diff --git a/src/assets/config/app.config.template.json b/src/assets/config/app.config.template.json index 1f0d4c3..67a83dd 100644 --- a/src/assets/config/app.config.template.json +++ b/src/assets/config/app.config.template.json @@ -23,6 +23,7 @@ }, "policy": { "baseUrl": "/v3/policydefinitions", + "complexBaseUrl": "/v3/complexpolicydefinitions", "get": "/", "getAll": "/request", "count": "/pagination/count?type=policyDefinition" diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 74f1e24..31408cd 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -31,6 +31,7 @@ export const environment = { }, policy: { baseUrl: '/v3/policydefinitions', + complexBaseUrl: '/v3/complexpolicydefinitions', get: '/', getAll: '/request', count: '/pagination/count?type=policyDefinition' diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 5afd920..2b25c57 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -35,6 +35,7 @@ export const environment = { }, policy: { baseUrl: '/v3/policydefinitions', + complexBaseUrl: '/v3/complexpolicydefinitions', get: '/', getAll: '/request', count: '/pagination/count?type=policyDefinition' diff --git a/src/styles.scss b/src/styles.scss index 27e2f22..a9c956e 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -53,3 +53,80 @@ mat-card:not(.dialog-card) { .paginator { margin-top: 1em; } + +//CREATE POLICY STYLES +.p-10 { + padding: 2.5rem; +} + +.flex-col { + flex-direction: column; +} + +.flex { + display: flex; +} + +.form-section-title { + margin: 16px 3px; + text-transform: uppercase; + color: #ffffff; + font-size: 14px; + letter-spacing: 0.04em; +} +.items-center { + align-items: center; +} + +.gap-4 { + gap: 1rem; +} +.h-\[4rem\] { + height: 4rem; +} +.font-medium { + font-weight: 500; +} +.grow { + flex-grow: 1; +} + +.border-gray-500 { + --tw-border-opacity: 1; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); +} +.border-solid { + border-style: solid; +} +.border-l-2 { + border-left-width: 2px; +} +.border-0 { + border-top-width: 0px; + border-right-width: 0px; + border-bottom-width: 0px; +} +.gap-1 { + gap: 0.25rem; +} + +.ml-2 { + margin-left: 0.5rem; +} +.pl-4 { + padding-left: 1rem; +} +.border-t-2 { + border-top-width: 2px; +}.w-\[1rem\] { + width: 1rem; +}.mr-\[1rem\] { + margin-right: 1rem; +} +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(66 66 66); +} +.justify-stretch { + justify-content: stretch; +} From afd9fd53756f275ae7eabc2ee789fda494509d82 Mon Sep 17 00:00:00 2001 From: "GRUPOGMV\\ssis" Date: Mon, 12 Aug 2024 08:56:42 +0200 Subject: [PATCH 2/2] Budget increased --- angular.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/angular.json b/angular.json index 19856a4..aa4778f 100644 --- a/angular.json +++ b/angular.json @@ -41,8 +41,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "1.5mb", - "maximumError": "2mb" + "maximumWarning": "2.5mb", + "maximumError": "5mb" }, { "type": "anyComponentStyle",