diff --git a/test/app/components/base/form/complex-form/complex-form.spec.ts b/app/components/base/form/complex-form/complex-form.component.spec.ts similarity index 97% rename from test/app/components/base/form/complex-form/complex-form.spec.ts rename to app/components/base/form/complex-form/complex-form.component.spec.ts index ed752116c7..8668237065 100644 --- a/test/app/components/base/form/complex-form/complex-form.spec.ts +++ b/app/components/base/form/complex-form/complex-form.component.spec.ts @@ -9,6 +9,7 @@ import { ButtonComponent } from "app/components/base/buttons"; import { ComplexFormComponent, FormPageComponent, FormPickerComponent, FormSectionComponent, } from "app/components/base/form"; +import { FormFooterComponent } from "app/components/base/form/complex-form/footer"; import { ServerErrorComponent } from "app/components/base/form/server-error"; import { ServerError } from "app/models"; import { AuthorizationHttpService } from "app/services"; @@ -98,6 +99,7 @@ describe("ComplexFormComponent", () => { FormPageComponent, FormSectionComponent, FormPickerComponent, + FormFooterComponent, ], providers: [ { provide: AuthorizationHttpService, useValue: null }, @@ -205,8 +207,8 @@ describe("ComplexFormComponent", () => { }); it("should toggle the error when clicking the warning button", () => { - const toggleBtn = de.query(By.css(".toggle-error-btn > button")); - expect(toggleBtn).not.toBeFalsy(); + const toggleBtn = de.query(By.css("bl-form-footer .toggle-error-btn > button")); + expect(toggleBtn).not.toBeFalsy("Error toggle button should be defined"); // Toggle hidden click(toggleBtn); diff --git a/app/components/base/form/complex-form/complex-form.component.ts b/app/components/base/form/complex-form/complex-form.component.ts index 6dfa75b559..54f75ec4c0 100644 --- a/app/components/base/form/complex-form/complex-form.component.ts +++ b/app/components/base/form/complex-form/complex-form.component.ts @@ -1,15 +1,17 @@ import { - AfterViewInit, ChangeDetectorRef, Component, ContentChildren, HostBinding, Input, QueryList, Type, + AfterViewInit, ChangeDetectorRef, Component, ContentChildren, HostBinding, Input, OnChanges, QueryList, Type, } from "@angular/core"; import { FormControl } from "@angular/forms"; -import { Dto, autobind } from "app/core"; +import { AsyncTask, Dto, autobind } from "app/core"; import { ServerError } from "app/models"; import { log } from "app/utils"; import { validJsonConfig } from "app/utils/validators"; -import { Observable } from "rxjs"; +import { Observable, Subscription } from "rxjs"; import { FormBase } from "../form-base"; import { FormPageComponent } from "../form-page"; +import { FormActionConfig } from "./footer"; + import "./complex-form.scss"; export type FormSize = "small" | "medium" | "large"; @@ -34,7 +36,7 @@ export const defaultComplexFormConfig: ComplexFormConfig = { selector: "bl-complex-form", templateUrl: "complex-form.html", }) -export class ComplexFormComponent extends FormBase implements AfterViewInit { +export class ComplexFormComponent extends FormBase implements AfterViewInit, OnChanges { /** * If the form should allow multi use. \ * If true the form will have a "Save" AND a "Save and Close" button. @@ -62,6 +64,7 @@ export class ComplexFormComponent extends FormBase implements AfterViewInit { * Needs to return an observable that will have a {ServerError} if failing. */ @Input() public submit: (dto?: Dto) => Observable; + @Input() public asyncTasks: Observable; @Input() @HostBinding("class") public size: FormSize = "large"; @@ -73,8 +76,13 @@ export class ComplexFormComponent extends FormBase implements AfterViewInit { public currentPage: FormPageComponent; public showJsonEditor = false; public jsonValue = new FormControl(null, null, validJsonConfig); + public waitingForAsyncTask = false; + public asyncTaskList: AsyncTask[]; + public actionConfig: FormActionConfig; private _pageStack: FormPageComponent[] = []; + private _asyncTaskSub: Subscription; + private _hasAsyncTask = false; constructor(private changeDetector: ChangeDetectorRef) { super(); @@ -90,14 +98,28 @@ export class ComplexFormComponent extends FormBase implements AfterViewInit { this.changeDetector.detectChanges(); } - public get isMainWindow() { - return this.currentPage === this.mainPage; + public ngOnChanges(changes) { + if (changes.asyncTasks) { + this._listenToAsyncTasks(); + } + this._buildActionConfig(); } @autobind() public save(): Observable { + let ready; + if (this._hasAsyncTask) { + this.waitingForAsyncTask = true; + ready = this.asyncTasks.filter(x => [...x].length === 0).first().do(() => { + this.waitingForAsyncTask = false; + }).share(); + } else { + ready = Observable.of(null); + } this.loading = true; - const obs = this.submit(this.getCurrentDto()); + const obs = ready.flatMap(() => { + return this.submit(this.getCurrentDto()); + }).shareReplay(1); obs.subscribe({ next: () => { this.loading = false; @@ -163,6 +185,14 @@ export class ComplexFormComponent extends FormBase implements AfterViewInit { this.closePage(); } + public toggleJsonEditor(jsonEditor) { + if (jsonEditor) { + this.switchToJsonEditor(); + } else { + this.switchToClassicForm(); + } + } + public switchToJsonEditor() { if (!this.config.jsonEditor) { return; } const obj = this.getCurrentDto().toJS(); @@ -189,21 +219,6 @@ export class ComplexFormComponent extends FormBase implements AfterViewInit { } } - public get saveAndCloseText() { - return this.multiUse ? `${this.actionName} and close` : this.actionName; - } - - /** - * Enabled if the formGroup is valid or there is no formGroup - */ - public get submitEnabled() { - if (this.showJsonEditor) { - return this.jsonValue.valid; - } else { - return !this.formGroup || this.formGroup.valid; - } - } - /** * There are two cases that classic form selector in footer should be disabled * 1. showJsonEditor variable is false which means current form is already classic form @@ -218,4 +233,27 @@ export class ComplexFormComponent extends FormBase implements AfterViewInit { const data = JSON.parse(this.jsonValue.value); return new this.config.jsonEditor.dtoType(data || {}); } + + private _listenToAsyncTasks() { + if (this._asyncTaskSub) { + this._asyncTaskSub.unsubscribe(); + this._asyncTaskSub = null; + this._hasAsyncTask = false; + } + if (this.asyncTasks) { + this._asyncTaskSub = this.asyncTasks.subscribe((asyncTasks) => { + this.asyncTaskList = asyncTasks; + this._hasAsyncTask = asyncTasks.length > 0; + }); + } + } + + private _buildActionConfig() { + this.actionConfig = { + name: this.actionName, + color: this.actionColor, + cancel: this.cancelText, + multiUse: this.multiUse, + }; + } } diff --git a/app/components/base/form/complex-form/complex-form.html b/app/components/base/form/complex-form/complex-form.html index 115a8b8315..8b877051bd 100644 --- a/app/components/base/form/complex-form/complex-form.html +++ b/app/components/base/form/complex-form/complex-form.html @@ -1,55 +1,39 @@
-
-
- Back +
+
+
+ Back +
+
+

{{currentPage.title}}

+

{{currentPage.subtitle}}

+
-
-

{{currentPage.title}}

-

{{currentPage.subtitle}}

+
+
+ +
-
-
-
- -
-
-
-
- +
+ +
+
- + + diff --git a/app/components/base/form/complex-form/complex-form.scss b/app/components/base/form/complex-form/complex-form.scss index d19ed08fff..dc6a3623cb 100644 --- a/app/components/base/form/complex-form/complex-form.scss +++ b/app/components/base/form/complex-form/complex-form.scss @@ -34,6 +34,7 @@ bl-complex-form { .header { display: flex; margin: 10px; + flex-shrink: 0; > .main { flex: 1; @@ -49,18 +50,25 @@ bl-complex-form { } > .content { - overflow-y: auto; + // overflow-y: auto; position: relative; height: calc(100% - #{$footer-height}); - > .loading-overlay { - position: absolute; - background: white; - opacity: 0.5; - top: 0; - left: 0; - bottom: 0; - right: 0; + > .content-wrapper { + display: flex; + flex-direction: column; + overflow-y: auto; + height: 100%; + + .loading-overlay { + position: absolute; + background: white; + opacity: 0.5; + top: 0; + left: 0; + bottom: 0; + right: 0; + } } .classic-form { @@ -82,33 +90,5 @@ bl-complex-form { > .form-footer { height: $footer-height; - - // background: map-get($primary, 500); - padding: 5px; - display: flex; - align-items: center; - - .toggle-mode { - margin: 0 10px; - } - - > .toggle-error-btn { - button { - font-size: 26px; - } - } - - > .summary { - margin: 0 5px; - flex: 1; - } - - > .form-buttons { - margin: 0 5px; - - bl-button { - margin-right: 5px; - } - } } } diff --git a/app/components/base/form/complex-form/footer/form-footer.component.ts b/app/components/base/form/complex-form/footer/form-footer.component.ts new file mode 100644 index 0000000000..a4fee85f72 --- /dev/null +++ b/app/components/base/form/complex-form/footer/form-footer.component.ts @@ -0,0 +1,136 @@ +import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnDestroy, Output, +} from "@angular/core"; +import { FormControl, FormGroup } from "@angular/forms"; + +import { FormPageComponent } from "app/components/base/form/form-page"; +import { ComplexFormComponent, ComplexFormConfig } from "../complex-form.component"; + +import { AsyncTask } from "app/core"; +import { ServerError } from "app/models"; +import { Subscription } from "rxjs"; +import "./form-footer.scss"; + +export interface FormActionConfig { + /** + * Action name + * @default: save + */ + name?: string; + + /** + * Action color + * @default primary + */ + color?: string; + + /** + * Cancel text + * @default Close + */ + cancel?: string; + + /** + * If the form should allow to save without closing + * @default true + */ + multiUse?: boolean; +} + +const defaultActionConfig: FormActionConfig = { + name: "Save", + color: "primary", + cancel: "Close", +}; + +@Component({ + selector: "bl-form-footer", + templateUrl: "form-footer.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FormFooterComponent implements OnChanges, OnDestroy { + @Input() public waitingForAsyncTask: boolean; + @Input() public asyncTasks: AsyncTask[]; + @Input() public config: ComplexFormConfig; + @Input() public set actionConfig(actionConfig: FormActionConfig) { + this._actionConfig = { ...defaultActionConfig, ...actionConfig }; + } + public get actionConfig() { return this._actionConfig; } + @Input() public jsonValue: FormControl; + @Input() public showJsonEditor: boolean; + @Input() public currentPage: FormPageComponent; + @Input() public formGroup: FormGroup; + @Input() public error: ServerError; + @Input() public showError: boolean; + @Output() public showErrorChange = new EventEmitter(); + @Output() public showJsonEditorChanges = new EventEmitter(); + + public isMainWindow: boolean; + private _actionConfig: FormActionConfig; + private _statusSub: Subscription; + + constructor(public form: ComplexFormComponent, private changeDetector: ChangeDetectorRef) { } + + public ngOnChanges(changes) { + if (changes.currentPage) { + this.isMainWindow = this.currentPage === this.form.mainPage; + if (this._statusSub) { + this._statusSub.unsubscribe(); + } + if (this.currentPage && this.currentPage.formGroup) { + this._statusSub = this.currentPage.formGroup.statusChanges.distinctUntilChanged() + .subscribe((status) => { + this.changeDetector.markForCheck(); + }); + } + } + } + + public ngOnDestroy() { + if (this._statusSub) { + this._statusSub.unsubscribe(); + } + } + + public toggleJsonEditor(show) { + this.showJsonEditorChanges.emit(show); + this.showJsonEditor = show; + this.changeDetector.markForCheck(); + } + + public toggleShowError() { + this.showError = !this.showError; + this.showErrorChange.emit(this.showError); + } + + /** + * There are two cases that classic form selector in footer should be disabled + * 1. showJsonEditor variable is false which means current form is already classic form + * 2. Current json edit JSON value is in a wrong format + */ + public get classicFormDisabled() { + return !this.showJsonEditor || !this.jsonValue.valid; + } + + /** + * Enabled if the formGroup is valid or there is no formGroup + */ + public get submitEnabled() { + if (this.showJsonEditor) { + return this.jsonValue.valid; + } else if (this.currentPage) { + return !this.currentPage.formGroup || this.currentPage.formGroup.valid; + } else { + return false; + } + } + + public get saveAndCloseText() { + return this.actionConfig.multiUse ? `${this.actionConfig.name} and close` : this.actionConfig.name; + } + + public get asyncTaskTooltip() { + if (!this.asyncTasks) { return null; } + return this.asyncTasks.slice(1).map(x => x.name).join("\n"); + } +} diff --git a/app/components/base/form/complex-form/footer/form-footer.html b/app/components/base/form/complex-form/footer/form-footer.html new file mode 100644 index 0000000000..65fdca2e59 --- /dev/null +++ b/app/components/base/form/complex-form/footer/form-footer.html @@ -0,0 +1,36 @@ +
+ + + + + + +
+
+ +
+
+
+ + Waiting for + {{asyncTasks[0].name}} + and {{asyncTasks.length - 1}} tasks +
+ +
+
+
+ {{actionConfig.name}} + {{saveAndCloseText}} + + {{actionConfig.cancel}} + +
+
+ Cancel + Select +
+
diff --git a/app/components/base/form/complex-form/footer/form-footer.scss b/app/components/base/form/complex-form/footer/form-footer.scss new file mode 100644 index 0000000000..07c8d601e4 --- /dev/null +++ b/app/components/base/form/complex-form/footer/form-footer.scss @@ -0,0 +1,37 @@ +@import "app/styles/variables"; + +bl-form-footer { + padding: 5px; + display: flex; + align-items: center; + + .toggle-mode { + margin: 0 10px; + } + + > .toggle-error-btn { + button { + font-size: 26px; + } + } + + > .summary { + margin: 0 5px; + flex: 1; + } + + > .form-buttons { + margin: 0 5px; + + bl-button { + margin-right: 5px; + } + } + + .waiting-for-async-task-message { + > .task { + font-style: italic; + color: $alto; + } + } +} diff --git a/app/components/base/form/complex-form/footer/index.ts b/app/components/base/form/complex-form/footer/index.ts new file mode 100644 index 0000000000..d799f93f76 --- /dev/null +++ b/app/components/base/form/complex-form/footer/index.ts @@ -0,0 +1 @@ +export * from "./form-footer.component"; diff --git a/app/components/base/form/editable-table/editable-table.component.ts b/app/components/base/form/editable-table/editable-table.component.ts index 95788ca7ad..d2a7c64a27 100644 --- a/app/components/base/form/editable-table/editable-table.component.ts +++ b/app/components/base/form/editable-table/editable-table.component.ts @@ -59,6 +59,10 @@ export class EditableTableComponent implements ControlValueAccessor, Validator, } public addNewItem() { + const last = this.items.value.last(); + if (last && this._isEmpty(last)) { + return; + } if (!this.columns) { return; } diff --git a/app/components/base/form/form-base.ts b/app/components/base/form/form-base.ts index 237803cdde..09e8915fe5 100644 --- a/app/components/base/form/form-base.ts +++ b/app/components/base/form/form-base.ts @@ -51,8 +51,4 @@ export class FormBase { container.destroy(); } } - - public toggleShowError() { - this.showError = !this.showError; - } } diff --git a/app/components/base/form/form-json-editor/form-json-editor.scss b/app/components/base/form/form-json-editor/form-json-editor.scss index a17bb5ad9b..c31ce9ea47 100644 --- a/app/components/base/form/form-json-editor/form-json-editor.scss +++ b/app/components/base/form/form-json-editor/form-json-editor.scss @@ -8,4 +8,8 @@ bl-form-json-editor { height: 20px; border-bottom: 1px solid $border-color; } + + > bl-editor { + height: calc(100% - 20px); + } } diff --git a/app/components/base/form/form.module.ts b/app/components/base/form/form.module.ts index 695bd98926..53d97fc496 100644 --- a/app/components/base/form/form.module.ts +++ b/app/components/base/form/form.module.ts @@ -7,6 +7,7 @@ import { MaterialModule } from "app/core"; import { EditorModule } from "app/components/base/editor"; import { ButtonsModule } from "../buttons"; import { ComplexFormComponent } from "./complex-form"; +import { FormFooterComponent } from "./complex-form/footer"; import { EditMetadataFormComponent } from "./edit-metadata-form"; import { EditableTableColumnComponent, EditableTableComponent } from "./editable-table"; import { ExpandingTextareaComponent } from "./expanding-textarea"; @@ -43,6 +44,7 @@ const components = [ ExpandingTextareaComponent, SingleLineTextareaDirective, FormJsonEditorComponent, + FormFooterComponent, ]; @NgModule({ diff --git a/app/components/settings/default-settings.json b/app/components/settings/default-settings.json index 9c4773cd02..5399ca37ba 100644 --- a/app/components/settings/default-settings.json +++ b/app/components/settings/default-settings.json @@ -10,5 +10,8 @@ "configuration.default-view": "pretty", // List of subscription pattern to ignore(e.g. ["other-subscription*"]) - "subscription.ignore": [] + "subscription.ignore": [], + + // Name of the container batchlabs will use to upload resource files when drag and drop + "storage.default-upload-container": "batchlabs-input" } diff --git a/app/components/task/action/add/task-create-basic-dialog.component.ts b/app/components/task/action/add/task-create-basic-dialog.component.ts index 12d92e0a4e..a3c5358680 100644 --- a/app/components/task/action/add/task-create-basic-dialog.component.ts +++ b/app/components/task/action/add/task-create-basic-dialog.component.ts @@ -7,6 +7,7 @@ import { ComplexFormConfig } from "app/components/base/form"; import { NotificationService } from "app/components/base/notifications"; import { SidebarRef } from "app/components/base/sidebar"; import { RangeValidator } from "app/components/base/validation"; +import { UploadResourceFileEvent } from "app/components/task/base"; import { DynamicForm, autobind } from "app/core"; import { Task, VirtualMachineConfiguration } from "app/models"; import { TaskCreateDto } from "app/models/dtos"; @@ -69,7 +70,7 @@ export class TaskCreateBasicDialogComponent extends DynamicForm { @@ -111,6 +112,10 @@ export class TaskCreateBasicDialogComponent extends DynamicForm +
@@ -46,7 +46,7 @@ - + diff --git a/app/components/task/base/resourcefile-picker.component.spec.ts b/app/components/task/base/resourcefile-picker.component.spec.ts new file mode 100644 index 0000000000..7cc1c8e175 --- /dev/null +++ b/app/components/task/base/resourcefile-picker.component.spec.ts @@ -0,0 +1,181 @@ +import { Component, DebugElement } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { MatSelectModule } from "@angular/material"; +import { By } from "@angular/platform-browser"; + +import { EditableTableColumnComponent, EditableTableComponent } from "app/components/base/form/editable-table"; +import { ResourcefilePickerComponent } from "app/components/task/base"; +import { FileSystemService, SettingsService, StorageService } from "app/services"; +import { Observable } from "rxjs"; +import { F } from "test/utils"; + +@Component({ + template: ``, +}) +class TestComponent { + public files = []; +} + +describe("ResourcefilePickerComponent", () => { + let fixture: ComponentFixture; + let testComponent: TestComponent; + let component: ResourcefilePickerComponent; + let editableTableEl: DebugElement; + let editableTable: EditableTableComponent; + let storageServiceSpy; + let fsSpy; + let settingsServiceSpy; + + beforeEach(() => { + storageServiceSpy = { + createContainerIfNotExists: jasmine.createSpy("createContainerIfNotExists") + .and.returnValue(Observable.of(null)), + uploadFile: jasmine.createSpy("uploadFile") + .and.returnValue(Observable.of(null)), + generateSharedAccessBlobUrl: jasmine.createSpy("uploadFile") + .and.callFake(x => Observable.of(`${x}?key=abc`)), + }; + fsSpy = { + lstat: () => Promise.resolve({ + isFile: () => true, + }), + }; + settingsServiceSpy = { + settings: { + "storage.default-upload-container": "test-custom-container", + }, + }; + TestBed.configureTestingModule({ + imports: [FormsModule, ReactiveFormsModule, MatSelectModule], + declarations: [ResourcefilePickerComponent, TestComponent, + EditableTableComponent, EditableTableColumnComponent], + providers: [ + { provide: StorageService, useValue: storageServiceSpy }, + { provide: FileSystemService, useValue: fsSpy }, + { provide: SettingsService, useValue: settingsServiceSpy }, + ], + }); + fixture = TestBed.createComponent(TestComponent); + testComponent = fixture.componentInstance; + component = fixture.debugElement.query(By.css("bl-resourcefile-picker")).componentInstance; + editableTableEl = fixture.debugElement.query(By.css("bl-editable-table")); + editableTable = editableTableEl.componentInstance; + fixture.detectChanges(); + }); + + it("should have the right column keys", () => { + const columns = editableTable.columns.toArray(); + + expect(columns.length).toBe(2); + expect(columns[0].name).toBe("blobSource"); + expect(columns[1].name).toBe("filePath"); + }); + + it("should have the right column labels", () => { + const columns = editableTableEl.queryAll(By.css("thead th")); + + expect(columns.length).toBe(3); + expect(columns[0].nativeElement.textContent).toContain("Blob source"); + expect(columns[1].nativeElement.textContent).toContain("File path"); + }); + + it("Should update the files", () => { + editableTable.addNewItem(); + editableTable.items.controls[0].setValue({ + blobSource: "https://example.com/file.json", + filePath: "path/file.json", + }); + expect(testComponent.files).toEqual([{ + blobSource: "https://example.com/file.json", + filePath: "path/file.json", + }]); + }); + + describe("when dropping files", () => { + it("adds a link to the list of files", () => { + const event = { + preventDefault: () => null, + stopPropagation: () => null, + dataTransfer: { + types: ["text/html", "text/uri-list"], + files: [], + getData: () => "https://example.com/path/file1.txt", + }, + }; + component.handleDrop(event as any); + expect(component.files.value.length).toBe(1); + expect(component.files.value.first()).toEqual({ + blobSource: "https://example.com/path/file1.txt", + filePath: "file1.txt", + }); + }); + + it("adds a file to the list of files", (done) => { + const event = { + preventDefault: () => null, + stopPropagation: () => null, + dataTransfer: { + types: ["Files"], + files: [{ + path: "some/file1.txt", + }], + }, + }; + component.handleDrop(event as any); + setTimeout(() => { + fixture.detectChanges(); + expect(component.files.value.length).toBe(1); + expect(component.files.value.first()).toEqual({ + blobSource: "test-custom-container?key=abc", + filePath: "file1.txt", + }); + done(); + }); + }); + + it("doesn't do anything if no links or files", () => { + const event = { + preventDefault: () => null, + stopPropagation: () => null, + dataTransfer: { + types: ["text/html"], + files: [], + getData: () => "some text invalid", + }, + }; + component.handleDrop(event as any); + expect(component.files.value.length).toBe(0); + }); + }); + + describe("uplading files", () => { + let uploadFolder: string; + + beforeEach(() => { + uploadFolder = `resource-files/${(component as any)._folderId}`; + }); + + it("should upload list of files", F(async () => { + await component.uploadFiles(["some/path/file1.txt", "some/other/file2.txt"]); + expect(storageServiceSpy.createContainerIfNotExists).toHaveBeenCalledOnce(); + expect(storageServiceSpy.createContainerIfNotExists).toHaveBeenCalledWith("test-custom-container"); + expect(storageServiceSpy.uploadFile).toHaveBeenCalledTimes(2); + expect(storageServiceSpy.uploadFile).toHaveBeenCalledWith("test-custom-container", + "some/path/file1.txt", `${uploadFolder}/file1.txt`); + expect(storageServiceSpy.uploadFile).toHaveBeenCalledWith("test-custom-container", + "some/other/file2.txt", `${uploadFolder}/file2.txt`); + })); + + it("should upload list of files when root is defined", F(async () => { + await component.uploadFiles(["some/path/file1.txt", "some/other/file2.txt"], "custom/path"); + expect(storageServiceSpy.createContainerIfNotExists).toHaveBeenCalledOnce(); + expect(storageServiceSpy.createContainerIfNotExists).toHaveBeenCalledWith("test-custom-container"); + expect(storageServiceSpy.uploadFile).toHaveBeenCalledTimes(2); + expect(storageServiceSpy.uploadFile).toHaveBeenCalledWith("test-custom-container", + "some/path/file1.txt", `${uploadFolder}/custom/path/file1.txt`); + expect(storageServiceSpy.uploadFile).toHaveBeenCalledWith("test-custom-container", + "some/other/file2.txt", `${uploadFolder}/custom/path/file2.txt`); + })); + }); +}); diff --git a/app/components/task/base/resourcefile-picker.component.ts b/app/components/task/base/resourcefile-picker.component.ts index 0aa60912fd..b28125466f 100644 --- a/app/components/task/base/resourcefile-picker.component.ts +++ b/app/components/task/base/resourcefile-picker.component.ts @@ -1,13 +1,26 @@ -import { Component, OnDestroy, forwardRef } from "@angular/core"; +import { ChangeDetectorRef, Component, EventEmitter, HostListener, OnDestroy, Output, forwardRef } from "@angular/core"; import { ControlValueAccessor, FormBuilder, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from "@angular/forms"; -import { Subscription } from "rxjs"; +import * as path from "path"; +import { Observable, Subscription } from "rxjs"; -import { ResourceFile } from "app/models"; +import { ResourceFileAttributes } from "app/models"; +import { CloudPathUtils, DragUtils } from "app/utils"; +import { BlobUtilities } from "azure-storage"; +import * as moment from "moment"; -// tslint:disable:no-forward-ref +import { FileSystemService, SettingsService, StorageService } from "app/services"; +import { SharedAccessPolicy } from "app/services/storage/models"; +import { SecureUtils, UrlUtils } from "common"; +import "./resourcefile-picker.scss"; + +export interface UploadResourceFileEvent { + filename: string; + done: Observable; +} +// tslint:disable:no-forward-ref @Component({ selector: "bl-resourcefile-picker", templateUrl: "resourcefile-picker.html", @@ -17,25 +30,45 @@ import { ResourceFile } from "app/models"; ], }) export class ResourcefilePickerComponent implements ControlValueAccessor, OnDestroy { - public files: FormControl; + /** + * Event emitted when a file is being uploaded, use this to add async task to the form + */ + @Output() public upload = new EventEmitter(); + public files: FormControl; + public isDraging = 0; + public uploadingFiles = []; - private _propagateChange: (value: ResourceFile[]) => void = null; + private _propagateChange: (value: ResourceFileAttributes[]) => void = null; private _sub: Subscription; + private _containerId: string; + + /** + * Unique id generated for this + */ + private _folderId: string; - constructor(private formBuilder: FormBuilder) { + constructor( + private formBuilder: FormBuilder, + private storageService: StorageService, + private fs: FileSystemService, + private settingsService: SettingsService, + private changeDetector: ChangeDetectorRef) { + this._folderId = SecureUtils.uuid(); this.files = this.formBuilder.control([]); this._sub = this.files.valueChanges.subscribe((files) => { if (this._propagateChange) { this._propagateChange(files); } }); + + this._containerId = this.settingsService.settings["storage.default-upload-container"]; } public ngOnDestroy() { this._sub.unsubscribe(); } - public writeValue(value: ResourceFile[]) { + public writeValue(value: ResourceFileAttributes[]) { if (value) { this.files.setValue(value); } @@ -52,4 +85,115 @@ export class ResourcefilePickerComponent implements ControlValueAccessor, OnDest public validate(c: FormControl) { return null; } + + @HostListener("dragover", ["$event"]) + public handleDragHover(event: DragEvent) { + const allowDrop = this._canDrop(event.dataTransfer); + DragUtils.allowDrop(event, allowDrop); + } + + @HostListener("dragenter", ["$event"]) + public dragEnter(event: DragEvent) { + if (!this._canDrop(event.dataTransfer)) { return; } + event.stopPropagation(); + this.isDraging++; + } + + @HostListener("dragleave", ["$event"]) + public dragLeave(event: DragEvent) { + if (!this._canDrop(event.dataTransfer)) { return; } + this.isDraging--; + } + + @HostListener("drop", ["$event"]) + public handleDrop(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + const dataTransfer = event.dataTransfer; + const files = [...event.dataTransfer.files as any]; + if (this._hasLink(dataTransfer)) { + const link = dataTransfer.getData("text/uri-list"); + if (UrlUtils.isHttpUrl(link)) { + this._addResourceFileFromUrl(link); + } + } else if (this._hasFile(dataTransfer)) { + this.uploadFiles(files.map(x => x.path)); + } + this.isDraging = 0; + } + + /** + * Upload files to storage and add then to the resource files list + * @param files: List of files or folder + * @returns async when the all file upload started + */ + public async uploadFiles(files: string[], root = "") { + await this.storageService.createContainerIfNotExists(this._containerId).toPromise(); + for (const file of files) { + await this._uploadPath(file, root); + } + } + + public trackUploadingFile(index, file: string) { + return file; + } + + private _addResourceFileFromUrl(url: string) { + this._addResourceFile(url, path.basename(url)); + } + + private async _uploadPath(filePath: string, root = "") { + const stats = await this.fs.lstat(filePath); + if (stats.isFile()) { + this._uploadFile(filePath, root); + } else { + const files = await this.fs.readdir(filePath); + const paths = files.map(x => path.join(filePath, x)); + await this.uploadFiles(paths, CloudPathUtils.join(root, path.basename(filePath))); + } + } + + private _uploadFile(filePath: string, root = "") { + const filename = path.basename(filePath); + const nodeFilePath = CloudPathUtils.join(root, filename); + const blobName = CloudPathUtils.join("resource-files", this._folderId, root, filename); + this.uploadingFiles.push(nodeFilePath); + + const obs = this.storageService.uploadFile(this._containerId, filePath, blobName).flatMap((result) => { + this.uploadingFiles = this.uploadingFiles.filter(x => x !== nodeFilePath); + this.changeDetector.detectChanges(); + const sas: SharedAccessPolicy = { + AccessPolicy: { + Permissions: BlobUtilities.SharedAccessPermissions.READ, + Start: new Date(), + Expiry: moment().add(1, "week").toDate(), + }, + }; + return this.storageService.generateSharedAccessBlobUrl(this._containerId, blobName, sas).do((url) => { + this._addResourceFile(url, nodeFilePath); + }); + }).share(); + this.upload.emit({ filename: nodeFilePath, done: obs }); + obs.subscribe(); + } + + private _addResourceFile(blobSource: string, filePath: string) { + const files = this.files.value.concat([{ + blobSource, + filePath, + }]); + this.files.setValue(files); + } + + private _canDrop(dataTransfer: DataTransfer) { + return this._hasFile(dataTransfer) || this._hasLink(dataTransfer); + } + + private _hasFile(dataTransfer: DataTransfer) { + return dataTransfer.types.includes("Files"); + } + + private _hasLink(dataTransfer: DataTransfer) { + return dataTransfer.types.includes("text/uri-list"); + } } diff --git a/app/components/task/base/resourcefile-picker.html b/app/components/task/base/resourcefile-picker.html index d70469def7..d9f7eb34a5 100644 --- a/app/components/task/base/resourcefile-picker.html +++ b/app/components/task/base/resourcefile-picker.html @@ -2,3 +2,14 @@ Blob source File path +You can drag and drop files here to be uploaded automatically +
+
+ + {{file}} +
+
+
+ + Upload file +
diff --git a/app/components/task/base/resourcefile-picker.scss b/app/components/task/base/resourcefile-picker.scss new file mode 100644 index 0000000000..dbe647491a --- /dev/null +++ b/app/components/task/base/resourcefile-picker.scss @@ -0,0 +1,43 @@ +@import "app/styles/variables"; + +bl-resourcefile-picker { + display: block; + position: relative; + + table { + margin-bottom: 0; + } + + .uploading-files { + + } + + .help { + font-style: italic; + color: $alto; + } + + .drop-overlay { + top: 0; + left: 0; + width: 100%; + height: 100%; + position: absolute; + background-color: white; + border: 2px dashed $alto; + display: flex; + align-items: center; + justify-content: center; + color: $alto; + + > .fa-upload { + font-size: 36px; + margin-right: 15px; + } + + > .message { + font-size: 20px; + } + + } +} diff --git a/app/core/dynamic-form.ts b/app/core/dynamic-form.ts index cf77e784db..41f2db7c29 100644 --- a/app/core/dynamic-form.ts +++ b/app/core/dynamic-form.ts @@ -2,12 +2,26 @@ import { Type } from "@angular/core"; import { FormGroup } from "@angular/forms"; import { Dto } from "app/core/dto"; import { FormUtils } from "app/utils"; +import { BehaviorSubject, Observable } from "rxjs"; + +export interface AsyncTask { + name: string; + done: Observable; +} export abstract class DynamicForm> { public originalData: TDto; public form: FormGroup; + public readonly asyncTasks: Observable; + public readonly hasAsyncTask: Observable; + + private _asyncTasks = new BehaviorSubject(new Map()); + + constructor(private dtoType: Type) { + this.asyncTasks = this._asyncTasks.map(x => [...x.values()]); + this.hasAsyncTask = this._asyncTasks.map(x => x.size > 0); + } - constructor(private dtoType: Type) { } public setValue(value: TDto) { this.originalData = value; this.form.patchValue(this.dtoToForm(value)); @@ -27,6 +41,32 @@ export abstract class DynamicForm> { return new this.dtoType(Object.assign({}, this.originalData, this.formToDto(this.form.getRawValue()))); } + public registerAsyncTask( + name: string, + done: Observable) { + const id = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + const value = this._asyncTasks.value; + value.set(id, { + name, + done, + }); + this._asyncTasks.next(value); + + done.subscribe({ + next: () => { + this.disposeAsyncTask(id); + }, + // TODO-TIM handle error somehow + }); + return id; + } + + public disposeAsyncTask( id: number) { + const value = this._asyncTasks.value; + value.delete(id); + this._asyncTasks.next(value); + } + public abstract formToDto(value: any): TDto; public abstract dtoToForm(dto: TDto): any; } diff --git a/app/models/resource-file.ts b/app/models/resource-file.ts index 7dc320c877..43fd68c676 100644 --- a/app/models/resource-file.ts +++ b/app/models/resource-file.ts @@ -3,7 +3,7 @@ import { Model, Prop, Record } from "app/core"; export interface ResourceFileAttributes { blobSource: string; filePath: string; - fileMode: string; + fileMode?: string; } /** @@ -11,12 +11,9 @@ export interface ResourceFileAttributes { */ @Model() export class ResourceFile extends Record { - @Prop() - public blobSource: string; + @Prop() public blobSource: string; - @Prop() - public filePath: string; + @Prop() public filePath: string; - @Prop() - public fileMode: string; + @Prop() public fileMode: string; } diff --git a/app/models/settings.ts b/app/models/settings.ts index 6d1a5d90a3..421ee04472 100644 --- a/app/models/settings.ts +++ b/app/models/settings.ts @@ -14,4 +14,5 @@ export interface Settings { fileTypes: StringMap; "configuration.default-view": EntityConfigurationView; "subscription.ignore": string[]; + "storage.default-upload-container": string; } diff --git a/app/services/fs.service.ts b/app/services/fs.service.ts index b88d7e41f8..d9de4543bb 100644 --- a/app/services/fs.service.ts +++ b/app/services/fs.service.ts @@ -45,12 +45,14 @@ export class FileSystemService { public readFile(path: string): Promise { return this._fs.readFile(path); + } + public async readdir(path: string): Promise { + return this._fs.readdir(path); } public download(source: string, dest: string): Promise { return this._fs.download(source, dest); - } /** @@ -61,4 +63,8 @@ export class FileSystemService { public unzip(source: string, dest: string): Promise { return this._fs.unzip(source, dest); } + + public async lstat(path: string) { + return this._fs.lstat(path); + } } diff --git a/app/services/storage.service.ts b/app/services/storage.service.ts index 87b5b3dcfc..51778a2b4c 100644 --- a/app/services/storage.service.ts +++ b/app/services/storage.service.ts @@ -1,5 +1,4 @@ import { Injectable, NgZone } from "@angular/core"; -import * as storage from "azure-storage"; import * as path from "path"; import { AsyncSubject, Observable, Subject } from "rxjs"; @@ -8,6 +7,7 @@ import { BlobContainer, File, ServerError } from "app/models"; import { FileSystemService } from "app/services"; import { SharedAccessPolicy } from "app/services/storage/models"; import { CloudPathUtils, log } from "app/utils"; +import { BlobService, createBlobServiceWithSas } from "azure-storage"; import { Constants } from "common"; import { DataCache, @@ -339,10 +339,18 @@ export class StorageService { }); } + public createContainerIfNotExists(containerName: string): Observable { + return this._callStorageClient((client) => { + return client.createContainerIfNotExists(containerName); + }, (error) => { + log.error(`Error creating container: ${containerName}`, { ...error }); + }); + } + public generateSharedAccessContainerUrl(container: string, sharedAccessPolicy: SharedAccessPolicy) : Observable { return this._callStorageClient((client) => { - const sasToken = client.generateSharedAccessSignature(container, sharedAccessPolicy); + const sasToken = client.generateSharedAccessSignature(container, null, sharedAccessPolicy); return Promise.resolve(client.getUrl(container, null, sasToken)); }, (error) => { // TODO-Andrew: test that errors are caught @@ -350,13 +358,26 @@ export class StorageService { }); } + public generateSharedAccessBlobUrl( + container: string, blob: string, + sharedAccessPolicy: SharedAccessPolicy): Observable { + + return this._callStorageClient((client) => { + const sasToken = client.generateSharedAccessSignature(container, blob, sharedAccessPolicy); + return Promise.resolve(client.getUrl(container, blob, sasToken)); + }, (error) => { + // TODO-Andrew: test that errors are caught + log.error(`Error generating container SAS: ${container}`, { ...error }); + }); + } + public uploadToSasUrl(sasUrl: string, filePath: string): Observable { - const subject = new AsyncSubject(); + const subject = new AsyncSubject(); const { accountUrl, sasToken, container, blob } = this._parseSasUrl(sasUrl); - const service = storage.createBlobServiceWithSas(accountUrl, sasToken); + const service = createBlobServiceWithSas(accountUrl, sasToken); service.createBlockBlobFromLocalFile(container, blob, filePath, - (error: any, result: storage.BlobService.BlobResult) => { + (error: any, result: BlobService.BlobResult) => { this.zone.run(() => { if (error) { @@ -377,7 +398,7 @@ export class StorageService { * @param file Absolute path to the local file * @param remotePath Blob name */ - public uploadFile(container: string, file: string, remotePath: string) { + public uploadFile(container: string, file: string, remotePath: string): Observable { return this._callStorageClient((client) => client.uploadFile(container, file, remotePath), (error) => { log.error(`Error upload file ${file} to container ${container}`, error); }); diff --git a/app/services/storage/blob-storage-client-proxy.ts b/app/services/storage/blob-storage-client-proxy.ts index e02cca71cd..c852f46382 100644 --- a/app/services/storage/blob-storage-client-proxy.ts +++ b/app/services/storage/blob-storage-client-proxy.ts @@ -1,4 +1,4 @@ -import * as storage from "azure-storage"; +import { BlobService } from "azure-storage"; import { BlobStorageResult, SharedAccessPolicy, StorageRequestOptions } from "./models"; @@ -26,9 +26,9 @@ export interface ListBlobResponse { } export class BlobStorageClientProxy { - public client: storage.BlobService; + public client: BlobService; - constructor(blobService: storage.BlobService) { + constructor(blobService: BlobService) { this.client = blobService; } @@ -307,6 +307,25 @@ export class BlobStorageClientProxy { }); } + /** + * Creates a new container under the specified account if it doesn't exsits. + * + * @param {string} container - Name of the storage container + */ + public createContainerIfNotExists(containerName: string) + : Promise { + + return new Promise((resolve, reject) => { + this.client.createContainerIfNotExists(containerName, (error, response) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + /** * Retrieves a shared access signature token. * http://azure.github.io/azure-storage-node/BlobService.html#generateSharedAccessSignature__anchor @@ -314,8 +333,9 @@ export class BlobStorageClientProxy { * @param {string} container - Name of the storage container * @param {string} sharedAccessPolicy - The shared access policy */ - public generateSharedAccessSignature(container: string, sharedAccessPolicy: SharedAccessPolicy): string { - return this.client.generateSharedAccessSignature(container, null, sharedAccessPolicy, null); + public generateSharedAccessSignature( + container: string, blob: string, sharedAccessPolicy: SharedAccessPolicy): string { + return this.client.generateSharedAccessSignature(container, blob, sharedAccessPolicy, null); } /** @@ -330,10 +350,10 @@ export class BlobStorageClientProxy { return this.client.getUrl(container, blob, sasToken); } - public async uploadFile(container: string, file: string, remotePath: string) { - return new Promise((resolve, reject) => { + public async uploadFile(container: string, file: string, remotePath: string): Promise { + return new Promise((resolve, reject) => { this.client.createBlockBlobFromLocalFile(container, remotePath, file, - (error: any, result: storage.BlobService.BlobResult) => { + (error: any, result: BlobService.BlobResult) => { if (error) { return reject(error); } resolve(result); }); diff --git a/app/utils/extensions/angular/forms.ts b/app/utils/extensions/angular/forms.ts new file mode 100644 index 0000000000..7bcd362c2f --- /dev/null +++ b/app/utils/extensions/angular/forms.ts @@ -0,0 +1,24 @@ +import { AsyncValidatorFn, ValidatorFn } from "@angular/forms"; +import { Observable } from "rxjs"; + +/** + * Add some typing to the angular forms + */ +declare module "@angular/forms/src/model" { + + interface FormControl { + readonly valueChanges: Observable; + readonly value: T; + + new(formState?: T, + validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, + asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null); + + setValue(value: T, options?: { + onlySelf?: boolean; + emitEvent?: boolean; + emitModelToViewChange?: boolean; + emitViewToModelChange?: boolean; + }): void; + } +} diff --git a/app/utils/extensions/index.ts b/app/utils/extensions/index.ts index 3b0bc60466..7bf97f6a41 100644 --- a/app/utils/extensions/index.ts +++ b/app/utils/extensions/index.ts @@ -1,4 +1,5 @@ import "moment-duration-format"; +import "./angular/forms"; import "./array"; import "./observable"; import "./security"; diff --git a/app/utils/index.ts b/app/utils/index.ts index f2a1147d64..26bc6c5900 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -19,7 +19,6 @@ export * from "./arm-resource-utils"; export * from "./shared-key-utils"; export * from "./storage-utils"; export * from "./string-utils"; -export * from "./url-utils"; export * from "common/utils"; export * from "./constants"; diff --git a/app/utils/url-utils.ts b/app/utils/url-utils.ts deleted file mode 100644 index 282b65cc98..0000000000 --- a/app/utils/url-utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class UrlUtils { - public static parseParams(queryParams: string): StringMap { - const params = {}; - for (const str of queryParams.split("&")) { - const [key, value] = str.split("="); - params[key] = value; - } - return params; - } -} diff --git a/src/client/core/fs.ts b/src/client/core/fs.ts index 8c15f7dd7e..254ed39501 100644 --- a/src/client/core/fs.ts +++ b/src/client/core/fs.ts @@ -90,6 +90,28 @@ export class FileSystem { return fileUtils.unzip(source, dest); } + public async lstat(path: string): Promise { + return new Promise((resolve, reject) => { + fs.lstat(path, (error, stats) => { + if (error) { + return reject(error); + } + resolve(stats); + }); + }); + } + + public async readdir(path: string): Promise { + return new Promise((resolve, reject) => { + fs.readdir(path, (error, files) => { + if (error) { + return reject(error); + } + resolve(files); + }); + }); + } + private _writeFile(path: string, content: string): Promise { return new Promise((resolve, reject) => { fs.writeFile(path, content, (err) => { diff --git a/src/client/main.ts b/src/client/main.ts index 6293c0bde1..aafc86250e 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -30,13 +30,15 @@ function startApplication() { callback(); }); - const shouldQuit = app.makeSingleInstance((commandLine) => { - logger.info("Try to open labs again", commandLine); - batchLabsApp.openFromArguments(commandLine); - }); + if (!Constants.isDev) { + const shouldQuit = app.makeSingleInstance((commandLine) => { + logger.info("Try to open labs again", commandLine); + batchLabsApp.openFromArguments(commandLine); + }); - if (shouldQuit) { - app.quit(); + if (shouldQuit) { + app.quit(); + } } // Uncomment to view why windows don't show up. diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 47c0d4196c..1c4bd31f42 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -1,2 +1,3 @@ export * from "./secure-utils"; export * from "./object-utils"; +export * from "./url-utils"; diff --git a/src/common/utils/url-utils/index.ts b/src/common/utils/url-utils/index.ts new file mode 100644 index 0000000000..0bd5065a16 --- /dev/null +++ b/src/common/utils/url-utils/index.ts @@ -0,0 +1 @@ +export * from "./url-utils"; diff --git a/src/common/utils/url-utils/url-utils.spec.ts b/src/common/utils/url-utils/url-utils.spec.ts new file mode 100644 index 0000000000..5062f259fe --- /dev/null +++ b/src/common/utils/url-utils/url-utils.spec.ts @@ -0,0 +1,12 @@ +import { UrlUtils } from "./url-utils"; + +describe("UrlUtils", () => { + it("#isHttpUrl() returns true only when starts with http(s)://", () => { + expect(UrlUtils.isHttpUrl("https://banana")).toBe(true); + expect(UrlUtils.isHttpUrl("https://example.com")).toBe(true); + expect(UrlUtils.isHttpUrl("https://example.com/long/path/to/file.txt")).toBe(true); + expect(UrlUtils.isHttpUrl("http://example.com")).toBe(true); + expect(UrlUtils.isHttpUrl("ftp://example.com")).toBe(false); + expect(UrlUtils.isHttpUrl("data://abcdefghi")).toBe(false); + }); +}); diff --git a/src/common/utils/url-utils/url-utils.ts b/src/common/utils/url-utils/url-utils.ts new file mode 100644 index 0000000000..9727fe811e --- /dev/null +++ b/src/common/utils/url-utils/url-utils.ts @@ -0,0 +1,5 @@ +export class UrlUtils { + public static isHttpUrl(url: string) { + return /^https?:\/\/.*$/.test(url); + } +} diff --git a/test/app/components/task/base/resourcefile-picker.component.spec.ts b/test/app/components/task/base/resourcefile-picker.component.spec.ts deleted file mode 100644 index 2a2b73ac97..0000000000 --- a/test/app/components/task/base/resourcefile-picker.component.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Component, DebugElement } from "@angular/core"; -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { FormsModule, ReactiveFormsModule } from "@angular/forms"; -import { MatSelectModule } from "@angular/material"; -import { By } from "@angular/platform-browser"; - -import { EditableTableColumnComponent, EditableTableComponent } from "app/components/base/form/editable-table"; -import { ResourcefilePickerComponent } from "app/components/task/base"; - -@Component({ - template: ``, -}) -class TestComponent { - public files = []; -} - -describe("ResourcefilePickerComponent", () => { - let fixture: ComponentFixture; - let testComponent: TestComponent; - let editableTableEl: DebugElement; - let editableTable: EditableTableComponent; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [FormsModule, ReactiveFormsModule, MatSelectModule], - declarations: [ResourcefilePickerComponent, TestComponent, - EditableTableComponent, EditableTableColumnComponent], - }); - fixture = TestBed.createComponent(TestComponent); - testComponent = fixture.componentInstance; - editableTableEl = fixture.debugElement.query(By.css("bl-editable-table")); - editableTable = editableTableEl.componentInstance; - fixture.detectChanges(); - }); - - it("should have the right column keys", () => { - const columns = editableTable.columns.toArray(); - - expect(columns.length).toBe(2); - expect(columns[0].name).toBe("blobSource"); - expect(columns[1].name).toBe("filePath"); - }); - - it("should have the right column labels", () => { - const columns = editableTableEl.queryAll(By.css("thead th")); - - expect(columns.length).toBe(3); - expect(columns[0].nativeElement.textContent).toContain("Blob source"); - expect(columns[1].nativeElement.textContent).toContain("File path"); - }); - - it("Should update the files", () => { - editableTable.addNewItem(); - editableTable.items.controls[0].setValue({ - blobSource: "https://example.com/file.json", - filePath: "path/file.json", - }); - expect(testComponent.files).toEqual([{ - blobSource: "https://example.com/file.json", - filePath: "path/file.json", - }]); - }); -}); diff --git a/test/app/utils/url-utils.spec.ts b/test/app/utils/url-utils.spec.ts deleted file mode 100644 index 823fca35bf..0000000000 --- a/test/app/utils/url-utils.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { UrlUtils } from "app/utils"; - -describe("UrlUtils", () => { - it("parseParams correctly", () => { - expect(UrlUtils.parseParams("param1=val1¶m2=val2")).toEqual({ - param1: "val1", - param2: "val2", - }); - }); -});