From cfb102a079fb677fea86542caaa7079753694cbf Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Fri, 19 Jun 2026 12:44:18 -0300 Subject: [PATCH 1/3] feat(content-drive): prompt Asset vs File on upload (#35575) Uploading to a folder with no preference now prompts the user to choose between Asset (dotAsset) and File (FileAsset) before the upload runs, for both the Upload button and OS drag-and-drop. The selected base type is sent to the upload endpoint instead of the hardcoded dotAsset. - Parameterize DotUploadFileService.uploadDotAsset with a contentType (defaults to dotAsset; backward compatible) - Add the dot-content-drive-dialog-upload-selector dialog (Assets recommended + selected by default, Files), wired through the shell's UPLOAD_SELECTOR dialog and emitting the full selection (folder + type + files) so a future per-folder "remember" can reuse it - Rename onAddNewDotAsset/addNewDotAsset to onUpload/upload - Fix pre-existing dropzone overlay icon centering and the locale chip wrapping at narrow widths - Tests for the dialog, both upload flows, and the service param Co-Authored-By: Claude Opus 4.8 --- .../dot-upload-file.service.spec.ts | 32 + .../dot-upload-file.service.ts | 9 +- ...rive-dialog-upload-selector.component.html | 52 ++ ...e-dialog-upload-selector.component.spec.ts | 143 +++++ ...-drive-dialog-upload-selector.component.ts | 71 ++ .../dot-content-drive-dropzone.component.html | 9 +- .../dot-content-drive-toolbar.component.html | 2 +- ...ot-content-drive-toolbar.component.spec.ts | 4 +- .../dot-content-drive-toolbar.component.ts | 2 +- .../dot-content-drive-shell.component.html | 15 +- .../dot-content-drive-shell.component.spec.ts | 605 +++++++----------- .../dot-content-drive-shell.component.ts | 148 ++++- .../portlet/src/lib/shared/constants.ts | 24 +- .../portlet/src/lib/shared/models.ts | 28 +- .../dot-folder-list-view.component.html | 1 + .../WEB-INF/messages/Language.properties | 7 + 16 files changed, 721 insertions(+), 431 deletions(-) create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.html create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.spec.ts create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.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 740b89170d07..1e05ad37b79e 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 @@ -52,5 +52,37 @@ describe('DotUploadFileService', () => { expect(dotWorkflowActionsFireService.newContentlet).toHaveBeenCalled(); }); + + it('should default to the dotAsset content type', () => { + dotWorkflowActionsFireService.newContentlet.mockReturnValueOnce( + of({ entity: { identifier: 'test' } }) + ); + + const file = new File([''], 'test.png', { type: 'image/png' }); + + spectator.service.uploadDotAsset(file).subscribe(); + + expect(dotWorkflowActionsFireService.newContentlet).toHaveBeenCalledWith( + 'dotAsset', + expect.anything(), + expect.anything() + ); + }); + + it('should fire the given content type when one is provided', () => { + dotWorkflowActionsFireService.newContentlet.mockReturnValueOnce( + of({ entity: { identifier: 'test' } }) + ); + + const file = new File([''], 'test.png', { type: 'image/png' }); + + spectator.service.uploadDotAsset(file, { hostFolder: '123' }, 'FileAsset').subscribe(); + + expect(dotWorkflowActionsFireService.newContentlet).toHaveBeenCalledWith( + 'FileAsset', + expect.anything(), + expect.anything() + ); + }); }); }); 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 8814e1186c5c..f4bd4caae628 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 @@ -107,24 +107,27 @@ export class DotUploadFileService { * @param file The file to be uploaded or the asset id. * @param extraData Additional data to be included in the contentlet object. This will be merged with * the base contentlet data in the request body. + * @param contentType The content type variable to create the contentlet as. Defaults to `dotAsset`; + * pass `FileAsset` (or another binary content type) to upload as a different type. * @returns An observable that resolves to the created contentlet. */ uploadDotAsset( file: File | string, - extraData?: DotActionRequestOptions['data'] + extraData?: DotActionRequestOptions['data'], + contentType = 'dotAsset' ): Observable { if (file instanceof File) { const formData = new FormData(); formData.append('file', file); return this.#workflowActionsFireService.newContentlet( - 'dotAsset', + contentType, { file: file.name, ...extraData }, formData ); } - return this.#workflowActionsFireService.newContentlet('dotAsset', { + return this.#workflowActionsFireService.newContentlet(contentType, { asset: file }); } diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.html new file mode 100644 index 000000000000..9651f8f59f55 --- /dev/null +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.html @@ -0,0 +1,52 @@ +
+
+ @for (option of options; track option.contentType) { + + } +
+
+ +
+ + +
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.spec.ts new file mode 100644 index 000000000000..195cc2cecf8d --- /dev/null +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.spec.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { + byTestId, + createComponentFactory, + mockProvider, + Spectator, + SpyObject +} from '@ngneat/spectator/jest'; + +import { By } from '@angular/platform-browser'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotFolderTreeNodeData } from '@dotcms/portlets/content-drive/ui'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotContentDriveDialogUploadSelectorComponent } from './dot-content-drive-dialog-upload-selector.component'; + +import { DotContentDriveUploadSelection } from '../../../shared/models'; +import { DotContentDriveStore } from '../../../store/dot-content-drive.store'; + +const TARGET_FOLDER = { + id: 'folder-123', + hostname: 'localhost', + path: 'folder-123', + type: 'folder' +} as DotFolderTreeNodeData; + +describe('DotContentDriveDialogUploadSelectorComponent', () => { + let spectator: Spectator; + let store: SpyObject>; + + const createComponent = createComponentFactory({ + component: DotContentDriveDialogUploadSelectorComponent, + providers: [ + mockProvider(DotContentDriveStore, { + closeDialog: jest.fn() + }), + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'dot.common.cancel': 'Cancel', + 'content-drive.dialog.upload-selector.asset': 'Asset', + 'content-drive.dialog.upload-selector.asset.description': 'For images', + 'content-drive.dialog.upload-selector.file': 'File', + 'content-drive.dialog.upload-selector.file.description': 'For code', + 'content-drive.dialog.upload-selector.recommended': 'Recommended', + 'content-drive.dialog.upload-selector.continue': 'Continue' + }) + } + ], + detectChanges: false + }); + + beforeEach(() => { + spectator = createComponent(); + spectator.setInput('targetFolder', TARGET_FOLDER); + spectator.detectChanges(); + + store = spectator.inject(DotContentDriveStore, true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const chooseFile = () => { + const radios = spectator.debugElement.queryAll(By.css('p-radiobutton')); + spectator.triggerEventHandler(radios[1], 'ngModelChange', 'FileAsset'); + spectator.detectChanges(); + }; + + const clickContinue = () => + spectator.click( + spectator.query(byTestId('upload-selector-continue')).querySelector('button') + ); + + describe('rendering', () => { + it('should render both upload options', () => { + expect(spectator.query(byTestId('upload-selector-option-dotAsset'))).toBeTruthy(); + expect(spectator.query(byTestId('upload-selector-option-FileAsset'))).toBeTruthy(); + }); + + it('should mark only the Asset option as recommended', () => { + const recommended = spectator.queryAll(byTestId('upload-selector-recommended')); + const assetOption = spectator.query(byTestId('upload-selector-option-dotAsset')); + + expect(recommended.length).toBe(1); + expect( + assetOption?.querySelector('[data-testid="upload-selector-recommended"]') + ).toBeTruthy(); + }); + + it('should enable Continue by default with Asset preselected', () => { + const continueButton = spectator + .query(byTestId('upload-selector-continue')) + ?.querySelector('button'); + + expect(continueButton?.disabled).toBe(false); + }); + }); + + describe('selection', () => { + it('should emit the dotAsset selection with the folder and files when Continue is clicked', () => { + const files = { length: 0 } as FileList; + spectator.setInput('files', files); + spectator.detectChanges(); + + let emitted: DotContentDriveUploadSelection | undefined; + spectator.component.selectUploadType.subscribe((selection) => (emitted = selection)); + + clickContinue(); + + expect(emitted).toEqual({ + targetFolder: TARGET_FOLDER, + contentType: 'dotAsset', + files + }); + }); + + it('should emit the FileAsset selection when File is chosen', () => { + let emitted: DotContentDriveUploadSelection | undefined; + spectator.component.selectUploadType.subscribe((selection) => (emitted = selection)); + + chooseFile(); + clickContinue(); + + expect(emitted?.contentType).toBe('FileAsset'); + expect(emitted?.targetFolder).toEqual(TARGET_FOLDER); + }); + + it('should not emit and should close the dialog when Cancel is clicked', () => { + const emitSpy = jest.fn(); + spectator.component.selectUploadType.subscribe(emitSpy); + + spectator.click( + spectator.query(byTestId('upload-selector-cancel'))?.querySelector('button') + ); + + expect(store.closeDialog).toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.ts new file mode 100644 index 000000000000..64775847d83e --- /dev/null +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.ts @@ -0,0 +1,71 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + signal +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { RadioButtonModule } from 'primeng/radiobutton'; + +import { DotFolderTreeNodeData } from '@dotcms/portlets/content-drive/ui'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { UPLOAD_SELECTOR_OPTIONS } from '../../../shared/constants'; +import { DotContentDriveUploadSelection } from '../../../shared/models'; +import { DotContentDriveStore } from '../../../store/dot-content-drive.store'; + +/** + * Content Drive upload dialog body: lets the user choose whether the upload is created as an + * Asset (`dotAsset`) or a File (`FileAsset`) before the upload runs. + * + * On confirm it emits the full {@link DotContentDriveUploadSelection} (target folder + chosen + * content type + the files, when already known) so the shell can trigger the upload directly. + * Carrying the folder forward also feeds the future "remember preference per folder" feature + * (epic #35436) without reshaping this contract. + */ +@Component({ + selector: 'dot-content-drive-dialog-upload-selector', + imports: [FormsModule, ButtonModule, RadioButtonModule, DotMessagePipe], + templateUrl: './dot-content-drive-dialog-upload-selector.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotContentDriveDialogUploadSelectorComponent { + readonly #store = inject(DotContentDriveStore); + + /** Folder the upload targets; carried through to the emitted selection (root when undefined). */ + $targetFolder = input(undefined, { alias: 'targetFolder' }); + + /** Files to upload — present for the drag-and-drop flow, absent for the Upload-button flow. */ + $files = input(undefined, { alias: 'files' }); + + /** Emits the chosen content type plus the upload context when the user confirms. */ + selectUploadType = output(); + + protected readonly options = UPLOAD_SELECTOR_OPTIONS; + + /** Currently selected content type variable. Defaults to the first (recommended) option. */ + protected readonly $selectedType = signal(UPLOAD_SELECTOR_OPTIONS[0].contentType); + protected readonly $canContinue = computed(() => !!this.$selectedType()); + + protected onContinue(): void { + const contentType = this.$selectedType(); + if (!contentType) { + return; + } + + this.selectUploadType.emit({ + targetFolder: this.$targetFolder(), + contentType, + files: this.$files() + }); + } + + protected onCancel(): void { + this.#store.closeDialog(); + } +} diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.html index 0ef0888f976b..8178b3eaa63f 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.html @@ -4,9 +4,12 @@ class="message pointer-events-none visible absolute inset-0 z-1001 flex size-full items-center justify-center opacity-0 transition-all duration-300 ease-in-out" data-testid="message">
- +
+ +
{{ 'content-drive-dropzone.message.drag-and-drop-header' | dm }} diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html index 1cc5a42e3145..473fea1e2b1f 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html @@ -15,7 +15,7 @@ icon="pi pi-upload" [outlined]="true" data-testid="upload-asset-button" - (click)="$addNewDotAsset.emit()" /> + (click)="$upload.emit()" /> @if ($displayButton()) { { expect(spectator.query(byTestId('upload-asset-button'))).toBeTruthy(); }); - it('should emit addNewDotAsset when the upload button is clicked', () => { + it('should emit upload when the upload button is clicked', () => { const emitSpy = jest.fn(); - spectator.component.$addNewDotAsset.subscribe(emitSpy); + spectator.component.$upload.subscribe(emitSpy); const uploadButton = spectator .query(byTestId('upload-asset-button')) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts index 53d182168f3c..ebaef3a1adcc 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts @@ -138,7 +138,7 @@ export class DotContentDriveToolbarComponent { readonly #store = inject(DotContentDriveStore); readonly #dotMessageService = inject(DotMessageService); - $addNewDotAsset = output({ alias: 'addNewDotAsset' }); + $upload = output({ alias: 'upload' }); readonly $items = signal([ { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html index 68afcb949fd6..6ee2192b2d40 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html @@ -30,7 +30,7 @@ }
- +
} } + @case (DIALOG_TYPE.UPLOAD_SELECTOR) { + @if ($uploadSelectorPayload(); as payload) { + + } + } } diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 52a27edc19b0..ae0e70257629 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -37,9 +37,9 @@ import { } from '@dotcms/dotcms-models'; import { DotFolderListViewComponent, + DotFolderTreeNodeData, DotFolderTreeNodeItem, - DotContentDriveMoveItems, - ALL_FOLDER + DotContentDriveMoveItems } from '@dotcms/portlets/content-drive/ui'; import { GlobalStore } from '@dotcms/store'; @@ -647,483 +647,315 @@ describe('DotContentDriveShellComponent', () => { }); }); - describe('file upload integration', () => { - let mockFile: File; - + const TARGET_FOLDER_DATA = { + id: 'folder-123', + hostname: 'localhost', + path: 'folder-123', + type: 'folder' + } as DotFolderTreeNodeData; + + const createFile = (name = 'test.jpg') => + new File(['test content'], name, { type: 'image/jpeg' }); + + const createFileList = (files: File[]): FileList => + ({ + ...files, + length: files.length, + item: (index: number) => files[index] ?? null + }) as unknown as FileList; + + // Opens the upload selector with the given context and emits the user's choice back to the + // shell, mirroring the dialog's (selectUploadType) output. + const selectUploadType = (selection: { + targetFolder?: DotFolderTreeNodeData; + contentType: string; + files?: FileList; + }) => { + dialogSignal.set({ + type: DIALOG_TYPE.UPLOAD_SELECTOR, + header: 'Upload', + payload: { targetFolder: selection.targetFolder, files: selection.files } + }); + spectator.detectChanges(); + + const dialog = spectator.debugElement.query( + By.css('[data-testId="dialog-upload-selector"]') + ); + spectator.triggerEventHandler(dialog, 'selectUploadType', selection); + }; + + describe('upload type selector — opening', () => { beforeEach(() => { - mockFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' }); spectator.detectChanges(); }); - it('should upload file when file input changes', () => { - uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); - - const addSpy = jest.spyOn(messageService, 'add'); + it('should open the upload selector with the selected folder when the upload button is clicked', () => { + store.selectedNode.mockReturnValue({ + data: TARGET_FOLDER_DATA + } as DotFolderTreeNodeItem); - const mockNode: DotFolderTreeNodeItem = { - data: { - id: 'folder-123', - hostname: 'localhost', - path: 'folder-123', - type: 'folder' - }, - key: 'folder-123', - label: 'folder-123' - }; - store.selectedNode.mockReturnValue(mockNode as DotFolderTreeNodeItem); + const toolbar = spectator.debugElement.query(By.css('[data-testid="toolbar"]')); + spectator.triggerEventHandler(toolbar, 'upload', undefined); - const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; - Object.defineProperty(fileInput, 'files', { - value: [mockFile], - writable: false + expect(store.setDialog).toHaveBeenCalledWith({ + type: DIALOG_TYPE.UPLOAD_SELECTOR, + header: expect.any(String), + payload: { targetFolder: TARGET_FOLDER_DATA } }); + expect(uploadService.uploadDotAsset).not.toHaveBeenCalled(); + }); - spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput }); + it('should open the upload selector carrying the files when the dropzone emits uploadFiles', () => { + const files = createFileList([createFile()]); - expect(addSpy).toHaveBeenCalledWith({ - severity: 'info', - summary: expect.any(String), - detail: expect.any(String) + const dropzone = spectator.debugElement.query(By.css('[data-testid="dropzone"]')); + spectator.triggerEventHandler(dropzone, 'uploadFiles', { + files, + targetFolder: TARGET_FOLDER_DATA }); - expect(uploadService.uploadDotAsset).toHaveBeenCalledWith(mockFile, { - baseType: 'dotAsset', - hostFolder: 'folder-123', - indexPolicy: 'WAIT_FOR' + + expect(store.setDialog).toHaveBeenCalledWith({ + type: DIALOG_TYPE.UPLOAD_SELECTOR, + header: expect.any(String), + payload: { targetFolder: TARGET_FOLDER_DATA, files } }); + expect(uploadService.uploadDotAsset).not.toHaveBeenCalled(); }); - it('should sent the current site identifier when the selected node is the all folder', () => { - store.selectedNode.mockReturnValue({ - ...ALL_FOLDER, - data: { - hostname: MOCK_SITES[0].hostname, - path: '', - type: 'folder', - id: MOCK_SITES[0].identifier - } - }); - store.currentSite.mockReturnValue(MOCK_SITES[0]); - spectator.detectChanges(); + it('should open the upload selector carrying the files when the sidebar emits uploadFiles', () => { + const files = createFileList([createFile()]); - const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; - Object.defineProperty(fileInput, 'files', { - value: [mockFile], - writable: false + const sidebar = spectator.debugElement.query(By.css('[data-testid="sidebar"]')); + spectator.triggerEventHandler(sidebar, 'uploadFiles', { + files, + targetFolder: TARGET_FOLDER_DATA }); - spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput }); - - expect(uploadService.uploadDotAsset).toHaveBeenCalledWith(mockFile, { - baseType: 'dotAsset', - hostFolder: MOCK_SITES[0].identifier, - indexPolicy: 'WAIT_FOR' + expect(store.setDialog).toHaveBeenCalledWith({ + type: DIALOG_TYPE.UPLOAD_SELECTOR, + header: expect.any(String), + payload: { targetFolder: TARGET_FOLDER_DATA, files } }); + expect(uploadService.uploadDotAsset).not.toHaveBeenCalled(); }); - it('should show info message when upload starts', () => { - uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); - const addSpy = jest.spyOn(messageService, 'add'); - - const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; - Object.defineProperty(fileInput, 'files', { - value: [mockFile], - writable: false + it('should render the upload selector dialog body when the dialog type is UPLOAD_SELECTOR', () => { + dialogSignal.set({ + type: DIALOG_TYPE.UPLOAD_SELECTOR, + header: 'Upload', + payload: { targetFolder: TARGET_FOLDER_DATA } }); + spectator.detectChanges(); - spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput }); + expect(spectator.query('[data-testId="dialog-upload-selector"]')).toBeTruthy(); + }); + }); - expect(addSpy).toHaveBeenCalledWith({ - severity: 'info', - summary: expect.any(String), - detail: expect.any(String) - }); + describe('upload — drag-and-drop flow (files already chosen)', () => { + beforeEach(() => { + spectator.detectChanges(); }); - it('should show error message on upload failure', () => { - const error = new Error('Upload failed'); - uploadService.uploadDotAsset.mockReturnValue(throwError(() => error)); - store.selectedNode.mockReturnValue({ - ...ALL_FOLDER, - data: { - hostname: MOCK_SITES[0].hostname, - path: '', - type: 'folder', - id: MOCK_SITES[0].identifier - } - }); - const addSpy = jest.spyOn(messageService, 'add'); + it('should upload the file as dotAsset when Asset is selected', () => { + uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); + const file = createFile(); - const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; - Object.defineProperty(fileInput, 'files', { - value: [mockFile], - writable: false + selectUploadType({ + targetFolder: TARGET_FOLDER_DATA, + files: createFileList([file]), + contentType: 'dotAsset' }); - spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput }); - - expect(addSpy).toHaveBeenCalledWith({ - severity: 'error', - summary: expect.any(String), - detail: expect.any(String), - life: ERROR_MESSAGE_LIFE - }); + expect(uploadService.uploadDotAsset).toHaveBeenCalledWith( + file, + { hostFolder: TARGET_FOLDER_DATA.id, indexPolicy: 'WAIT_FOR' }, + 'dotAsset' + ); }); - it('should show error message on upload failure with errors', () => { - const error = { - error: { - errors: [{ message: 'Upload failed' }] - } - }; - uploadService.uploadDotAsset.mockReturnValue(throwError(() => error)); - store.selectedNode.mockReturnValue({ - ...ALL_FOLDER, - data: { - hostname: MOCK_SITES[0].hostname, - path: '', - type: 'folder', - id: MOCK_SITES[0].identifier - } - }); - const addSpy = jest.spyOn(messageService, 'add'); + it('should upload the file as FileAsset when File is selected', () => { + uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); + const file = createFile(); - const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; - Object.defineProperty(fileInput, 'files', { - value: [mockFile], - writable: false + selectUploadType({ + targetFolder: TARGET_FOLDER_DATA, + files: createFileList([file]), + contentType: 'FileAsset' }); - spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput }); - - expect(addSpy).toHaveBeenCalledTimes(2); - expect(addSpy).toHaveBeenNthCalledWith(1, { - severity: 'info', - summary: 'content-drive.file-upload-in-progress', - detail: 'content-drive.file-upload-in-progress-detail' - }); - expect(addSpy).toHaveBeenNthCalledWith(2, { - severity: 'error', - summary: 'content-drive.add-dotasset-error', - detail: 'Upload failed', - life: ERROR_MESSAGE_LIFE - }); + expect(uploadService.uploadDotAsset).toHaveBeenCalledWith( + file, + { hostFolder: TARGET_FOLDER_DATA.id, indexPolicy: 'WAIT_FOR' }, + 'FileAsset' + ); }); - it('should not upload when no files are selected', () => { - const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; - Object.defineProperty(fileInput, 'files', { - value: [], - writable: false - }); + it('should upload to the site root when no folder is selected', () => { + uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); + const file = createFile(); - spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput }); + selectUploadType({ + targetFolder: undefined, + files: createFileList([file]), + contentType: 'dotAsset' + }); - expect(uploadService.uploadDotAsset).not.toHaveBeenCalled(); - expect(store.setStatus).not.toHaveBeenCalled(); + expect(uploadService.uploadDotAsset).toHaveBeenCalledWith( + file, + { hostFolder: '', indexPolicy: 'WAIT_FOR' }, + 'dotAsset' + ); }); - it('should show warning message when multiple files are selected and upload only the first file', () => { + it('should show the info message when the upload starts', () => { uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); const addSpy = jest.spyOn(messageService, 'add'); - const mockFile1 = new File(['test content 1'], 'test1.jpg', { type: 'image/jpeg' }); - const mockFile2 = new File(['test content 2'], 'test2.jpg', { type: 'image/jpeg' }); - const mockFile3 = new File(['test content 3'], 'test3.jpg', { type: 'image/jpeg' }); - - const mockNode: DotFolderTreeNodeItem = { - data: { - id: 'folder-123', - hostname: 'localhost', - path: 'folder-123', - type: 'folder' - }, - key: 'folder-123', - label: 'folder-123' - }; - store.selectedNode.mockReturnValue(mockNode); - - const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; - Object.defineProperty(fileInput, 'files', { - value: [mockFile1, mockFile2, mockFile3], - writable: false + selectUploadType({ + targetFolder: TARGET_FOLDER_DATA, + files: createFileList([createFile()]), + contentType: 'dotAsset' }); - spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput }); - - // Should show warning message expect(addSpy).toHaveBeenCalledWith({ - severity: 'warn', + severity: 'info', summary: expect.any(String), - detail: expect.any(String), - life: WARNING_MESSAGE_LIFE - }); - - // Should upload only the first file - expect(uploadService.uploadDotAsset).toHaveBeenCalledTimes(1); - expect(uploadService.uploadDotAsset).toHaveBeenCalledWith(mockFile1, { - baseType: 'dotAsset', - hostFolder: 'folder-123', - indexPolicy: 'WAIT_FOR' + detail: expect.any(String) }); }); - }); - - describe('sidebar file upload', () => { - beforeEach(() => { - spectator.detectChanges(); - }); - - it('should trigger resolveFilesUpload when sidebar emits uploadFiles event with single file', () => { - const mockNode: DotFolderTreeNodeItem = { - data: { - id: 'folder-123', - hostname: 'localhost', - path: 'folder-123', - type: 'folder' - }, - key: 'folder-123', - label: 'folder-123' - }; - uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); - const mockFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' }); - const mockFileList = { - 0: mockFile, - length: 1, - item: (index: number) => (index === 0 ? mockFile : null) - } as unknown as FileList; + it('should show a success message after a successful upload', () => { + uploadService.uploadDotAsset.mockReturnValue( + of({ title: 'test.jpg', contentType: 'image/jpeg' } as DotCMSContentlet) + ); + const addSpy = jest.spyOn(messageService, 'add'); - const sidebar = spectator.debugElement.query(By.css('[data-testid="sidebar"]')); - spectator.triggerEventHandler(sidebar, 'uploadFiles', { - files: mockFileList, - targetFolder: mockNode.data + selectUploadType({ + targetFolder: TARGET_FOLDER_DATA, + files: createFileList([createFile()]), + contentType: 'dotAsset' }); - expect(uploadService.uploadDotAsset).toHaveBeenCalledWith(mockFile, { - baseType: 'dotAsset', - hostFolder: mockNode.data.id, - indexPolicy: 'WAIT_FOR' + expect(addSpy).toHaveBeenCalledWith({ + severity: 'success', + summary: expect.any(String), + detail: expect.any(String), + life: SUCCESS_MESSAGE_LIFE }); }); - it('should trigger resolveFilesUpload when sidebar emits uploadFiles event with multiple files', () => { - uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); + it('should show an error message on upload failure', () => { + uploadService.uploadDotAsset.mockReturnValue( + throwError(() => new Error('Upload failed')) + ); const addSpy = jest.spyOn(messageService, 'add'); - const mockFile1 = new File(['test content 1'], 'test1.jpg', { type: 'image/jpeg' }); - const mockFile2 = new File(['test content 2'], 'test2.jpg', { type: 'image/jpeg' }); - const mockFileList = { - 0: mockFile1, - 1: mockFile2, - length: 2, - item: (index: number) => { - if (index === 0) return mockFile1; - if (index === 1) return mockFile2; - - return null; - } - } as unknown as FileList; - - const mockNode: DotFolderTreeNodeItem = { - data: { - id: 'folder-456', - hostname: 'localhost', - path: 'folder-456', - type: 'folder' - }, - key: 'folder-456', - label: 'folder-456' - }; - - const sidebar = spectator.debugElement.query(By.css('[data-testid="sidebar"]')); - spectator.triggerEventHandler(sidebar, 'uploadFiles', { - files: mockFileList, - targetFolder: mockNode.data + selectUploadType({ + targetFolder: TARGET_FOLDER_DATA, + files: createFileList([createFile()]), + contentType: 'dotAsset' }); - // Should show warning message for multiple files expect(addSpy).toHaveBeenCalledWith({ - severity: 'warn', + severity: 'error', summary: expect.any(String), detail: expect.any(String), - life: WARNING_MESSAGE_LIFE - }); - - // Should upload only the first file - expect(uploadService.uploadDotAsset).toHaveBeenCalledTimes(1); - expect(uploadService.uploadDotAsset).toHaveBeenCalledWith(mockFile1, { - baseType: 'dotAsset', - hostFolder: 'folder-456', - indexPolicy: 'WAIT_FOR' + life: ERROR_MESSAGE_LIFE }); }); - it('should show success message after successful upload from sidebar', () => { - const mockNode: DotFolderTreeNodeItem = { - data: { - id: 'folder-123', - hostname: 'localhost', - path: 'folder-123', - type: 'folder' - }, - key: 'folder-123', - label: 'folder-123' - }; - - const mockContentlet = { - title: 'test.jpg', - contentType: 'image/jpeg' - } as DotCMSContentlet; - uploadService.uploadDotAsset.mockReturnValue(of(mockContentlet)); + it('should show the server error message on failure with an errors payload', () => { + uploadService.uploadDotAsset.mockReturnValue( + throwError(() => ({ error: { errors: [{ message: 'Upload failed' }] } })) + ); const addSpy = jest.spyOn(messageService, 'add'); - const mockFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' }); - const mockFileList = { - 0: mockFile, - length: 1, - item: (index: number) => (index === 0 ? mockFile : null) - } as unknown as FileList; - - const sidebar = spectator.debugElement.query(By.css('[data-testid="sidebar"]')); - spectator.triggerEventHandler(sidebar, 'uploadFiles', { - files: mockFileList, - targetFolder: mockNode.data + selectUploadType({ + targetFolder: TARGET_FOLDER_DATA, + files: createFileList([createFile()]), + contentType: 'dotAsset' }); expect(addSpy).toHaveBeenCalledWith({ - severity: 'success', - summary: expect.any(String), - detail: expect.any(String), - life: SUCCESS_MESSAGE_LIFE + severity: 'error', + summary: 'content-drive.add-dotasset-error', + detail: 'Upload failed', + life: ERROR_MESSAGE_LIFE }); }); - it('should show error message after failed upload from sidebar', () => { - const mockNode: DotFolderTreeNodeItem = { - data: { - id: 'folder-123', - hostname: 'localhost', - path: 'folder-123', - type: 'folder' - }, - key: 'folder-123', - label: 'folder-123' - }; - const error = new Error('Upload failed'); - uploadService.uploadDotAsset.mockReturnValue(throwError(() => error)); - + it('should warn and upload only the first file when multiple files are selected', () => { + uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); const addSpy = jest.spyOn(messageService, 'add'); + const file1 = createFile('test1.jpg'); + const file2 = createFile('test2.jpg'); - const mockFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' }); - const mockFileList = { - 0: mockFile, - length: 1, - item: (index: number) => (index === 0 ? mockFile : null) - } as unknown as FileList; - - const sidebar = spectator.debugElement.query(By.css('[data-testid="sidebar"]')); - spectator.triggerEventHandler(sidebar, 'uploadFiles', { - files: mockFileList, - targetFolder: mockNode.data + selectUploadType({ + targetFolder: TARGET_FOLDER_DATA, + files: createFileList([file1, file2]), + contentType: 'dotAsset' }); expect(addSpy).toHaveBeenCalledWith({ - severity: 'error', + severity: 'warn', summary: expect.any(String), detail: expect.any(String), - life: ERROR_MESSAGE_LIFE + life: WARNING_MESSAGE_LIFE }); + expect(uploadService.uploadDotAsset).toHaveBeenCalledTimes(1); + expect(uploadService.uploadDotAsset).toHaveBeenCalledWith( + file1, + { hostFolder: TARGET_FOLDER_DATA.id, indexPolicy: 'WAIT_FOR' }, + 'dotAsset' + ); }); }); - describe('dropzone file upload', () => { + describe('upload — button flow (file picker opens after choosing)', () => { beforeEach(() => { spectator.detectChanges(); }); - it('should trigger resolveFilesUpload when dropzone emits uploadFiles event with single file', () => { + it('should open the file picker after a type is chosen, then upload with that type', () => { uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); - const mockFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' }); - const mockFileList = { - 0: mockFile, - length: 1, - item: (index: number) => (index === 0 ? mockFile : null) - } as unknown as FileList; - - const mockNode: DotFolderTreeNodeItem = { - data: { - id: 'folder-123', - hostname: 'localhost', - path: 'folder-123', - type: 'folder' - }, - key: 'folder-123', - label: 'folder-123' - }; + const file = createFile(); - const dropzone = spectator.debugElement.query(By.css('[data-testid="dropzone"]')); - spectator.triggerEventHandler(dropzone, 'uploadFiles', { - files: mockFileList, - targetFolder: mockNode.data - }); + const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; + const clickSpy = jest.spyOn(fileInput, 'click'); - expect(uploadService.uploadDotAsset).toHaveBeenCalledWith(mockFile, { - baseType: 'dotAsset', - hostFolder: mockNode.data.id, - indexPolicy: 'WAIT_FOR' - }); - }); + // Button flow: dialog opens with NO files in the payload. + selectUploadType({ targetFolder: TARGET_FOLDER_DATA, contentType: 'FileAsset' }); - it('should trigger resolveFilesUpload when dropzone emits uploadFiles event with multiple files', () => { - uploadService.uploadDotAsset.mockReturnValue(of({} as DotCMSContentlet)); - const addSpy = jest.spyOn(messageService, 'add'); + expect(clickSpy).toHaveBeenCalled(); + expect(uploadService.uploadDotAsset).not.toHaveBeenCalled(); - const mockFile1 = new File(['test content 1'], 'test1.jpg', { type: 'image/jpeg' }); - const mockFile2 = new File(['test content 2'], 'test2.jpg', { type: 'image/jpeg' }); - const mockFileList = { - 0: mockFile1, - 1: mockFile2, - length: 2, - item: (index: number) => { - if (index === 0) return mockFile1; - if (index === 1) return mockFile2; + Object.defineProperty(fileInput, 'files', { + value: [file], + writable: true, + configurable: true + }); + spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput }); - return null; - } - } as unknown as FileList; + expect(uploadService.uploadDotAsset).toHaveBeenCalledWith( + file, + { hostFolder: TARGET_FOLDER_DATA.id, indexPolicy: 'WAIT_FOR' }, + 'FileAsset' + ); + }); - const mockNode: DotFolderTreeNodeItem = { - data: { - id: 'folder-123', - hostname: 'localhost', - path: 'folder-123', - type: 'folder' - }, - key: 'folder-123', - label: 'folder-123' - }; + it('should not upload when the file picker is dismissed without files', () => { + const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; - const dropzone = spectator.debugElement.query(By.css('[data-testid="dropzone"]')); - spectator.triggerEventHandler(dropzone, 'uploadFiles', { - files: mockFileList, - targetFolder: mockNode.data - }); + selectUploadType({ targetFolder: TARGET_FOLDER_DATA, contentType: 'dotAsset' }); - // Should show warning message for multiple files - expect(addSpy).toHaveBeenCalledWith({ - severity: 'warn', - summary: expect.any(String), - detail: expect.any(String), - life: WARNING_MESSAGE_LIFE + Object.defineProperty(fileInput, 'files', { + value: [], + writable: true, + configurable: true }); + spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput }); - // Should upload only the first file - expect(uploadService.uploadDotAsset).toHaveBeenCalledTimes(1); - expect(uploadService.uploadDotAsset).toHaveBeenCalledWith(mockFile1, { - baseType: 'dotAsset', - hostFolder: 'folder-123', - indexPolicy: 'WAIT_FOR' - }); + expect(uploadService.uploadDotAsset).not.toHaveBeenCalled(); }); }); @@ -2028,8 +1860,8 @@ describe('DotContentDriveShellComponent', () => { }); }); - describe('onAddNewDotAsset', () => { - it('should trigger file input click', () => { + describe('onUpload', () => { + it('should open the upload selector dialog instead of the file picker directly', () => { spectator.detectChanges(); const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; @@ -2037,9 +1869,12 @@ describe('DotContentDriveShellComponent', () => { const toolbar = spectator.debugElement.query(By.css('[data-testid="toolbar"]')); - spectator.triggerEventHandler(toolbar, 'addNewDotAsset', undefined); + spectator.triggerEventHandler(toolbar, 'upload', undefined); - expect(clickSpy).toHaveBeenCalled(); + expect(store.setDialog).toHaveBeenCalledWith( + expect.objectContaining({ type: DIALOG_TYPE.UPLOAD_SELECTOR }) + ); + expect(clickSpy).not.toHaveBeenCalled(); }); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 91d489558bf8..2f6d892d61d1 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -49,6 +49,7 @@ import { DotAddToBundleComponent, DotMessagePipe, DotSeverityIconComponent } fro import { DotContentDriveDialogContentTypeSelectorComponent } from '../components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component'; import { DotContentDriveDialogFolderComponent } from '../components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component'; +import { DotContentDriveDialogUploadSelectorComponent } from '../components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component'; import { DotContentDriveDropzoneComponent } from '../components/dot-content-drive-dropzone/dot-content-drive-dropzone.component'; import { DotContentDriveSidebarComponent } from '../components/dot-content-drive-sidebar/dot-content-drive-sidebar.component'; import { DotContentDriveToolbarComponent } from '../components/dot-content-drive-toolbar/dot-content-drive-toolbar.component'; @@ -66,7 +67,9 @@ import { DotContentDriveContentTypeSelectorPayload, DotContentDriveDialog, DotContentDriveSortOrder, - DotContentDriveStatus + DotContentDriveStatus, + DotContentDriveUploadSelection, + DotContentDriveUploadSelectorPayload } from '../shared/models'; import { DotContentDriveNavigationService } from '../shared/services'; import { DotContentDriveStore } from '../store/dot-content-drive.store'; @@ -84,6 +87,7 @@ import { encodeFilters, isFolder } from '../utils/functions'; DialogModule, DotContentDriveDialogFolderComponent, DotContentDriveDialogContentTypeSelectorComponent, + DotContentDriveDialogUploadSelectorComponent, MessageModule, ButtonModule, DotMessagePipe, @@ -147,15 +151,37 @@ export class DotContentDriveShellComponent implements OnInit { : undefined; }); + /** Payload (target folder + optional dropped files) for the upload-type selector dialog. */ + readonly $uploadSelectorPayload = computed( + () => { + const dialog = this.$activeDialog(); + + return dialog?.type === DIALOG_TYPE.UPLOAD_SELECTOR + ? (dialog.payload as DotContentDriveUploadSelectorPayload) + : undefined; + } + ); + + /** + * Holds the selection emitted by the upload dialog while the OS file picker is open (Upload-button + * flow only). The dropped-files flow uploads immediately and never sets this. + */ + readonly $activeSelection = signal(undefined); + /** * Content-type selector: sized to fit ~4 UVE-width cards per row. No horizontal padding so * the paginator/footer separators span edge-to-edge; the list and footer add their own inset. */ - readonly $dialogContentClass = computed(() => - this.$activeDialog()?.type === DIALOG_TYPE.CONTENT_TYPE_SELECTOR - ? 'w-[min(92vw,38rem)] px-0! pt-0 pb-4' - : 'w-[43.75rem] pt-0 p-4' - ); + readonly $dialogContentClass = computed(() => { + switch (this.$activeDialog()?.type) { + case DIALOG_TYPE.CONTENT_TYPE_SELECTOR: + return 'w-[min(92vw,38rem)] px-0! pt-0 pb-4'; + case DIALOG_TYPE.UPLOAD_SELECTOR: + return 'w-[min(92vw,31.25rem)] pt-0 p-4'; + default: + return 'w-[43.75rem] pt-0 p-4'; + } + }); /** * Syncs the dialog open/close state from the store. Opening sets the body and visibility @@ -347,26 +373,72 @@ export class DotContentDriveShellComponent implements OnInit { this.#localStorageService.setItem(HIDE_MESSAGE_BANNER_LOCALSTORAGE_KEY, true); } - protected onAddNewDotAsset() { + /** + * Upload-button flow: prompt for the asset type first; the OS file picker opens later, once the + * user confirms a type in {@link onUploadTypeSelected}. + */ + protected onUpload() { + this.openUploadSelector({ targetFolder: this.#store.selectedNode()?.data }); + } + + /** + * Drag-and-drop / sidebar flow: the files are already known, so prompt for the asset type and + * carry the files into the dialog payload to upload right after the user confirms. + */ + protected onRequestUpload({ files, targetFolder }: DotContentDriveUploadFiles) { + this.openUploadSelector({ targetFolder, files }); + } + + /** + * Opens the upload-type selector dialog (Asset vs File). + */ + protected openUploadSelector(payload: DotContentDriveUploadSelectorPayload) { + this.#store.setDialog({ + type: DIALOG_TYPE.UPLOAD_SELECTOR, + header: this.#dotMessageService.get('content-drive.dialog.upload-selector.header'), + payload + }); + } + + /** + * Handles the asset-type choice emitted by the upload selector dialog. + * - Drag-and-drop: the files are already in the selection, so upload immediately. + * - Upload button: stash the selection and open the OS file picker; {@link onFileChange} + * completes the upload once files are chosen. + */ + protected onUploadTypeSelected(selection: DotContentDriveUploadSelection) { + this.#store.closeDialog(); + + if (selection.files?.length) { + this.resolveFilesUpload(selection); + + return; + } + + this.$activeSelection.set(selection); this.$fileInput().nativeElement.click(); } /** - * Handles file change event + * Handles file change event (Upload-button flow): merges the chosen files into the pending + * selection and triggers the upload with the previously chosen content type. * @param event The event that triggered the file change */ protected onFileChange(event: Event) { const input = event.target as HTMLInputElement; const files = input.files; + const selection = this.$activeSelection(); - if (!files || files.length === 0) { + // Always reset so a cancelled/re-opened picker can't reuse a stale selection. + this.$activeSelection.set(undefined); + input.value = ''; + + if (!files || files.length === 0 || !selection) { return; } - const targetFolder = this.#store.selectedNode()?.data; - - this.resolveFilesUpload({ files, targetFolder }); + this.resolveFilesUpload({ ...selection, files }); } /** @@ -386,26 +458,34 @@ export class DotContentDriveShellComponent implements OnInit { /** * Resolves the upload of multiple files or a single file - * @param files The files to upload + * @param selection The chosen content type, target folder and files to upload */ - protected resolveFilesUpload({ files, targetFolder }: DotContentDriveUploadFiles) { + protected resolveFilesUpload({ + files, + targetFolder, + contentType + }: DotContentDriveUploadSelection) { + if (!files?.length) { + return; + } + if (files.length > 1) { - this.uploadFiles({ files, targetFolder }); + this.uploadFiles({ files, targetFolder, contentType }); return; } - this.uploadFile({ files, targetFolder }); + this.uploadFile({ files, targetFolder, contentType }); } /** * Shows a warning message when multiple files are uploaded * * @protected - * @param {FileList} files + * @param {DotContentDriveUploadSelection} selection * @memberof DotContentDriveShellComponent */ - protected uploadFiles({ files, targetFolder }: DotContentDriveUploadFiles) { + protected uploadFiles({ files, targetFolder, contentType }: DotContentDriveUploadSelection) { this.#messageService.add({ severity: 'warn', summary: this.#dotMessageService.get('content-drive.work-in-progress'), @@ -413,38 +493,46 @@ export class DotContentDriveShellComponent implements OnInit { life: WARNING_MESSAGE_LIFE }); - this.uploadFile({ files, targetFolder }); + this.uploadFile({ files, targetFolder, contentType }); } /** * Uploads a file to the content drive - * @param file The file to upload + * @param selection The chosen content type, target folder and files to upload */ - protected uploadFile({ files, targetFolder }: DotContentDriveUploadFiles) { + protected uploadFile({ files, targetFolder, contentType }: DotContentDriveUploadSelection) { + if (!files?.length) { + return; + } + this.#messageService.add({ severity: 'info', summary: this.#dotMessageService.get('content-drive.file-upload-in-progress'), detail: this.#dotMessageService.get('content-drive.file-upload-in-progress-detail') }); - this.uploadDotAsset(files[0], targetFolder); + this.uploadDotAsset(files[0], targetFolder, contentType); } /** - * Uploads a file to the content drive + * Uploads a file to the content drive as the given content type (`dotAsset` or `FileAsset`). * * @protected * @param {File} file - * @param {string} hostFolder + * @param {DotFolderTreeNodeData} [hostFolder] + * @param {string} [contentType] * @memberof DotContentDriveShellComponent */ - protected uploadDotAsset(file: File, hostFolder: DotFolderTreeNodeData) { + protected uploadDotAsset(file: File, hostFolder?: DotFolderTreeNodeData, contentType?: string) { this.#fileService - .uploadDotAsset(file, { - baseType: 'dotAsset', - hostFolder: hostFolder?.id, - indexPolicy: 'WAIT_FOR' - }) + .uploadDotAsset( + file, + { + hostFolder: hostFolder?.id ?? '', + indexPolicy: 'WAIT_FOR' + }, + contentType + ) .subscribe({ next: ({ title, contentType }) => { this.#messageService.add({ diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts index 6c83a60ff834..c637a9a56ec2 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts @@ -78,11 +78,33 @@ export const PANEL_SCROLL_HEIGHT = '25rem'; // Dialog type export const DIALOG_TYPE = { FOLDER: 'FOLDER', - CONTENT_TYPE_SELECTOR: 'CONTENT_TYPE_SELECTOR' + CONTENT_TYPE_SELECTOR: 'CONTENT_TYPE_SELECTOR', + UPLOAD_SELECTOR: 'UPLOAD_SELECTOR' } as const; export const DEFAULT_FILE_ASSET_TYPES = [{ id: 'FileAsset', name: 'File' }]; +/** + * Options shown in the upload-type selector dialog. `contentType` is the content type variable + * fired to the upload endpoint: `dotAsset` for Assets, `FileAsset` for Files. + */ +export const UPLOAD_SELECTOR_OPTIONS = [ + { + contentType: 'dotAsset', + icon: 'image', + labelKey: 'content-drive.dialog.upload-selector.asset', + descriptionKey: 'content-drive.dialog.upload-selector.asset.description', + recommended: true + }, + { + contentType: 'FileAsset', + icon: 'code_blocks', + labelKey: 'content-drive.dialog.upload-selector.file', + descriptionKey: 'content-drive.dialog.upload-selector.file.description', + recommended: false + } +] as const; + export const SUGGESTED_ALLOWED_FILE_EXTENSIONS = [ '*.jpg', '*.jpeg', diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts index 80cdc76846a9..70d2c196cfd9 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts @@ -4,7 +4,7 @@ import { DotFolder, DotSite } from '@dotcms/dotcms-models'; -import { DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; +import { DotFolderTreeNodeData, DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; import { DotUVEPaletteListTypes } from '@dotcms/portlets/dot-ema/ui'; import { DIALOG_TYPE } from './constants'; @@ -83,7 +83,10 @@ export interface DotContentDriveContextMenu { export interface DotContentDriveDialog { type: keyof typeof DIALOG_TYPE; header: string; - payload?: DotContentDriveFolder | DotContentDriveContentTypeSelectorPayload; + payload?: + | DotContentDriveFolder + | DotContentDriveContentTypeSelectorPayload + | DotContentDriveUploadSelectorPayload; } /** @@ -94,6 +97,27 @@ export interface DotContentDriveContentTypeSelectorPayload { listType: DotUVEPaletteListTypes; } +/** + * Payload passed INTO the upload-type selector dialog. `files` is present for the drag-and-drop + * flow (the dropped files are already known) and absent for the Upload-button flow (the OS file + * picker opens after the user picks a type). + */ +export interface DotContentDriveUploadSelectorPayload { + targetFolder?: DotFolderTreeNodeData; + files?: FileList; +} + +/** + * Object emitted BACK by the upload-type selector dialog. Carries everything needed to trigger the + * upload (and, in the future, to remember the chosen type per folder — see epic #35436). + * `targetFolder` is omitted when nothing is selected (uploads to the site root). + */ +export interface DotContentDriveUploadSelection { + contentType: string; + targetFolder?: DotFolderTreeNodeData; + files?: FileList; +} + export interface DotContentDrivePage { hasMoreContent: boolean; hasMoreFolders: boolean; diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.html b/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.html index 943ab128dd4e..c7b2be689570 100644 --- a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.html +++ b/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.html @@ -117,6 +117,7 @@ @if (!isFolder) { } diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 631f1393b2dc..43bb73b23778 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6783,6 +6783,13 @@ content-drive.base-type.key_value=Key/Value content-drive.base-type.htmlpage=Page content-drive.dialog.content-type-selector.header=Select a Content Type content-drive.dialog.content-type-selector.create=Create +content-drive.dialog.upload-selector.header=Upload +content-drive.dialog.upload-selector.recommended=Recommended +content-drive.dialog.upload-selector.asset=Asset +content-drive.dialog.upload-selector.asset.description=For images, documents, and media used in your content +content-drive.dialog.upload-selector.file=File +content-drive.dialog.upload-selector.file.description=For code, templates, and developer files that need predictable URLs +content-drive.dialog.upload-selector.continue=Continue content-drive.dialog.folder.header=Create Folder content-drive.dialog.folder.header.edit=Edit Folder content-drive.dialog.folder.general.header=General From e097bfaa681954ffbabf35a5348d753d75c82a83 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Fri, 19 Jun 2026 13:22:02 -0300 Subject: [PATCH 2/3] refactor(content-drive): address PR review on upload selector (#35575) - Type the upload selection's contentType as a union derived from UPLOAD_SELECTOR_OPTIONS so it can't drift or accept arbitrary values - Rename the shadowed contentType in the upload success handler Co-Authored-By: Claude Opus 4.8 --- ...-content-drive-dialog-upload-selector.component.ts | 9 +++++++-- .../dot-content-drive-shell.component.ts | 6 ++++-- .../portlet/src/lib/shared/models.ts | 11 +++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.ts index 64775847d83e..247c309a478e 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.ts @@ -16,7 +16,10 @@ import { DotFolderTreeNodeData } from '@dotcms/portlets/content-drive/ui'; import { DotMessagePipe } from '@dotcms/ui'; import { UPLOAD_SELECTOR_OPTIONS } from '../../../shared/constants'; -import { DotContentDriveUploadSelection } from '../../../shared/models'; +import { + DotContentDriveUploadContentType, + DotContentDriveUploadSelection +} from '../../../shared/models'; import { DotContentDriveStore } from '../../../store/dot-content-drive.store'; /** @@ -49,7 +52,9 @@ export class DotContentDriveDialogUploadSelectorComponent { protected readonly options = UPLOAD_SELECTOR_OPTIONS; /** Currently selected content type variable. Defaults to the first (recommended) option. */ - protected readonly $selectedType = signal(UPLOAD_SELECTOR_OPTIONS[0].contentType); + protected readonly $selectedType = signal( + UPLOAD_SELECTOR_OPTIONS[0].contentType + ); protected readonly $canContinue = computed(() => !!this.$selectedType()); protected onContinue(): void { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 2f6d892d61d1..a219dcf34207 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -534,14 +534,16 @@ export class DotContentDriveShellComponent implements OnInit { contentType ) .subscribe({ - next: ({ title, contentType }) => { + // `contentType` here is the created contentlet's resolved type (from the response), + // distinct from the requested `contentType` parameter above. + next: ({ title, contentType: uploadedContentType }) => { this.#messageService.add({ severity: 'success', summary: this.#dotMessageService.get('content-drive.add-dotasset-success'), detail: this.#dotMessageService.get( 'content-drive.add-dotasset-success-detail', title, - contentType + uploadedContentType ), life: SUCCESS_MESSAGE_LIFE }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts index 70d2c196cfd9..1ce9367d801c 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts @@ -7,7 +7,14 @@ import { import { DotFolderTreeNodeData, DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; import { DotUVEPaletteListTypes } from '@dotcms/portlets/dot-ema/ui'; -import { DIALOG_TYPE } from './constants'; +import { DIALOG_TYPE, UPLOAD_SELECTOR_OPTIONS } from './constants'; + +/** + * Content type variables the upload selector can produce, derived from the selector options so the + * type and the rendered choices never drift apart. + */ +export type DotContentDriveUploadContentType = + (typeof UPLOAD_SELECTOR_OPTIONS)[number]['contentType']; /** * The status of the content drive. @@ -113,7 +120,7 @@ export interface DotContentDriveUploadSelectorPayload { * `targetFolder` is omitted when nothing is selected (uploads to the site root). */ export interface DotContentDriveUploadSelection { - contentType: string; + contentType: DotContentDriveUploadContentType; targetFolder?: DotFolderTreeNodeData; files?: FileList; } From 7edb971641477fb7063bf4f33fb95d76baea281d Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Fri, 19 Jun 2026 13:38:32 -0300 Subject: [PATCH 3/3] style(content-drive): prettier-format upload selector template (#35575) Co-Authored-By: Claude Opus 4.8 --- .../dot-content-drive-dialog-upload-selector.component.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.html index 9651f8f59f55..511c70bdd50e 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.html @@ -17,7 +17,8 @@ [inputId]="option.contentType" /> - + {{ option.icon }} @@ -25,7 +26,7 @@ @if (option.recommended) { {{ 'content-drive.dialog.upload-selector.recommended' | dm }}