"node_modules/ng2-toastr/bundles/ng2-toastr.min.css",
"node_modules/fork-awesome/css/fork-awesome.css",
"node_modules/awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css",
+ "node_modules/ngx-bootstrap/datepicker/bs-datepicker.css",
"src/styles.scss"
],
"scripts": [
import { RouterModule } from '@angular/router';
import { BsDropdownModule, ModalModule, TabsModule, TooltipModule } from 'ngx-bootstrap';
+import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
import { ProgressbarModule } from 'ngx-bootstrap/progressbar';
import { SharedModule } from '../../shared/shared.module';
import { RbdListComponent } from './rbd-list/rbd-list.component';
import { RbdSnapshotFormComponent } from './rbd-snapshot-form/rbd-snapshot-form.component';
import { RbdSnapshotListComponent } from './rbd-snapshot-list/rbd-snapshot-list.component';
+import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal/rbd-trash-move-modal.component';
@NgModule({
- entryComponents: [RbdDetailsComponent, RbdSnapshotFormComponent],
+ entryComponents: [
+ RbdDetailsComponent,
+ RbdSnapshotFormComponent,
+ RbdTrashMoveModalComponent
+ ],
imports: [
CommonModule,
FormsModule,
TabsModule.forRoot(),
ProgressbarModule.forRoot(),
BsDropdownModule.forRoot(),
+ BsDatepickerModule.forRoot(),
TooltipModule.forRoot(),
ModalModule.forRoot(),
SharedModule,
RbdDetailsComponent,
RbdFormComponent,
RbdSnapshotListComponent,
- RbdSnapshotFormComponent
+ RbdSnapshotFormComponent,
+ RbdTrashMoveModalComponent
]
})
export class BlockModule {}
permissionHelper.testScenarios(scenario));
it('shows all actions', () => {
- expect(tableActions.tableActions.length).toBe(5);
+ expect(tableActions.tableActions.length).toBe(6);
expect(tableActions.tableActions).toEqual(component.tableActions);
});
});
it(`shows 'Edit' for single selection else 'Add' as main action`, () =>
permissionHelper.testScenarios(scenario));
- it(`shows all actions except for 'Delete'`, () => {
+ it(`shows all actions except for 'Delete' and 'Move'`, () => {
expect(tableActions.tableActions.length).toBe(4);
component.tableActions.pop();
+ component.tableActions.pop();
expect(tableActions.tableActions).toEqual(component.tableActions);
});
});
permissionHelper.testScenarios(scenario);
});
- it(`shows 'Add', 'Copy' and 'Delete' action`, () => {
- expect(tableActions.tableActions.length).toBe(3);
+ it(`shows 'Add', 'Copy', 'Delete' and 'Move' action`, () => {
+ expect(tableActions.tableActions.length).toBe(4);
expect(tableActions.tableActions).toEqual([
component.tableActions[0],
component.tableActions[2],
- component.tableActions[4]
+ component.tableActions[4],
+ component.tableActions[5]
]);
});
});
permissionHelper.testScenarios(scenario);
});
- it(`shows 'Edit', 'Flatten' and 'Delete' action`, () => {
- expect(tableActions.tableActions.length).toBe(3);
+ it(`shows 'Edit', 'Flatten', 'Delete' and 'Move' action`, () => {
+ expect(tableActions.tableActions.length).toBe(4);
expect(tableActions.tableActions).toEqual([
component.tableActions[1],
component.tableActions[3],
- component.tableActions[4]
+ component.tableActions[4],
+ component.tableActions[5]
]);
});
});
permissionHelper.testScenarios(scenario);
});
- it(`shows only 'Delete' action`, () => {
- expect(tableActions.tableActions.length).toBe(1);
- expect(tableActions.tableActions).toEqual([component.tableActions[4]]);
+ it(`shows 'Delete' and 'Move' actions`, () => {
+ expect(tableActions.tableActions.length).toBe(2);
+ expect(tableActions.tableActions).toEqual([
+ component.tableActions[4],
+ component.tableActions[5]
+ ]);
});
});
import { TaskListService } from '../../../shared/services/task-list.service';
import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
import { RbdParentModel } from '../rbd-form/rbd-parent.model';
+import { RbdTrashMoveModalComponent } from '../rbd-trash-move-modal/rbd-trash-move-modal.component';
import { RbdModel } from './rbd-model';
@Component({
click: () => this.flattenRbdModal(),
name: 'Flatten'
};
- this.tableActions = [addAction, editAction, copyAction, flattenAction, deleteAction];
+ const moveAction: CdTableAction = {
+ permission: 'delete',
+ disable: (selection: CdTableSelection) =>
+ !selection.hasSingleSelection || selection.first().cdExecuting,
+ icon: 'fa-trash-o',
+ click: () => this.trashRbdModal(),
+ name: 'Move to Trash'
+ };
+ this.tableActions = [
+ addAction,
+ editAction,
+ copyAction,
+ flattenAction,
+ deleteAction,
+ moveAction
+ ];
}
ngOnInit() {
'rbd/create',
'rbd/delete',
'rbd/edit',
- 'rbd/flatten'
+ 'rbd/flatten',
+ 'rbd/trash/move'
].includes(task.name);
}
});
}
+ trashRbdModal() {
+ const initialState = {
+ metaType: 'RBD',
+ poolName: this.selection.first().pool_name,
+ imageName: this.selection.first().name
+ };
+ this.modalRef = this.modalService.show(RbdTrashMoveModalComponent, { initialState });
+ }
+
flattenRbd(poolName, imageName) {
this.taskWrapper
.wrapTaskAroundCall({
--- /dev/null
+<cd-modal>
+ <ng-container i18n
+ class="modal-title">Move an image to trash</ng-container>
+
+ <ng-container class="modal-content">
+ <form name="moveForm"
+ class="form"
+ #formDir="ngForm"
+ [formGroup]="moveForm"
+ novalidate>
+ <div class="modal-body">
+ <p>
+ <ng-container i18n>To move</ng-container>
+ <kbd>{{ poolName }}/{{ imageName }}</kbd>
+ <ng-container i18n>to trash, click</ng-container>
+ <kbd i18n>Move Image</kbd>.
+ <ng-container i18n>Optionally, you can pick an expiration date.</ng-container>
+ </p>
+
+ <div class="form-group"
+ [ngClass]="{'has-error': moveForm.showError('expiresAt', formDir)}">
+ <label for="expires"
+ i18n>Protection expires at</label>
+ <input type="text"
+ placeholder="NOT PROTECTED"
+ i18n-placeholder
+ class="form-control"
+ [minDate]="minDate"
+ [bsConfig]="bsConfig"
+ formControlName="expiresAt"
+ bsDatepicker>
+ <span i18n
+ class="help-block"
+ *ngIf="moveForm.showError('expiresAt', formDir, 'format')">
+ Wrong date format. Please use "YYYY-MM-DD HH:mm:ss".
+ </span>
+ <span i18n
+ class="help-block"
+ *ngIf="moveForm.showError('expiresAt', formDir, 'expired')">
+ Protection has already expired. Please pick a future date or leave it empty.
+ </span>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <div class="button-group text-right">
+ <cd-submit-button i18n
+ [form]="moveForm"
+ (submitAction)="moveImage()">
+ Move Image
+ </cd-submit-button>
+ <button i18n
+ type="button"
+ class="btn btn-sm btn-default"
+ (click)="modalRef.hide()">Cancel</button>
+ </div>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
--- /dev/null
+// Temprary fix until ngx-bootstrap merges: https://github.com/valor-software/ngx-bootstrap/pull/4509
+::ng-deep .bs-datepicker-head bs-datepicker-navigation-view {
+ display: flex;
+ justify-content: space-between;
+}
--- /dev/null
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import * as moment from 'moment';
+import { ToastModule } from 'ng2-toastr';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap';
+import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
+
+import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { ApiModule } from '../../../shared/api/api.module';
+import { NotificationService } from '../../../shared/services/notification.service';
+import { ServicesModule } from '../../../shared/services/services.module';
+import { SharedModule } from '../../../shared/shared.module';
+import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal.component';
+
+describe('RbdTrashMoveModalComponent', () => {
+ let component: RbdTrashMoveModalComponent;
+ let fixture: ComponentFixture<RbdTrashMoveModalComponent>;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ SharedModule,
+ ServicesModule,
+ ApiModule,
+ ToastModule.forRoot(),
+ BsDatepickerModule.forRoot()
+ ],
+ declarations: [RbdTrashMoveModalComponent],
+ providers: [BsModalRef, BsModalService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdTrashMoveModalComponent);
+ component = fixture.componentInstance;
+ httpTesting = TestBed.get(HttpTestingController);
+
+ component.metaType = 'RBD';
+ component.poolName = 'foo';
+ component.imageName = 'bar';
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(component.moveForm).toBeDefined();
+ });
+
+ it('should finish running ngOnInit', () => {
+ fixture.detectChanges();
+ expect(component.pattern).toEqual('foo/bar');
+ });
+
+ describe('should call moveImage', () => {
+ let notificationService;
+
+ beforeEach(() => {
+ notificationService = TestBed.get(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+ spyOn(component.modalRef, 'hide').and.callThrough();
+ });
+
+ afterEach(() => {
+ expect(notificationService.show).toHaveBeenCalledTimes(1);
+ expect(component.modalRef.hide).toHaveBeenCalledTimes(1);
+ });
+
+ it('with normal delay', () => {
+ component.moveImage();
+ const req = httpTesting.expectOne('api/block/image/foo/bar/move_trash');
+ req.flush(null);
+ expect(req.request.body).toEqual({ delay: 0 });
+ });
+
+ it('with delay < 0', () => {
+ const oldDate = moment()
+ .subtract(24, 'hour')
+ .toDate();
+ component.moveForm.patchValue({ expiresAt: oldDate });
+
+ component.moveImage();
+ const req = httpTesting.expectOne('api/block/image/foo/bar/move_trash');
+ req.flush(null);
+ expect(req.request.body).toEqual({ delay: 0 });
+ });
+
+ it('with delay < 0', () => {
+ const oldDate = moment()
+ .add(24, 'hour')
+ .toISOString();
+ fixture.detectChanges();
+ component.moveForm.patchValue({ expiresAt: oldDate });
+
+ component.moveImage();
+ const req = httpTesting.expectOne('api/block/image/foo/bar/move_trash');
+ req.flush(null);
+ expect(req.request.body.delay).toBeGreaterThan(86390);
+ });
+ });
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+
+import * as moment from 'moment';
+import { BsModalRef } from 'ngx-bootstrap';
+
+import { RbdService } from '../../../shared/api/rbd.service';
+import { CdFormBuilder } from '../../../shared/forms/cd-form-builder';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { CdValidators } from '../../../shared/forms/cd-validators';
+import { ExecutingTask } from '../../../shared/models/executing-task';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-rbd-trash-move-modal',
+ templateUrl: './rbd-trash-move-modal.component.html',
+ styleUrls: ['./rbd-trash-move-modal.component.scss']
+})
+export class RbdTrashMoveModalComponent implements OnInit {
+ metaType: string;
+ poolName: string;
+ imageName: string;
+ executingTasks: ExecutingTask[];
+
+ moveForm: CdFormGroup;
+ minDate = new Date();
+ bsConfig = {
+ dateInputFormat: 'YYYY-MM-DD HH:mm:ss',
+ containerClass: 'theme-default'
+ };
+ pattern: string;
+
+ constructor(
+ private rbdService: RbdService,
+ public modalRef: BsModalRef,
+ private fb: CdFormBuilder,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.createForm();
+ }
+
+ createForm() {
+ this.moveForm = this.fb.group({
+ expiresAt: [
+ '',
+ [
+ CdValidators.custom('format', (expiresAt) => {
+ const result = expiresAt === '' || moment(expiresAt, 'YYYY-MM-DD HH:mm:ss').isValid();
+ return !result;
+ }),
+ CdValidators.custom('expired', (expiresAt) => {
+ const result = moment().isAfter(expiresAt);
+ return result;
+ })
+ ]
+ ]
+ });
+ }
+
+ ngOnInit() {
+ this.pattern = `${this.poolName}/${this.imageName}`;
+ }
+
+ moveImage() {
+ let delay = 0;
+ const expiresAt = this.moveForm.getValue('expiresAt');
+
+ if (expiresAt) {
+ delay = moment(expiresAt).diff(moment(), 'seconds', true);
+ }
+
+ if (delay < 0) {
+ delay = 0;
+ }
+
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/trash/move', {
+ pool_name: this.poolName,
+ image_name: this.imageName
+ }),
+ call: this.rbdService.moveTrash(this.poolName, this.imageName, delay)
+ })
+ .subscribe(undefined, undefined, () => {
+ this.modalRef.hide();
+ });
+ }
+}
const req = httpTesting.expectOne('api/block/image/poolName/rbdName/snap/snapshotName');
expect(req.request.method).toBe('DELETE');
});
+
+ it('should call moveTrash', () => {
+ service.moveTrash('poolName', 'rbdName', 1).subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName/rbdName/move_trash');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ delay: 1 });
+ });
});
observe: 'response'
});
}
+
+ moveTrash(poolName, rbdName, delay) {
+ return this.http.post(
+ `api/block/image/${poolName}/${rbdName}/move_trash`,
+ { delay: delay },
+ { observe: 'response' }
+ );
+ }
}
'rbd/snap/rollback': new TaskMessage(
new TaskMessageOperation('Rolling back', 'rollback', 'Rolled back'),
this.rbd.snapshot
+ ),
+ 'rbd/trash/move': new TaskMessage(
+ new TaskMessageOperation('Moving', 'move', 'Moved'),
+ (metadata) => `image '${metadata.pool_name}/${metadata.image_name}' to trash`,
+ () => ({
+ 2: `Could not find image.`
+ })
)
};