From b72fb248b9142e7ce375be2fb810e26845ef77ec Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Fri, 27 Sep 2024 13:16:06 -0400 Subject: [PATCH] feat(edit-content): drag and drop functionality (#30135) ### Parent Issue #29872 ### Proposed Changes * Create new component for Image & File component * Create new state to handle drag and drop functionality * Work in Preview component * Upload file as dotAsset ### Checklist - [x] Tests - [x] Translations - [x] Security Implications Contemplated (add notes if applicable) ### Screenshots https://github.com/user-attachments/assets/90f5deb2-a416-4e7f-bfcd-db68d9d51a76 --- .../dot-upload-file.service.spec.ts | 39 ++- .../dot-upload-file.service.ts | 46 ++-- .../dot-workflow-actions-fire.service.spec.ts | 36 +++ .../dot-workflow-actions-fire.service.ts | 32 ++- .../src/lib/dot-contentlet.model.ts | 1 + core-web/libs/dotcms-scss/jsp/index.html | 228 +++++++++++------- core-web/libs/dotcms-scss/jsp/styleguide.html | 96 +++++--- .../dot-edit-content-field.component.spec.ts | 34 ++- .../dot-binary-field-preview.component.scss | 1 - .../dot-file-field-preview.component.html | 140 ++++++++++- .../dot-file-field-preview.component.scss | 207 ++++++++++++++++ .../dot-file-field-preview.component.spec.ts | 176 ++++++++++++++ .../dot-file-field-preview.component.ts | 200 ++++++++++++++- .../dot-file-field-ui-message.component.html | 13 +- .../dot-file-field-ui-message.component.scss | 1 + ...ot-file-field-ui-message.component.spec.ts | 56 +++++ .../dot-file-field-ui-message.component.ts | 9 +- ...dot-edit-content-file-field.component.html | 45 ++-- ...dot-edit-content-file-field.component.scss | 9 +- ...-edit-content-file-field.component.spec.ts | 3 +- ...it-content-file-field.component.stories.ts | 76 ------ .../dot-edit-content-file-field.component.ts | 132 +++++++++- .../dot-edit-content-file-field.const.ts | 16 +- .../dot-edit-content-file-field.stories.ts | 133 ++++++++++ .../models/index.ts | 27 +++ .../upload-file/upload-file.service.spec.ts | 98 ++++++++ .../upload-file/upload-file.service.ts | 68 ++++++ .../store/file-field.store.ts | 193 +++++++++++++-- .../utils/index.ts | 13 + .../utils/messages.ts | 33 +++ .../utils/mocks.ts | 3 +- .../libs/edit-content/src/lib/utils/mocks.ts | 148 ++++++++++++ 32 files changed, 1997 insertions(+), 315 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.scss create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.spec.ts delete mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.stories.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.stories.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/index.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/messages.ts diff --git a/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.spec.ts b/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.spec.ts index e49dbf3f3ae4..4895e1488a0b 100644 --- a/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.spec.ts @@ -1,27 +1,42 @@ -import { createServiceFactory, SpectatorService } from '@ngneat/spectator'; +import { createHttpFactory, SpectatorHttp, SpyObject, mockProvider } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { DotWorkflowActionsFireService } from '@dotcms/data-access'; import { DotUploadFileService } from './dot-upload-file.service'; -import { DotUploadService } from '../dot-upload/dot-upload.service'; - describe('DotUploadFileService', () => { - let spectator: SpectatorService; - let service: DotUploadFileService; + let spectator: SpectatorHttp; + let dotWorkflowActionsFireService: SpyObject; - const createService = createServiceFactory({ + const createHttp = createHttpFactory({ service: DotUploadFileService, - imports: [HttpClientTestingModule], - providers: [DotUploadService] + providers: [DotUploadFileService, mockProvider(DotWorkflowActionsFireService)] }); beforeEach(() => { - spectator = createService(); - service = spectator.service; + spectator = createHttp(); + + dotWorkflowActionsFireService = spectator.inject(DotWorkflowActionsFireService); }); it('should be created', () => { - expect(service).toBeTruthy(); + expect(spectator.service).toBeTruthy(); + }); + + describe('uploadDotAsset', () => { + it('should upload a file as a dotAsset', () => { + dotWorkflowActionsFireService.newContentlet.mockReturnValueOnce( + of({ entity: { identifier: 'test' } }) + ); + + const file = new File([''], 'test.png', { + type: 'image/png' + }); + + spectator.service.uploadDotAsset(file).subscribe(); + + expect(dotWorkflowActionsFireService.newContentlet).toHaveBeenCalled(); + }); }); }); 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 59b962a07fcc..1dd3d986188b 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 @@ -1,11 +1,11 @@ import { from, Observable, throwError } from 'rxjs'; import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { catchError, pluck, switchMap } from 'rxjs/operators'; -import { DotUploadService } from '@dotcms/data-access'; +import { DotUploadService, DotWorkflowActionsFireService } from '@dotcms/data-access'; import { DotCMSContentlet, DotCMSTempFile } from '@dotcms/dotcms-models'; export enum FileStatus { @@ -29,10 +29,10 @@ interface PublishContentProps { */ @Injectable() export class DotUploadFileService { - constructor( - private http: HttpClient, - private dotUploadService: DotUploadService - ) {} + readonly #BASE_URL = '/api/v1/workflow/actions/default'; + readonly #httpClient = inject(HttpClient); + readonly #uploadService = inject(DotUploadService); + readonly #workflowActionsFireService = inject(DotWorkflowActionsFireService); publishContent({ data, @@ -59,17 +59,13 @@ export class DotUploadFileService { statusCallback(FileStatus.IMPORT); - return this.http - .post( - '/api/v1/workflow/actions/default/fire/PUBLISH', - JSON.stringify({ contentlets }), - { - headers: { - Origin: window.location.hostname, - 'Content-Type': 'application/json;charset=UTF-8' - } + return this.#httpClient + .post(`${this.#BASE_URL}/fire/PUBLISH`, JSON.stringify({ contentlets }), { + headers: { + Origin: window.location.hostname, + 'Content-Type': 'application/json;charset=UTF-8' } - ) + }) .pipe(pluck('entity', 'results')) as Observable; }), catchError((error) => throwError(error)) @@ -82,11 +78,27 @@ export class DotUploadFileService { signal }: PublishContentProps): Observable { return from( - this.dotUploadService.uploadFile({ + this.#uploadService.uploadFile({ file, maxSize, signal }) ); } + + /** + * 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 + */ + uploadDotAsset(file: File) { + const formData = new FormData(); + formData.append('file', file); + + return this.#workflowActionsFireService.newContentlet( + 'dotAsset', + { file: file.name }, + formData + ); + } } diff --git a/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.spec.ts b/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.spec.ts index fc32a26a9e73..c22ef3a7ea76 100644 --- a/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.spec.ts @@ -68,6 +68,42 @@ describe('DotWorkflowActionsFireService', () => { }); }); + it('should SAVE and return a new contentlet with FormData', (done) => { + const mockResult = { + name: 'test' + }; + + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }); + + const requestBody = { + contentlet: { + contentType: 'dotAsset', + file: file.name + } + }; + + const formData = new FormData(); + formData.append('file', file); + + spectator.service + .newContentlet('dotAsset', { file: file.name }, formData) + .subscribe((res) => { + expect(res).toEqual([mockResult]); + done(); + }); + + const req = spectator.expectOne( + '/api/v1/workflow/actions/default/fire/NEW', + HttpMethod.PUT + ); + + expect(req.request.body.get('json')).toEqual(JSON.stringify(requestBody)); + + req.flush({ + entity: [mockResult] + }); + }); + it('should EDIT and return the updated contentlet', (done) => { const mockResult = { inode: '123' diff --git a/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.ts b/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.ts index d48ff5070338..19ddff4c3bea 100644 --- a/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.ts +++ b/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.ts @@ -16,6 +16,7 @@ interface DotActionRequestOptions { data: { [key: string]: string }; action: ActionToFire; individualPermissions?: { [key: string]: string[] }; + formData?: FormData; } export interface DotFireActionOptions { @@ -86,8 +87,12 @@ export class DotWorkflowActionsFireService { * * @memberof DotWorkflowActionsFireService */ - newContentlet(contentType: string, data: { [key: string]: string }): Observable { - return this.request({ contentType, data, action: ActionToFire.NEW }); + newContentlet( + contentType: string, + data: { [key: string]: string }, + formData?: FormData + ): Observable { + return this.request({ contentType, data, action: ActionToFire.NEW, formData }); } /** @@ -171,21 +176,28 @@ export class DotWorkflowActionsFireService { contentType, data, action, - individualPermissions + individualPermissions, + formData }: DotActionRequestOptions): Observable { + let url = `${this.BASE_URL}/actions/default/fire/${action}`; + const contentlet = contentType ? { contentType: contentType, ...data } : data; const bodyRequest = individualPermissions ? { contentlet, individualPermissions } : { contentlet }; + if (data['inode']) { + url += `?inode=${data['inode']}`; + } + + if (formData) { + formData.append('json', JSON.stringify(bodyRequest)); + } + return this.httpClient - .put( - `${this.BASE_URL}/actions/default/fire/${action}${ - data['inode'] ? `?inode=${data['inode']}` : '' - }`, - bodyRequest, - { headers: this.defaultHeaders } - ) + .put(url, formData ? formData : bodyRequest, { + headers: formData ? new HttpHeaders() : this.defaultHeaders + }) .pipe(take(1), pluck('entity')); } } diff --git a/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts b/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts index d48c2d46c53e..bbaaabda9188 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts @@ -32,6 +32,7 @@ export interface DotCMSContentlet { url: string; working: boolean; body?: string; + content?: string; contentTypeIcon?: string; variant?: string; __icon__?: string; diff --git a/core-web/libs/dotcms-scss/jsp/index.html b/core-web/libs/dotcms-scss/jsp/index.html index f7bc4e6b2372..1ba3793e780e 100644 --- a/core-web/libs/dotcms-scss/jsp/index.html +++ b/core-web/libs/dotcms-scss/jsp/index.html @@ -591,12 +591,19 @@

Tooltips

  • - rich text tooltip + + rich + text + tooltip + - Embedded bold RICH text + Embedded + bold + RICH + text weirdness!
  • @@ -606,9 +613,9 @@

    Tooltips

    tooltip on anchor + style="display: none"> + tooltip on anchor +
@@ -621,7 +628,9 @@

Tooltips

data-dojo-type="dijit/Tooltip" data-dojo-props="connectId:'ttBelow', position:['below-centered']" style="display: none; width: 100px"> - I'm below! + I'm + below + ! @@ -633,7 +642,11 @@

Tooltips

data-dojo-type="dijit/Tooltip" data-dojo-props="connectId:'ttRight', position:['after-centered']" style="display: none"> - I'm on the right!
(or left on RTL systems) + I'm on the + right + ! +
+ (or left on RTL systems) @@ -643,7 +656,11 @@

Tooltips

data-dojo-type="dijit/Tooltip" data-dojo-props="connectId:'ttLeft', position:['before-centered','after-centered']" style="display: none"> - I'm on the left!
(or right on RTL systems) + I'm on the + left + ! +
+ (or right on RTL systems) @@ -655,7 +672,9 @@

Tooltips

data-dojo-type="dijit/Tooltip" data-dojo-props="connectId:'ttAbove', position:['above-centered']" style="display: none"> - I'm above! + I'm + above + ! @@ -667,14 +686,14 @@

Tooltips

Dialogs

@@ -852,7 +871,8 @@

Buttons

Toggle -

+
+
@@ -1087,12 +1107,12 @@

dijit/form/CurrencyTextBox


dijit/form/NumberSpinner max=100

- + - + dijit/form/Select value="AK" aria-labelledby="l_selectEnabled"> Alabama - Alaska + + A + l + a + s + k + a + Arizona Arkansas - California - New
  Mexico
+ + C + a + l + i + f + o + r + n + i + a + + + New +
+   Mexico +
Disabled: @@ -1143,33 +1169,39 @@

dijit/form/Select

disabled aria-labelledby="l_selectDisabled"> Alabama - Alaska + + A + l + a + s + k + a + Arizona Arkansas - California - New
  Mexico
+ + C + a + l + i + f + o + r + n + i + a + + + New +
+   Mexico +

dijit/form/FilteringSelect

- + dijit/form/FilteringSelect name:'state2' " /> - + dijit/form/FilteringSelect disabled:true, store:continentStore, name:'state2', searchAttr:'name' " /> - + dijit/form/SimpleTextarea data-dojo-props="height:175, extraPlugins:['|', 'createLink', 'fontName'], styleSheets:'./css/document.css'">
  • - Lorem and a link, what do you - think? + Lorem + and a link + , what do you think?
  • This is the Editor with a Toolbar attached.
@@ -1295,8 +1328,9 @@

dijit/form/SimpleTextarea

data-dojo-props="height:175, extraPlugins:['|', 'createLink', 'fontName'], styleSheets:'./css/document.css', disabled:true">
  • - Lorem and a link, what do you - think? + Lorem + and a link + , what do you think?
  • This is the Editor with a Toolbar attached.
@@ -1337,12 +1371,8 @@

Enabled

data-dojo-props="container:'rightDecoration', style:'width:2em;', maximum:100, count:6, numericMargin:1, constraints:{ pattern:'#' }">
- Slider2 Value: + Slider2 Value: +
Enabled
  • highest
  • -
    Value: +
    + Value:
    dijit/InlineEditBox + dijit/form/Textarea

    These links will - disable + + disable + / - enable + + enable + the text area above.

    @@ -1576,9 +1607,9 @@

    dijit/form/FilteringSelect + Inline + remote data store

    editor: "dijit/form/FilteringSelect", editorParams:{ store: continentStore, autoComplete: true, promptMessage: "Please enter a place"}, width:"300px" - ' - >The earth + '> + The earth + (HTML after)
    @@ -1593,9 +1624,18 @@

    Vertical Source

    -
    Item X
    -
    Item Y
    -
    Item Z
    +
    + Item + X +
    +
    + Item + Y +
    +
    + Item + Z +
    @@ -1605,9 +1645,18 @@

    Horizontal

    data-dojo-type="dojo/dnd/Source" data-dojo-props="horizontal: true" style="border: 3px solid #ccc; padding: 1em 3em"> -
    Item 1
    -
    Item 2
    -
    Item 3
    +
    + Item + 1 +
    +
    + Item + 2 +
    +
    + Item + 3 +
    @@ -1661,7 +1710,8 @@

    I am tab 4, inlined.

    I am whole slew of Widgets on a page. Jump to - dijit tests to test individual components. + dijit tests + to test individual components.

    There is a right-click [context] pop-up menu here, as well.

    @@ -1700,7 +1750,7 @@

    I am tab 4, inlined.

    style="display: none"> - + - + - + - + - + - + - + - + - + @@ -1029,7 +1047,10 @@

    Forms

    -
    First Name:
    +
    + + First Name: +
    Example Code

    Split Screen

    Demonstration of how to split a screen for a side navigation, folder - tree, or search interface.
    ALETR: + tree, or search interface. +
    + ALETR: Height of Dojo BorderContainer and internal wrappers must be set with JavaScript based of window size. See file /dotCMS/html/portlet/ext/useradmin/view_user.jsp for a working example. @@ -1531,20 +1552,23 @@

    Dojo Quick Reference

    Below are few example of commen Dojo functions used in the dotCMS. For other Dojo resources and examples checkout these other sites: - Dojo Demos + + Dojo Demos + | Dijit Button Test + rel="noopener"> + Dijit Button Test + | - Dojo Campus + + Dojo Campus +


    @@ -1573,9 +1597,9 @@

    Tool Tip

    - This is a tool tip + + This is a tool tip +
    
                           <a href="#" id="tip1">Tool Tip</a>
    diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts
    index 3ce0e4c95b20..4dbc05c58831 100644
    --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts
    +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts
    @@ -14,7 +14,8 @@ import {
         DotHttpErrorManagerService,
         DotLicenseService,
         DotMessageDisplayService,
    -    DotMessageService
    +    DotMessageService,
    +    DotWorkflowActionsFireService
     } from '@dotcms/data-access';
     import { DotKeyValueComponent } from '@dotcms/ui';
     
    @@ -26,6 +27,7 @@ import { DotEditContentCategoryFieldComponent } from '../../fields/dot-edit-cont
     import { DotEditContentCheckboxFieldComponent } from '../../fields/dot-edit-content-checkbox-field/dot-edit-content-checkbox-field.component';
     import { DotEditContentCustomFieldComponent } from '../../fields/dot-edit-content-custom-field/dot-edit-content-custom-field.component';
     import { DotEditContentFileFieldComponent } from '../../fields/dot-edit-content-file-field/dot-edit-content-file-field.component';
    +import { DotFileFieldUploadService } from '../../fields/dot-edit-content-file-field/services/upload-file/upload-file.service';
     import { DotEditContentHostFolderFieldComponent } from '../../fields/dot-edit-content-host-folder-field/dot-edit-content-host-folder-field.component';
     import { DotEditContentJsonFieldComponent } from '../../fields/dot-edit-content-json-field/dot-edit-content-json-field.component';
     import { DotEditContentKeyValueComponent } from '../../fields/dot-edit-content-key-value/dot-edit-content-key-value.component';
    @@ -70,8 +72,24 @@ declare module '@tiptap/core' {
     const FIELD_TYPES_COMPONENTS: Record | DotEditFieldTestBed> = {
         // We had to use unknown because components have different types.
         [FIELD_TYPES.TEXT]: DotEditContentTextFieldComponent,
    -    [FIELD_TYPES.FILE]: DotEditContentFileFieldComponent,
    -    [FIELD_TYPES.IMAGE]: DotEditContentFileFieldComponent,
    +    [FIELD_TYPES.FILE]: {
    +        component: DotEditContentFileFieldComponent,
    +        providers: [
    +            {
    +                provide: DotFileFieldUploadService,
    +                useValue: {}
    +            }
    +        ]
    +    },
    +    [FIELD_TYPES.IMAGE]: {
    +        component: DotEditContentFileFieldComponent,
    +        providers: [
    +            {
    +                provide: DotFileFieldUploadService,
    +                useValue: {}
    +            }
    +        ]
    +    },
         [FIELD_TYPES.TEXTAREA]: DotEditContentTextAreaComponent,
         [FIELD_TYPES.SELECT]: DotEditContentSelectFieldComponent,
         [FIELD_TYPES.RADIO]: DotEditContentRadioFieldComponent,
    @@ -142,6 +160,16 @@ const FIELD_TYPES_COMPONENTS: Record | DotEditFieldTe
         },
         [FIELD_TYPES.WYSIWYG]: {
             component: DotEditContentWYSIWYGFieldComponent,
    +        providers: [
    +            {
    +                provide: DotFileFieldUploadService,
    +                useValue: {}
    +            },
    +            {
    +                provide: DotWorkflowActionsFireService,
    +                useValue: {}
    +            }
    +        ],
             declarations: [MockComponent(EditorComponent)]
         },
         [FIELD_TYPES.CATEGORY]: {
    diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.scss
    index 3c706de304e5..61ccaa81bc1d 100644
    --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.scss
    +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.scss
    @@ -105,7 +105,6 @@ dot-contentlet-thumbnail {
         flex-direction: column;
         gap: $spacing-0;
         padding-top: $spacing-1;
    -    display: none;
     }
     
     .preview-metadata__actions {
    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.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html
    index f0dc6ce69bcf..999f460223cf 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.html
    +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html
    @@ -1 +1,139 @@
    -

    Preview

    +@let previewFile = $previewFile(); +@let metadata = $metadata(); +@let downloadLink = $downloadLink(); + +
    + @if (metadata?.editableAsText) { +
    + {{ $content() }} +
    + } @else { +
    + @if (previewFile.source === 'temp') { + + } @else { + + } +
    + + } + + + + + + +
    + + +
    + {{ 'Size' | dm }}: + +
    + @for (sourceLink of $resourceLinks(); track $index) { + + } + + + + +
    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.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.scss new file mode 100644 index 000000000000..89a60a90421d --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.scss @@ -0,0 +1,207 @@ +@use "variables" as *; + +:host { + display: block; + width: 100%; + height: 100%; +} + +dot-contentlet-thumbnail::ng-deep { + .background-image:not(.svg-thumbnail) { + img { + object-fit: cover; + } + } + + img { + object-fit: contain; + } +} + +.preview-container { + display: flex; + gap: $spacing-1; + align-items: flex-start; + justify-content: center; + height: 100%; + width: 100%; + position: relative; + container-type: inline-size; + container-name: preview; + + &:only-child { + gap: 0; + } +} + +.preview-image__container { + height: 100%; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + background: $color-palette-gray-200; + overflow: hidden; +} + +.preview-metadata__info { + display: flex; + justify-content: flex-start; + align-items: center; + gap: $spacing-0; +} + +.preview-metadata__container { + flex-grow: 1; + padding: $spacing-1; + padding-right: $spacing-6; + flex-direction: column; + overflow: hidden; + gap: $spacing-2; + min-width: 150px; + display: none; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .preview-metadata_header { + font-size: $font-size-md; + font-weight: $font-weight-semi-bold; + margin: 0; + color: $black; + } +} + +.preview-code_container { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + user-select: none; + + code { + background: #ffffff; + color: $color-palette-primary-500; + height: 100%; + width: 100%; + white-space: pre-wrap; + overflow: hidden; + line-height: normal; + } +} + +.preview-metadata__action--responsive { + position: absolute; + bottom: $spacing-1; + right: $spacing-1; + display: flex; + flex-direction: column; + gap: $spacing-1; + z-index: 100; +} + +.preview-resource-links__actions { + position: absolute; + top: 0; + right: 0; + display: none; + flex-direction: column; + gap: $spacing-0; + padding-top: $spacing-1; +} + +.preview-metadata__actions { + position: absolute; + bottom: $spacing-1; + right: 0; + display: none; + justify-content: flex-end; + align-items: center; + gap: $spacing-1; + z-index: 100; +} + +.file-info__item { + display: flex; + padding: $spacing-0 0; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: $spacing-0; + + &:not(:last-child)::after { + content: ""; + display: block; + width: 100%; + height: 1px; + background: $color-palette-gray-200; + margin: $spacing-1 0; + } +} + +.file-info__link { + display: flex; + align-items: center; + gap: $spacing-1; + min-height: 32px; + font-size: $font-size-sm; + width: 100%; + + a { + color: $black; + text-decoration: none; + flex: 1 0 0; + } +} + +.file-info__title { + font-size: $font-size-sm; + font-style: normal; + font-weight: 600; +} + +.file-info__size { + display: flex; + align-items: center; + gap: $spacing-0; +} + +.preview-container--fade::after { + content: ""; + background: linear-gradient(0deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%); + position: absolute; + width: 100%; + height: 50%; + bottom: 0; + left: 0; + border-radius: $border-radius-md; + pointer-events: none; +} + +@container preview (min-width: 500px) { + .preview-metadata__container, + .preview-metadata__actions { + display: flex; + } + + .preview-metadata__action--responsive { + display: none; + } + + .preview-image__container { + height: 100%; + max-width: 17.5rem; + } + + .preview-resource-links__actions { + display: flex; + } + + .preview-overlay__container { + display: none; + } +} 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.spec.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.spec.ts new file mode 100644 index 000000000000..37cd017d3e2d --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.spec.ts @@ -0,0 +1,176 @@ +import { + Spectator, + createComponentFactory, + mockProvider, + SpyObject, + byTestId +} from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; + +import { Dialog } from 'primeng/dialog'; + +import { DotResourceLinksService } from '@dotcms/data-access'; +import { DotCopyButtonComponent } from '@dotcms/ui'; + +import { DotFileFieldPreviewComponent } from './dot-file-field-preview.component'; + +import { NEW_FILE_MOCK, TEMP_FILE_MOCK, NEW_FILE_EDITABLE_MOCK } from '../../../../utils/mocks'; + +describe('DotFileFieldPreviewComponent', () => { + let spectator: Spectator; + let dotResourceLinksService: SpyObject; + + const createComponent = createComponentFactory({ + component: DotFileFieldPreviewComponent, + detectChanges: false, + providers: [provideHttpClient()], + componentProviders: [ + mockProvider(DotResourceLinksService, { + getFileResourceLinks: jest.fn().mockReturnValue( + of({ + configuredImageURL: 'testConfiguredImageURL', + idPath: 'testIdPath', + mimeType: 'testMimeType', + text: 'testText', + versionPath: 'testVersionPath' + }) + ) + }) + ] + }); + + describe('temp preview file', () => { + beforeEach(() => { + spectator = createComponent({ + props: { + previewFile: { + source: 'temp', + file: TEMP_FILE_MOCK + } + } as unknown + }); + dotResourceLinksService = spectator.inject(DotResourceLinksService, true); + }); + + it('should be created', () => { + spectator.detectChanges(); + expect(spectator.component).toBeTruthy(); + }); + }); + + describe('contentlet without content preview file', () => { + beforeEach(() => { + spectator = createComponent({ + props: { + previewFile: { + source: 'contentlet', + file: NEW_FILE_MOCK.entity + } + } as unknown + }); + dotResourceLinksService = spectator.inject(DotResourceLinksService, true); + }); + + it('should be created', () => { + spectator.detectChanges(); + expect(spectator.component).toBeTruthy(); + }); + + it('should be have a dot-contentlet-thumbnail', () => { + spectator.detectChanges(); + expect(spectator.query('dot-contentlet-thumbnail')).toBeTruthy(); + }); + + it('should show proper metadata', () => { + spectator.detectChanges(); + + const { title, width, height } = NEW_FILE_MOCK.entity.assetMetaData; + + const metadataTitleElement = spectator.query(byTestId('metadata-title')); + const metadataDimensionsElement = spectator.query(byTestId('metadata-dimensions')); + const metadataFileSizeElement = spectator.query(byTestId('metadata-file-size')); + + expect(metadataTitleElement).toHaveText(title); + expect(metadataDimensionsElement).toHaveText(`${width} x ${height}`); + expect(metadataFileSizeElement).toHaveText('3.70 MB'); + }); + + it('should show a dialog when click on the proper btn', async () => { + spectator.detectChanges(); + + const infoBtnElement = spectator.query(byTestId('info-btn')); + + const dialogComponent = spectator.query(Dialog); + + spectator.click(infoBtnElement); + expect(dialogComponent.visible).toBeTruthy(); + }); + + it('should show a dialog when click on the proper responsive btn', async () => { + spectator.detectChanges(); + + const infoBtnElement = spectator.query(byTestId('info-btn-responsive')); + + const dialogComponent = spectator.query(Dialog); + + spectator.click(infoBtnElement); + expect(dialogComponent.visible).toBeTruthy(); + }); + + it('should call downloadAsset when click on the proper btn', async () => { + const spyWindowOpen = jest.spyOn(window, 'open'); + spyWindowOpen.mockImplementation(jest.fn()); + + const { inode } = NEW_FILE_MOCK.entity; + + const expectedUrl = `/contentAsset/raw-data/${inode}/asset?byInode=true&force_download=true`; + + spectator.detectChanges(); + + const downloadBtnElement = spectator.query(byTestId('download-btn')); + + spectator.click(downloadBtnElement); + expect(spyWindowOpen).toHaveBeenCalledWith(expectedUrl, '_self'); + }); + + it('should handle a error in fetchResourceLinks', async () => { + dotResourceLinksService.getFileResourceLinks.mockReturnValue(throwError('error')); + spectator.detectChanges(); + }); + + it('should there are the proper resources links', async () => { + spectator.detectChanges(); + + const infoBtnElement = spectator.query(byTestId('info-btn')); + + spectator.click(infoBtnElement); + + const links = spectator.queryAll('.file-info__item'); + const copyBtns = spectator.queryAll(DotCopyButtonComponent); + + expect(links.length).toBe(4); + expect(copyBtns.length).toBe(3); + }); + }); + + describe('contentlet with content preview file', () => { + beforeEach(() => { + spectator = createComponent({ + props: { + previewFile: { + source: 'contentlet', + file: NEW_FILE_EDITABLE_MOCK.entity + } + } as unknown + }); + dotResourceLinksService = spectator.inject(DotResourceLinksService, true); + }); + + it('should be created', () => { + spectator.detectChanges(); + expect(spectator.component).toBeTruthy(); + }); + }); +}); 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 00e7c8b5ec2a..8e9058f27097 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 @@ -1,12 +1,206 @@ -import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, Component } from '@angular/core'; +import { of } from 'rxjs'; + +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + signal, + OnInit +} from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { DialogModule } from 'primeng/dialog'; + +import { catchError } from 'rxjs/operators'; + +import { DotResourceLinksService } from '@dotcms/data-access'; +import { DotCMSBaseTypesContentTypes, DotCMSContentlet } from '@dotcms/dotcms-models'; +import { + DotTempFileThumbnailComponent, + DotFileSizeFormatPipe, + DotMessagePipe, + DotCopyButtonComponent +} from '@dotcms/ui'; + +import { DotPreviewResourceLink, PreviewFile } from '../../models'; +import { getFileMetadata } from '../../utils'; @Component({ selector: 'dot-file-field-preview', standalone: true, - imports: [], + imports: [ + DotTempFileThumbnailComponent, + DotFileSizeFormatPipe, + DotMessagePipe, + ButtonModule, + DialogModule, + DotCopyButtonComponent + ], providers: [], templateUrl: './dot-file-field-preview.component.html', + styleUrls: ['./dot-file-field-preview.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, schemas: [CUSTOM_ELEMENTS_SCHEMA] }) -export class DotFileFieldPreviewComponent {} +export class DotFileFieldPreviewComponent implements OnInit { + readonly #dotResourceLinksService = inject(DotResourceLinksService); + /** + * Preview file + * + * @memberof DotFileFieldPreviewComponent + */ + $previewFile = input.required({ alias: 'previewFile' }); + /** + * Remove file + * + * @memberof DotFileFieldPreviewComponent + */ + removeFile = output(); + /** + * Show dialog + * + * @memberof DotFileFieldPreviewComponent + */ + $showDialog = signal(false); + /** + * File metadata + * + * @memberof DotFileFieldPreviewComponent + */ + $metadata = computed(() => { + const previewFile = this.$previewFile(); + if (previewFile.source === 'temp') { + return previewFile.file.metadata; + } + + return getFileMetadata(previewFile.file); + }); + /** + * Content + * + * @memberof DotFileFieldPreviewComponent + */ + $content = computed(() => { + const previewFile = this.$previewFile(); + if (previewFile.source === 'contentlet') { + return previewFile.file.content; + } + + return null; + }); + /** + * Download link + * + * @memberof DotFileFieldPreviewComponent + */ + $downloadLink = computed(() => { + const previewFile = this.$previewFile(); + if (previewFile.source === 'contentlet') { + const file = previewFile.file; + + return `/contentAsset/raw-data/${file.inode}/asset?byInode=true&force_download=true`; + } + + return null; + }); + + /** + * Resource links + * + * @memberof DotFileFieldPreviewComponent + */ + $resourceLinks = signal([]); + + /** + * OnInit lifecycle hook. + * + * If the source is 'contentlet', calls {@link fetchResourceLinks} to fetch the resource links for the file. + * + * @memberof DotFileFieldPreviewComponent + */ + ngOnInit() { + const previewFile = this.$previewFile(); + + if (previewFile.source === 'contentlet') { + this.fetchResourceLinks(previewFile.file); + } + } + + /** + * Toggle the visibility of the dialog. + * + * @memberof DotFileFieldPreviewComponent + */ + toggleShowDialog() { + this.$showDialog.update((value) => !value); + } + + /** + * Downloads the file at the given link. + * + * @param {string} link The link to the file to download. + * + * @memberof DotFileFieldPreviewComponent + */ + downloadAsset(link: string): void { + window.open(link, '_self'); + } + + /** + * Fetches the resource links for the given contentlet. + * + * @private + * @param {DotCMSContentlet} contentlet The contentlet to fetch the resource links for. + * @memberof DotFileFieldPreviewComponent + */ + private fetchResourceLinks(contentlet: DotCMSContentlet): void { + this.#dotResourceLinksService + .getFileResourceLinks({ + fieldVariable: 'asset', + inodeOrIdentifier: contentlet.identifier + }) + .pipe( + catchError(() => { + return of({ + configuredImageURL: '', + text: '', + versionPath: '', + idPath: '' + }); + }) + ) + .subscribe(({ configuredImageURL, text, versionPath, idPath }) => { + const fileLink = configuredImageURL + ? `${window.location.origin}${configuredImageURL}` + : ''; + + const options = [ + { + key: 'FileLink', + value: fileLink + }, + { + key: 'VersionPath', + value: versionPath + }, + { + key: 'IdPath', + value: idPath + } + ]; + + if (contentlet.baseType === DotCMSBaseTypesContentTypes.FILEASSET) { + options.push({ + key: 'Resource-Link', + value: text + }); + } + + this.$resourceLinks.set(options); + }); + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.html index 56e014741fcb..f77e24d304bf 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.html @@ -1,10 +1,11 @@ -
    - +@let uiMessage = $uiMessage(); + +
    +
    - +
    diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.scss index fccd0d7f5e15..fbd0b6ef596d 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.scss +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.scss @@ -33,4 +33,5 @@ .text { text-align: center; line-height: 140%; + font-size: $font-size-default; } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.spec.ts new file mode 100644 index 000000000000..533e67bc932f --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.spec.ts @@ -0,0 +1,56 @@ +import { Spectator, byTestId, createComponentFactory } from '@ngneat/spectator/jest'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotFileFieldUiMessageComponent } from './dot-file-field-ui-message.component'; + +import { getUiMessage } from '../../utils/messages'; + +describe('DotFileFieldUiMessageComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotFileFieldUiMessageComponent, + detectChanges: false, + imports: [DotMessagePipe], + providers: [ + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'dot.file.field.drag.and.drop.message': 'Drag and Drop File' + }) + } + ] + }); + + describe('default uiMessage', () => { + beforeEach(() => { + spectator = createComponent({ + props: { + uiMessage: getUiMessage('DEFAULT') + } as unknown + }); + }); + + it('should be created', () => { + spectator.detectChanges(); + expect(spectator.component).toBeTruthy(); + }); + + it('should have proper data', () => { + spectator.detectChanges(); + + const expectMessage = getUiMessage('DEFAULT'); + + const severity = spectator.query(byTestId('ui-message-icon-container')); + const messageIcon = spectator.query(byTestId('ui-message-icon')); + const messageText = spectator.query(byTestId('ui-message-span')); + + expect(severity).toHaveClass(expectMessage.severity); + expect(messageIcon).toHaveClass(expectMessage.icon); + expect(messageText).toContainText('Drag and Drop File'); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.ts index 0662de9f92e8..3ffdbf45fa3c 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.ts @@ -1,16 +1,23 @@ import { NgClass } from '@angular/common'; import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { DotMessagePipe } from '@dotcms/ui'; + import { UIMessage } from '../../models'; @Component({ selector: 'dot-file-field-ui-message', standalone: true, - imports: [NgClass], + imports: [NgClass, DotMessagePipe], templateUrl: './dot-file-field-ui-message.component.html', styleUrls: ['./dot-file-field-ui-message.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class DotFileFieldUiMessageComponent { + /** + * The UI message. + * + * @memberof DotFileFieldPreviewComponent + */ $uiMessage = input.required({ alias: 'uiMessage' }); } 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 c8b15727cbb2..42aa3426f436 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 @@ -1,25 +1,36 @@
    @switch (store.fileStatus()) { @case ('init') {
    - - @if (store.uiMessage()) { - - - - } + + + + + -
    @@ -48,7 +59,7 @@ } @case ('preview') { - + @if (store.previewFile()) { + + } } }
    diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.scss index 84690d8c28b1..4171feaaa386 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.scss +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.scss @@ -3,7 +3,7 @@ :host { display: block; container-type: inline-size; - container-name: binaryField; + container-name: fileField; } .file-field__container { @@ -82,13 +82,16 @@ input[type="file"] { display: none; } -@container fileField (max-width: 306px) { +@container fileField (max-width: 500px) { + .file-field__container--preview, .file-field__container--empty { - height: auto; flex-direction: column; justify-content: center; align-items: flex-start; } + .file-field__container--empty { + height: auto; + } .file-field__drop-zone { width: 100%; 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 371a6a458421..a0838c6ecd31 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 @@ -7,6 +7,7 @@ import { DotMessageService } from '@dotcms/data-access'; import { DotDropZoneComponent } from '@dotcms/ui'; import { DotEditContentFileFieldComponent } from './dot-edit-content-file-field.component'; +import { DotFileFieldUploadService } from './services/upload-file/upload-file.service'; import { FileFieldStore } from './store/file-field.store'; import { @@ -21,7 +22,7 @@ describe('DotEditContentFileFieldComponent', () => { const createComponent = createComponentFactory({ component: DotEditContentFileFieldComponent, detectChanges: false, - componentProviders: [FileFieldStore], + componentProviders: [FileFieldStore, mockProvider(DotFileFieldUploadService)], providers: [provideHttpClient(), mockProvider(DotMessageService)], 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.stories.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.stories.ts deleted file mode 100644 index 38d3e6119bef..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.stories.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - moduleMetadata, - StoryObj, - Meta, - applicationConfig, - argsToTemplate -} from '@storybook/angular'; - -import { provideHttpClient } from '@angular/common/http'; -import { FormsModule } from '@angular/forms'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { DotMessageService } from '@dotcms/data-access'; -import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; - -import { DotEditContentFileFieldComponent } from './dot-edit-content-file-field.component'; -import { FileFieldStore } from './store/file-field.store'; -import { MessageServiceMock } from './utils/mocks'; - -import { FILE_FIELD_MOCK, IMAGE_FIELD_MOCK, BINARY_FIELD_MOCK } from '../../utils/mocks'; - -type Args = DotEditContentFileFieldComponent & { - field: DotCMSContentTypeField; - value: string; -}; - -const meta: Meta = { - title: 'Library / Edit Content / File Field', - component: DotEditContentFileFieldComponent, - decorators: [ - applicationConfig({ - providers: [ - provideHttpClient(), - { - provide: DotMessageService, - useValue: MessageServiceMock - } - ] - }), - moduleMetadata({ - imports: [BrowserAnimationsModule, FormsModule], - providers: [FileFieldStore] - }) - ], - render: (args) => ({ - props: args, - template: ` - -

    Current value: {{ value }}

    - ` - }) -}; -export default meta; - -type Story = StoryObj; - -export const FileField: Story = { - args: { - value: '', - field: { ...FILE_FIELD_MOCK } - } -}; - -export const ImageField: Story = { - args: { - value: '', - field: { ...IMAGE_FIELD_MOCK } - } -}; - -export const BinaryField: Story = { - args: { - value: '', - field: { ...BINARY_FIELD_MOCK } - } -}; 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 1b652b4f6ca1..d645cd0ce8fd 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 @@ -1,29 +1,32 @@ import { ChangeDetectionStrategy, Component, + effect, forwardRef, inject, input, - signal, OnInit } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ButtonModule } from 'primeng/button'; -import { DotMessageService } from '@dotcms/data-access'; import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; import { DotDropZoneComponent, DotMessagePipe, DotAIImagePromptComponent, - DotSpinnerModule + DotSpinnerModule, + DropZoneFileEvent, + DropZoneFileValidity } from '@dotcms/ui'; 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 { INPUT_TYPES } from './models'; +import { DotFileFieldUploadService } from './services/upload-file/upload-file.service'; import { FileFieldStore } from './store/file-field.store'; +import { getUiMessage } from './utils/messages'; @Component({ selector: 'dot-edit-content-file-field', @@ -38,6 +41,7 @@ import { FileFieldStore } from './store/file-field.store'; DotFileFieldPreviewComponent ], providers: [ + DotFileFieldUploadService, FileFieldStore, { multi: true, @@ -50,35 +54,137 @@ import { FileFieldStore } from './store/file-field.store'; changeDetection: ChangeDetectionStrategy.OnPush }) export class DotEditContentFileFieldComponent implements ControlValueAccessor, OnInit { + /** + * FileFieldStore + * + * @memberof DotEditContentFileFieldComponent + */ readonly store = inject(FileFieldStore); - readonly #messageService = inject(DotMessageService); - + /** + * DotCMS Content Type Field + * + * @memberof DotEditContentFileFieldComponent + */ $field = input.required({ alias: 'field' }); private onChange: (value: string) => void; private onTouched: () => void; - $value = signal(''); + constructor() { + effect(() => { + const value = this.store.value(); + this.onChange(value); + this.onTouched(); + }); + } + /** + * OnInit lifecycle hook. + * + * Initialize the store with the content type field data. + * + * @memberof DotEditContentFileFieldComponent + */ ngOnInit() { + const field = this.$field(); + this.store.initLoad({ - inputType: this.$field().fieldType as INPUT_TYPES, - uiMessage: { - message: this.#messageService.get('dot.file.field.drag.and.drop.message'), - severity: 'info', - icon: 'pi pi-upload' - } + fieldVariable: field.variable, + inputType: field.fieldType as INPUT_TYPES }); } + /** + * Set the value of the field. + * If the value is empty, nothing happens. + * If the value is not empty, the store is called to get the asset data. + * + * @param value the value to set + */ writeValue(value: string): void { - this.$value.set(value); + if (!value) { + return; + } + + this.store.getAssetData(value); } + /** + * Registers a callback function that is called when the control's value changes in the UI. + * This function is passed to the {@link NG_VALUE_ACCESSOR} token. + * + * @param fn The callback function to register. + */ registerOnChange(fn: (value: string) => void) { this.onChange = fn; } + /** + * Registers a callback function that is called when the control is marked as touched in the UI. + * This function is passed to the {@link NG_VALUE_ACCESSOR} token. + * + * @param fn The callback function to register. + */ registerOnTouched(fn: () => void) { this.onTouched = fn; } + + /** + * Handle file drop event. + * + * If the file is invalid, show an error message. + * If the file is valid, call the store to handle the upload file. + * + * @param {DropZoneFileEvent} { validity, file } + * + * @return {void} + */ + handleFileDrop({ validity, file }: DropZoneFileEvent): void { + if (!file) { + return; + } + + if (!validity.valid) { + this.handleFileDropError(validity); + + return; + } + + this.store.handleUploadFile(file); + } + + /** + * Handles the file input change event. + * + * If the file is empty, nothing happens. + * If the file is not empty, the store is called to handle the upload file. + * + * @param files The file list from the input change event. + * + * @return {void} + */ + fileSelected(files: FileList) { + const file = files[0]; + + if (!file) { + return; + } + + this.store.handleUploadFile(file); + } + + /** + * Handles the file drop error event. + * + * Gets the first error type from the validity and gets the corresponding UI message. + * Sets the UI message in the store. + * + * @param {DropZoneFileValidity} validity The validity object with the error type. + * + * @return {void} + */ + private handleFileDropError({ errorsType }: DropZoneFileValidity): void { + const errorType = errorsType[0]; + const uiMessage = getUiMessage(errorType); + this.store.setUIMessage(uiMessage); + } } 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 1b2230740421..796aa5997f6f 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 @@ -5,27 +5,35 @@ type Actions = { allowURLImport: boolean; allowCreateFile: boolean; allowGenerateImg: boolean; + acceptedFiles: string[]; + maxFileSize: number; }; type ConfigActions = Record; -export const INPUT_CONFIG_ACTIONS: ConfigActions = { +export const INPUT_CONFIG: ConfigActions = { File: { allowExistingFile: true, allowURLImport: true, allowCreateFile: true, - allowGenerateImg: false + allowGenerateImg: false, + acceptedFiles: [], + maxFileSize: null }, Image: { allowExistingFile: true, allowURLImport: true, allowCreateFile: false, - allowGenerateImg: true + allowGenerateImg: true, + acceptedFiles: ['image/*'], + maxFileSize: null }, Binary: { allowExistingFile: false, allowURLImport: true, allowCreateFile: true, - allowGenerateImg: true + allowGenerateImg: true, + acceptedFiles: [], + maxFileSize: null } }; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.stories.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.stories.ts new file mode 100644 index 000000000000..30af8811f100 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.stories.ts @@ -0,0 +1,133 @@ +import { + moduleMetadata, + StoryObj, + Meta, + applicationConfig, + argsToTemplate +} from '@storybook/angular'; +import { of } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; +import { FormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { DotMessageService, DotUploadFileService } from '@dotcms/data-access'; +import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; + +import { DotEditContentFileFieldComponent } from './dot-edit-content-file-field.component'; +import { UIMessage } from './models'; +import { DotFileFieldUploadService } from './services/upload-file/upload-file.service'; +import { FileFieldStore } from './store/file-field.store'; +import { MessageServiceMock } from './utils/mocks'; + +import { DotEditContentService } from '../../services/dot-edit-content.service'; +import { + BINARY_FIELD_MOCK, + FILE_FIELD_MOCK, + IMAGE_FIELD_MOCK, + NEW_FILE_MOCK +} from '../../utils/mocks'; + +type Args = DotEditContentFileFieldComponent & { + field: DotCMSContentTypeField; + value: string; + uiMessage?: UIMessage; +}; + +const meta: Meta = { + title: 'Library / Edit Content / File Field', + component: DotEditContentFileFieldComponent, + decorators: [ + applicationConfig({ + providers: [ + provideHttpClient(), + { + provide: DotMessageService, + useValue: MessageServiceMock + } + ] + }), + moduleMetadata({ + imports: [BrowserAnimationsModule, FormsModule], + providers: [ + { + provide: DotUploadFileService, + useValue: {} + }, + { + provide: DotEditContentService, + useValue: {} + }, + { + provide: DotFileFieldUploadService, + useValue: { + uploadDotAsset: () => of(NEW_FILE_MOCK.entity), + getContentById: () => of(NEW_FILE_MOCK.entity) + } + }, + FileFieldStore + ] + }) + ], + render: (args) => { + const { value, ...newArgs } = args; + + return { + props: { + ...newArgs, + value + }, + template: ` + +

    Current value: {{ value }}

    + ` + }; + } +}; +export default meta; + +type Story = StoryObj; + +export const FileField: Story = { + args: { + value: '', + field: { ...FILE_FIELD_MOCK } + } +}; + +export const BinaryField: Story = { + args: { + value: '', + field: { ...BINARY_FIELD_MOCK } + } +}; + +export const ImageField: Story = { + args: { + value: '', + field: { ...IMAGE_FIELD_MOCK } + } +}; + +export const ResposiveFileField: Story = { + args: { + value: '', + field: { ...FILE_FIELD_MOCK } + }, + render: (args) => { + const { value, ...newArgs } = args; + + return { + props: { + ...newArgs, + value + }, + template: ` +
    + +

    Current value: {{ value }}

    +
    + ` + }; + } +}; 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 adbe5448d197..7eab20aa55ef 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 @@ -1,3 +1,5 @@ +import { DotCMSContentlet, DotCMSTempFile } from '@dotcms/dotcms-models'; + export type INPUT_TYPES = 'File' | 'Image' | 'Binary'; export type FILE_STATUS = 'init' | 'uploading' | 'preview'; @@ -6,4 +8,29 @@ export interface UIMessage { message: string; severity: 'info' | 'error' | 'warning' | 'success'; icon: string; + args?: string[]; +} + +export type MESSAGES_TYPES = + | 'DEFAULT' + | 'SERVER_ERROR' + | 'FILE_TYPE_MISMATCH' + | 'MAX_FILE_SIZE_EXCEEDED' + | 'MULTIPLE_FILES_DROPPED'; + +export type UIMessagesMap = Record; + +export type PreviewFile = + | { + source: 'temp'; + file: DotCMSTempFile; + } + | { + source: 'contentlet'; + file: DotCMSContentlet; + }; + +export interface DotPreviewResourceLink { + key: string; + value: string; } 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 new file mode 100644 index 000000000000..eb4110ecc1b7 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.spec.ts @@ -0,0 +1,98 @@ +import { + createHttpFactory, + mockProvider, + SpectatorHttp, + SpyObject, + HttpMethod +} from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; + +import { DotUploadFileService } 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'; + +describe('DotFileFieldUploadService', () => { + let spectator: SpectatorHttp; + let dotUploadFileService: SpyObject; + let dotEditContentService: SpyObject; + + const createHttp = createHttpFactory({ + service: DotFileFieldUploadService, + providers: [mockProvider(DotUploadFileService), mockProvider(DotEditContentService)] + }); + + beforeEach(() => { + spectator = createHttp(); + dotUploadFileService = spectator.inject(DotUploadFileService); + dotEditContentService = spectator.inject(DotEditContentService); + }); + + it('should be created', () => { + expect(spectator.service).toBeTruthy(); + }); + + describe('uploadFile', () => { + it('should upload a file without content', () => { + dotUploadFileService.uploadDotAsset.mockReturnValue(of(NEW_FILE_MOCK.entity)); + + const file = new File([''], 'test.png', { + type: 'image/png' + }); + + spectator.service.uploadDotAsset(file).subscribe(); + + expect(dotUploadFileService.uploadDotAsset).toHaveBeenCalled(); + }); + + it('should upload a file with content', () => { + dotUploadFileService.uploadDotAsset.mockReturnValue(of(NEW_FILE_EDITABLE_MOCK.entity)); + + const file = new File(['my content'], 'docker-compose.yml', { + type: 'text/plain' + }); + + spectator.service.uploadDotAsset(file).subscribe((fileContent) => { + expect(fileContent.content).toEqual('my content'); + }); + + const req = spectator.expectOne( + NEW_FILE_EDITABLE_MOCK.entity.assetVersion, + HttpMethod.GET + ); + req.flush('my content'); + + expect(dotUploadFileService.uploadDotAsset).toHaveBeenCalled(); + }); + }); + + describe('getContentById', () => { + it('should get a contentlet without content', () => { + dotEditContentService.getContentById.mockReturnValue(of(NEW_FILE_MOCK.entity)); + + spectator.service.getContentById(NEW_FILE_MOCK.entity.identifier).subscribe(); + + expect(dotEditContentService.getContentById).toHaveBeenCalled(); + }); + + it('should get a contentlet with content', () => { + dotEditContentService.getContentById.mockReturnValue(of(NEW_FILE_EDITABLE_MOCK.entity)); + + spectator.service + .getContentById(NEW_FILE_EDITABLE_MOCK.entity.identifier) + .subscribe((fileContent) => { + expect(fileContent.content).toEqual('my content'); + }); + + const req = spectator.expectOne( + NEW_FILE_EDITABLE_MOCK.entity.assetVersion, + HttpMethod.GET + ); + req.flush('my content'); + + expect(dotEditContentService.getContentById).toHaveBeenCalled(); + }); + }); +}); 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 new file mode 100644 index 000000000000..681046a028da --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.ts @@ -0,0 +1,68 @@ +import { 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 { DotCMSContentlet } from '@dotcms/dotcms-models'; + +import { DotEditContentService } from '../../../../services/dot-edit-content.service'; +import { getFileMetadata, getFileVersion } from '../../utils'; + +@Injectable() +export class DotFileFieldUploadService { + readonly #fileService = inject(DotUploadFileService); + readonly #contentService = inject(DotEditContentService); + readonly #httpClient = inject(HttpClient); + + /** + * 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) { + return this.#fileService + .uploadDotAsset(file) + .pipe(switchMap((contentlet) => this.#addContent(contentlet))); + } + + /** + * Returns a contentlet by its identifier and adds the content if it's a editable as text file. + * @param identifier the identifier of the contentlet + * @returns a contentlet with the content if it's a editable as text file + */ + getContentById(identifier: string) { + return this.#contentService + .getContentById(identifier) + .pipe(switchMap((contentlet) => this.#addContent(contentlet))); + } + + /** + * Adds the content of a contentlet if it's a editable as text file. + * @param contentlet the contentlet to be processed + * @returns a contentlet with the content if it's a editable as text file, otherwise the original contentlet + */ + #addContent(contentlet: DotCMSContentlet) { + const { editableAsText } = getFileMetadata(contentlet); + const contentURL = getFileVersion(contentlet); + + if (editableAsText && contentURL) { + return this.#getContentFile(contentURL).pipe( + map((content) => ({ ...contentlet, content })) + ); + } + + return of(contentlet); + } + + /** + * Downloads the content of a file by its URL. + * @param contentURL the URL of the file content + * @returns an observable of the file content + */ + #getContentFile(contentURL: string) { + return this.#httpClient.get(contentURL, { responseType: 'text' }); + } +} 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 bc82b878d76e..c5056a9e7ab8 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 @@ -1,11 +1,18 @@ +import { tapResponse } from '@ngrx/component-store'; import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { pipe } from 'rxjs'; -import { computed } from '@angular/core'; +import { computed, inject } from '@angular/core'; + +import { filter, switchMap, tap } from 'rxjs/operators'; import { DotCMSContentlet, DotCMSTempFile } from '@dotcms/dotcms-models'; -import { INPUT_CONFIG_ACTIONS } from '../dot-edit-content-file-field.const'; -import { INPUT_TYPES, FILE_STATUS, UIMessage } from '../models'; +import { INPUT_CONFIG } from '../dot-edit-content-file-field.const'; +import { INPUT_TYPES, FILE_STATUS, UIMessage, PreviewFile } from '../models'; +import { DotFileFieldUploadService } from '../services/upload-file/upload-file.service'; +import { getUiMessage } from '../utils/messages'; export interface FileFieldState { contentlet: DotCMSContentlet | null; @@ -20,7 +27,11 @@ export interface FileFieldState { allowGenerateImg: boolean; allowExistingFile: boolean; allowCreateFile: boolean; - uiMessage: UIMessage | null; + uiMessage: UIMessage; + acceptedFiles: string[]; + maxFileSize: number | null; + fieldVariable: string; + previewFile: PreviewFile | null; } const initialState: FileFieldState = { @@ -36,16 +47,25 @@ const initialState: FileFieldState = { allowGenerateImg: false, allowExistingFile: false, allowCreateFile: false, - uiMessage: null + uiMessage: getUiMessage('DEFAULT'), + acceptedFiles: [], + maxFileSize: null, + fieldVariable: '', + previewFile: null }; export const FileFieldStore = signalStore( withState(initialState), withComputed(({ fileStatus }) => ({ - isEmpty: computed(() => { + isInit: computed(() => { + const currentStatus = fileStatus(); + + return currentStatus === 'init'; + }), + isPreview: computed(() => { const currentStatus = fileStatus(); - return currentStatus === 'init' || currentStatus === 'preview'; + return currentStatus === 'preview'; }), isUploading: computed(() => { const currentStatus = fileStatus(); @@ -53,20 +73,147 @@ export const FileFieldStore = signalStore( return currentStatus === 'uploading'; }) })), - withMethods((store) => ({ - initLoad: (initState: { - inputType: FileFieldState['inputType']; - uiMessage: FileFieldState['uiMessage']; - }) => { - const { inputType, uiMessage } = initState; - - const actions = INPUT_CONFIG_ACTIONS[inputType] || {}; - - patchState(store, { - inputType, - uiMessage, - ...actions - }); - } - })) + withMethods((store) => { + const uploadService = inject(DotFileFieldUploadService); + + return { + /** + * initLoad is used to init load + * @param initState + */ + initLoad: (initState: { + inputType: FileFieldState['inputType']; + fieldVariable: FileFieldState['fieldVariable']; + }) => { + const { inputType, fieldVariable } = initState; + + const actions = INPUT_CONFIG[inputType] || {}; + + patchState(store, { + inputType, + fieldVariable, + ...actions + }); + }, + /** + * setUIMessage is used to set uiMessage + * @param uiMessage + */ + setUIMessage: (uiMessage: UIMessage) => { + const acceptedFiles = store.acceptedFiles(); + const maxFileSize = store.maxFileSize(); + + patchState(store, { + uiMessage: { + ...uiMessage, + args: [`${maxFileSize}`, acceptedFiles.join(', ')] + } + }); + }, + /** + * removeFile is used to remove file + * @param + */ + removeFile: () => { + patchState(store, { + contentlet: null, + tempFile: null, + value: '', + fileStatus: 'init', + uiMessage: getUiMessage('DEFAULT') + }); + }, + /** + * setDropZoneState is used to set dropZoneActive + * @param state + */ + setDropZoneState: (state: boolean) => { + patchState(store, { + dropZoneActive: state + }); + }, + /** + * handleUploadFile is used to upload file + * @param File + */ + handleUploadFile: rxMethod( + pipe( + tap(() => { + patchState(store, { + dropZoneActive: false, + fileStatus: 'uploading' + }); + }), + filter((file) => { + const maxFileSize = store.maxFileSize(); + + if (maxFileSize && file.size > maxFileSize) { + patchState(store, { + fileStatus: 'init', + dropZoneActive: true, + uiMessage: { + ...getUiMessage('MAX_FILE_SIZE_EXCEEDED'), + args: [`${maxFileSize}`] + } + }); + + return false; + } + + return true; + }), + switchMap((file) => { + return uploadService.uploadDotAsset(file).pipe( + tapResponse({ + next: (file) => { + patchState(store, { + tempFile: null, + contentlet: file, + fileStatus: 'preview', + value: file.identifier, + previewFile: { source: 'contentlet', file } + }); + }, + error: () => { + patchState(store, { + fileStatus: 'init', + uiMessage: getUiMessage('SERVER_ERROR') + }); + } + }) + ); + }) + ) + ), + /** + * getAssetData is used to get asset data + * @param File + */ + getAssetData: rxMethod( + pipe( + switchMap((id) => { + return uploadService.getContentById(id).pipe( + tapResponse({ + next: (file) => { + patchState(store, { + tempFile: null, + contentlet: file, + fileStatus: 'preview', + value: file.identifier, + previewFile: { source: 'contentlet', file } + }); + }, + error: () => { + patchState(store, { + fileStatus: 'init', + uiMessage: getUiMessage('SERVER_ERROR') + }); + } + }) + ); + }) + ) + ) + }; + }) ); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/index.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/index.ts new file mode 100644 index 000000000000..18e3633de32c --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/index.ts @@ -0,0 +1,13 @@ +import { DotCMSContentlet, DotFileMetadata } from '@dotcms/dotcms-models'; + +export const getFileMetadata = (contentlet: DotCMSContentlet): DotFileMetadata => { + const { metaData } = contentlet; + + const metadata = metaData || contentlet[`assetMetaData`]; + + return metadata || {}; +}; + +export const getFileVersion = (contentlet: DotCMSContentlet) => { + return contentlet['assetVersion'] || null; +}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/messages.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/messages.ts new file mode 100644 index 000000000000..30f188a17dc4 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/messages.ts @@ -0,0 +1,33 @@ +import { MESSAGES_TYPES, UIMessagesMap } from '../models'; + +export const UiMessageMap: UIMessagesMap = { + DEFAULT: { + message: 'dot.file.field.drag.and.drop.message', + severity: 'info', + icon: 'pi pi-upload' + }, + SERVER_ERROR: { + message: 'dot.file.field.drag.and.drop.error.server.error.message', + severity: 'error', + icon: 'pi pi-exclamation-triangle' + }, + FILE_TYPE_MISMATCH: { + message: 'dot.file.field.drag.and.drop.error.file.not.supported.message', + severity: 'error', + icon: 'pi pi-exclamation-triangle' + }, + MAX_FILE_SIZE_EXCEEDED: { + message: 'dot.file.field.drag.and.drop.error.file.maxsize.exceeded.message', + severity: 'error', + icon: 'pi pi-exclamation-triangle' + }, + MULTIPLE_FILES_DROPPED: { + message: 'dot.file.field.drag.and.drop.error.multiple.files.dropped.message', + severity: 'error', + icon: 'pi pi-exclamation-triangle' + } +}; + +export function getUiMessage(key: MESSAGES_TYPES) { + return UiMessageMap[key]; +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/mocks.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/mocks.ts index 12e5092f8765..5bd006a59da4 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/mocks.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/mocks.ts @@ -15,7 +15,8 @@ const FILE_MESSAGES_MOCK = { 'dot.common.import': 'Import', 'dot.common.remove': 'Remove', 'dot.common.save': 'Save', - 'error.form.validator.required': 'This field is required' + 'error.form.validator.required': 'This field is required', + 'dot.file.field.action.generate.with.dotai': 'Generate With dotAI' }; export const MessageServiceMock = new MockDotMessageService(FILE_MESSAGES_MOCK); diff --git a/core-web/libs/edit-content/src/lib/utils/mocks.ts b/core-web/libs/edit-content/src/lib/utils/mocks.ts index a9525f74a8a0..70e235c7a5d4 100644 --- a/core-web/libs/edit-content/src/lib/utils/mocks.ts +++ b/core-web/libs/edit-content/src/lib/utils/mocks.ts @@ -12,6 +12,7 @@ import { DotCMSContentType, DotCMSContentTypeField, DotCMSContentTypeLayoutRow, + DotCMSTempFile, FeaturedFlags } from '@dotcms/dotcms-models'; import { MockDotMessageService } from '@dotcms/utils-testing'; @@ -1390,3 +1391,150 @@ export const TREE_SELECT_MOCK_NODE: CustomTreeNode = { folders: [...TREE_SELECT_MOCK[0].children] } }; + +export const NEW_FILE_MOCK: { entity: DotCMSContentlet } = { + entity: { + AUTO_ASSIGN_WORKFLOW: false, + __IS_NEW_CONTENT__: true, + __icon__: 'Icon', + archived: false, + asset: '/dA/a991ddc5-39dc-4782-bc04-f4c4fa0ccff6/asset/image 2.jpg', + assetContentAsset: 'a991ddc5-39dc-4782-bc04-f4c4fa0ccff6/asset', + assetMetaData: { + contentType: 'image/jpeg', + editableAsText: false, + fileSize: 3878653, + height: 1536, + isImage: true, + length: 3878653, + modDate: 1727377876393, + name: 'image 2.jpg', + sha256: '132597a99d807d12d0b13d9bf3149c6644d9f252e33896d95fc9fd177320da62', + title: 'image 2.jpg', + version: 20220201, + width: 2688 + }, + assetVersion: '/dA/fe160e65-5cf4-4ef6-9b1d-47c5326fec30/asset/image 2.jpg', + baseType: 'DOTASSET', + contentType: 'dotAsset', + creationDate: 1727377876409, + extension: 'jpg', + folder: 'SYSTEM_FOLDER', + hasLiveVersion: true, + hasTitleImage: true, + host: 'SYSTEM_HOST', + hostName: 'System Host', + identifier: 'a991ddc5-39dc-4782-bc04-f4c4fa0ccff6', + inode: 'fe160e65-5cf4-4ef6-9b1d-47c5326fec30', + isContentlet: true, + languageId: 1, + live: true, + locked: false, + mimeType: 'image/jpeg', + modDate: '1727377876407', + modUser: 'dotcms.org.1', + modUserName: 'Admin User', + name: 'image 2.jpg', + owner: 'dotcms.org.1', + ownerName: 'Admin User', + path: '/content.fe160e65-5cf4-4ef6-9b1d-47c5326fec30', + publishDate: 1727377876428, + publishUser: 'dotcms.org.1', + publishUserName: 'Admin User', + size: 3878653, + sortOrder: 0, + stInode: 'f2d8a1c7-2b77-2081-bcf1-b5348988c08d', + statusIcons: + "", + title: 'image 2.jpg', + titleImage: 'asset', + type: 'dotasset', + url: '/content.fe160e65-5cf4-4ef6-9b1d-47c5326fec30', + working: true + } +}; + +export const NEW_FILE_EDITABLE_MOCK: { entity: DotCMSContentlet } = { + entity: { + AUTO_ASSIGN_WORKFLOW: false, + __IS_NEW_CONTENT__: true, + __icon__: 'Icon', + archived: false, + asset: '/dA/a1bb59eb-6708-4701-aeea-ab93c8831203/asset/docker-compose.yml', + assetContentAsset: 'a1bb59eb-6708-4701-aeea-ab93c8831203/asset', + assetMetaData: { + contentType: 'text/plain; charset=ISO-8859-1', + editableAsText: true, + fileSize: 3786, + isImage: false, + length: 3786, + modDate: 1727356262640, + name: 'docker-compose.yml', + sha256: 'd5d719c2f9fe025252e421b0344310d5839ff39ef9e4a38bafd93148591a2439', + title: 'docker-compose.yml', + version: 20220201 + }, + assetVersion: '/dA/eccdb89f-5aa1-4f0c-a9ec-aa97304d80d5/asset/docker-compose.yml', + baseType: 'DOTASSET', + contentType: 'dotAsset', + creationDate: 1727356262667, + extension: 'yml', + folder: 'SYSTEM_FOLDER', + hasLiveVersion: true, + hasTitleImage: false, + host: 'SYSTEM_HOST', + hostName: 'System Host', + identifier: 'a1bb59eb-6708-4701-aeea-ab93c8831203', + inode: 'eccdb89f-5aa1-4f0c-a9ec-aa97304d80d5', + isContentlet: true, + languageId: 1, + live: true, + locked: false, + mimeType: 'unknown', + modDate: '1727356262665', + modUser: 'dotcms.org.1', + modUserName: 'Admin User', + name: 'docker-compose.yml', + owner: 'dotcms.org.1', + ownerName: 'Admin User', + path: '/content.eccdb89f-5aa1-4f0c-a9ec-aa97304d80d5', + publishDate: 1727356262706, + publishUser: 'dotcms.org.1', + publishUserName: 'Admin User', + size: 3786, + sortOrder: 0, + stInode: 'f2d8a1c7-2b77-2081-bcf1-b5348988c08d', + statusIcons: + "", + title: 'docker-compose.yml', + titleImage: 'TITLE_IMAGE_NOT_FOUND', + type: 'dotasset', + url: '/content.eccdb89f-5aa1-4f0c-a9ec-aa97304d80d5', + working: true, + content: 'my content' + } +}; + +export const TEMP_FILE_MOCK: DotCMSTempFile = { + fileName: 'enterprise-angular.pdf', + folder: '', + id: 'temp_1e8021f973', + image: false, + length: 13909932, + metadata: { + contentType: 'application/pdf', + editableAsText: false, + fileSize: 13909932, + isImage: false, + length: 13909932, + modDate: 1727375044693, + name: 'enterprise-angular.pdf', + sha256: '7f8bc1f6485876ca6d49be77917bd35ae3de99f9a56ff94a42df3217419b30cd', + title: 'enterprise-angular.pdf', + version: 20220201 + }, + mimeType: 'application/pdf', + referenceUrl: '/dA/temp_1e8021f973/tmp/enterprise-angular.pdf', + thumbnailUrl: + '/contentAsset/image/temp_1e8021f973/tmp/filter/Thumbnail/thumbnail_w/250/thumbnail_h/250/enterprise-angular.pdf' +};
    I am tab 4, inlined.
    I am tab 4, inlined.

    dotCMS UI Style Guide

    - Code Style Guide + + + Code Style Guide + + | Dojo Demos + rel="noopener"> + Dojo Demos + | Dijit Button Test + rel="noopener"> + Dijit Button Test + | - Dojo Campus + + Dojo Campus +
    @@ -451,42 +451,60 @@

    Action Icons

    Work Flow Task New + Work Flow Task + New + newWorkflowIcon
    Work Flow Task Cancel + Work Flow Task + Cancel + cancelWorkflowIcon
    Work Flow Task Resolve + Work Flow Task + Resolve + resolveWorkflowIcon
    Work Flow Task Assign + Work Flow Task + Assign + assignWorkflowIcon
    Work Flow Task Delete + Work Flow Task + Delete + deleteWorkflowIcon
    Work Flow Task Reopen + Work Flow Task + Reopen + reopenWorkflowIcon