diff --git a/components/automate-ui/src/app/entities/orgs/org.actions.ts b/components/automate-ui/src/app/entities/orgs/org.actions.ts index 16866205263..12e9bb11d63 100644 --- a/components/automate-ui/src/app/entities/orgs/org.actions.ts +++ b/components/automate-ui/src/app/entities/orgs/org.actions.ts @@ -1,7 +1,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Action } from '@ngrx/store'; -import { Org } from './org.model'; +import { Org, UploadFile } from './org.model'; export enum OrgActionTypes { GET_ALL = 'ORGS::GET_ALL', @@ -18,7 +18,10 @@ export enum OrgActionTypes { DELETE_FAILURE = 'ORGS::DELETE::FAILURE', UPDATE = 'ORGS::UPDATE', UPDATE_SUCCESS = 'ORGS::UPDATE::SUCCESS', - UPDATE_FAILURE = 'ORGS::UPDATE::FAILURE' + UPDATE_FAILURE = 'ORGS::UPDATE::FAILURE', + UPLOAD = 'ORGS::UPLOAD', + UPLOAD_SUCCESS = 'ORGS::UPLOAD::SUCCESS', + UPLOAD_FAILURE = 'ORGS::UPLOAD::FAILURE' } export interface OrgSuccessPayload { @@ -124,6 +127,29 @@ export class UpdateOrgFailure implements Action { constructor(public payload: HttpErrorResponse) { } } +export interface UploadSuccessPayload { + success: boolean; + migrationId: string; +} + +export class UploadZip implements Action { + readonly type = OrgActionTypes.UPLOAD; + + constructor(public payload: UploadFile) { } +} + +export class UploadZipSuccess implements Action { + readonly type = OrgActionTypes.UPLOAD_SUCCESS; + + constructor(public payload: UploadSuccessPayload) { } +} + +export class UploadZipFailure implements Action { + readonly type = OrgActionTypes.UPLOAD_FAILURE; + + constructor(public payload: HttpErrorResponse) { } +} + export type OrgActions = | GetOrgs | GetOrgsSuccess @@ -139,4 +165,7 @@ export type OrgActions = | DeleteOrgFailure | UpdateOrg | UpdateOrgSuccess - | UpdateOrgFailure; + | UpdateOrgFailure + | UploadZip + | UploadZipSuccess + | UploadZipFailure; diff --git a/components/automate-ui/src/app/entities/orgs/org.effects.ts b/components/automate-ui/src/app/entities/orgs/org.effects.ts index 2da496dbd53..92a3ef25020 100644 --- a/components/automate-ui/src/app/entities/orgs/org.effects.ts +++ b/components/automate-ui/src/app/entities/orgs/org.effects.ts @@ -26,6 +26,10 @@ import { UpdateOrgFailure, UpdateOrgSuccess, OrgSuccessPayload, + UploadZip, + UploadZipSuccess, + UploadZipFailure, + UploadSuccessPayload, OrgActionTypes } from './org.actions'; @@ -161,4 +165,32 @@ export class OrgEffects { }); }))); + uploadZip$ = createEffect(() => + this.actions$.pipe( + ofType(OrgActionTypes.UPLOAD), + mergeMap(({ payload: { formData } }: UploadZip) => + this.requests.uploadZip(formData).pipe( + map((resp: UploadSuccessPayload) => new UploadZipSuccess(resp)), + catchError((error: HttpErrorResponse) => + observableOf(new UploadZipFailure(error))))))); + + uploadZipSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(OrgActionTypes.UPLOAD_SUCCESS), + map((_UploadZipSuccess) => new CreateNotification({ + type: Type.info, + message: 'Successfully uploaded file.' + })))); + + uploadZipFailure$ = createEffect(() => + this.actions$.pipe( + ofType(OrgActionTypes.UPLOAD_FAILURE), + map(({ payload }: UploadZipFailure) => { + const msg = payload.error.error; + return new CreateNotification({ + type: Type.error, + message: `Could not upload file: ${msg || payload.error}` + }); + }))); + } diff --git a/components/automate-ui/src/app/entities/orgs/org.model.ts b/components/automate-ui/src/app/entities/orgs/org.model.ts index 6f04a6b871e..4139dbf3a8e 100644 --- a/components/automate-ui/src/app/entities/orgs/org.model.ts +++ b/components/automate-ui/src/app/entities/orgs/org.model.ts @@ -5,3 +5,7 @@ export interface Org { admin_user: string; projects?: string[]; } + +export interface UploadFile { + formData: FormData; +} diff --git a/components/automate-ui/src/app/entities/orgs/org.reducer.ts b/components/automate-ui/src/app/entities/orgs/org.reducer.ts index 0ff6307328d..0ebf48d30bd 100644 --- a/components/automate-ui/src/app/entities/orgs/org.reducer.ts +++ b/components/automate-ui/src/app/entities/orgs/org.reducer.ts @@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { set, pipe, unset } from 'lodash/fp'; import { EntityStatus } from 'app/entities/entities'; -import { OrgActionTypes, OrgActions } from './org.actions'; +import { OrgActionTypes, OrgActions, UploadSuccessPayload } from './org.actions'; import { Org } from './org.model'; export interface OrgEntityState extends EntityState { @@ -13,6 +13,8 @@ export interface OrgEntityState extends EntityState { createError: HttpErrorResponse; updateStatus: EntityStatus; deleteStatus: EntityStatus; + uploadStatus: EntityStatus; + uploadDetails: UploadSuccessPayload; } const GET_ALL_STATUS = 'getAllStatus'; @@ -21,6 +23,7 @@ const CREATE_STATUS = 'createStatus'; const CREATE_ERROR = 'createError'; const DELETE_STATUS = 'deleteStatus'; const UPDATE_STATUS = 'updateStatus'; +const UPLOAD_STATUS = 'uploadStatus'; export const orgEntityAdapter: EntityAdapter = createEntityAdapter(); @@ -31,7 +34,10 @@ export const OrgEntityInitialState: OrgEntityState = createStatus: EntityStatus.notLoaded, createError: null, deleteStatus: EntityStatus.notLoaded, - updateStatus: EntityStatus.notLoaded + updateStatus: EntityStatus.notLoaded, + uploadStatus: EntityStatus.notLoaded, + uploadDetails: null + }); export function orgEntityReducer( @@ -98,6 +104,18 @@ export function orgEntityReducer( case OrgActionTypes.UPDATE_FAILURE: return set(UPDATE_STATUS, EntityStatus.loadingFailure, state); + case OrgActionTypes.UPLOAD: + return set(UPLOAD_STATUS, EntityStatus.loading, state); + + case OrgActionTypes.UPLOAD_SUCCESS: + return pipe( + set(UPLOAD_STATUS, EntityStatus.loadingSuccess), + set('uploadDetails', action.payload || []) + )(state) as OrgEntityState; + + case OrgActionTypes.UPLOAD_FAILURE: + return set(UPLOAD_STATUS, EntityStatus.loadingFailure, state); + default: return state; } diff --git a/components/automate-ui/src/app/entities/orgs/org.requests.ts b/components/automate-ui/src/app/entities/orgs/org.requests.ts index f5fa5888462..2e320fbaeb9 100644 --- a/components/automate-ui/src/app/entities/orgs/org.requests.ts +++ b/components/automate-ui/src/app/entities/orgs/org.requests.ts @@ -3,9 +3,11 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment as env } from 'environments/environment'; import { Org } from './org.model'; - import { - OrgsSuccessPayload, OrgSuccessPayload, CreateOrgPayload + OrgsSuccessPayload, + OrgSuccessPayload, + CreateOrgPayload, + UploadSuccessPayload } from './org.actions'; @Injectable() @@ -36,4 +38,9 @@ export class OrgRequests { return this.http.put( `${env.infra_proxy_url}/servers/${org.server_id}/orgs/${org.id}`, org); } + + public uploadZip(formData: FormData): Observable { + return this.http.post + (`${env.infra_proxy_url}/servers/migrations/upload`, formData); + } } diff --git a/components/automate-ui/src/app/entities/orgs/org.selectors.ts b/components/automate-ui/src/app/entities/orgs/org.selectors.ts index ea4d32cb494..a9581cc0ff4 100644 --- a/components/automate-ui/src/app/entities/orgs/org.selectors.ts +++ b/components/automate-ui/src/app/entities/orgs/org.selectors.ts @@ -45,3 +45,13 @@ export const orgFromRoute = createSelector( routeParams, (state, { 'org-id': org_id }) => state[org_id] ); + +export const uploadStatus = createSelector( + orgState, + (state) => state.uploadStatus +); + +export const uploadDetails = createSelector( + orgState, + (state) => state.uploadDetails +); diff --git a/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.html b/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.html index 028a8dbc0dc..63566d19671 100644 --- a/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.html +++ b/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.html @@ -46,7 +46,9 @@
- Sync Org and Users + + sync-button + Sync Org and Users
@@ -75,6 +77,13 @@ (deleteClicked)="deleteOrg()" objectAction="Delete"> + +
diff --git a/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.scss b/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.scss index 776c3e1fa6c..0dcaac0ba2f 100644 --- a/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.scss +++ b/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.scss @@ -44,6 +44,7 @@ chef-page-header { li { display: flex; margin-bottom: 15px; + height: 50px; span { font-size: 14px; @@ -81,6 +82,12 @@ chef-page-header { .sync-button { float: right; margin-bottom: 0; + + .img-inline { + width: 17px; + height: 17px; + margin-right: 5px; + } } } } @@ -124,6 +131,7 @@ th { tbody > .detail-row { padding-top: 0px; padding-bottom: 15px; + height: 50px; } .detail-row { diff --git a/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.ts b/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.ts index 8e5ed919484..3f02179bf48 100644 --- a/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.ts +++ b/components/automate-ui/src/app/modules/infra-proxy/chef-server-details/chef-server-details.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy, EventEmitter } from '@angular/core'; +import { Component, OnInit, OnDestroy, EventEmitter, ViewChild } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MatOptionSelectionChange } from '@angular/material/core/option'; import { Router } from '@angular/router'; @@ -31,16 +31,19 @@ import { ValidateWebUIKey // , GetUsers } from 'app/entities/servers/server.actions'; -import { GetOrgs, CreateOrg, DeleteOrg } from 'app/entities/orgs/org.actions'; +import { GetOrgs, CreateOrg, DeleteOrg, UploadZip } from 'app/entities/orgs/org.actions'; import { Org } from 'app/entities/orgs/org.model'; import { createStatus, createError, allOrgs, getAllStatus as getAllOrgsForServerStatus, - deleteStatus as deleteOrgStatus + deleteStatus as deleteOrgStatus, + uploadStatus, + uploadDetails } from 'app/entities/orgs/org.selectors'; import { ProjectConstants } from 'app/entities/projects/project.model'; +import { SyncOrgUsersSliderComponent } from '../sync-org-users-slider/sync-org-users-slider.component'; export type ChefServerTabName = 'orgs' | 'users' | 'details'; @Component({ @@ -80,12 +83,17 @@ export class ChefServerDetailsComponent implements OnInit, OnDestroy { public isServerLoaded = false; public validating = true; - public updateWebuiKeyForm: FormGroup; public updatingWebuiKey = false; public webuiKey: WebUIKey; public updateWebUIKeySuccessful = false; + public uploadZipForm: FormGroup; + public isUploaded = false; + public migrationID: string; + + @ViewChild('upload', { static: false }) upload: SyncOrgUsersSliderComponent; + constructor( private fb: FormBuilder, private store: Store, @@ -144,6 +152,9 @@ export class ChefServerDetailsComponent implements OnInit, OnDestroy { Validators.pattern(Regex.patterns.VALID_IP_ADDRESS) ]] }); + this.uploadZipForm = this.fb.group({ + file: ['', [Validators.required]] + }); this.store.select(routeParams).pipe( pluck('id'), @@ -255,7 +266,23 @@ export class ChefServerDetailsComponent implements OnInit, OnDestroy { } }); - setTimeout(() => { + combineLatest([ + this.store.select(uploadStatus), + this.store.select(uploadDetails) + ]).pipe(takeUntil(this.isDestroyed)) + .subscribe(([uploadStatusSt, uploadDetailsState]) => { + if (uploadStatusSt === EntityStatus.loadingSuccess && !isNil(uploadDetailsState)) { + // show migration slider + this.isUploaded = true; + this.migrationID = uploadDetailsState.migrationId; + } else if (uploadStatusSt === EntityStatus.loadingFailure) { + // close upload slider with error notification + this.isUploaded = false; + this.upload.closeUploadSlider(); + } + }); + + setTimeout(() => { if (this.isServerLoaded) { this.validateWebUIKey(this.server); } @@ -361,4 +388,17 @@ export class ChefServerDetailsComponent implements OnInit, OnDestroy { private updatingWebuiKeyData(webuikey: WebUIKey) { this.store.dispatch(new UpdateWebUIKey(webuikey)); } + + // upload zip slider functions + public uploadZipFile(file: File): void { + const formData: FormData = new FormData(); + if (file) { + formData.append('server_id', this.server.id); + formData.append('file', file); + } + const uploadZipPayload = { + formData: formData + }; + this.store.dispatch(new UploadZip( uploadZipPayload )); + } } diff --git a/components/automate-ui/src/app/modules/infra-proxy/drag-drop/drag-drop.directive.spec.ts b/components/automate-ui/src/app/modules/infra-proxy/drag-drop/drag-drop.directive.spec.ts new file mode 100644 index 00000000000..0d39e1f01cd --- /dev/null +++ b/components/automate-ui/src/app/modules/infra-proxy/drag-drop/drag-drop.directive.spec.ts @@ -0,0 +1,8 @@ +import { DragDropDirective } from './drag-drop.directive'; + +describe('DragDropDirective', () => { + it('should create an instance', () => { + const directive = new DragDropDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/components/automate-ui/src/app/modules/infra-proxy/drag-drop/drag-drop.directive.ts b/components/automate-ui/src/app/modules/infra-proxy/drag-drop/drag-drop.directive.ts new file mode 100644 index 00000000000..74332c2bf35 --- /dev/null +++ b/components/automate-ui/src/app/modules/infra-proxy/drag-drop/drag-drop.directive.ts @@ -0,0 +1,47 @@ +import { + Directive, + Output, + EventEmitter, + HostBinding, + HostListener +} from '@angular/core'; + +@Directive({ + selector: '[appDragDrop]' +}) +export class DragDropDirective { + @HostBinding('class.fileover') fileOver: boolean; + @Output() fileDropped = new EventEmitter(); + + // Dragover listener + @HostListener('dragover', ['$event']) onDragOver( + evt: { preventDefault: () => void; + stopPropagation: () => void; }) { + evt.preventDefault(); + evt.stopPropagation(); + this.fileOver = true; + } + + // Dragleave listener + @HostListener('dragleave', ['$event']) public onDragLeave( + evt: { preventDefault: () => void; + stopPropagation: () => void; }) { + evt.preventDefault(); + evt.stopPropagation(); + this.fileOver = false; + } + + // Drop listener + @HostListener('drop', ['$event']) public ondrop( + evt: { preventDefault: () => void; + stopPropagation: () => void; + dataTransfer: { files: any; }; }) { + evt.preventDefault(); + evt.stopPropagation(); + this.fileOver = false; + const files = evt.dataTransfer.files; + if (files.length > 0) { + this.fileDropped.emit(files); + } + } +} diff --git a/components/automate-ui/src/app/modules/infra-proxy/infra-proxy.module.ts b/components/automate-ui/src/app/modules/infra-proxy/infra-proxy.module.ts index 89089b750e9..7ecc99f728c 100644 --- a/components/automate-ui/src/app/modules/infra-proxy/infra-proxy.module.ts +++ b/components/automate-ui/src/app/modules/infra-proxy/infra-proxy.module.ts @@ -23,6 +23,7 @@ import { CreateInfraRoleModalComponent } from './create-infra-role-modal/create- import { DataBagsDetailsComponent } from './data-bags-details/data-bags-details.component'; import { DataBagsListComponent } from './data-bags-list/data-bags-list.component'; import { DeleteInfraObjectModalComponent } from './delete-infra-object-modal/delete-infra-object-modal.component'; +import { DragDropDirective } from './drag-drop/drag-drop.directive'; import { EditDataBagItemModalComponent } from './edit-data-bag-item-modal/edit-data-bag-item-modal.component'; import { EditEnvironmentAttributeModalComponent } from './edit-environment-attribute-modal/edit-environment-attribute-modal.component'; import { EditInfraNodeModalComponent } from './edit-infra-node-modal/edit-infra-node-modal.component'; @@ -59,9 +60,11 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatPaginatorModule } from '@angular/material/paginator'; import { MatTabsModule } from '@angular/material/tabs'; +import { MigrationSliderComponent } from './migration-slider/migration-slider.component'; import { NgSelectModule } from '@ng-select/ng-select'; import { PaginatorComponent } from './paginator/paginator.component'; import { SelectBoxModule } from './select-box/src/public_api'; +import { SyncOrgUsersSliderComponent } from './sync-org-users-slider/sync-org-users-slider.component'; import { TreeTableModule } from './tree-table/tree-table.module'; import { UpdateWebUIKeySliderComponent } from './update-web-uikey-slider/update-web-uikey-slider.component'; @@ -85,6 +88,7 @@ import { UpdateWebUIKeySliderComponent } from './update-web-uikey-slider/update- DataBagsDetailsComponent, DataBagsListComponent, DeleteInfraObjectModalComponent, + DragDropDirective, EditDataBagItemModalComponent, EditEnvironmentAttributeModalComponent, EditInfraNodeModalComponent, @@ -103,9 +107,11 @@ import { UpdateWebUIKeySliderComponent } from './update-web-uikey-slider/update- InfraSearchBarComponent, InfraTabComponent, InfraTabChangeComponent, + MigrationSliderComponent, OrgDetailsComponent, OrgEditComponent, PaginationComponent, + PaginatorComponent, PolicyFilesComponent, PolicyFileDetailsComponent, PolicyGroupsComponent, @@ -115,9 +121,9 @@ import { UpdateWebUIKeySliderComponent } from './update-web-uikey-slider/update- ResetClientKeyComponent, ResetNodeKeyComponent, RevisionIdComponent, + SyncOrgUsersSliderComponent, UpdateNodeTagModalComponent, - UpdateWebUIKeySliderComponent, - PaginatorComponent + UpdateWebUIKeySliderComponent ], imports: [ CommonModule, diff --git a/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.html b/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.html new file mode 100644 index 00000000000..221db8368f0 --- /dev/null +++ b/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.html @@ -0,0 +1,30 @@ +
+
+ + arrow_back + +

Migration

+ + close + +
+
+
+ +
+ +
+
+ + Cancel + + + + Continue + Uploading File ... + +
+ +
+
+
diff --git a/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.scss b/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.scss new file mode 100644 index 00000000000..e57263377a6 --- /dev/null +++ b/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.scss @@ -0,0 +1,27 @@ +@import "~styles/variables"; +@import "../infra_shared/slide-panel.scss"; + +:host { + z-index: 2; + width: 80%; + box-shadow: 0 35.8px 140.2px $chef-gulf-blue; + padding: 25px; + + .sidenav-header { + margin-bottom: 5vh; + + h2 { + flex-grow: 2; + font-size: 18px; + font-weight: bold; + letter-spacing: 0.8px; + color: $chef-primary-dark; + line-height: 27px; + margin-bottom: 10px; + } + + .back-button { + margin-left: 0; + } + } +} diff --git a/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.spec.ts b/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.spec.ts new file mode 100644 index 00000000000..262682b3a10 --- /dev/null +++ b/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MigrationSliderComponent } from './migration-slider.component'; + +describe('MigrationSliderComponent', () => { + let component: MigrationSliderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ MigrationSliderComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(MigrationSliderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.ts b/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.ts new file mode 100644 index 00000000000..0cc1902cd3e --- /dev/null +++ b/components/automate-ui/src/app/modules/infra-proxy/migration-slider/migration-slider.component.ts @@ -0,0 +1,30 @@ +import { Component, HostBinding } from '@angular/core'; + +@Component({ + selector: 'app-migration-slider', + templateUrl: './migration-slider.component.html', + styleUrls: ['./migration-slider.component.scss'] +}) +export class MigrationSliderComponent { + public migrating = false; + @HostBinding('class.active') isSlideOpen1 = false; + + constructor() { } + + closeMigrationSlider() { + this.toggleSlide(); + } + + toggleSlide() { + this.isSlideOpen1 = !this.isSlideOpen1; + } + + migrationFile() { + this.toggleSlide(); + } + + slidePanel() { + console.log('migration part'); + this.isSlideOpen1 = true; + } +} diff --git a/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.html b/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.html new file mode 100644 index 00000000000..a122dd44e41 --- /dev/null +++ b/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.html @@ -0,0 +1,50 @@ +
+ +
+ + arrow_back + +

Sync Org and User

+ + close + +
+
+
+
+
+
+ {{ file.name }} +
+
+ + upload-file +

Drag you Zip file here

+

OR

+ Browse file +
+
+
+ Cancel + + + upload_icon + + Upload File + Uploading File ... + +
+
+
+ + +
+
diff --git a/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.scss b/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.scss new file mode 100644 index 00000000000..431eb5191cc --- /dev/null +++ b/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.scss @@ -0,0 +1,134 @@ +@import "~styles/variables"; +@import '../infra_shared/slide-panel'; + +:host { + z-index: 1; + width: 50%; + box-shadow: 0 35.8px 140.2px $chef-gulf-blue; + padding: 25px; + + .sidenav-header { + margin-bottom: 8vh; + + h2 { + flex-grow: 2; + font-size: 18px; + font-weight: bold; + letter-spacing: 0.8px; + color: $chef-primary-dark; + line-height: 27px; + margin-bottom: 10px; + } + + .back-button { + margin-left: 0; + } + } + + .flex-container { + margin-top: 28px; + justify-content: center; + + .input-margin { + margin-bottom: 33px; + + .file-info { + display: block; + height: 36px; + padding: 6px; + background-color: $chef-white; + font-size: 14px; + border: 1px solid $chef-light-grey; + border-radius: 4px; + } + } + + form { + flex-basis: 60%; + + .chef-input { + font-size: 13px; + height: 36px; + padding: 10px; + } + } + + #button-bar { + text-align: right; + + chef-loading-spinner { + position: relative; + top: 2px; + margin-right: 5px; + } + + .svg-inline { + width: 17px; + height: 17px; + margin-right: 5px; + } + } + } + + chef-button { + height: 36px; + margin-top: 0; + + .close { + margin: 0; + border: none; + color: var(--chef-primary-dark); + outline: none; + + &:active { + background: none; + color: var(--chef-primary-dark); + } + + } + } + + .container { + margin: 0 auto; + width: 100%; + padding: 2rem; + text-align: center; + border: dashed 1px $chef-form-input; + position: relative; + + input { + opacity: 0; + position: absolute; + z-index: 2; + width: 100%; + height: 100%; + top: 0; + left: 0; + cursor: pointer; + } + + h4 { + font-size: 14px; + line-height: 21px; + letter-spacing: 0.8px; + + &.grayText { + margin: 10px auto 20px; + color: $chef-light-grey; + width: 50%; + text-align: center; + border-bottom: 1px solid $chef-grey; + line-height: 0.1em; + + span { + background: $chef-white; + padding: 0 10px; + } + } + } + + img { + width: 35px; + } + } +} diff --git a/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.spec.ts b/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.spec.ts new file mode 100644 index 00000000000..2ae1213aa64 --- /dev/null +++ b/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.spec.ts @@ -0,0 +1,55 @@ +import { EventEmitter } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators, FormGroup } from '@angular/forms'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MockComponent } from 'ng2-mock-component'; + +import { SyncOrgUsersSliderComponent } from './sync-org-users-slider.component'; + +describe('SyncOrgUsersSliderComponent', () => { + let component: SyncOrgUsersSliderComponent; + let fixture: ComponentFixture; + + let uploadForm: FormGroup; + + beforeEach( waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + MockComponent({ selector: 'chef-button', inputs: ['disabled'] }), + MockComponent({ selector: 'input'}), + MockComponent({ selector: 'chef-loading-spinner' }), + SyncOrgUsersSliderComponent + ], + imports: [ + ReactiveFormsModule + ], + schemas: [ CUSTOM_ELEMENTS_SCHEMA ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SyncOrgUsersSliderComponent); + component = fixture.componentInstance; + + component.uploadForm = new FormBuilder().group({ + file: ['', [Validators.required]] + }); + + component.conflictErrorEvent = new EventEmitter(); + uploadForm = component.uploadForm; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('form validity', () => { + describe('the form should be invalid', () => { + it('when file inputs are empty', () => { + expect(uploadForm.valid).toBeFalsy(); + }); + }); + }); +}); diff --git a/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.ts b/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.ts new file mode 100644 index 00000000000..791d3c206e9 --- /dev/null +++ b/components/automate-ui/src/app/modules/infra-proxy/sync-org-users-slider/sync-org-users-slider.component.ts @@ -0,0 +1,106 @@ +import { Component, EventEmitter, Input, Output, OnInit, HostBinding, ViewChild, ElementRef } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { IdMapper } from 'app/helpers/auth/id-mapper'; +import { Utilities } from 'app/helpers/utilities/utilities'; + +@Component({ + selector: 'app-sync-org-users-slider', + templateUrl: './sync-org-users-slider.component.html', + styleUrls: ['./sync-org-users-slider.component.scss'] +}) +export class SyncOrgUsersSliderComponent implements OnInit { + @Input() isUploaded = false; + @Input() uploadForm: FormGroup; + @Input() conflictErrorEvent: EventEmitter; + @Output() uploadClicked = new EventEmitter(); + @HostBinding('class.active') isSlideOpen = false; + + public uploading = false; + public conflictError = false; + public migrationSliderVisible = false; + public migrationSlider = false; + + @ViewChild('fileDropRef', { static: false }) fileDropEl: ElementRef; + public file: File = null; + public isFile = false; + + constructor() { } + + ngOnInit(): void { + this.conflictErrorEvent.subscribe((isConflict: boolean) => { + this.conflictError = isConflict; + // Open the ID input on conflict so user can resolve it. + }); + } + + handleNameInput(event: KeyboardEvent): void { + if (!Utilities.isNavigationKey(event)) { + this.conflictError = false; + this.uploadForm.controls.id.setValue( + IdMapper.transform(this.uploadForm.controls.file.value.trim())); + } + } + + uploadFile(): void { + this.uploading = true; + const formData = new FormData(); + this.file = this.uploadForm.get('file').value; + formData.append('file', this.file); + this.uploadClicked.emit(this.file); + this.migrationSlider = true; + this.closeUploadSlider(); + } + + closeUploadSlider() { + this.uploading = false; + this.isUploaded = false; + this.toggleSlide(); + this.file = null; + this.isFile = false; + this.isSlideOpen = false; + } + + toggleSlide() { + this.isSlideOpen = !this.isSlideOpen; + } + + slidePanel() { + this.isSlideOpen = true; + } + + public openMigrationSlider(): void { + this.migrationSliderVisible = true; + this.resetMigrationSlider(); + } + + public closeMigrationSlider(): void { + this.migrationSliderVisible = false; + this.resetMigrationSlider(); + } + + private resetMigrationSlider(): void { + this.conflictErrorEvent.emit(false); + } + + /*** on file drop handler */ + onFileDropped($event: any[]) { + this.prepareFilesList($event); + } + + /*** handle file from browsing */ + fileBrowseHandler(file: any) { + this.prepareFilesList(file); + } + + prepareFilesList(file: any) { + this.file = file[0]; + this.uploadForm.get('file').setValue(this.file); + if ( this.file.type !== 'application/zip' ) { + this.isFile = false; + this.file = null; + } else { + this.isFile = true; + } + this.fileDropEl.nativeElement.value = ''; + } +} diff --git a/components/automate-ui/src/app/services/http/http-client-auth.interceptor.ts b/components/automate-ui/src/app/services/http/http-client-auth.interceptor.ts index 3d03fadc42f..cdfd3c58060 100644 --- a/components/automate-ui/src/app/services/http/http-client-auth.interceptor.ts +++ b/components/automate-ui/src/app/services/http/http-client-auth.interceptor.ts @@ -39,7 +39,12 @@ export class HttpClientAuthInterceptor implements HttpInterceptor { // request payloads in many places. We should not send any of those. But // Fixing that blows the scope of my little API validation adventure, so // we take a shortcut: ask the API not to be too strict on us. - headers = headers.set('Content-Type', 'application/json+lax'); + + // Infra Proxy Server API - restrict to set the headers for migration file upload + if (!request.url.includes('/migrations/upload')) { + headers = headers.set('Content-Type', 'application/json+lax'); + } + // Check and then remove `unfiltered` param; it is a piggybacked parameter // needed by this interceptor, not to be passed on. // It allows certain URLs to suppress sending the projects filter. diff --git a/components/automate-ui/src/assets/img/sync_arrow.png b/components/automate-ui/src/assets/img/sync_arrow.png new file mode 100644 index 00000000000..8cd565fdf6c Binary files /dev/null and b/components/automate-ui/src/assets/img/sync_arrow.png differ diff --git a/components/automate-ui/src/assets/img/upload-file.png b/components/automate-ui/src/assets/img/upload-file.png new file mode 100644 index 00000000000..5d57e3f4351 Binary files /dev/null and b/components/automate-ui/src/assets/img/upload-file.png differ diff --git a/e2e/cypress/fixtures/infra-proxy/backup.zip b/e2e/cypress/fixtures/infra-proxy/backup.zip new file mode 100644 index 00000000000..d2db55154ec Binary files /dev/null and b/e2e/cypress/fixtures/infra-proxy/backup.zip differ diff --git a/e2e/cypress/integration/ui/infra-proxy/upload-file-slider.spec.ts b/e2e/cypress/integration/ui/infra-proxy/upload-file-slider.spec.ts new file mode 100644 index 00000000000..837c2c43f85 --- /dev/null +++ b/e2e/cypress/integration/ui/infra-proxy/upload-file-slider.spec.ts @@ -0,0 +1,77 @@ +describe('chef server details', () => { + let adminIdToken = ''; + const now = Cypress.moment().format('MMDDYYhhmm'); + const cypressPrefix = 'infra'; + const serverName = `${cypressPrefix} server ${now}`; + const serverID = serverName.split(' ').join('-'); + const serverFQDN = 'https://ec2-18-117-112-129.us-east-2.compute.amazonaws.com'; + const serverIP = '18-117-112-129'; + const webuiKey = Cypress.env('AUTOMATE_INFRA_WEBUI_KEY').replace(/\\n/g, '\n'); + + before(() => { + cy.adminLogin('/').then(() => { + const admin = JSON.parse(localStorage.getItem('chef-automate-user')); + adminIdToken = admin.id_token; + + cy.request({ + auth: { bearer: adminIdToken }, + method: 'POST', + url: '/api/v0/infra/servers', + body: { + id: serverID, + name: serverName, + fqdn: serverFQDN, + ip_address: serverIP, + webui_key: webuiKey + } + }); + + cy.visit(`/infrastructure/chef-servers/${serverID}`); + cy.get('app-welcome-modal').invoke('hide'); + }); + + cy.restoreStorage(); + }); + + beforeEach(() => { + cy.restoreStorage(); + }); + + afterEach(() => { + cy.saveStorage(); + }); + + describe('chef server details page', () => { + it('displays server details', () => { + cy.get('chef-breadcrumbs').contains('Chef Infra Servers'); + cy.get('chef-breadcrumbs').contains(serverName); + cy.get('.page-title').contains(serverName); + cy.get('[data-cy=add-org-button]').contains('Add Chef Organization'); + }); + + it('click Sync Org and User button and pressing cancel button to close slider', () => { + cy.get('[data-cy=sync-org-and-users]').contains('Sync Org and Users').click(); + cy.get('[data-cy=title]').contains('Sync Org and User'); + cy.get('[data-cy=cancel]').click({multiple: true, force: true}); + }); + + it('check if Upload button is disabled before entering file', () => { + cy.get('[data-cy=sync-org-and-users]').contains('Sync Org and Users').click(); + cy.get('[data-cy=title]').contains('Sync Org and User'); + cy.get('[data-cy=upload-button]') + .invoke('attr', 'disabled') + .then(disabled => { + disabled ? cy.log('buttonIsDiabled') : cy.get('[data-cy=upload-button]').click(); + }); + cy.get('[data-cy=cancel]').click({multiple: true, force: true}); + }); + + it('Upload compressed file', () => { + cy.get('[data-cy=sync-org-and-users]').contains('Sync Org and Users').click(); + cy.get('[data-cy=title]').contains('Sync Org and User'); + cy.get('[data-cy="file-input"]') + .attachFile('infra-proxy/backup.zip'); + cy.get('[data-cy=upload-button').click(); + }); + }); +}); diff --git a/e2e/cypress/support/commands.ts b/e2e/cypress/support/commands.ts index 14d5596266b..e34222fedae 100644 --- a/e2e/cypress/support/commands.ts +++ b/e2e/cypress/support/commands.ts @@ -1,4 +1,5 @@ import { eventExist } from '../support/helpers'; +import 'cypress-file-upload'; // Cypress Commands: any action that could be taken in any test // any command added in here must also have its signature added to index.d.ts diff --git a/e2e/package.json b/e2e/package.json index b9a0a18b364..a939182d477 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@cypress/webpack-preprocessor": "^4.1.0", "@types/js-base64": "^2.3.1", + "cypress-file-upload": "^5.0.8", "js-base64": "^2.5.1", "ts-loader": "^6.0.4", "tslint": "^5.18.0", diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 7436fd4a405..25f882e704b 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -9,7 +9,8 @@ "dom" ], "types": [ - "cypress" + "cypress", + "cypress-file-upload" ], "typeRoots": [ "./support"