Skip to content

Commit

Permalink
feat(block-editor): add import from url feature (#30242)
Browse files Browse the repository at this point in the history
### Parent Issue

#29874 

### Proposed Changes
* Create `dot-form-import-url` component
* Create `FormImportUrlStore` store using signals
* Implement the "Import from URL" for Image and File fields

### Checklist
- [x] Tests
- [x] Translations
- [x] Security Implications Contemplated (add notes if applicable)


### Screenshots


https://github.com/user-attachments/assets/5362fd77-2d5e-4062-b0af-c8e89f79e271
  • Loading branch information
nicobytes authored Oct 8, 2024
1 parent 9583e6c commit a13a99e
Show file tree
Hide file tree
Showing 15 changed files with 489 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<DotCMSContentlet> {
if (file instanceof File) {
const formData = new FormData();
formData.append('file', file);

return this.#workflowActionsFireService.newContentlet<DotCMSContentlet>(
'dotAsset',
{ file: file.name },
formData
);
return this.#workflowActionsFireService.newContentlet<DotCMSContentlet>(
'dotAsset',
{ file: file.name },
formData
);
}

return this.#workflowActionsFireService.newContentlet<DotCMSContentlet>('dotAsset', {
asset: file
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
DotCopyButtonComponent
} from '@dotcms/ui';

import { DotPreviewResourceLink, PreviewFile } from '../../models';
import { DotPreviewResourceLink, UploadedFile } from '../../models';
import { getFileMetadata } from '../../utils';

@Component({
Expand All @@ -53,7 +53,7 @@ export class DotFileFieldPreviewComponent implements OnInit {
*
* @memberof DotFileFieldPreviewComponent
*/
$previewFile = input.required<PreviewFile>({ alias: 'previewFile' });
$previewFile = input.required<UploadedFile>({ alias: 'previewFile' });
/**
* Remove file
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<form (ngSubmit)="onSubmit()" [formGroup]="form" class="url-mode__form" data-testId="form">
<div class="url-mode__input-container">
<label for="url-input">{{ 'dot.file.field.action.import.from.url' | dm }}</label>
<input
id="url-input"
type="text"
autocomplete="off"
formControlName="url"
pInputText
placeholder="https://www.dotcms.com/image.png"
aria-label="URL input field"
data-testId="url-input" />
<div class="error-messsage__container">
@let error = store.error();
@if (error) {
<small class="p-invalid" data-testId="error-msg">
{{ error | dm }}
</small>
} @else {
<dot-field-validation-message
[message]="'dot.file.field.action.import.from.url.error.message' | dm"
[field]="form.get('url')"
data-testId="error-message" />
}
</div>
</div>
<div class="url-mode__actions">
<p-button
(click)="cancelUpload()"
[label]="'dot.common.cancel' | dm"
styleClass="p-button-outlined"
type="button"
aria-label="Cancel button"
data-testId="cancel-button" />
<div>
@if (store.isLoading()) {
<p-button
[icon]="'pi pi-spin pi-spinner'"
type="button"
aria-label="Loading button"
data-testId="loading-button" />
} @else {
<p-button
[label]="'dot.common.import' | dm"
[icon]="'pi pi-download'"
type="submit"
aria-label="Import button"
data-testId="import-button" />
}
</div>
</div>
</form>
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<string>(
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 });
}
}))
);
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<div class="file-field__actions">
@if (store.allowURLImport()) {
<p-button
(click)="showImportUrlDialog()"
[label]="'dot.file.field.action.import.from.url' | dm"
data-testId="action-import-from-url"
icon="pi pi-link"
Expand Down Expand Up @@ -87,10 +88,10 @@
<dot-spinner data-testId="loading" />
}
@case ('preview') {
@if (store.previewFile()) {
@if (store.uploadedFile()) {
<dot-file-field-preview
(removeFile)="store.removeFile()"
[previewFile]="store.previewFile()" />
[previewFile]="store.uploadedFile()" />
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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() }
]
Expand Down
Loading

0 comments on commit a13a99e

Please sign in to comment.