Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<DotCMSContentlet> {
if (file instanceof File) {
const formData = new FormData();
formData.append('file', file);

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

return this.#workflowActionsFireService.newContentlet<DotCMSContentlet>('dotAsset', {
return this.#workflowActionsFireService.newContentlet<DotCMSContentlet>(contentType, {
asset: file
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<form class="form">
<div class="flex flex-col gap-4 py-2">
@for (option of options; track option.contentType) {
<label
class="flex cursor-pointer items-center gap-4 rounded-lg border p-4 transition-colors"
[class]="
$selectedType() === option.contentType
? 'border-primary bg-primary/5'
: 'border-gray-300 hover:border-primary'
"
[attr.data-testid]="'upload-selector-option-' + option.contentType">
<p-radiobutton
name="uploadType"
[value]="option.contentType"
[ngModel]="$selectedType()"
(ngModelChange)="$selectedType.set($event)"
[inputId]="option.contentType" />
<span class="flex flex-col gap-1">
<span class="flex items-center gap-2">
<i
class="material-symbols-outlined text-[1.5rem]! leading-none text-primary">
{{ option.icon }}
</i>
<span class="text-base font-semibold text-gray-800">
{{ option.labelKey | dm }}
</span>
@if (option.recommended) {
<span
class="rounded-sm bg-green-100 px-2 py-0.5 text-xs font-semibold tracking-wide text-green-700 uppercase"
data-testid="upload-selector-recommended">
{{ 'content-drive.dialog.upload-selector.recommended' | dm }}
</span>
}
</span>
<span class="text-sm text-gray-600">{{ option.descriptionKey | dm }}</span>
</span>
</label>
}
</div>
</form>

<div class="mt-4 flex justify-end gap-2 border-t border-gray-200 px-4 pt-4">
<p-button
[label]="'dot.common.cancel' | dm"
[text]="true"
data-testid="upload-selector-cancel"
(click)="onCancel()" />
<p-button
[label]="'content-drive.dialog.upload-selector.continue' | dm"
[disabled]="!$canContinue()"
data-testid="upload-selector-continue"
(click)="onContinue()" />
</div>
Original file line number Diff line number Diff line change
@@ -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<DotContentDriveDialogUploadSelectorComponent>;
let store: SpyObject<InstanceType<typeof DotContentDriveStore>>;

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();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 {
DotContentDriveUploadContentType,
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<DotFolderTreeNodeData | undefined>(undefined, { alias: 'targetFolder' });

/** Files to upload — present for the drag-and-drop flow, absent for the Upload-button flow. */
$files = input<FileList | undefined>(undefined, { alias: 'files' });

/** Emits the chosen content type plus the upload context when the user confirms. */
selectUploadType = output<DotContentDriveUploadSelection>();

protected readonly options = UPLOAD_SELECTOR_OPTIONS;

/** Currently selected content type variable. Defaults to the first (recommended) option. */
protected readonly $selectedType = signal<DotContentDriveUploadContentType>(
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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<div class="flex flex-col items-center justify-center gap-3" data-testid="message-content">
<i
class="pi pi-upload h-[calc(48px+1.5rem)] w-[calc(48px+1.5rem)] rounded-full bg-primary-200 text-4xl text-primary-500"
data-testid="message-content-icon"></i>
<div
class="flex h-[calc(48px+1.5rem)] w-[calc(48px+1.5rem)] items-center justify-center rounded-full bg-primary-200">
<i
class="pi pi-upload text-[2.25rem]! leading-none text-primary-500"
data-testid="message-content-icon"></i>
</div>
<span class="text-lg leading-[140%]" data-testid="message-content-text">
{{ 'content-drive-dropzone.message.drag-and-drop-header' | dm }}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
icon="pi pi-upload"
[outlined]="true"
data-testid="upload-asset-button"
(click)="$addNewDotAsset.emit()" />
(click)="$upload.emit()" />

@if ($displayButton()) {
<p-button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,9 @@ describe('DotContentDriveToolbarComponent', () => {
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'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export class DotContentDriveToolbarComponent {
readonly #store = inject(DotContentDriveStore);
readonly #dotMessageService = inject(DotMessageService);

$addNewDotAsset = output<void>({ alias: 'addNewDotAsset' });
$upload = output<void>({ alias: 'upload' });

readonly $items = signal<MenuItem[]>([
{
Expand Down
Loading
Loading