From 0c1b222dc8c39779a6ddbf8e23786b17f06a6874 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Thu, 24 Oct 2024 12:10:10 -0400 Subject: [PATCH] feat(edit-content): create new file functionality on File Fields (#30391) ### Parent Issue #29875 ### Proposed Changes This pull request introduces a new file editor component with Angular and PrimeNG, adds a store for managing the editor state, and updates related components and tests. The most important changes include the addition of the `DotFormFileEditorComponent`, its associated styles, constants, and store, as well as updates to the `DotFormImportUrlComponent` and its tests. ### New File Editor Component: * [`dot-form-file-editor.component.html`](diffhunk://#diff-dad0deafcdeaeb3aa5c4374c558e9e5493505545f78dcc46ec7dbaea8cff4f56R1-R59): Added a form for file editing with fields for file name and content, and buttons for saving or canceling the upload. * [`dot-form-file-editor.component.scss`](diffhunk://#diff-4508b6c98b0ef6b04cce00f33fc93d89eaf27283ec4fc731c983ad1d3e637919R1-R93): Added styles for the file editor component, including layout and state-specific styles. * [`dot-form-file-editor.component.ts`](diffhunk://#diff-4e07289537a18f138b8657c2505e3416523ee356c698aeb38f3d519a2257dadaR1-R270): Implemented the logic for the file editor component, including form handling, state management, and interaction with the store. * [`dot-form-file-editor.conts.ts`](diffhunk://#diff-32c45307bdce4649b35b4279ce966eeb847285431f1761f71cbde3caebea90f1R1-R17): Defined default configuration options for the Monaco editor used in the file editor component. * [`form-file-editor.store.ts`](diffhunk://#diff-5184979843abf3baa538afc8613a1fa040a7907c9614bfb64ddc472c31b4cfa7R1-R207): Created a store to manage the state of the file editor, including file upload logic and state transitions. ### Related Component Updates: * [`dot-form-import-url.component.html`](diffhunk://#diff-47b91a5a2108d26181aede1a70773f546e1499df45a54a630daaa7d5670f13c0L22-R22): Updated the validation message binding to use `form.controls.url` instead of `form.get('url')`. * [`dot-form-import-url.component.spec.ts`](diffhunk://#diff-0d18173fe874af517af41ad1e4422ff7681ba1c2ad4daf75c9806a07fa37e7b2R11): Updated the test cases to use the new `ComponentStatus` enum for state transitions. [[1]](diffhunk://#diff-0d18173fe874af517af41ad1e4422ff7681ba1c2ad4daf75c9806a07fa37e7b2R11) [[2]](diffhunk://#diff-0d18173fe874af517af41ad1e4422ff7681ba1c2ad4daf75c9806a07fa37e7b2L67-R68) [[3]](diffhunk://#diff-0d18173fe874af517af41ad1e4422ff7681ba1c2ad4daf75c9806a07fa37e7b2L81-R90) ### Checklist - [x] Tests - [x] Translations - [x] Security Implications Contemplated (add notes if applicable) ### Video https://github.com/user-attachments/assets/123eb3f8-8689-4210-ae03-d31ae18688b1 --------- Co-authored-by: Arcadio Quintero --- .../dot-form-file-editor.component.html | 60 ++++ .../dot-form-file-editor.component.scss | 93 ++++++ .../dot-form-file-editor.component.ts | 275 ++++++++++++++++++ .../dot-form-file-editor.conts.ts | 17 ++ .../store/form-file-editor.store.ts | 207 +++++++++++++ .../dot-form-import-url.component.html | 2 +- .../dot-form-import-url.component.spec.ts | 7 +- .../dot-form-import-url.component.ts | 19 +- .../store/form-import-url.store.spec.ts | 12 +- .../store/form-import-url.store.ts | 16 +- ...dot-edit-content-file-field.component.html | 6 +- .../dot-edit-content-file-field.component.ts | 63 +++- .../dot-edit-content-file-field.const.ts | 2 +- .../store/file-field.store.ts | 2 +- .../utils/editor.ts | 53 ++++ core-web/libs/edit-content/tsconfig.json | 3 +- .../dot-drop-zone/dot-drop-zone.component.ts | 2 +- 17 files changed, 801 insertions(+), 38 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.html create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.scss create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.conts.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/store/form-file-editor.store.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/editor.ts diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.html new file mode 100644 index 000000000000..aff434f107ef --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.html @@ -0,0 +1,60 @@ +
+ @if (store.allowFileNameEdit()) { +
+ + +
+ @let error = store.error(); + @if (error) { + + {{ error | dm: [store.allowFiles()] }} + + } @else { + + } +
+
+ } +
+ + + @let file = store.file(); +
+ + Mime Type: {{ file.mimeType }} +
+
+
+ + + +
+
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.scss new file mode 100644 index 000000000000..ea37a78b38f9 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.scss @@ -0,0 +1,93 @@ +@use "variables" as *; + +:host ::ng-deep { + .editor-container { + position: absolute !important; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} + +.file-field__editor-container { + display: flex; + justify-content: center; + align-items: flex-start; + flex-direction: column; + flex: 1; + width: 100%; + gap: $spacing-1; +} + +.file-field__code-editor { + border: 1px solid $color-palette-gray-400; // Input + display: block; + flex-grow: 1; + width: 100%; + min-height: 30rem; + border-radius: $border-radius-md; + overflow: auto; + position: relative; +} + +.file-field__code-editor--disabled { + background-color: $color-palette-gray-200; + opacity: 0.5; + + &::ng-deep { + .monaco-mouse-cursor-text, + .overflow-guard { + cursor: not-allowed; + } + } +} + +.editor-mode__form { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; +} + +.editor-mode__input-container { + width: 100%; + display: flex; + gap: $spacing-1; + flex-direction: column; +} + +.editor-mode__input { + width: 100%; + display: flex; + flex-direction: column; +} + +.editor-mode__actions { + width: 100%; + display: flex; + gap: $spacing-1; + align-items: center; + justify-content: flex-end; +} + +.editor-mode__helper { + display: flex; + justify-content: flex-start; + align-items: center; + gap: $spacing-1; + color: $color-palette-gray-700; + font-weight: $font-size-sm; + visibility: hidden; +} + +.editor-mode__helper--visible { + visibility: visible; +} + +.error-message { + min-height: $spacing-4; // Fix height to avoid jumping + justify-content: flex-start; + display: flex; +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts new file mode 100644 index 000000000000..edc56360c0ed --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts @@ -0,0 +1,275 @@ +import { MonacoEditorConstructionOptions, MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; + +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + OnInit, + untracked +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { InputTextModule } from 'primeng/inputtext'; + +import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'; + +import { DotMessagePipe, DotFieldValidationMessageComponent } from '@dotcms/ui'; + +import { FormFileEditorStore } from './store/form-file-editor.store'; + +import { UploadedFile } from '../../models'; + +type DialogProps = { + allowFileNameEdit: boolean; + userMonacoOptions: Partial; + uploadedFile: UploadedFile | null; +}; + +@Component({ + selector: 'dot-form-file-editor', + standalone: true, + imports: [ + DotMessagePipe, + ReactiveFormsModule, + DotFieldValidationMessageComponent, + ButtonModule, + InputTextModule, + MonacoEditorModule + ], + templateUrl: './dot-form-file-editor.component.html', + styleUrls: ['./dot-form-file-editor.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [FormFileEditorStore] +}) +export class DotFormFileEditorComponent implements OnInit { + /** + * Injects the FormFileEditorStore into the component. + * + * @readonly + * @type {FormFileEditorStore} + */ + readonly store = inject(FormFileEditorStore); + /** + * A private and readonly instance of FormBuilder injected into the component. + * This instance is used to create and manage forms within the component. + */ + readonly #formBuilder = inject(FormBuilder); + /** + * A reference to the dynamic dialog instance. + * This is a read-only property that is injected using the `DynamicDialogRef` token. + */ + readonly #dialogRef = inject(DynamicDialogRef); + /** + * A read-only private property that holds the configuration for the dynamic dialog. + * This configuration is injected using the `DynamicDialogConfig` class with a generic type of `DialogProps`. + */ + readonly #dialogConfig = inject(DynamicDialogConfig); + + /** + * Form group for the file editor component. + * + * This form contains the following controls: + * - `name`: A required string field that must match the pattern of a valid file name (e.g., "filename.extension"). + * - `content`: An optional string field for the file content. + * + * @readonly + */ + readonly form = this.#formBuilder.nonNullable.group({ + name: ['', [Validators.required, Validators.pattern(/^[^.]+\.[^.]+$/)]], + content: [''] + }); + + /** + * Reference to the MonacoEditorComponent instance within the view. + * This is used to interact with the Monaco Editor component in the template. + * + * @type {MonacoEditorComponent} + */ + #editorRef: monaco.editor.IStandaloneCodeEditor | null = null; + + constructor() { + effect(() => { + const isUploading = this.store.isUploading(); + + if (isUploading) { + this.#disableEditor(); + } else { + this.#enableEditor(); + } + }); + + effect( + () => { + const isDone = this.store.isDone(); + const uploadedFile = this.store.uploadedFile(); + + untracked(() => { + if (isDone) { + this.#dialogRef.close(uploadedFile); + } + }); + }, + { + allowSignalWrites: true + } + ); + + this.nameField.valueChanges + .pipe( + debounceTime(350), + distinctUntilChanged(), + filter((value) => value.length > 0), + takeUntilDestroyed() + ) + .subscribe((value) => { + this.store.setFileName(value); + }); + } + + /** + * Initializes the component by extracting data from the dialog configuration + * and setting up the form and store with the provided values. + * + * @returns {void} + * + * @memberof DotFormFileEditorComponent + * + * @method ngOnInit + * + * @description + * This method is called once the component is initialized. It retrieves the + * dialog configuration data and initializes the form with the uploaded file + * if available. It also sets up the store with the provided Monaco editor + * options, file name edit permission, uploaded file, accepted files, and + * upload type. + */ + ngOnInit(): void { + const data = this.#dialogConfig?.data as DialogProps; + if (!data) { + return; + } + + const { uploadedFile, userMonacoOptions, allowFileNameEdit } = data; + + if (uploadedFile) { + this.#initValuesForm(uploadedFile); + } + + this.store.initLoad({ + monacoOptions: userMonacoOptions || {}, + allowFileNameEdit: allowFileNameEdit || true, + uploadedFile, + acceptedFiles: [], + uploadType: 'dotasset' + }); + } + + /** + * Handles the form submission event. + * + * This method performs the following actions: + * 1. Checks if the form is invalid. If so, marks the form as dirty and updates its validity status. + * 2. If the form is valid, retrieves the raw values from the form and triggers the file upload process via the store. + * + * @returns {void} + */ + onSubmit(): void { + if (this.form.invalid) { + this.form.markAsDirty(); + this.form.updateValueAndValidity(); + + return; + } + + const values = this.form.getRawValue(); + this.store.uploadFile(values); + } + + /** + * Getter for the 'name' field control from the form. + * + * @returns The form control associated with the 'name' field. + */ + get nameField() { + return this.form.controls.name; + } + + /** + * Getter for the 'content' form control. + * + * @returns The 'content' form control from the form group. + */ + get contentField() { + return this.form.controls.content; + } + + /** + * Disables the form and sets the editor to read-only mode. + * + * This method disables the form associated with the component and updates the editor's options + * to make it read-only. It is useful for preventing further user interaction with the form and editor. + * + * @private + */ + #disableEditor() { + if (!this.#editorRef) { + return; + } + + this.form.disable(); + this.#editorRef.updateOptions({ readOnly: true }); + } + + /** + * Enables the form and sets the editor to be editable. + * + * This method performs the following actions: + * 1. Enables the form associated with this component. + * 2. Retrieves the editor instance from the `$editorRef` method. + * 3. Updates the editor options to make it writable (readOnly: false). + */ + #enableEditor() { + if (!this.#editorRef) { + return; + } + + this.form.enable(); + this.#editorRef.updateOptions({ readOnly: false }); + } + + /** + * Initializes the form values with the provided uploaded file data. + * + * @param {UploadedFile} param0 - The uploaded file object containing source and file information. + * @param {string} param0.source - The source of the file, which can be 'temp' or another value. + * @param {File} param0.file - The file object containing file details. + * @param {string} param0.file.fileName - The name of the file if the source is 'temp'. + * @param {string} param0.file.title - The title of the file if the source is not 'temp'. + * @param {string} param0.file.content - The content of the file. + */ + #initValuesForm({ source, file }: UploadedFile): void { + this.form.patchValue({ + name: source === 'temp' ? file.fileName : file.title, + content: file.content + }); + } + + /** + * Cancels the current file upload and closes the dialog. + * + * @remarks + * This method is used to terminate the ongoing file upload process and + * close the associated dialog reference. + */ + cancelUpload(): void { + this.#dialogRef.close(); + } + + onEditorInit(editor: monaco.editor.IStandaloneCodeEditor) { + this.#editorRef = editor; + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.conts.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.conts.ts new file mode 100644 index 000000000000..db6cdf79463b --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.conts.ts @@ -0,0 +1,17 @@ +import { MonacoEditorConstructionOptions } from '@materia-ui/ngx-monaco-editor'; + +export const DEFAULT_MONACO_CONFIG: MonacoEditorConstructionOptions = { + theme: 'vs', + minimap: { + enabled: false + }, + cursorBlinking: 'solid', + overviewRulerBorder: false, + mouseWheelZoom: false, + lineNumbers: 'on', + roundedSelection: false, + automaticLayout: true, + fixedOverflowWidgets: true, + language: 'text', + fontSize: 14 +}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/store/form-file-editor.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/store/form-file-editor.store.ts new file mode 100644 index 000000000000..22dacea3f112 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/store/form-file-editor.store.ts @@ -0,0 +1,207 @@ +import { MonacoEditorConstructionOptions } from '@materia-ui/ngx-monaco-editor'; +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 { ComponentStatus, DotHttpErrorResponse } from '@dotcms/dotcms-models'; + +import { UPLOAD_TYPE, UploadedFile } from '../../../models'; +import { DotFileFieldUploadService } from '../../../services/upload-file/upload-file.service'; +import { extractFileExtension, getInfoByLang } from '../../../utils/editor'; +import { DEFAULT_MONACO_CONFIG } from '../dot-form-file-editor.conts'; + +type FileInfo = { + name: string; + content: string; + mimeType: string; + extension: string; + language: string; +}; + +export interface FormFileEditorState { + file: FileInfo; + allowFileNameEdit: boolean; + status: ComponentStatus; + error: string | null; + monacoOptions: MonacoEditorConstructionOptions; + uploadedFile: UploadedFile | null; + uploadType: UPLOAD_TYPE; + acceptedFiles: string[]; +} + +const initialState: FormFileEditorState = { + file: { + name: '', + content: '', + mimeType: 'plain/text', + extension: '.txt', + language: 'text' + }, + allowFileNameEdit: false, + status: ComponentStatus.INIT, + error: null, + monacoOptions: DEFAULT_MONACO_CONFIG, + uploadedFile: null, + uploadType: 'dotasset', + acceptedFiles: [] +}; + +export const FormFileEditorStore = signalStore( + withState(initialState), + withComputed((state) => ({ + isUploading: computed(() => state.status() === ComponentStatus.LOADING), + isDone: computed(() => state.status() === ComponentStatus.LOADED && state.uploadedFile), + allowFiles: computed(() => state.acceptedFiles().join(', ')), + monacoConfig: computed(() => { + const monacoOptions = state.monacoOptions(); + const { language } = state.file(); + + return { + ...monacoOptions, + language: language + }; + }) + })), + withMethods((store) => { + const uploadService = inject(DotFileFieldUploadService); + + return { + /** + * Sets the file name and updates the file's metadata in the store. + * + * @param name - The new name of the file. + * + * This method performs the following actions: + * 1. Extracts the file extension from the provided name. + * 2. Retrieves file information based on the extracted extension. + * 3. Updates the store with the new file name and its associated metadata, including MIME type, extension, and language. + */ + setFileName(name: string) { + const file = store.file(); + + const extension = extractFileExtension(name); + const info = getInfoByLang(extension); + + patchState(store, { + file: { + ...file, + name, + mimeType: info.mimeType, + extension: info.extension, + language: info.lang + } + }); + }, + /** + * Initializes the file editor state with the provided options. + * + * @param params - The parameters for initializing the file editor. + * @param params.monacoOptions - Partial options for configuring the Monaco editor. + * @param params.allowFileNameEdit - Flag indicating if the file name can be edited. + * @param params.uploadedFile - The uploaded file information, or null if no file is uploaded. + * @param params.acceptedFiles - Array of accepted file types. + * @param params.uploadType - The type of upload being performed. + */ + initLoad({ + monacoOptions, + allowFileNameEdit, + uploadedFile, + acceptedFiles, + uploadType + }: { + monacoOptions: Partial; + allowFileNameEdit: boolean; + uploadedFile: UploadedFile | null; + acceptedFiles: string[]; + uploadType: UPLOAD_TYPE; + }) { + const prevState = store.monacoOptions(); + + const state: Partial = { + monacoOptions: { + ...prevState, + ...monacoOptions + }, + allowFileNameEdit, + acceptedFiles, + uploadType + }; + + if (uploadedFile) { + const { file, source } = uploadedFile; + + const name = source === 'contentlet' ? file.title : file.fileName; + const extension = extractFileExtension(name); + const info = getInfoByLang(extension); + + state.file = { + name, + content: file.content || '', + mimeType: info.mimeType, + extension: info.extension, + language: info.lang + }; + } + + patchState(store, state); + }, + /** + * Uploads the file content to the server. + * + */ + uploadFile: rxMethod<{ + name: string; + content: string; + }>( + pipe( + tap(() => patchState(store, { status: ComponentStatus.LOADING })), + switchMap(({ name, content }) => { + const { mimeType: type } = store.file(); + const uploadType = store.uploadType(); + const acceptedFiles = store.acceptedFiles(); + + const file = new File([content], name, { type }); + + return uploadService + .uploadFile({ + file, + uploadType, + acceptedFiles, + maxSize: null + }) + .pipe( + tapResponse({ + next: (uploadedFile) => { + patchState(store, { + uploadedFile, + status: ComponentStatus.LOADED + }); + }, + error: (error: DotHttpErrorResponse) => { + let errorMessage = error?.message || ''; + + if (error instanceof Error) { + if (errorMessage === 'Invalid file type') { + errorMessage = + 'dot.file.field.error.type.file.not.supported.message'; + } + } + + patchState(store, { + error: errorMessage, + status: ComponentStatus.ERROR + }); + } + }) + ); + }) + ) + ) + }; + }) +); 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 index d72d4c9817e4..1a996fa390d7 100644 --- 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 @@ -19,7 +19,7 @@ } @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.spec.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.spec.ts index c5d122b79d38..278f73bdad29 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.spec.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.spec.ts @@ -8,6 +8,7 @@ import { ReactiveFormsModule } from '@angular/forms'; import { DialogService, DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; +import { ComponentStatus } from '@dotcms/dotcms-models'; import { DotFormImportUrlComponent } from './dot-form-import-url.component'; import { FormImportUrlStore } from './store/form-import-url.store'; @@ -64,7 +65,7 @@ describe('DotFormImportUrlComponent', () => { patchState(store, { file: mockPreviewFile, - status: 'done' + status: ComponentStatus.LOADED }); spectator.flushEffects(); @@ -78,7 +79,7 @@ describe('DotFormImportUrlComponent', () => { const enableSpy = jest.spyOn(spectator.component.form, 'enable'); patchState(store, { - status: 'uploading' + status: ComponentStatus.LOADING }); spectator.flushEffects(); @@ -86,7 +87,7 @@ describe('DotFormImportUrlComponent', () => { expect(disableSpy).toHaveBeenCalled(); patchState(store, { - status: 'done' + status: ComponentStatus.LOADED }); spectator.flushEffects(); 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 index 1e38d552e515..a97121b6daac 100644 --- 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 @@ -1,4 +1,11 @@ -import { ChangeDetectionStrategy, Component, effect, inject, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + OnInit, + untracked +} from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ButtonModule } from 'primeng/button'; @@ -35,7 +42,7 @@ export class DotFormImportUrlComponent implements OnInit { ); #abortController: AbortController | null = null; - readonly form = this.#formBuilder.group({ + readonly form = this.#formBuilder.nonNullable.group({ url: ['', [Validators.required, DotValidators.url]] }); @@ -49,9 +56,11 @@ export class DotFormImportUrlComponent implements OnInit { const file = this.store.file(); const isDone = this.store.isDone(); - if (file && isDone) { - this.#dialogRef.close(file); - } + untracked(() => { + if (isDone) { + this.#dialogRef.close(file); + } + }); }, { allowSignalWrites: true 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.spec.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.spec.ts index 821f0ded9f53..64be5196b51b 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.spec.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.spec.ts @@ -3,6 +3,8 @@ import { of, throwError } from 'rxjs'; import { TestBed } from '@angular/core/testing'; +import { ComponentStatus } from '@dotcms/dotcms-models'; + import { NEW_FILE_MOCK } from './../../../../../utils/mocks'; import { FormImportUrlStore, FormImportUrlState } from './form-import-url.store'; @@ -29,7 +31,7 @@ describe('FormImportUrlStore', () => { it('should initialize with the correct state', () => { expect(store.file()).toBeNull(); - expect(store.status()).toBe('init'); + expect(store.status()).toBe(ComponentStatus.INIT); expect(store.error()).toBeNull(); expect(store.uploadType()).toBe('temp'); expect(store.acceptedFiles()).toEqual([]); @@ -61,9 +63,9 @@ describe('FormImportUrlStore', () => { store.uploadFileByUrl({ fileUrl, abortSignal: abortController.signal }); - expect(store.file().file).toEqual(mockContentlet); - expect(store.file().source).toEqual('contentlet'); - expect(store.status()).toBe('done'); + expect(store.file()?.file).toEqual(mockContentlet); + expect(store.file()?.source).toEqual('contentlet'); + expect(store.status()).toBe(ComponentStatus.LOADED); }); it('should handle upload file by URL error', () => { @@ -77,7 +79,7 @@ describe('FormImportUrlStore', () => { expect(store.error()).toBe( 'dot.file.field.import.from.url.error.file.not.supported.message' ); - expect(store.status()).toBe('error'); + expect(store.status()).toBe(ComponentStatus.ERROR); }); }); }); 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 index 6677b0b6c897..de1dad6995a7 100644 --- 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 @@ -7,14 +7,14 @@ import { computed, inject } from '@angular/core'; import { switchMap, tap } from 'rxjs/operators'; -import { DotHttpErrorResponse } from '@dotcms/dotcms-models'; +import { ComponentStatus, DotHttpErrorResponse } from '@dotcms/dotcms-models'; 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'; + status: ComponentStatus; error: string | null; uploadType: UPLOAD_TYPE; acceptedFiles: string[]; @@ -22,7 +22,7 @@ export interface FormImportUrlState { const initialState: FormImportUrlState = { file: null, - status: 'init', + status: ComponentStatus.INIT, error: null, uploadType: 'temp', acceptedFiles: [] @@ -31,8 +31,8 @@ const initialState: FormImportUrlState = { export const FormImportUrlStore = signalStore( withState(initialState), withComputed((state) => ({ - isLoading: computed(() => state.status() === 'uploading'), - isDone: computed(() => state.status() === 'done'), + isLoading: computed(() => state.status() === ComponentStatus.LOADING), + isDone: computed(() => state.status() === ComponentStatus.LOADED && state.file), allowFiles: computed(() => state.acceptedFiles().join(', ')) })), withMethods((store, uploadService = inject(DotFileFieldUploadService)) => ({ @@ -45,7 +45,7 @@ export const FormImportUrlStore = signalStore( abortSignal: AbortSignal; }>( pipe( - tap(() => patchState(store, { status: 'uploading' })), + tap(() => patchState(store, { status: ComponentStatus.LOADING })), switchMap(({ fileUrl, abortSignal }) => { return uploadService .uploadFile({ @@ -58,7 +58,7 @@ export const FormImportUrlStore = signalStore( .pipe( tapResponse({ next: (file) => { - patchState(store, { file, status: 'done' }); + patchState(store, { file, status: ComponentStatus.LOADED }); }, error: (error: DotHttpErrorResponse) => { let errorMessage = error?.message || ''; @@ -72,7 +72,7 @@ export const FormImportUrlStore = signalStore( patchState(store, { error: errorMessage, - status: 'error' + status: ComponentStatus.ERROR }); } }) 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 39c6954bc2a0..44b1be904025 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 @@ -51,6 +51,7 @@ } @if (store.allowCreateFile()) { } @case ('preview') { - @if (store.uploadedFile()) { + @let uploadedFile = store.uploadedFile(); + @if (uploadedFile) { + [previewFile]="uploadedFile" /> } } } 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 e7a3bdab3743..2782ccb6bc35 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 @@ -32,6 +32,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 { DotFormFileEditorComponent } from './components/dot-form-file-editor/dot-form-file-editor.component'; import { DotFormImportUrlComponent } from './components/dot-form-import-url/dot-form-import-url.component'; import { INPUT_TYPES, UploadedFile } from './models'; import { DotFileFieldUploadService } from './services/upload-file/upload-file.service'; @@ -132,21 +133,19 @@ export class DotEditContentFileFieldComponent implements ControlValueAccessor, O return this.#dotMessageService.get('dot.file.field.action.generate.with.tooltip'); } - return null; + return ''; }); - private onChange: (value: string) => void; - private onTouched: () => void; + private onChange: ((value: string) => void) | null = null; + private onTouched: (() => void) | null = null; constructor() { effect(() => { - if (!this.onChange && !this.onTouched) { - return; + if (this.onChange && this.onTouched) { + const value = this.store.value(); + this.onChange(value); + this.onTouched(); } - - const value = this.store.value(); - this.onChange(value); - this.onTouched(); }); } @@ -234,7 +233,11 @@ export class DotEditContentFileFieldComponent implements ControlValueAccessor, O * * @return {void} */ - fileSelected(files: FileList) { + fileSelected(files: FileList | null) { + if (!files || files.length === 0) { + return; + } + const file = files[0]; if (!file) { @@ -346,6 +349,46 @@ export class DotEditContentFileFieldComponent implements ControlValueAccessor, O }); } + /** + * Opens the file editor dialog with specific configurations and handles the file upload process. + * + * This method performs the following actions: + * - Retrieves the header message for the dialog. + * - Opens the `DotFormFileEditorComponent` dialog with various options such as header, appendTo, closeOnEscape, draggable, keepInViewport, maskStyleClass, resizable, modal, width, and style. + * - Passes data to the dialog, including the uploaded file and a flag to allow file name editing. + * - Subscribes to the dialog's onClose event to handle the uploaded file and update the store with the preview file. + * + */ + showFileEditorDialog() { + const header = this.#dotMessageService.get('dot.file.field.dialog.create.new.file.header'); + + this.#dialogRef = this.#dialogService.open(DotFormFileEditorComponent, { + header, + appendTo: 'body', + closeOnEscape: false, + draggable: false, + keepInViewport: false, + maskStyleClass: 'p-dialog-mask-transparent-ai', + resizable: false, + modal: true, + width: '90%', + style: { 'max-width': '1040px' }, + data: { + uploadedFile: this.store.uploadedFile(), + allowFileNameEdit: true + } + }); + + this.#dialogRef.onClose + .pipe( + filter((file) => !!file), + takeUntilDestroyed(this.#destroyRef) + ) + .subscribe((file) => { + this.store.setPreviewFile(file); + }); + } + /** * Cleanup method. * diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts index 796aa5997f6f..2cd8dcccd076 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts @@ -6,7 +6,7 @@ type Actions = { allowCreateFile: boolean; allowGenerateImg: boolean; acceptedFiles: string[]; - maxFileSize: number; + maxFileSize: number | null; }; type ConfigActions = Record; 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 62c65593b450..5ec332eecd08 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 @@ -76,7 +76,7 @@ export const FileFieldStore = signalStore( * @param initState */ initLoad: (initState: { - inputType: FileFieldState['inputType']; + inputType: INPUT_TYPES; fieldVariable: FileFieldState['fieldVariable']; isAIPluginInstalled?: boolean; }) => { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/editor.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/editor.ts new file mode 100644 index 000000000000..b1e189198080 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/editor.ts @@ -0,0 +1,53 @@ +/** + * Extracts the file extension from a given file name. + * + * @param fileName - The name of the file from which to extract the extension. + * @returns The file extension if present, otherwise an empty string. + */ +export function extractFileExtension(fileName: string): string { + const includesDot = fileName.includes('.'); + + if (!includesDot) { + return ''; + } + + return fileName.split('.').pop() || ''; +} + +/** + * Retrieves language information based on the provided file extension. + * + * @param extension - The file extension to get the language information for. + * @returns An object containing the language id, MIME type, and extension. + * + * @example + * ```typescript + * const info = getInfoByLang('vtl'); + * console.log(info); + * // Output: { lang: 'html', mimeType: 'text/x-velocity', extension: '.vtl' } + * ``` + * + * @remarks + * If the extension is 'vtl', it returns a predefined set of values. + * Otherwise, it searches through the Monaco Editor languages to find a match. + * If no match is found, it defaults to 'text' for the language id, 'text/plain' for the MIME type, and '.txt' for the extension. + */ +export function getInfoByLang(extension: string) { + if (extension === 'vtl') { + return { + lang: 'html', + mimeType: 'text/x-velocity', + extension: '.vtl' + }; + } + + const language = monaco.languages + .getLanguages() + .find((language) => language.extensions?.includes(`.${extension}`)); + + return { + lang: language?.id || 'text', + mimeType: language?.mimetypes?.[0] || 'text/plain', + extension: language?.extensions?.[0] || '.txt' + }; +} diff --git a/core-web/libs/edit-content/tsconfig.json b/core-web/libs/edit-content/tsconfig.json index 56e37bfd9172..2c3af383b4f6 100644 --- a/core-web/libs/edit-content/tsconfig.json +++ b/core-web/libs/edit-content/tsconfig.json @@ -5,7 +5,8 @@ "forceConsistentCasingInFileNames": true, "noImplicitOverride": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "strict": false }, "files": [], "include": [], diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts index 5292f81ac39d..53833d171948 100644 --- a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts @@ -44,7 +44,7 @@ export class DotDropZoneComponent { * Max file size in bytes. * See Docs: https://www.dotcms.com/docs/latest/binary-field#FieldVariables */ - @Input() maxFileSize: number; + @Input() maxFileSize: number | null = null; @Input() set accept(types: string[]) { this._accept = types