diff --git a/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.ts b/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.ts index 1dd3d986188b..a58774b1db73 100644 --- a/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.ts +++ b/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.ts @@ -87,18 +87,30 @@ export class DotUploadFileService { } /** - * Uploads a file to dotCMS and creates a new dotAsset contentlet - * @param file the file to be uploaded - * @returns an Observable that emits the created contentlet + * Uploads a file or a string as a dotAsset contentlet. + * + * If a File is passed, it will be uploaded and the asset will be created + * with the file name as the contentlet name. + * + * If a string is passed, it will be used as the asset id. + * + * @param file The file to be uploaded or the asset id. + * @returns An observable that resolves to the created contentlet. */ - uploadDotAsset(file: File) { - const formData = new FormData(); - formData.append('file', file); + uploadDotAsset(file: File | string): Observable { + if (file instanceof File) { + const formData = new FormData(); + formData.append('file', file); - return this.#workflowActionsFireService.newContentlet( - 'dotAsset', - { file: file.name }, - formData - ); + return this.#workflowActionsFireService.newContentlet( + 'dotAsset', + { file: file.name }, + formData + ); + } + + return this.#workflowActionsFireService.newContentlet('dotAsset', { + asset: file + }); } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts index 8e9058f27097..f194051b3e88 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts @@ -26,7 +26,7 @@ import { DotCopyButtonComponent } from '@dotcms/ui'; -import { DotPreviewResourceLink, PreviewFile } from '../../models'; +import { DotPreviewResourceLink, UploadedFile } from '../../models'; import { getFileMetadata } from '../../utils'; @Component({ @@ -53,7 +53,7 @@ export class DotFileFieldPreviewComponent implements OnInit { * * @memberof DotFileFieldPreviewComponent */ - $previewFile = input.required({ alias: 'previewFile' }); + $previewFile = input.required({ alias: 'previewFile' }); /** * Remove file * diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.html new file mode 100644 index 000000000000..12bf2368f22d --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.html @@ -0,0 +1,52 @@ +
+
+ + +
+ @let error = store.error(); + @if (error) { + + {{ error | dm }} + + } @else { + + } +
+
+
+ +
+ @if (store.isLoading()) { + + } @else { + + } +
+
+
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.scss new file mode 100644 index 000000000000..861746e22128 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.scss @@ -0,0 +1,37 @@ +@use "variables" as *; + +:host ::ng-deep { + display: block; + width: 32rem; + + .p-button { + width: 100%; + } + + .error-messsage__container { + min-height: $spacing-4; + } +} + +.url-mode__form { + display: flex; + flex-direction: column; + gap: $spacing-3; + justify-content: center; + align-items: flex-start; +} + +.url-mode__input-container { + width: 100%; + display: flex; + gap: $spacing-1; + flex-direction: column; +} + +.url-mode__actions { + width: 100%; + display: flex; + gap: $spacing-1; + align-items: center; + justify-content: flex-end; +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.ts new file mode 100644 index 000000000000..795af8cd9e6c --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.ts @@ -0,0 +1,101 @@ +import { ChangeDetectionStrategy, Component, effect, inject, OnInit } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { InputTextModule } from 'primeng/inputtext'; + +import { DotMessagePipe, DotFieldValidationMessageComponent, DotValidators } from '@dotcms/ui'; + +import { FormImportUrlStore } from './store/form-import-url.store'; + +import { INPUT_TYPE } from '../../../dot-edit-content-text-field/utils'; + +@Component({ + selector: 'dot-form-import-url', + standalone: true, + imports: [ + DotMessagePipe, + ReactiveFormsModule, + DotFieldValidationMessageComponent, + ButtonModule, + InputTextModule + ], + templateUrl: './dot-form-import-url.component.html', + styleUrls: ['./dot-form-import-url.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [FormImportUrlStore] +}) +export class DotFormImportUrlComponent implements OnInit { + readonly store = inject(FormImportUrlStore); + readonly #formBuilder = inject(FormBuilder); + readonly #dialogRef = inject(DynamicDialogRef); + readonly #dialogConfig = inject(DynamicDialogConfig<{ inputType: INPUT_TYPE }>); + + readonly form = this.#formBuilder.group({ + url: ['', [Validators.required, DotValidators.url]] + }); + + /** + * Listens to the `file` and `isDone` signals and closes the dialog once both are truthy. + * The `file` value is passed as the dialog result. + */ + constructor() { + effect( + () => { + const file = this.store.file(); + const isDone = this.store.isDone(); + + if (file && isDone) { + this.#dialogRef.close(file); + } + }, + { + allowSignalWrites: true + } + ); + + effect(() => { + const isLoading = this.store.isLoading(); + if (isLoading) { + this.form.disable(); + } else { + this.form.enable(); + } + }); + } + + /** + * Initializes the component by setting the upload type based on the input type + * of the parent dialog. + * + * If the input type is 'Binary', the upload type is set to 'temp', otherwise it's set to 'dotasset'. + */ + ngOnInit(): void { + const uploadType = this.#dialogConfig?.data?.inputType === 'Binary' ? 'temp' : 'dotasset'; + this.store.setUploadType(uploadType); + } + + /** + * Submits the form, if it's valid, by calling the `uploadFileByUrl` method of the store. + * + * @return {void} + */ + onSubmit(): void { + if (this.form.invalid) { + return; + } + + const { url } = this.form.getRawValue(); + this.store.uploadFileByUrl(url); + } + + /** + * Cancels the upload and closes the dialog. + * + * @return {void} + */ + cancelUpload(): void { + this.#dialogRef.close(); + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.ts new file mode 100644 index 000000000000..d3a86de0db39 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.ts @@ -0,0 +1,64 @@ +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { pipe } from 'rxjs'; + +import { computed, inject } from '@angular/core'; + +import { switchMap, tap } from 'rxjs/operators'; + +import { UploadedFile, UPLOAD_TYPE } from '../../../models'; +import { DotFileFieldUploadService } from '../../../services/upload-file/upload-file.service'; + +export interface FormImportUrlState { + file: UploadedFile | null; + status: 'init' | 'uploading' | 'done' | 'error'; + error: string | null; + uploadType: UPLOAD_TYPE; +} + +const initialState: FormImportUrlState = { + file: null, + status: 'init', + error: null, + uploadType: 'temp' +}; + +export const FormImportUrlStore = signalStore( + withState(initialState), + withComputed((state) => ({ + isLoading: computed(() => state.status() === 'uploading'), + isDone: computed(() => state.status() === 'done') + })), + withMethods((store, uploadService = inject(DotFileFieldUploadService)) => ({ + /** + * uploadFileByUrl - Uploads a file using its URL. + * @param {string} fileUrl - The URL of the file to be uploaded. + */ + uploadFileByUrl: rxMethod( + pipe( + tap(() => patchState(store, { status: 'uploading' })), + switchMap((fileUrl) => { + return uploadService + .uploadFile({ + file: fileUrl, + uploadType: store.uploadType() + }) + .pipe( + tapResponse({ + next: (file) => patchState(store, { file, status: 'done' }), + error: console.error + }) + ); + }) + ) + ), + /** + * Set the upload type (contentlet or temp) for the file. + * @param uploadType the type of upload to perform + */ + setUploadType: (uploadType: FormImportUrlState['uploadType']) => { + patchState(store, { uploadType }); + } + })) +); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html index 42aa3426f436..727714c25340 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html @@ -36,6 +36,7 @@
@if (store.allowURLImport()) { } @case ('preview') { - @if (store.previewFile()) { + @if (store.uploadedFile()) { + [previewFile]="store.uploadedFile()" /> } } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts index eef74e278543..e3b9a5414c49 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts @@ -10,6 +10,8 @@ import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { ControlContainer } from '@angular/forms'; +import { DialogService } from 'primeng/dynamicdialog'; + import { DotMessageService } from '@dotcms/data-access'; import { DotDropZoneComponent, DropZoneErrorType, DropZoneFileEvent } from '@dotcms/ui'; @@ -36,7 +38,7 @@ describe('DotEditContentFileFieldComponent', () => { component: DotEditContentFileFieldComponent, detectChanges: false, componentProviders: [FileFieldStore, mockProvider(DotFileFieldUploadService)], - providers: [provideHttpClient(), mockProvider(DotMessageService)], + providers: [provideHttpClient(), mockProvider(DotMessageService), DialogService], componentViewProviders: [ { provide: ControlContainer, useValue: createFormGroupDirectiveMock() } ] diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts index 6a8081f554e5..0ca97eecdffb 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts @@ -5,12 +5,18 @@ import { forwardRef, inject, input, - OnInit + OnInit, + OnDestroy } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ButtonModule } from 'primeng/button'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { filter } from 'rxjs/operators'; + +import { DotMessageService } from '@dotcms/data-access'; import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; import { DotDropZoneComponent, @@ -23,6 +29,7 @@ import { import { DotFileFieldPreviewComponent } from './components/dot-file-field-preview/dot-file-field-preview.component'; import { DotFileFieldUiMessageComponent } from './components/dot-file-field-ui-message/dot-file-field-ui-message.component'; +import { DotFormImportUrlComponent } from './components/dot-form-import-url/dot-form-import-url.component'; import { INPUT_TYPES } from './models'; import { DotFileFieldUploadService } from './services/upload-file/upload-file.service'; import { FileFieldStore } from './store/file-field.store'; @@ -38,11 +45,13 @@ import { getUiMessage } from './utils/messages'; DotAIImagePromptComponent, DotSpinnerModule, DotFileFieldUiMessageComponent, - DotFileFieldPreviewComponent + DotFileFieldPreviewComponent, + DotFormImportUrlComponent ], providers: [ DotFileFieldUploadService, FileFieldStore, + DialogService, { multi: true, provide: NG_VALUE_ACCESSOR, @@ -53,7 +62,7 @@ import { getUiMessage } from './utils/messages'; styleUrls: ['./dot-edit-content-file-field.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class DotEditContentFileFieldComponent implements ControlValueAccessor, OnInit { +export class DotEditContentFileFieldComponent implements ControlValueAccessor, OnInit, OnDestroy { /** * FileFieldStore * @@ -67,6 +76,10 @@ export class DotEditContentFileFieldComponent implements ControlValueAccessor, O */ $field = input.required({ alias: 'field' }); + readonly #dialogService = inject(DialogService); + readonly #dotMessageService = inject(DotMessageService); + #dialogRef: DynamicDialogRef | null = null; + private onChange: (value: string) => void; private onTouched: () => void; @@ -148,7 +161,7 @@ export class DotEditContentFileFieldComponent implements ControlValueAccessor, O } if (!validity.valid) { - this.handleFileDropError(validity); + this.#handleFileDropError(validity); return; } @@ -186,9 +199,61 @@ export class DotEditContentFileFieldComponent implements ControlValueAccessor, O * * @return {void} */ - private handleFileDropError({ errorsType }: DropZoneFileValidity): void { + #handleFileDropError({ errorsType }: DropZoneFileValidity): void { const errorType = errorsType[0]; const uiMessage = getUiMessage(errorType); this.store.setUIMessage(uiMessage); } + + /** + * Shows the import from URL dialog. + * + * Opens the dialog with the `DotFormImportUrlComponent` component + * and passes the field type as data to the component. + * + * When the dialog is closed, gets the uploaded file from the component + * and sets it as the preview file in the store. + * + * @return {void} + */ + showImportUrlDialog() { + const header = this.#dotMessageService.get('dot.file.field.dialog.import.from.url.header'); + + this.#dialogRef = this.#dialogService.open(DotFormImportUrlComponent, { + header, + appendTo: 'body', + closeOnEscape: false, + draggable: false, + keepInViewport: false, + maskStyleClass: 'p-dialog-mask-transparent', + modal: true, + resizable: false, + position: 'center', + data: { + inputType: this.$field().fieldType + } + }); + + this.#dialogRef.onClose + .pipe( + filter((file) => !!file), + takeUntilDestroyed() + ) + .subscribe((file) => { + this.store.setPreviewFile(file); + }); + } + + /** + * Cleanup method. + * + * Closes the dialog if it is still open when the component is destroyed. + * + * @return {void} + */ + ngOnDestroy() { + if (this.#dialogRef) { + this.#dialogRef.close(); + } + } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/models/index.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/models/index.ts index 7eab20aa55ef..a350dd69aa40 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/models/index.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/models/index.ts @@ -4,6 +4,8 @@ export type INPUT_TYPES = 'File' | 'Image' | 'Binary'; export type FILE_STATUS = 'init' | 'uploading' | 'preview'; +export type UPLOAD_TYPE = 'temp' | 'dotasset'; + export interface UIMessage { message: string; severity: 'info' | 'error' | 'warning' | 'success'; @@ -20,7 +22,7 @@ export type MESSAGES_TYPES = export type UIMessagesMap = Record; -export type PreviewFile = +export type UploadedFile = | { source: 'temp'; file: DotCMSTempFile; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.spec.ts index eb4110ecc1b7..58fae20e6fbe 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.spec.ts @@ -7,27 +7,33 @@ import { } from '@ngneat/spectator/jest'; import { of } from 'rxjs'; -import { DotUploadFileService } from '@dotcms/data-access'; +import { DotUploadFileService, DotUploadService } from '@dotcms/data-access'; import { DotFileFieldUploadService } from './upload-file.service'; import { DotEditContentService } from '../../../../services/dot-edit-content.service'; -import { NEW_FILE_MOCK, NEW_FILE_EDITABLE_MOCK } from '../../../../utils/mocks'; +import { NEW_FILE_MOCK, NEW_FILE_EDITABLE_MOCK, TEMP_FILE_MOCK } from '../../../../utils/mocks'; describe('DotFileFieldUploadService', () => { let spectator: SpectatorHttp; let dotUploadFileService: SpyObject; let dotEditContentService: SpyObject; + let tempFileService: SpyObject; const createHttp = createHttpFactory({ service: DotFileFieldUploadService, - providers: [mockProvider(DotUploadFileService), mockProvider(DotEditContentService)] + providers: [ + mockProvider(DotUploadFileService), + mockProvider(DotEditContentService), + mockProvider(DotUploadService) + ] }); beforeEach(() => { spectator = createHttp(); dotUploadFileService = spectator.inject(DotUploadFileService); dotEditContentService = spectator.inject(DotEditContentService); + tempFileService = spectator.inject(DotUploadService); }); it('should be created', () => { @@ -95,4 +101,45 @@ describe('DotFileFieldUploadService', () => { expect(dotEditContentService.getContentById).toHaveBeenCalled(); }); }); + + describe('uploadFile', () => { + it('should upload a file with temp upload type', () => { + tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); + + const file = new File([''], 'test.png', { type: 'image/png' }); + const uploadType = 'temp'; + spectator.service.uploadFile({ file, uploadType }).subscribe((result) => { + expect(result.source).toBe('temp'); + expect(result.file).toBe(TEMP_FILE_MOCK); + expect(tempFileService.uploadFile).toHaveBeenCalledTimes(1); + }); + }); + + it('should upload a file with contentlet upload type', () => { + dotUploadFileService.uploadDotAsset.mockReturnValue(of(NEW_FILE_MOCK.entity)); + + const file = new File([''], 'test.png', { type: 'image/png' }); + const uploadType = 'dotasset'; + spectator.service.uploadFile({ file, uploadType }).subscribe((result) => { + expect(result.source).toBe('contentlet'); + expect(result.file).toBe(NEW_FILE_MOCK.entity); + expect(dotUploadFileService.uploadDotAsset).toHaveBeenCalledTimes(1); + }); + }); + + it('should upload a file with contentlet upload type', () => { + dotUploadFileService.uploadDotAsset.mockReturnValue(of(NEW_FILE_MOCK.entity)); + tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); + + const file = 'file'; + const uploadType = 'dotasset'; + spectator.service.uploadFile({ file, uploadType }).subscribe((result) => { + expect(result.source).toBe('contentlet'); + expect(result.file).toBe(NEW_FILE_MOCK.entity); + expect(tempFileService.uploadFile).toHaveBeenCalledTimes(1); + expect(dotUploadFileService.uploadDotAsset).toHaveBeenCalledTimes(1); + expect(dotUploadFileService.uploadDotAsset).toHaveBeenCalledWith(TEMP_FILE_MOCK.id); + }); + }); + }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.ts index 681046a028da..f0ae203f0176 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.ts @@ -1,28 +1,66 @@ -import { of } from 'rxjs'; +import { from, Observable, of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { map, switchMap } from 'rxjs/operators'; -import { DotUploadFileService } from '@dotcms/data-access'; +import { DotUploadFileService, DotUploadService } from '@dotcms/data-access'; import { DotCMSContentlet } from '@dotcms/dotcms-models'; import { DotEditContentService } from '../../../../services/dot-edit-content.service'; +import { UploadedFile, UPLOAD_TYPE } from '../../models'; import { getFileMetadata, getFileVersion } from '../../utils'; @Injectable() export class DotFileFieldUploadService { readonly #fileService = inject(DotUploadFileService); + readonly #tempFileService = inject(DotUploadService); readonly #contentService = inject(DotEditContentService); readonly #httpClient = inject(HttpClient); + /** + * Uploads a file or a string as a dotAsset contentlet. + * + * If a File is passed, it will be uploaded and the asset will be created + * with the file name as the contentlet name. + * + * If a string is passed, it will be used as the asset id. + * + * @param file The file to be uploaded or the asset id. + * @param uploadType The type of upload, can be 'temp' or 'contentlet'. + * @returns An observable that resolves to the created contentlet. + */ + uploadFile({ + file, + uploadType + }: { + file: File | string; + uploadType: UPLOAD_TYPE; + }): Observable { + if (uploadType === 'temp') { + return from(this.#tempFileService.uploadFile({ file })).pipe( + map((tempFile) => ({ source: 'temp', file: tempFile })) + ); + } else { + if (file instanceof File) { + return this.uploadDotAsset(file).pipe( + map((file) => ({ source: 'contentlet', file })) + ); + } + + return from(this.#tempFileService.uploadFile({ file })).pipe( + switchMap((tempFile) => this.uploadDotAsset(tempFile.id)), + map((file) => ({ source: 'contentlet', file })) + ); + } + } /** * Uploads a file and returns a contentlet with the file metadata and id. * @param file the file to be uploaded * @returns a contentlet with the file metadata and id */ - uploadDotAsset(file: File) { + uploadDotAsset(file: File | string) { return this.#fileService .uploadDotAsset(file) .pipe(switchMap((contentlet) => this.#addContent(contentlet))); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.spec.ts index 4904da297ec6..8cb29d2f5b51 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.spec.ts @@ -6,7 +6,7 @@ import { TestBed } from '@angular/core/testing'; import { FileFieldStore } from './file-field.store'; -import { NEW_FILE_MOCK, TEMP_FILE_MOCK } from '../../../utils/mocks'; +import { NEW_FILE_MOCK } from '../../../utils/mocks'; import { UIMessage } from '../models'; import { DotFileFieldUploadService } from '../services/upload-file/upload-file.service'; import { getUiMessage } from '../utils/messages'; @@ -100,18 +100,19 @@ describe('FileFieldStore', () => { describe('Method: removeFile', () => { it('should set the state properly when removeFile is called', () => { patchState(store, { - contentlet: NEW_FILE_MOCK.entity, - tempFile: TEMP_FILE_MOCK, + uploadedFile: { + source: 'contentlet', + file: NEW_FILE_MOCK.entity + }, value: 'some value', fileStatus: 'preview', uiMessage: getUiMessage('SERVER_ERROR') }); store.removeFile(); - expect(store.contentlet()).toBeNull(); - expect(store.tempFile()).toBeNull(); expect(store.value()).toBe(''); expect(store.fileStatus()).toBe('init'); expect(store.uiMessage()).toBe(getUiMessage('DEFAULT')); + expect(store.uploadedFile()).toBeNull(); }); }); @@ -190,11 +191,9 @@ describe('FileFieldStore', () => { Object.defineProperty(file, 'size', { value: 5000 }); store.handleUploadFile(file); - expect(store.tempFile()).toBeNull(); expect(store.value()).toBe(mockContentlet.identifier); - expect(store.contentlet()).toEqual(mockContentlet); expect(store.fileStatus()).toBe('preview'); - expect(store.previewFile()).toEqual({ + expect(store.uploadedFile()).toEqual({ source: 'contentlet', file: mockContentlet }); @@ -226,12 +225,9 @@ describe('FileFieldStore', () => { service.getContentById.mockReturnValue(of(mockContentlet)); store.getAssetData(mockContentlet.identifier); - - expect(store.tempFile()).toBeNull(); expect(store.value()).toBe(mockContentlet.identifier); - expect(store.contentlet()).toEqual(mockContentlet); expect(store.fileStatus()).toBe('preview'); - expect(store.previewFile()).toEqual({ + expect(store.uploadedFile()).toEqual({ source: 'contentlet', file: mockContentlet }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts index c5056a9e7ab8..1c253983d190 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts @@ -7,16 +7,12 @@ import { computed, inject } from '@angular/core'; import { filter, switchMap, tap } from 'rxjs/operators'; -import { DotCMSContentlet, DotCMSTempFile } from '@dotcms/dotcms-models'; - import { INPUT_CONFIG } from '../dot-edit-content-file-field.const'; -import { INPUT_TYPES, FILE_STATUS, UIMessage, PreviewFile } from '../models'; +import { INPUT_TYPES, FILE_STATUS, UIMessage, UploadedFile } from '../models'; import { DotFileFieldUploadService } from '../services/upload-file/upload-file.service'; import { getUiMessage } from '../utils/messages'; export interface FileFieldState { - contentlet: DotCMSContentlet | null; - tempFile: DotCMSTempFile | null; value: string; inputType: INPUT_TYPES | null; fileStatus: FILE_STATUS; @@ -31,12 +27,10 @@ export interface FileFieldState { acceptedFiles: string[]; maxFileSize: number | null; fieldVariable: string; - previewFile: PreviewFile | null; + uploadedFile: UploadedFile | null; } const initialState: FileFieldState = { - contentlet: null, - tempFile: null, value: '', inputType: null, fileStatus: 'init', @@ -51,7 +45,7 @@ const initialState: FileFieldState = { acceptedFiles: [], maxFileSize: null, fieldVariable: '', - previewFile: null + uploadedFile: null }; export const FileFieldStore = signalStore( @@ -116,8 +110,7 @@ export const FileFieldStore = signalStore( */ removeFile: () => { patchState(store, { - contentlet: null, - tempFile: null, + uploadedFile: null, value: '', fileStatus: 'init', uiMessage: getUiMessage('DEFAULT') @@ -132,6 +125,17 @@ export const FileFieldStore = signalStore( dropZoneActive: state }); }, + /** + * setPreviewFile is used to set previewFile + * @param file uploaded file + */ + setPreviewFile: (file: UploadedFile) => { + patchState(store, { + fileStatus: 'preview', + uploadedFile: file, + value: file.source === 'temp' ? file.file.id : file.file.identifier + }); + }, /** * handleUploadFile is used to upload file * @param File @@ -167,11 +171,9 @@ export const FileFieldStore = signalStore( tapResponse({ next: (file) => { patchState(store, { - tempFile: null, - contentlet: file, fileStatus: 'preview', value: file.identifier, - previewFile: { source: 'contentlet', file } + uploadedFile: { source: 'contentlet', file } }); }, error: () => { @@ -196,11 +198,9 @@ export const FileFieldStore = signalStore( tapResponse({ next: (file) => { patchState(store, { - tempFile: null, - contentlet: file, fileStatus: 'preview', value: file.identifier, - previewFile: { source: 'contentlet', file } + uploadedFile: { source: 'contentlet', file } }); }, error: () => { diff --git a/core-web/libs/ui/src/lib/validators/dotValidators.ts b/core-web/libs/ui/src/lib/validators/dotValidators.ts index 38b8bc8b755f..82625742f2de 100644 --- a/core-web/libs/ui/src/lib/validators/dotValidators.ts +++ b/core-web/libs/ui/src/lib/validators/dotValidators.ts @@ -1,7 +1,8 @@ -import { AbstractControl } from '@angular/forms'; +import { AbstractControl, ValidationErrors } from '@angular/forms'; const QUERY_PARAM_NAME_REGEX = /^[a-zA-Z0-9\-_]+$/; const ALPHA_NUMERIC_REGEX = /^[a-zA-Z0-9_]*$/; +const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/; const DOT_ERROR_MESSAGES = { alphaNumericErrorMsg: { @@ -12,6 +13,9 @@ const DOT_ERROR_MESSAGES = { }, whiteSpaceOnlyMgs: { whiteSpaceOnly: 'dot.common.form.field.validation.noWhitespace' + }, + urlMsg: { + invalidUrl: 'dot.common.form.field.validation.url' } }; @@ -46,4 +50,14 @@ export class DotValidators { ? null : DOT_ERROR_MESSAGES.whiteSpaceOnlyMgs; } + + /** + * Validate if the given control value is a valid URL + * + * @param {AbstractControl} control + * @returns {ValidationErrors | null} + */ + static url(control: AbstractControl): ValidationErrors | null { + return URL_REGEX.test(control.value) ? null : DOT_ERROR_MESSAGES.urlMsg; + } }