diff --git a/docs/docassets/images/group-cloud.component-multiple-mode.png b/docs/docassets/images/group-cloud.component-multiple-mode.png new file mode 100644 index 0000000000..0e4ccb3c1d Binary files /dev/null and b/docs/docassets/images/group-cloud.component-multiple-mode.png differ diff --git a/docs/docassets/images/group-cloud.component-single.png b/docs/docassets/images/group-cloud.component-single.png new file mode 100644 index 0000000000..4ad7022642 Binary files /dev/null and b/docs/docassets/images/group-cloud.component-single.png differ diff --git a/docs/docassets/images/group-cloud.component.png b/docs/docassets/images/group-cloud.component.png new file mode 100644 index 0000000000..7089eb39a2 Binary files /dev/null and b/docs/docassets/images/group-cloud.component.png differ diff --git a/docs/process-services-cloud/group-cloud.component.md b/docs/process-services-cloud/group-cloud.component.md new file mode 100644 index 0000000000..0f63ad95a8 --- /dev/null +++ b/docs/process-services-cloud/group-cloud.component.md @@ -0,0 +1,92 @@ +--- +Title: Group Cloud component +Added: v3.0.0 +Status: Active +Last reviewed: 2018-20-11 +--- + +# [Group Cloud component](../../lib/process-services-cloud/src/lib/group-cloud/components/group-cloud.component.ts "Defined in group-cloud.component.ts") + +Searches Groups. + +## Basic Usage + +```html + + +``` + +![adf-cloud-group](../docassets/images/group-cloud.component.png) + +## Class members + +### Properties + +| Name | Type | Default value | Description | +| ---- | ---- | ------------- | ----------- | +| applicationName | `string` | | Name of the application. If specified, shows the groups who have access to the app. | +| mode | `string` | 'single' | Mode of the user selection (single/multiple). | +| preSelectGroups | `GroupModel[]` | Array of groups to be pre-selected. Pre-select all groups in `multiple` mode and only the first group of the array in `single` mode. | + +### Events + +| Name | Type | Description | +| ---- | ---- | ----------- | +| selectGroup | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`GroupModel`](../../lib/process-services-cloud/src/lib/group-cloud/models/group.model.ts)`>` | Emitted when a group selected. | +| removeGroup | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`GroupModel`](../../lib/process-services-cloud/src/lib/group-cloud/models/group.model.ts)`>` | Emitted when selected group is removed in `multiple` mode. | +| error | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | + + +## Details + +### Selection Mode + +You can provide selection mode singe(default)/multiple + +## Single select + +```html + +``` + +![adf-cloud-group](../docassets/images/group-cloud.component-single.png) + +## Multiple select + +```html + + +``` + +![adf-cloud-group](../docassets/images/group-cloud.component-multiple-mode.png) + +## Pre-select + +Usage example: + +```ts +import { ObjectDataTableAdapter } from '@alfresco/adf-core'; + +@Component({...}) +export class MyComponent { + groups: any; + + constructor() { + this.groups = + [ + {id: 1, name: 'Group 1'}, + {id: 2, name: 'Group 2'} + ]; + } +} +``` + +```html + + +``` diff --git a/lib/process-services-cloud/src/lib/group/components/group-cloud.component.html b/lib/process-services-cloud/src/lib/group/components/group-cloud.component.html new file mode 100644 index 0000000000..4d296d4a31 --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/components/group-cloud.component.html @@ -0,0 +1,51 @@ +
+ + + + {{group.name}} + cancel + + + + + + + + +
+ + {{group.name}} +
+
+
+
+
+
+
+ {{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate : { groupName : searchedValue } }} +
+ warning +
+
+
diff --git a/lib/process-services-cloud/src/lib/group/components/group-cloud.component.scss b/lib/process-services-cloud/src/lib/group/components/group-cloud.component.scss new file mode 100644 index 0000000000..81363b1ef9 --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/components/group-cloud.component.scss @@ -0,0 +1,56 @@ +@mixin adf-cloud-group-theme($theme) { + + $warn: map-get($theme, warn); + $primary: map-get($theme, primary); + $background: map-get($theme, background); + $foreground: map-get($theme, foreground); + + .adf { + &-cloud-group { + .mat-form-field { + padding-top: 8px; + width: 100%; + } + + &-error { + margin-top: -10px; + } + + &-error { + position: absolute; + height: 20px; + + &-message { + padding-right: 8px; + height: 16px; + font-size: 12px; + line-height: 1.33; + color: mat-color($warn); + width: auto; + } + + &-icon { + font-size: 17px; + color: mat-color($warn); + } + } + } + } + + + .mat-autocomplete-panel .mat-fab { + background: mat-color($primary); + width: 40px; + height: 40px; + font-weight: bolder; + font-size: 18px; + } + + .mat-autocomplete-panel .mat-fab { + box-shadow: none !important; + } + + .mat-autocomplete-panel .mat-fab .mat-button-wrapper { + display: inline !important; + } +} diff --git a/lib/process-services-cloud/src/lib/group/components/group-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/group/components/group-cloud.component.spec.ts new file mode 100644 index 0000000000..7e9b0341aa --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/components/group-cloud.component.spec.ts @@ -0,0 +1,258 @@ +/*! + * @license + * Copyright 2016 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { of } from 'rxjs'; +import { ProcessServiceCloudTestingModule } from './../../testing/process-service-cloud.testing.module'; + +import { GroupCloudModule } from '../group-cloud.module'; +import { GroupCloudComponent } from './group-cloud.component'; +import { GroupCloudService } from '../services/group-cloud.service'; +import { setupTestBed, AlfrescoApiServiceMock } from '@alfresco/adf-core'; +import { mockGroups } from '../mock/group-cloud.mock'; +import { GroupModel } from '../models/group.model'; + +describe('GroupCloudComponent', () => { + let component: GroupCloudComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let service: GroupCloudService; + let findGroupsByNameSpy: jasmine.Spy; + let getClientIdByApplicationNameSpy: jasmine.Spy; + let checkGroupHasClientRoleMappingSpy: jasmine.Spy; + + setupTestBed({ + imports: [ProcessServiceCloudTestingModule, GroupCloudModule], + providers: [AlfrescoApiServiceMock, GroupCloudService] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(GroupCloudComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + service = TestBed.get(GroupCloudService); + findGroupsByNameSpy = spyOn(service, 'findGroupsByName').and.returnValue(of(mockGroups)); + getClientIdByApplicationNameSpy = spyOn(service, 'getClientIdByApplicationName').and.returnValue(of('mock-client-id')); + checkGroupHasClientRoleMappingSpy = spyOn(service, 'checkGroupHasClientRoleMapping').and.returnValue(of(true)); + component.applicationName = 'mock-app-name'; + }); + + it('should create GroupCloudComponent', () => { + expect(component instanceof GroupCloudComponent).toBeTruthy(); + }); + + it('should be able to fetch client id', async(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(getClientIdByApplicationNameSpy).toHaveBeenCalled(); + expect(component.clientId).toBe('mock-client-id'); + }); + })); + + it('should show the groups if the typed result match', async(() => { + fixture.detectChanges(); + component.searchGroups$ = of( mockGroups); + let inputHTMLElement: HTMLInputElement = element.querySelector('input'); + inputHTMLElement.focus(); + inputHTMLElement.dispatchEvent(new Event('input')); + inputHTMLElement.dispatchEvent(new Event('keyup')); + inputHTMLElement.dispatchEvent(new Event('keydown')); + inputHTMLElement.value = 'M'; + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('mat-option'))).toBeDefined(); + }); + })); + + it('should hide result list if input is empty', async(() => { + fixture.detectChanges(); + let inputHTMLElement: HTMLInputElement = element.querySelector('input'); + inputHTMLElement.focus(); + inputHTMLElement.value = ''; + inputHTMLElement.dispatchEvent(new Event('keyup')); + inputHTMLElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.query(By.css('mat-option'))).toBeNull(); + expect(fixture.debugElement.query(By.css('#adf-group-0'))).toBeNull(); + }); + })); + + it('should emit selectedGroup if option is valid', async(() => { + fixture.detectChanges(); + let selectEmitSpy = spyOn(component.selectGroup, 'emit'); + component.onSelect(new GroupModel({ name: 'group name'})); + fixture.whenStable().then(() => { + expect(selectEmitSpy).toHaveBeenCalled(); + }); + })); + + it('should show an error message if the group is invalid', async(() => { + fixture.detectChanges(); + checkGroupHasClientRoleMappingSpy.and.returnValue(of(false)); + findGroupsByNameSpy.and.returnValue(of([])); + fixture.detectChanges(); + const inputHTMLElement: HTMLInputElement = element.querySelector('input'); + inputHTMLElement.focus(); + inputHTMLElement.value = 'ZZZ'; + inputHTMLElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + const errorMessage = element.querySelector('.adf-cloud-group-error-message'); + expect(errorMessage).not.toBeNull(); + expect(errorMessage.textContent).toContain('ADF_CLOUD_GROUPS.ERROR.NOT_FOUN'); + }); + })); + + it('should show chip list when mode=multiple', async(() => { + component.mode = 'multiple'; + fixture.detectChanges(); + fixture.whenStable().then(() => { + const chip = element.querySelector('mat-chip-list'); + expect(chip).toBeDefined(); + }); + })); + + it('should not show chip list when mode=single', async(() => { + component.mode = 'single'; + fixture.detectChanges(); + fixture.whenStable().then(() => { + const chip = element.querySelector('mat-chip-list'); + expect(chip).toBeNull(); + }); + })); + + it('should pre-select all preSelectGroups when mode=multiple', async(() => { + component.mode = 'multiple'; + component.preSelectGroups = [{id: mockGroups[1].id}, {id: mockGroups[2].id}]; + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + const chips = fixture.debugElement.queryAll(By.css('mat-chip')); + expect(chips.length).toBe(2); + }); + })); + + it('should not pre-select any group when preSelectGroups is empty and mode=multiple', async(() => { + component.mode = 'multiple'; + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + const chip = fixture.debugElement.query(By.css('mat-chip')); + expect(chip).toBeNull(); + }); + })); + + it('should pre-select preSelectGroups[0] when mode=single', async(() => { + component.mode = 'single'; + component.preSelectGroups = [{id: mockGroups[1].id}, {id: mockGroups[2].id}]; + fixture.detectChanges(); + fixture.whenStable().then(() => { + const selectedGroup = component.searchGroupsControl.value; + expect(selectedGroup.id).toBe(mockGroups[1].id); + }); + })); + + it('should not pre-select any group when preSelectGroups is empty and mode=single', async(() => { + component.mode = 'single'; + fixture.detectChanges(); + fixture.whenStable().then(() => { + const selectedGroup = component.searchGroupsControl.value; + expect(selectedGroup).toBeNull(); + }); + })); + + it('should emit removeGroup when a selected group is removed if mode=multiple', async(() => { + let removeGroupSpy = spyOn(component.removeGroup, 'emit'); + + component.mode = 'multiple'; + component.preSelectGroups = [{id: mockGroups[1].id}, {id: mockGroups[2].id}]; + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + const removeIcon = fixture.debugElement.query(By.css('mat-chip mat-icon')); + removeIcon.nativeElement.click(); + + expect(removeGroupSpy).toHaveBeenCalledWith({ id: mockGroups[1].id }); + }); + + })); + + it('should list groups who have access to the app when appName is specified', async(() => { + component.applicationName = 'sample-app'; + fixture.detectChanges(); + let inputHTMLElement: HTMLInputElement = element.querySelector('input'); + inputHTMLElement.focus(); + inputHTMLElement.value = 'M'; + inputHTMLElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + const groupsList = fixture.debugElement.queryAll(By.css('mat-option')); + expect(groupsList.length).toBe(mockGroups.length); + }); + })); + + it('should not list groups who do not have access to the app when appName is specified', async(() => { + checkGroupHasClientRoleMappingSpy.and.returnValue(of(false)); + component.applicationName = 'sample-app'; + + fixture.detectChanges(); + let inputHTMLElement: HTMLInputElement = element.querySelector('[data-automation-id="adf-cloud-group-search-input"]'); + inputHTMLElement.focus(); + inputHTMLElement.value = 'Mock'; + inputHTMLElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + const groupsList = fixture.debugElement.queryAll(By.css('mat-option')); + expect(groupsList.length).toBe(0); + }); + })); + + it('should validate access to the app when appName is specified', async(() => { + findGroupsByNameSpy.and.returnValue(of(mockGroups)); + checkGroupHasClientRoleMappingSpy.and.returnValue(of(true)); + fixture.detectChanges(); + let inputHTMLElement: HTMLInputElement = element.querySelector('input'); + inputHTMLElement.focus(); + inputHTMLElement.value = 'Mock'; + inputHTMLElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(checkGroupHasClientRoleMappingSpy).toHaveBeenCalledTimes(mockGroups.length); + }); + })); + + it('should not validate access to the app when appName is not specified', async(() => { + fixture.detectChanges(); + let inputHTMLElement: HTMLInputElement = element.querySelector('input'); + inputHTMLElement.focus(); + inputHTMLElement.value = 'M'; + inputHTMLElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(checkGroupHasClientRoleMappingSpy).not.toHaveBeenCalled(); + }); + })); +}); diff --git a/lib/process-services-cloud/src/lib/group/components/group-cloud.component.ts b/lib/process-services-cloud/src/lib/group/components/group-cloud.component.ts new file mode 100644 index 0000000000..7fe10462e5 --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/components/group-cloud.component.ts @@ -0,0 +1,274 @@ +/*! + * @license + * Copyright 2016 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, ElementRef, OnInit, Output, EventEmitter, ViewChild, ViewEncapsulation, Input } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { trigger, state, style, transition, animate } from '@angular/animations'; +import { Observable, of, BehaviorSubject } from 'rxjs'; +import { GroupModel, GroupSearchParam } from '../models/group.model'; +import { GroupCloudService } from '../services/group-cloud.service'; +import { debounceTime } from 'rxjs/internal/operators/debounceTime'; +import { distinctUntilChanged, switchMap, flatMap, mergeMap, filter, tap } from 'rxjs/operators'; + +@Component({ + selector: 'adf-cloud-group', + templateUrl: './group-cloud.component.html', + styleUrls: ['./group-cloud.component.scss'], + animations: [ + trigger('transitionMessages', [ + state('enter', style({ opacity: 1, transform: 'translateY(0%)' })), + transition('void => enter', [ + style({ opacity: 0, transform: 'translateY(-100%)' }), + animate('300ms cubic-bezier(0.55, 0, 0.55, 0.2)') + ]) + ]) + ], + encapsulation: ViewEncapsulation.None +}) +export class GroupCloudComponent implements OnInit { + + static MODE_SINGLE = 'single'; + static MODE_MULTIPLE = 'multiple'; + + @ViewChild('groupInput') groupInput: ElementRef; + + /** Name of the application. If specified, shows the users who have access to the app. */ + @Input() + applicationName: string; + + /** Mode of the user selection (single/multiple). */ + @Input() + mode: string = GroupCloudComponent.MODE_SINGLE; + + /** Array of users to be pre-selected. Pre-select all users in multi selection mode and only the first user of the array in single selection mode. */ + @Input() + preSelectGroups: GroupModel[] = []; + + /** Emitted when a group is selected. */ + @Output() + selectGroup: EventEmitter = new EventEmitter(); + + /** Emitted when a group is removed. */ + @Output() + removeGroup: EventEmitter = new EventEmitter(); + + private selectedGroups: GroupModel[] = []; + + private searchGroups: GroupModel[] = []; + + private searchGroupsSubject: BehaviorSubject; + + private selectedGroupsSubject: BehaviorSubject; + + searchGroups$: Observable; + + selectedGroups$: Observable; + + searchGroupsControl: FormControl = new FormControl(''); + + _subscriptAnimationState = 'enter'; + + clientId: string; + + searchedValue = ''; + + constructor(private groupService: GroupCloudService) { + this.selectedGroupsSubject = new BehaviorSubject(this.selectedGroups); + this.searchGroupsSubject = new BehaviorSubject(this.searchGroups); + this.selectedGroups$ = this.selectedGroupsSubject.asObservable(); + this.searchGroups$ = this.searchGroupsSubject.asObservable(); + } + + ngOnInit() { + this.loadPreSelectGroups(); + this.initSearch(); + + if (this.applicationName) { + this.disableSearch(); + this.loadClientId(); + } + } + + private async loadClientId() { + this.clientId = await this.groupService.getClientIdByApplicationName(this.applicationName).toPromise(); + if (this.clientId) { + this.enableSearch(); + } + } + + initSearch() { + this.searchGroupsControl.valueChanges.pipe( + debounceTime(500), + distinctUntilChanged(), + tap(() => { + this.resetSearchGroups(); + }), + switchMap((inputValue) => { + const queryParams = this.createSearchParam(inputValue); + return this.findGroupsByName(queryParams); + }), + filter((group: any) => { + return !this.isGroupAlreadySelected(group); + }), + mergeMap((group: any) => { + if (this.clientId) { + return this.checkGroupHasClientRoleMapping(group); + } else { + return of(group); + } + }) + ).subscribe((searchedGroup) => { + this.searchGroups.push(searchedGroup); + this.clearError(); + this.searchGroupsSubject.next(this.searchGroups); + }); + } + + findGroupsByName(searchParam: GroupSearchParam): Observable { + return this.groupService.findGroupsByName(searchParam).pipe( + flatMap((groups: GroupModel[]) => { + this.searchedValue = searchParam.name; + if (this.searchedValue && !this.hasGroups(groups)) { + this.setError(); + } + return groups; + }) + ); + } + + checkGroupHasClientRoleMapping(group: GroupModel): Observable { + return this.groupService.checkGroupHasClientRoleMapping(group.id, this.clientId).pipe( + mergeMap((hasRole: boolean) => { + if (hasRole) { + return of(group); + } else { + this.setError(); + return of(); + } + }) + ); + } + + isGroupAlreadySelected(group: GroupModel): boolean { + if (this.hasGroups(this.selectedGroups)) { + const result = this.selectedGroups.filter((selectedGroup: GroupModel) => { + return selectedGroup.id === group.id; + }); + if (this.hasGroups(result)) { + return true; + } + } + return false; + } + + private loadPreSelectGroups() { + if (this.hasGroups(this.preSelectGroups)) { + if (this.isMultipleMode()) { + this.preSelectGroups.forEach((group: GroupModel) => { + this.selectedGroups.push(group); + }); + } else { + this.searchGroupsControl.setValue(this.preSelectGroups[0]); + this.onSelect(this.preSelectGroups[0]); + } + } + } + + onSelect(selectedGroup: GroupModel) { + if (this.isMultipleMode()) { + if (!this.isGroupAlreadySelected(selectedGroup)) { + this.selectedGroups.push(selectedGroup); + this.selectedGroupsSubject.next(this.selectedGroups); + this.selectGroup.emit(selectedGroup); + this.searchGroupsSubject.next([]); + } + this.groupInput.nativeElement.value = ''; + this.searchGroupsControl.setValue(''); + } else { + this.selectGroup.emit(selectedGroup); + } + + this.clearError(); + this.resetSearchGroups(); + } + + onRemove(selectedGroup: GroupModel) { + this.removeGroup.emit(selectedGroup); + const indexToRemove = this.selectedGroups.findIndex((group: GroupModel) => { return group.id === selectedGroup.id; }); + this.selectedGroups.splice(indexToRemove, 1); + this.selectedGroupsSubject.next(this.selectedGroups); + } + + private resetSearchGroups() { + this.searchGroups = []; + this.searchGroupsSubject.next([]); + } + + isMultipleMode(): boolean { + return this.mode === GroupCloudComponent.MODE_MULTIPLE; + } + + getDisplayName(group: GroupModel): string { + return group ? group.name : ''; + } + + private hasGroups(groups: GroupModel[]): boolean { + return groups && groups.length > 0; + } + + createSearchParam(value: any): GroupSearchParam { + let queryParams: GroupSearchParam = { name: '' }; + if (this.isString(value)) { + queryParams.name = value.trim(); + } else { + queryParams.name = value.name.trim(); + } + return queryParams; + } + + isString(value: any): boolean { + return typeof value === 'string'; + } + + setValidationError() { + if (this.hasGroups(this.searchGroups)) { + this.clearError(); + } else { + this.setError(); + } + } + + private disableSearch() { + this.searchGroupsControl.disable(); + } + + private enableSearch() { + this.searchGroupsControl.enable(); + } + + private setError() { + this.searchGroupsControl.setErrors({invalid: true}); + } + + private clearError() { + this.searchGroupsControl.setErrors(null); + } + + hasError(): boolean { + return this.searchGroupsControl && this.searchGroupsControl.errors && this.searchGroupsControl.errors.invalid; + } +} diff --git a/lib/process-services-cloud/src/lib/group/group-cloud.module.ts b/lib/process-services-cloud/src/lib/group/group-cloud.module.ts new file mode 100644 index 0000000000..a0cd2b023c --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/group-cloud.module.ts @@ -0,0 +1,49 @@ +/*! + * @license + * Copyright 2016 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { FlexLayoutModule } from '@angular/flex-layout'; +import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; + +import { TemplateModule, TranslateLoaderService, FormModule, PipeModule } from '@alfresco/adf-core'; +import { MaterialModule } from '../material.module'; +import { GroupCloudComponent } from './components/group-cloud.component'; +import { InitialGroupNamePipe } from './pipe/group-initial.pipe'; + +@NgModule({ + imports: [ + CommonModule, + PipeModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderService + } + }), + TemplateModule, + FlexLayoutModule, + MaterialModule, + FormsModule, + ReactiveFormsModule, + FormModule + ], + declarations: [GroupCloudComponent, InitialGroupNamePipe], + exports: [GroupCloudComponent, InitialGroupNamePipe] +}) +export class GroupCloudModule { } diff --git a/lib/process-services-cloud/src/lib/group/mock/group-cloud.mock.ts b/lib/process-services-cloud/src/lib/group/mock/group-cloud.mock.ts new file mode 100644 index 0000000000..32813c8439 --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/mock/group-cloud.mock.ts @@ -0,0 +1,104 @@ +/*! + * @license + * Copyright 2016 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GroupModel } from '../models/group.model'; + +export let mockGroup1 = new GroupModel({ + id: 'mock-id-1', name: 'Mock Group 1', path: '/mock', subGroups: [] +}); + +export let mockGroup2 = new GroupModel({ + id: 'mock-id-2', name: 'Mock Group 2', path: '', subGroups: [] +}); + +export let mockGroup3 = new GroupModel({ + id: 'mock-id-3', name: 'Fake Group 3', path: '', subGroups: [] +}); + +export let mockGroups = [ + mockGroup1, mockGroup2, mockGroup3 +]; + +export let mockApplicationDetails = {id: 'mock-app-id', name: 'mock-app-name'}; + +export let mockError = { + error: { + errorKey: 'failed', + statusCode: 400, + stackTrace: 'For security reasons the stack trace is no longer displayed, but the property is kept for previous versions.' + } +}; + +export let mockApiError = { + oauth2Auth: { + callCustomApi: () => { + return Promise.reject(mockError); + } + } +}; + +export let roleMappingMock = [ + { id: 'role-id-1', name: 'role-name-1' }, { id: 'role-id-2', name: 'role-name-2' } +]; + +export let roleMappingApi = { + oauth2Auth: { + callCustomApi: () => { + return Promise.resolve(roleMappingMock); + } + } +}; + +export let noRoleMappingApi = { + oauth2Auth: { + callCustomApi: () => { + return Promise.resolve([]); + } + } +}; + +export let groupsMockApi = { + oauth2Auth: { + callCustomApi: () => { + return Promise.resolve(mockGroups); + } + } +}; + +export let returnCallQueryParameters = { + oauth2Auth: { + callCustomApi: (queryUrl, operation, context, queryParams) => { + return Promise.resolve(queryParams); + } + } +}; + +export let returnCallUrl = { + oauth2Auth: { + callCustomApi: (queryUrl, operation, context, queryParams) => { + return Promise.resolve(queryUrl); + } + } +}; + +export let applicationDetailsMockApi = { + oauth2Auth: { + callCustomApi: () => { + return Promise.resolve([mockApplicationDetails]); + } + } +}; diff --git a/lib/process-services-cloud/src/lib/group/models/group.model.ts b/lib/process-services-cloud/src/lib/group/models/group.model.ts new file mode 100644 index 0000000000..4ed0304bf0 --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/models/group.model.ts @@ -0,0 +1,41 @@ +/*! + * @license + * Copyright 2016 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class GroupModel { + + id: string; + name: string; + path: string; + realmRoles: string[]; + access: any; + attributes: any; + clientRoles: any; + + constructor(obj?: any) { + this.id = obj.id || null; + this.name = obj.name || null; + this.path = obj.path || null; + this.realmRoles = obj.realmRoles || null; + this.access = obj.access || null; + this.attributes = obj.attributes || null; + this.clientRoles = obj.clientRoles || null; + } +} + +export interface GroupSearchParam { + name?: string; +} diff --git a/lib/process-services-cloud/src/lib/group/pipe/group-initial.pipe.spec.ts b/lib/process-services-cloud/src/lib/group/pipe/group-initial.pipe.spec.ts new file mode 100644 index 0000000000..87e8bc5801 --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/pipe/group-initial.pipe.spec.ts @@ -0,0 +1,41 @@ +/*! + * @license + * Copyright 2016 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InitialGroupNamePipe } from './group-initial.pipe'; +import { GroupModel } from '../models/group.model'; + +describe('InitialGroupNamePipe', () => { + + let pipe: InitialGroupNamePipe; + let fakeGroup: GroupModel; + + beforeEach(() => { + pipe = new InitialGroupNamePipe(); + fakeGroup = new GroupModel({name: 'mock'}); + }); + + it('should return with the group initial', () => { + fakeGroup.name = 'FAKE-GROUP-NAME'; + let result = pipe.transform(fakeGroup); + expect(result).toBe('F'); + }); + + it('should return an empty string when group is null', () => { + let result = pipe.transform(null); + expect(result).toBe(''); + }); +}); diff --git a/lib/process-services-cloud/src/lib/group/pipe/group-initial.pipe.ts b/lib/process-services-cloud/src/lib/group/pipe/group-initial.pipe.ts new file mode 100644 index 0000000000..bbd71eb44b --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/pipe/group-initial.pipe.ts @@ -0,0 +1,40 @@ +/*! + * @license + * Copyright 2016 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Pipe, PipeTransform } from '@angular/core'; +import { GroupModel } from '../models/group.model'; + +@Pipe({ + name: 'groupNameInitial' +}) +export class InitialGroupNamePipe implements PipeTransform { + + constructor() {} + + transform(group: GroupModel): string { + let result = ''; + if (group) { + result = this.getInitialGroupName(group.name).toUpperCase(); + } + return result; + } + + getInitialGroupName(groupName: string) { + groupName = (groupName ? groupName[0] : ''); + return groupName; + } +} diff --git a/lib/process-services-cloud/src/lib/group/public-api.ts b/lib/process-services-cloud/src/lib/group/public-api.ts new file mode 100644 index 0000000000..bf5ee93a2d --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/public-api.ts @@ -0,0 +1,21 @@ +/*! + * @license + * Copyright 2016 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './models/group.model'; +export * from './services/group-cloud.service'; +export * from './components/group-cloud.component'; +export * from './group-cloud.module'; diff --git a/lib/process-services-cloud/src/lib/group/services/group-cloud.service.spec.ts b/lib/process-services-cloud/src/lib/group/services/group-cloud.service.spec.ts new file mode 100644 index 0000000000..35f07c725d --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/services/group-cloud.service.spec.ts @@ -0,0 +1,135 @@ +/*! + * @license + * Copyright 2016 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { async } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; +import { GroupCloudService } from './group-cloud.service'; +import { + AlfrescoApiServiceMock, + CoreModule, + setupTestBed, + AlfrescoApiService, + LogService +} from '@alfresco/adf-core'; +import { + applicationDetailsMockApi, + groupsMockApi, + returnCallQueryParameters, + returnCallUrl, + mockApiError, + mockError, + roleMappingApi, + noRoleMappingApi +} from '../mock/group-cloud.mock'; +import { GroupSearchParam } from '../models/group.model'; + +describe('GroupCloudService', () => { + let service: GroupCloudService; + let apiService: AlfrescoApiService; + let logService: LogService; + + setupTestBed({ + imports: [CoreModule.forRoot()], + providers: [ + { provide: AlfrescoApiService, useClass: AlfrescoApiServiceMock } + ] + }); + + beforeEach(async(() => { + service = TestBed.get(GroupCloudService); + apiService = TestBed.get(AlfrescoApiService); + logService = TestBed.get(LogService); + })); + + it('should be able to fetch groups', (done) => { + spyOn(apiService, 'getInstance').and.returnValue(groupsMockApi); + service.findGroupsByName( {name: 'mock'}).subscribe((res) => { + expect(res).toBeDefined(); + expect(res).not.toBeNull(); + expect(res.length).toBe(3); + expect(res[0].id).toBe('mock-id-1'); + expect(res[0].name).toBe('Mock Group 1'); + expect(res[1].id).toBe('mock-id-2'); + expect(res[1].name).toBe('Mock Group 2'); + expect(res[2].id).toBe('mock-id-3'); + expect(res[2].name).toBe('Fake Group 3'); + done(); + }); + }); + + it('should return true if group has client role mapping', (done) => { + spyOn(apiService, 'getInstance').and.returnValue(roleMappingApi); + service.checkGroupHasClientRoleMapping('mock-group-id', 'mock-app-id').subscribe((hasRole) => { + expect(hasRole).toBeDefined(); + expect(hasRole).toBe(true); + done(); + }); + }); + + it('should return false if group does not have client role mapping', (done) => { + spyOn(apiService, 'getInstance').and.returnValue(noRoleMappingApi); + service.checkGroupHasClientRoleMapping('mock-group-id', 'mock-app-id').subscribe((hasRole) => { + expect(hasRole).toBeDefined(); + expect(hasRole).toBe(false); + done(); + }); + }); + + it('should append to the call all the parameters', (done) => { + spyOn(apiService, 'getInstance').and.returnValue(returnCallQueryParameters); + service.findGroupsByName( {name: 'mock'}).subscribe((res) => { + expect(res).toBeDefined(); + expect(res).not.toBeNull(); + expect(res.search).toBe('mock'); + done(); + }); + }); + + it('should request groups api url', (done) => { + spyOn(apiService, 'getInstance').and.returnValue(returnCallUrl); + service.findGroupsByName( {name: 'mock'}).subscribe((requestUrl) => { + expect(requestUrl).toBeDefined(); + expect(requestUrl).not.toBeNull(); + expect(requestUrl).toContain('/groups'); + done(); + }); + }); + + it('should be able to fetch the client id', (done) => { + spyOn(apiService, 'getInstance').and.returnValue(applicationDetailsMockApi); + service.getClientIdByApplicationName('mock-app-name').subscribe((clientId) => { + expect(clientId).toBeDefined(); + expect(clientId).not.toBeNull(); + expect(clientId).toBe('mock-app-id'); + done(); + }); + }); + + it('should notify errors returned from the API', (done) => { + const logServiceSpy = spyOn(logService, 'error').and.callThrough(); + spyOn(apiService, 'getInstance').and.returnValue(mockApiError); + service.findGroupsByName( {name: 'mock'}).subscribe( + () => {}, + (res: any) => { + expect(res).toBeDefined(); + expect(res).toEqual(mockError); + expect(logServiceSpy).toHaveBeenCalled(); + done(); + } + ); + }); +}); diff --git a/lib/process-services-cloud/src/lib/group/services/group-cloud.service.ts b/lib/process-services-cloud/src/lib/group/services/group-cloud.service.ts new file mode 100644 index 0000000000..16c3c41443 --- /dev/null +++ b/lib/process-services-cloud/src/lib/group/services/group-cloud.service.ts @@ -0,0 +1,110 @@ +/*! + * @license + * Copyright 2016 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { from, of, Observable, throwError } from 'rxjs'; +import { map, catchError } from 'rxjs/operators'; + +import { AlfrescoApiService, AppConfigService, LogService } from '@alfresco/adf-core'; +import { GroupSearchParam } from '../models/group.model'; + +@Injectable({ + providedIn: 'root' +}) +export class GroupCloudService { + + constructor( + private apiService: AlfrescoApiService, + private appConfigService: AppConfigService, + private logService: LogService + ) {} + + findGroupsByName(searchParams: GroupSearchParam): Observable { + if (searchParams.name === '') { + return of([]); + } + const url = this.getGroupsApi(); + const httpMethod = 'GET', pathParams = {}, queryParams = {search: searchParams.name}, bodyParam = {}, headerParams = {}, + formParams = {}, contentTypes = ['application/json'], accepts = ['application/json']; + + return (from(this.apiService.getInstance().oauth2Auth.callCustomApi( + url, httpMethod, pathParams, queryParams, + headerParams, formParams, bodyParam, + contentTypes, accepts, Object, null, null) + )).pipe( + catchError((err) => this.handleError(err)) + ); + } + + getClientIdByApplicationName(applicationName: string): Observable { + const url = this.getApplicationIdApi(); + const httpMethod = 'GET', pathParams = {}, queryParams = {clientId: applicationName}, bodyParam = {}, headerParams = {}, formParams = {}, + contentTypes = ['application/json'], accepts = ['application/json']; + return from(this.apiService.getInstance() + .oauth2Auth.callCustomApi(url, httpMethod, pathParams, queryParams, headerParams, + formParams, bodyParam, contentTypes, + accepts, Object, null, null) + ).pipe( + map((response: any[]) => { + const clientId = response && response.length > 0 ? response[0].id : ''; + return clientId; + }), + catchError((err) => this.handleError(err)) + ); + } + + checkGroupHasClientRoleMapping(groupId: string, clientId: string): Observable { + const url = this.groupClientRoleMappingApi(groupId, clientId); + const httpMethod = 'GET', pathParams = {}, queryParams = {}, bodyParam = {}, headerParams = {}, + formParams = {}, contentTypes = ['application/json'], accepts = ['application/json']; + + return from(this.apiService.getInstance().oauth2Auth.callCustomApi( + url, httpMethod, pathParams, queryParams, + headerParams, formParams, bodyParam, + contentTypes, accepts, Object, null, null) + ).pipe( + map((response: any[]) => { + if (response && response.length > 0) { + return true; + } + return false; + }), + catchError((err) => this.handleError(err)) + ); + } + + private groupClientRoleMappingApi(groupId: string, clientId: string): any { + return `${this.appConfigService.get('identityHost')}/groups/${groupId}/role-mappings/clients/${clientId}`; + } + + private getApplicationIdApi() { + return `${this.appConfigService.get('identityHost')}/clients`; + } + + private getGroupsApi() { + return `${this.appConfigService.get('identityHost')}/groups`; + } + + /** + * Throw the error + * @param error + */ + private handleError(error: Response) { + this.logService.error(error); + return throwError(error || 'Server error'); + } +} diff --git a/lib/process-services-cloud/src/lib/i18n/en.json b/lib/process-services-cloud/src/lib/i18n/en.json index 2986c5c904..ce16e8c87c 100644 --- a/lib/process-services-cloud/src/lib/i18n/en.json +++ b/lib/process-services-cloud/src/lib/i18n/en.json @@ -145,5 +145,11 @@ "SAVE": "SAVE", "CANCEL": "CANCEL" } + }, + "ADF_CLOUD_GROUPS": { + "SEARCH-GROUP": "Groups", + "ERROR": { + "NOT_FOUND": "No group found with the name {{groupName}}" + } } } diff --git a/lib/process-services-cloud/src/lib/process-services-cloud.module.ts b/lib/process-services-cloud/src/lib/process-services-cloud.module.ts index 2b9eeaec79..2ad550703f 100644 --- a/lib/process-services-cloud/src/lib/process-services-cloud.module.ts +++ b/lib/process-services-cloud/src/lib/process-services-cloud.module.ts @@ -20,6 +20,7 @@ import { TRANSLATION_PROVIDER } from '@alfresco/adf-core'; import { AppListCloudModule } from './app/app-list-cloud.module'; import { TaskCloudModule } from './task/task-cloud.module'; import { ProcessCloudModule } from './process/process-cloud.module'; +import { GroupCloudModule } from './group/group-cloud.module'; @NgModule({ imports: [ @@ -40,7 +41,8 @@ import { ProcessCloudModule } from './process/process-cloud.module'; exports: [ AppListCloudModule, ProcessCloudModule, - TaskCloudModule + TaskCloudModule, + GroupCloudModule ] }) export class ProcessServicesCloudModule { } diff --git a/lib/process-services-cloud/src/lib/styles/_index.scss b/lib/process-services-cloud/src/lib/styles/_index.scss index 6b5c25aa7a..96dbccb9bd 100644 --- a/lib/process-services-cloud/src/lib/styles/_index.scss +++ b/lib/process-services-cloud/src/lib/styles/_index.scss @@ -5,6 +5,7 @@ @import './../process/process-list/components/process-list-cloud.component.scss'; @import './../task/start-task/components/start-task-cloud.component.scss'; @import './../task/start-task/components/people-cloud/people-cloud.component.scss'; +@import './../group/components/group-cloud.component'; @mixin adf-process-services-cloud-theme($theme) { @@ -15,4 +16,5 @@ @include adf-process-filters-cloud-theme($theme); @include adf-start-task-cloud-theme($theme); @include adf-cloud-people-theme($theme); + @include adf-cloud-group-theme($theme); }