diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e0ec15a055..7ae4e90d1f 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -90,7 +90,9 @@ import { IntermediateComponent } from './workspace/intermediate/intermediate.com import { DragDropDirective } from './workspace/resource/directives/drag-drop.directive'; import { TextValueHtmlLinkDirective } from './workspace/resource/directives/text-value-html-link.directive'; import { AddValueComponent } from './workspace/resource/operations/add-value/add-value.component'; +import { CreateLinkResourceComponent } from './workspace/resource/operations/create-link-resource/create-link-resource.component'; import { DisplayEditComponent } from './workspace/resource/operations/display-edit/display-edit.component'; +import { PermissionInfoComponent } from './workspace/resource/permission-info/permission-info.component'; import { PropertiesComponent } from './workspace/resource/properties/properties.component'; import { AddRegionFormComponent } from './workspace/resource/representation/add-region-form/add-region-form.component'; import { ArchiveComponent } from './workspace/resource/representation/archive/archive.component'; @@ -155,7 +157,7 @@ import { SearchSelectOntologyComponent } from './workspace/search/advanced-searc import { ExpertSearchComponent } from './workspace/search/expert-search/expert-search.component'; import { FulltextSearchComponent } from './workspace/search/fulltext-search/fulltext-search.component'; import { SearchPanelComponent } from './workspace/search/search-panel/search-panel.component'; -import { CreateLinkResourceComponent } from './workspace/resource/operations/create-link-resource/create-link-resource.component'; +import { TitleFromCamelCasePipe } from './main/pipes/string-transformation/title-from-camel-case.pipe'; // translate: AoT requires an exported function for factories export function httpLoaderFactory(httpClient: HttpClient) { @@ -185,6 +187,7 @@ export function httpLoaderFactory(httpClient: HttpClient) { ConfirmationDialogComponent, ConfirmationMessageComponent, CookiePolicyComponent, + CreateLinkResourceComponent, DashboardComponent, DateEditComponent, DateInputComponent, @@ -236,6 +239,7 @@ export function httpLoaderFactory(httpClient: HttpClient) { OntologyFormComponent, PasswordFormComponent, PermissionComponent, + PermissionInfoComponent, ProfileComponent, ProgressIndicatorComponent, ProjectComponent, @@ -299,7 +303,7 @@ export function httpLoaderFactory(httpClient: HttpClient) { UsersComponent, UsersListComponent, YetAnotherDateValueComponent, - CreateLinkResourceComponent, + TitleFromCamelCasePipe ], imports: [ AngularSplitModule.forRoot(), diff --git a/src/app/main/declarations/admin-permissions.ts b/src/app/main/declarations/admin-permissions.ts deleted file mode 100644 index 99b6df1ed9..0000000000 --- a/src/app/main/declarations/admin-permissions.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface AdminPermissions { - add?: boolean; - remove?: boolean; - create?: boolean; - delete?: boolean; - modify?: boolean; - password?: boolean; -} diff --git a/src/app/main/pipes/string-transformation/title-from-camel-case.pipe.spec.ts b/src/app/main/pipes/string-transformation/title-from-camel-case.pipe.spec.ts new file mode 100644 index 0000000000..282dd96e1c --- /dev/null +++ b/src/app/main/pipes/string-transformation/title-from-camel-case.pipe.spec.ts @@ -0,0 +1,27 @@ +import { TitleFromCamelCasePipe } from './title-from-camel-case.pipe'; +import { waitForAsync, TestBed } from '@angular/core/testing'; + +describe('TitleFromCamelCasePipe', () => { + let pipe: TitleFromCamelCasePipe; + let textToBeTransformed: string; + + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({}); + + pipe = new TitleFromCamelCasePipe(); + + textToBeTransformed = 'HowTheCamelGotItsHump'; + })); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should return "How The Camel Got Its Hump"', () => { + const transformedText = pipe.transform(textToBeTransformed); + expect(transformedText).toEqual('How The Camel Got Its Hump'); + }); + + +}); diff --git a/src/app/main/pipes/string-transformation/title-from-camel-case.pipe.ts b/src/app/main/pipes/string-transformation/title-from-camel-case.pipe.ts new file mode 100644 index 0000000000..b6a2c0cd3e --- /dev/null +++ b/src/app/main/pipes/string-transformation/title-from-camel-case.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'titleFromCamelCase' +}) +export class TitleFromCamelCasePipe implements PipeTransform { + + transform(value: string): string { + const title = value.replace(/([A-Z]+)/g, ' $1').replace(/([A-Z][a-z])/g, '$1'); + return title.trim(); + } + +} diff --git a/src/app/material-module.ts b/src/app/material-module.ts index 6250e1c93e..8314b89087 100644 --- a/src/app/material-module.ts +++ b/src/app/material-module.ts @@ -33,6 +33,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTreeModule } from '@angular/material/tree'; +import { OverlayModule } from '@angular/cdk/overlay'; const matModules = [ DragDropModule, @@ -66,7 +67,8 @@ const matModules = [ MatTabsModule, MatToolbarModule, MatTooltipModule, - MatTreeModule + MatTreeModule, + OverlayModule ]; @NgModule({ diff --git a/src/app/system/projects/projects.component.ts b/src/app/system/projects/projects.component.ts index 7731c67341..df542a7e46 100644 --- a/src/app/system/projects/projects.component.ts +++ b/src/app/system/projects/projects.component.ts @@ -2,7 +2,6 @@ import { Component, Inject, Input, OnInit } from '@angular/core'; import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; import { Title } from '@angular/platform-browser'; import { ApiResponseData, ApiResponseError, KnoraApiConnection, ProjectsResponse, StoredProject, UserResponse } from '@dasch-swiss/dsp-js'; -import { AdminPermissions } from 'src/app/main/declarations/admin-permissions'; import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; import { DialogComponent } from 'src/app/main/dialog/dialog.component'; import { ErrorHandlerService } from 'src/app/main/error/error-handler.service'; @@ -47,8 +46,6 @@ export class ProjectsComponent implements OnInit { */ session: Session; - permissions: AdminPermissions; - // list of active projects active: StoredProject[] = []; // list of archived (deleted) projects diff --git a/src/app/workspace/resource/permission-info/permission-info.component.html b/src/app/workspace/resource/permission-info/permission-info.component.html new file mode 100644 index 0000000000..4d81f8ae91 --- /dev/null +++ b/src/app/workspace/resource/permission-info/permission-info.component.html @@ -0,0 +1,52 @@ + + + +
+ + + + + + + + + + + + + + + +
Permissions + + {{dp.icon}} + +
{{item.group.split(':')[1] | titleFromCamelCase}} + + {{getStatus(dp.name, item.restriction) ? 'radio_button_checked' : 'radio_button_unchecked'}} + +
Your permissions + + {{getStatus(dp.name, userRestrictions) ? 'radio_button_checked' : 'radio_button_unchecked'}} + +
+
+
diff --git a/src/app/workspace/resource/permission-info/permission-info.component.scss b/src/app/workspace/resource/permission-info/permission-info.component.scss new file mode 100644 index 0000000000..3de7939797 --- /dev/null +++ b/src/app/workspace/resource/permission-info/permission-info.component.scss @@ -0,0 +1,41 @@ +@import "../../../../assets/style/config"; +@import "../../../../assets/style/mixins"; + +.overlay-info-box { + background: white; + padding: 8px 16px; + @include mat-box-shadow(); + + table { + border-collapse: collapse; + + th, td { + min-width: 48px; + text-align: center; + vertical-align: middle; + + .mat-icon { + margin-top: 3px; + } + } + + .first-col { + padding-right: 16px; + text-align: left; + } + + .border-top { + border-top: 1px solid black; + } + } + +} + +.status { + color: $warn; + + &.checked { + color: $active; + } +} + diff --git a/src/app/workspace/resource/permission-info/permission-info.component.spec.ts b/src/app/workspace/resource/permission-info/permission-info.component.spec.ts new file mode 100644 index 0000000000..0964bbf134 --- /dev/null +++ b/src/app/workspace/resource/permission-info/permission-info.component.spec.ts @@ -0,0 +1,182 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { Component, DebugElement, OnInit, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { MockResource, ReadResource } from '@dasch-swiss/dsp-js'; +import { TitleFromCamelCasePipe } from 'src/app/main/pipes/string-transformation/title-from-camel-case.pipe'; +import { PermissionInfoComponent } from './permission-info.component'; + +/** + * test host component to simulate parent component + */ +@Component({ + template: ` + + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild('permissionInfo', { static: false }) permissionInfoComponent: PermissionInfoComponent; + + resource: ReadResource; + + constructor() { + } + + ngOnInit() { + // get a resource from DSP-JS-Lib test data + MockResource.getTestThing().subscribe( + (response: ReadResource) => { + this.resource = response; + } + ); + } + +} + +describe('PermissionInfoComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({ + imports: [ + MatButtonModule, + MatIconModule, + MatTooltipModule, + OverlayModule, + RouterTestingModule, + ], + declarations: [ + TestHostComponent, + PermissionInfoComponent, + TitleFromCamelCasePipe, + ], + providers: [] + }) + .compileComponents(); + + })); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + + expect(testHostComponent).toBeTruthy(); + }); + + it('should create', () => { + expect(testHostComponent.permissionInfoComponent).toBeTruthy(); + }); + + it('should have permission values and an icon button', () => { + expect(testHostFixture.nativeElement.querySelector('button.permissions').innerText).toEqual('lock'); + expect(testHostComponent.permissionInfoComponent.hasPermissions).toEqual('CR knora-admin:Creator|M knora-admin:ProjectMember|V knora-admin:KnownUser|RV knora-admin:UnknownUser'); + expect(testHostComponent.permissionInfoComponent.userHasPermission).toEqual('RV'); + }); + + it('should display all permissions as enabled in the first line (Creator has CR)', () => { + const hostCompDe = testHostFixture.debugElement; + const permissionInfoEl = hostCompDe.query(By.directive(PermissionInfoComponent)); + const permissionBtnEl = permissionInfoEl.query(By.css('button.permissions')); + permissionBtnEl.triggerEventHandler('click', null); + testHostFixture.detectChanges(); + + const permissionInfoBox = permissionInfoEl.query(By.css('div.overlay-info-box')); + const rowEle = permissionInfoBox.nativeElement.querySelector('tr.Creator'); + + expect(rowEle.querySelector('td.first-col').innerText).toEqual('Creator'); + expect(rowEle.querySelector('td.perm-RV').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-V').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-M').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-D').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-CR').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + + }); + + it('should display three permissions as enabled in the second line (Project Member has M)', () => { + const hostCompDe = testHostFixture.debugElement; + const permissionInfoEl = hostCompDe.query(By.directive(PermissionInfoComponent)); + const permissionBtnEl = permissionInfoEl.query(By.css('button.permissions')); + permissionBtnEl.triggerEventHandler('click', null); + testHostFixture.detectChanges(); + + const permissionInfoBox = permissionInfoEl.query(By.css('div.overlay-info-box')); + const rowEle = permissionInfoBox.nativeElement.querySelector('tr.ProjectMember'); + + expect(rowEle.querySelector('td.first-col').innerText).toEqual('Project Member'); + expect(rowEle.querySelector('td.perm-RV').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-V').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-M').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-D').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + expect(rowEle.querySelector('td.perm-CR').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + + }); + + it('should display two permissions as enabled in the third line (Known User has V)', () => { + const hostCompDe = testHostFixture.debugElement; + const permissionInfoEl = hostCompDe.query(By.directive(PermissionInfoComponent)); + const permissionBtnEl = permissionInfoEl.query(By.css('button.permissions')); + permissionBtnEl.triggerEventHandler('click', null); + testHostFixture.detectChanges(); + + const permissionInfoBox = permissionInfoEl.query(By.css('div.overlay-info-box')); + const rowEle = permissionInfoBox.nativeElement.querySelector('tr.KnownUser'); + + expect(rowEle.querySelector('td.first-col').innerText).toEqual('Known User'); + expect(rowEle.querySelector('td.perm-RV').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-V').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-M').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + expect(rowEle.querySelector('td.perm-D').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + expect(rowEle.querySelector('td.perm-CR').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + + }); + + it('should display only one permissions as enabled in the fourth line (Unknown User has RV)', () => { + const hostCompDe = testHostFixture.debugElement; + const permissionInfoEl = hostCompDe.query(By.directive(PermissionInfoComponent)); + const permissionBtnEl = permissionInfoEl.query(By.css('button.permissions')); + permissionBtnEl.triggerEventHandler('click', null); + testHostFixture.detectChanges(); + + const permissionInfoBox = permissionInfoEl.query(By.css('div.overlay-info-box')); + const rowEle = permissionInfoBox.nativeElement.querySelector('tr.UnknownUser'); + + expect(rowEle.querySelector('td.first-col').innerText).toEqual('Unknown User'); + expect(rowEle.querySelector('td.perm-RV').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-V').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + expect(rowEle.querySelector('td.perm-M').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + expect(rowEle.querySelector('td.perm-D').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + expect(rowEle.querySelector('td.perm-CR').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + + }); + + it('should display only one permissions as enabled in the logged-in-user line (Logged-in User has RV)', () => { + const hostCompDe = testHostFixture.debugElement; + const permissionInfoEl = hostCompDe.query(By.directive(PermissionInfoComponent)); + const permissionBtnEl = permissionInfoEl.query(By.css('button.permissions')); + permissionBtnEl.triggerEventHandler('click', null); + testHostFixture.detectChanges(); + + const permissionInfoBox = permissionInfoEl.query(By.css('div.overlay-info-box')); + const rowEle = permissionInfoBox.nativeElement.querySelector('tr.LoggedInUser'); + + expect(rowEle.querySelector('td.first-col').innerText).toEqual('Your permissions'); + expect(rowEle.querySelector('td.perm-RV').querySelector('.mat-icon').innerText).toEqual('radio_button_checked'); + expect(rowEle.querySelector('td.perm-V').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + expect(rowEle.querySelector('td.perm-M').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + expect(rowEle.querySelector('td.perm-D').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + expect(rowEle.querySelector('td.perm-CR').querySelector('.mat-icon').innerText).toEqual('radio_button_unchecked'); + + }); + + +}); diff --git a/src/app/workspace/resource/permission-info/permission-info.component.ts b/src/app/workspace/resource/permission-info/permission-info.component.ts new file mode 100644 index 0000000000..e4ec72c09d --- /dev/null +++ b/src/app/workspace/resource/permission-info/permission-info.component.ts @@ -0,0 +1,172 @@ +import { + ConnectionPositionPair, + OriginConnectionPosition, + OverlayConnectionPosition, + ScrollStrategy, + ScrollStrategyOptions +} from '@angular/cdk/overlay'; +import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { PermissionUtil } from '@dasch-swiss/dsp-js'; + +export interface PermissionObj { + name: string; + label: string; + icon: string; +} + +export interface PermissionGroup { + group: string; + restriction: PermissionUtil.Permissions[]; +} + +@Component({ + selector: 'app-permission-info', + templateUrl: './permission-info.component.html', + styleUrls: ['./permission-info.component.scss'] +}) +export class PermissionInfoComponent implements OnInit { + + // the permission info can display the `hasPermission` of a resource and `userHasPermission`together + // or only user's permission in case of restricted view + @Input() hasPermissions: string; + + @Input() userHasPermission: string; + + @ViewChild('infoButton', { static: false }) infoButton: ElementRef; + + // info menu is open or not + isOpen = false; + + // default premission values based on DSP-API permissions concept: + // https://docs.dasch.swiss/DSP-API/02-knora-ontologies/knora-base/?h=unknown#permissions + defaultPermissions: PermissionObj[] = [ + { + name: 'RV', + label: 'Restricted view permission (RV)', + icon: 'visibility_off' // or disabled_visible or block + }, + { + name: 'V', + label: 'View permission (V)', + icon: 'visibility' + }, + { + name: 'M', + label: 'Modify permission (M)', + icon: 'mode_edit' + }, + { + name: 'D', + label: 'Delete permission (D)', + icon: 'delete' + }, + { + name: 'CR', + label: 'Change rights permission (CR)', + icon: 'admin_panel_settings' // or key + } + ]; + + // default user groups based on DSP-API users and groups concept: + // https://docs.dasch.swiss/DSP-API/02-knora-ontologies/knora-base/?h=unknown#users-and-groups + defaultGroups: string[] = [ + 'knora-admin:SystemAdmin', + 'knora-admin:ProjectAdmin', + 'knora-admin:Creator', + 'knora-admin:ProjectMember', + 'knora-admin:KnownUser', + 'knora-admin:UnknownUser' + ]; + + listOfPermissions: PermissionGroup[] = []; + + userRestrictions: PermissionUtil.Permissions[]; + + scrollStrategy: ScrollStrategy; + + infoBoxPositions: ConnectionPositionPair[]; + private _originPos: OriginConnectionPosition = { + originX: 'end', + originY: 'bottom' + }; + + private _overlayPos: OverlayConnectionPosition = { + overlayX: 'end', + overlayY: 'top' + }; + + constructor( + private _sso: ScrollStrategyOptions + ) { + this.scrollStrategy = this._sso.noop(); + } + + ngOnInit(): void { + + if (this.hasPermissions) { + // split by | to get each permission section + const sections = this.hasPermissions.split('|'); + + sections.forEach(item => { + // split by space + const unit = item.split(' '); + + const allPermissions = PermissionUtil.allUserPermissions( + unit[0] as 'RV' | 'V' | 'M' | 'D' | 'CR' + ); + + const permission: PermissionGroup = { + 'group': unit[1], + 'restriction': allPermissions + }; + + this.listOfPermissions.push(permission); + + }); + + this.defaultGroups.forEach((group, i) => { + + // current index + const currentIndex = this.listOfPermissions.findIndex(e => e.group === group); + + if (currentIndex !== -1) { + // new index = i + this.arrayMove(this.listOfPermissions, currentIndex, i); + } + + }); + } + + if (this.userHasPermission) { + this.userRestrictions = PermissionUtil.allUserPermissions( + this.userHasPermission as 'RV' | 'V' | 'M' | 'D' | 'CR' + ); + } + + } + + toggleMenu() { + this.isOpen = !this.isOpen; + // console.log(this.infoButton) + const pos: ConnectionPositionPair = new ConnectionPositionPair( + this._originPos, + this._overlayPos, + 0, + 0 + ); + + this.infoBoxPositions = [pos]; + } + + getStatus(restriction: string, listOfRestrictions: number[]): boolean { + return (listOfRestrictions.indexOf(PermissionUtil.Permissions[restriction]) !== -1); + } + + arrayMove(arr: PermissionGroup[], fromIndex: number, toIndex: number) { + const element = arr[fromIndex]; + arr.splice(fromIndex, 1); + arr.splice(toIndex, 0, element); + } + +} + diff --git a/src/app/workspace/resource/properties/properties.component.html b/src/app/workspace/resource/properties/properties.component.html index a9d2e83815..e5ae906b96 100644 --- a/src/app/workspace/resource/properties/properties.component.html +++ b/src/app/workspace/resource/properties/properties.component.html @@ -49,6 +49,10 @@

--> + + + + -