diff --git a/core-web/.storybook/main.js b/core-web/.storybook/main.js index 94bb43476f6d..c34ad1c5261f 100644 --- a/core-web/.storybook/main.js +++ b/core-web/.storybook/main.js @@ -8,7 +8,8 @@ module.exports = { sourceLoaderOptions: null, transcludeMarkdown: true } - } + }, + '@storybook/addon-actions' ], stories: [] // uncomment the property below if you want to apply some webpack config globally diff --git a/core-web/apps/dotcms-ui/.storybook/main.js b/core-web/apps/dotcms-ui/.storybook/main.js index fe9e35b846ad..8d8ae7d46183 100644 --- a/core-web/apps/dotcms-ui/.storybook/main.js +++ b/core-web/apps/dotcms-ui/.storybook/main.js @@ -12,7 +12,8 @@ module.exports = { '../src/**/*.stories.@(js|jsx|ts|tsx)', '../../../libs/template-builder/**/*.stories.@(js|jsx|ts|tsx|mdx)', '../../../libs/block-editor/**/*.stories.@(js|jsx|ts|tsx|mdx)', - '../../../libs/contenttype-fields/**/*.stories.@(js|jsx|ts|tsx|mdx)' + '../../../libs/contenttype-fields/**/*.stories.@(js|jsx|ts|tsx|mdx)', + '../../../libs/ui/**/*.stories.@(js|jsx|ts|tsx|mdx)' ], addons: ['storybook-design-token', '@storybook/addon-essentials', ...rootMain.addons], features: { diff --git a/core-web/apps/dotcms-ui/.storybook/tsconfig.json b/core-web/apps/dotcms-ui/.storybook/tsconfig.json index 25bd1c93dd53..65c1db876c17 100644 --- a/core-web/apps/dotcms-ui/.storybook/tsconfig.json +++ b/core-web/apps/dotcms-ui/.storybook/tsconfig.json @@ -8,6 +8,7 @@ "../src/**/*", "../../../**/template-builder/**/src/lib/**/*.stories.ts", "../../../**/block-editor/**/src/lib/**/*.stories.ts", - "../../../**/contenttype-fields/**/src/lib/**/*.stories.ts" + "../../../**/contenttype-fields/**/src/lib/**/*.stories.ts", + "../../../**/ui/**/src/lib/**/*.stories.ts" ] } diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/directive/dot-drop-zone-value-accesor/dot-drop-zone-value-accessor.directive.spec.ts b/core-web/libs/ui/src/lib/components/dot-drop-zone/directive/dot-drop-zone-value-accesor/dot-drop-zone-value-accessor.directive.spec.ts new file mode 100644 index 000000000000..1da733f053e1 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-drop-zone/directive/dot-drop-zone-value-accesor/dot-drop-zone-value-accessor.directive.spec.ts @@ -0,0 +1,55 @@ +import { createDirectiveFactory, SpectatorDirective } from '@ngneat/spectator'; +import { MockComponent } from 'ng-mocks'; + +import { forwardRef } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { DotDropZoneValueAccessorDirective } from './dot-drop-zone-value-accessor.directive'; + +import { DotDropZoneComponent } from '../../dot-drop-zone.component'; + +describe('DotDropZoneValueAccessorDirective', () => { + let spectator: SpectatorDirective; + + const createDirective = createDirectiveFactory({ + directive: DotDropZoneValueAccessorDirective, + declarations: [MockComponent(DotDropZoneComponent)], + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DotDropZoneValueAccessorDirective) + } + ] + }); + + describe('Used with dot-drop-zone component', () => { + beforeEach(() => { + spectator = createDirective(` + +
+ Content +
+
`); + }); + + it('should create', () => { + expect(spectator.directive).toBeTruthy(); + }); + }); + + describe('Used without dot-drop-zone component', () => { + it('should throw error if not inside dot-drop-zone', () => { + expect(() => { + spectator = createDirective(` +
+
+ Content +
+
`); + }).toThrowError( + 'dot-drop-zone-value-accessor can only be used inside of a dot-drop-zone' + ); + }); + }); +}); diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/directive/dot-drop-zone-value-accesor/dot-drop-zone-value-accessor.directive.stories.ts b/core-web/libs/ui/src/lib/components/dot-drop-zone/directive/dot-drop-zone-value-accesor/dot-drop-zone-value-accessor.directive.stories.ts new file mode 100644 index 000000000000..203b90ef4896 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-drop-zone/directive/dot-drop-zone-value-accesor/dot-drop-zone-value-accessor.directive.stories.ts @@ -0,0 +1,122 @@ +import { action } from '@storybook/addon-actions'; +import { moduleMetadata, Story, Meta } from '@storybook/angular'; + +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { DotDropZoneValueAccessorDirective } from './dot-drop-zone-value-accessor.directive'; + +import { DotDropZoneComponent } from '../../dot-drop-zone.component'; + +/** + * This component is used to test the value accessor directive on Storybook + * + * @class DotDropZoneValueAccessorTestComponent + * @implements {OnInit} + */ +@Component({ + selector: 'dot-drop-zone-value-accessor', + styles: [ + ` + .dot-drop-zone__content { + width: 100%; + height: 200px; + background: #f8f9fa; + display: flex; + justify-content: center; + flex-direction: column; + gap: 1rem; + align-items: center; + border: 1px dashed #ced4da; + border-radius: 5px; + } + ` + ], + template: ` +
+ +
+ Drop files here. + +
Allowed Type: {{ accept }}
+ +
Max File Size: {{ maxFileSize }}
+
+
+
+ ` +}) +class DotDropZoneValueAccessorTestComponent implements OnInit { + @Input() accept: string[]; + @Input() maxFileSize: number; + + @Output() formChanged = new EventEmitter(); + @Output() formErrors = new EventEmitter(); + + myForm: FormGroup; + + constructor(private fb: FormBuilder) {} + + ngOnInit() { + this.myForm = this.fb.group({ + file: null + }); + + this.myForm.valueChanges.subscribe((value) => { + // eslint-disable-next-line no-console + this.formChanged.emit(value); + + if (this.myForm.invalid) { + this.formErrors.emit(this.myForm.errors); + } + }); + } +} + +export default { + title: 'Library/ui/Components/DropZone/ValueAccessor', + component: DotDropZoneValueAccessorTestComponent, + decorators: [ + moduleMetadata({ + imports: [FormsModule, ReactiveFormsModule, CommonModule, DotDropZoneComponent], + declarations: [DotDropZoneValueAccessorDirective] + }) + ], + parameters: { + // https://storybook.js.org/docs/6.5/angular/essentials/actions#action-event-handlers + actions: { + // detect if the component is emitting the correct HTML events + handles: ['formChanged', 'formErrors'] + } + } +} as Meta; + +const Template: Story = (args: DotDropZoneComponent) => ({ + props: { + ...args, + // https://storybook.js.org/docs/6.5/angular/essentials/actions#action-args + formChanged: action('formChanged'), + formErrors: action('formErrors') + }, + template: ` + + ` +}); + +export const Base = Template.bind({}); + +Base.args = { + accept: [], + maxFileSize: 1000000 +}; diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/directive/dot-drop-zone-value-accesor/dot-drop-zone-value-accessor.directive.ts b/core-web/libs/ui/src/lib/components/dot-drop-zone/directive/dot-drop-zone-value-accesor/dot-drop-zone-value-accessor.directive.ts new file mode 100644 index 000000000000..fc9273a4a8f0 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-drop-zone/directive/dot-drop-zone-value-accesor/dot-drop-zone-value-accessor.directive.ts @@ -0,0 +1,78 @@ +import { Directive, Host, OnInit, Optional, forwardRef } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALUE_ACCESSOR, + Validator, + NG_VALIDATORS, + ValidationErrors +} from '@angular/forms'; + +import { DotDropZoneComponent, DropZoneFileEvent } from '../../dot-drop-zone.component'; + +@Directive({ + selector: '[dotDropZoneValueAccessor]', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DotDropZoneValueAccessorDirective), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DotDropZoneValueAccessorDirective), + multi: true + } + ] +}) +export class DotDropZoneValueAccessorDirective implements ControlValueAccessor, Validator, OnInit { + private onChange: (value: File) => void; + private onTouched: () => void; + + constructor(@Optional() @Host() private _dotDropZone: DotDropZoneComponent) { + if (!this._dotDropZone) { + throw new Error( + 'dot-drop-zone-value-accessor can only be used inside of a dot-drop-zone' + ); + } + } + + ngOnInit() { + this._dotDropZone.fileDropped.subscribe(({ file }: DropZoneFileEvent) => { + this.onChange(file); // Only File + this.onTouched(); + }); + } + + writeValue(_value: unknown) { + /* + We can set a value here by doing this._dotDropZone.setFile(value), if needed + */ + } + + registerOnChange(fn: (value: unknown) => void) { + this.onChange = fn; + } + + registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + + validate(_control: AbstractControl): ValidationErrors | null { + const validity = this._dotDropZone.validity; + + if (validity.valid) { + return null; + } + + const errors = Object.entries(validity).reduce((acc, [key, value]) => { + if (value === true) { + acc[key] = value; + } + + return acc; + }, {}); + + return errors; + } +} diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.html b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.html new file mode 100644 index 000000000000..6dbc74306383 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.html @@ -0,0 +1 @@ + diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.scss b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.spec.ts new file mode 100644 index 000000000000..13942499d708 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.spec.ts @@ -0,0 +1,235 @@ +import { SpectatorHost, createHostFactory } from '@ngneat/spectator'; + +import { CommonModule } from '@angular/common'; + +import { DotDropZoneComponent } from './dot-drop-zone.component'; + +const MOCK_VALIDITY = { + fileTypeMismatch: false, + maxFileSizeExceeded: false, + multipleFilesDropped: false, + valid: true +}; + +describe('DotDropZoneComponent', () => { + let spectator: SpectatorHost; + let mockFile: File; + let mockDataTransfer: DataTransfer; + + const createHost = createHostFactory({ + component: DotDropZoneComponent, + imports: [CommonModule] + }); + + beforeEach(async () => { + spectator = createHost(` + +
+ Content +
+
+ `); + + spectator.detectChanges(); + }); + + beforeEach(() => { + mockFile = new File([''], 'filename', { type: 'text/html' }); + mockDataTransfer = new DataTransfer(); + mockDataTransfer.items.add(mockFile); + }); + + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should have content', () => { + expect(spectator.query('#dot-drop-zone__content')).toBeTruthy(); + }); + + describe('onDrop', () => { + it('should emit fileDrop', () => { + const spy = spyOn(spectator.component.fileDropped, 'emit'); + const event = new DragEvent('drop', { + dataTransfer: mockDataTransfer + }); + + spectator.component.onDrop(event); + + expect(spy).toHaveBeenCalledWith({ + file: mockFile, + validity: { + ...MOCK_VALIDITY + } + }); + }); + + it('should prevent default', () => { + const event = new DragEvent('drop', { + dataTransfer: mockDataTransfer + }); + const spyEventPrevent = spyOn(event, 'preventDefault'); + const spyEventStop = spyOn(event, 'stopPropagation'); + + spectator.component.onDrop(event); + + expect(spyEventPrevent).toHaveBeenCalled(); + expect(spyEventStop).toHaveBeenCalled(); + }); + + describe('when file is valid', () => { + beforeEach(() => { + spectator.component.accept = ['.html', 'text/html']; + spectator.detectChanges(); + }); + + it('should emit fileDrop', () => { + const spy = spyOn(spectator.component.fileDropped, 'emit'); + const event = new DragEvent('drop', { + dataTransfer: mockDataTransfer + }); + + spectator.component.onDrop(event); + + expect(spy).toHaveBeenCalledWith({ + file: mockFile, + validity: { + ...MOCK_VALIDITY + } + }); + }); + }); + + describe('when multiple files are being dragged', () => { + it('should set multiFileError to true if multiplefiles are being dragged', () => { + const spy = spyOn(spectator.component.fileDropped, 'emit'); + + const file1 = new File([''], 'filename', { type: 'text/html' }); + const file2 = new File([''], 'filename', { type: 'text/html' }); + mockDataTransfer.items.add(file1); + mockDataTransfer.items.add(file2); + + const event = new DragEvent('drop', { + dataTransfer: mockDataTransfer + }); + + spectator.component.onDrop(event); + + expect(spy).toHaveBeenCalledWith({ + file: null, + validity: { + ...MOCK_VALIDITY, + multipleFilesDropped: true, + valid: false + } + }); + }); + }); + + describe('when file is invalid', () => { + beforeEach(() => { + spectator.component.accept = ['.png', 'image/']; + spectator.component.maxFileSize = 10; + spectator.detectChanges(); + }); + + it('should emit fileDropped event with validity to true', () => { + const spy = spyOn(spectator.component.fileDropped, 'emit'); + const event = new DragEvent('drop', { + dataTransfer: mockDataTransfer + }); + + spectator.component.onDrop(event); + expect(spy).toHaveBeenCalledWith({ + file: mockFile, + validity: { + ...MOCK_VALIDITY, + fileTypeMismatch: true, + valid: false + } + }); + }); + + it('should emit fileDropped event with validity maxFileSizeExceeded to true', () => { + const file = new File([''], 'mockfile.png', { type: 'image/png' }); + Object.defineProperty(file, 'size', { value: 2000000 }); + const mockDataTransfer = new DataTransfer(); + mockDataTransfer.items.add(file); + + const spy = spyOn(spectator.component.fileDropped, 'emit'); + const event = new DragEvent('drop', { + dataTransfer: mockDataTransfer + }); + + spectator.component.onDrop(event); + expect(spy).toHaveBeenCalledWith({ + file: mockFile, + validity: { + ...MOCK_VALIDITY, + maxFileSizeExceeded: true, + valid: false + } + }); + }); + }); + }); + + describe('onDragEnter', () => { + it('should emit fileDragEnter event', () => { + const spy = spyOn(spectator.component.fileDragEnter, 'emit'); + const event = new DragEvent('dragenter'); + + spectator.component.onDragEnter(event); + spectator.detectChanges(); + + expect(spy).toHaveBeenCalledWith(true); + }); + + it('should prevent default', () => { + const event = new DragEvent('dragenter'); + const spyEventPrevent = spyOn(event, 'preventDefault'); + const spyEventStop = spyOn(event, 'stopPropagation'); + + spectator.component.onDragEnter(event); + + expect(spyEventPrevent).toHaveBeenCalled(); + expect(spyEventStop).toHaveBeenCalled(); + }); + }); + + describe('onDragOver', () => { + it('should prevent default', () => { + const event = new DragEvent('dragover'); + const spyEventPrevent = spyOn(event, 'preventDefault'); + const spyEventStop = spyOn(event, 'stopPropagation'); + + spectator.component.onDragOver(event); + + expect(spyEventPrevent).toHaveBeenCalled(); + expect(spyEventStop).toHaveBeenCalled(); + }); + }); + + describe('onDragLeave', () => { + it('should emit fileDragLeave event', () => { + const spy = spyOn(spectator.component.fileDragLeave, 'emit'); + const event = new DragEvent('dragleave'); + + spectator.component.onDragLeave(event); + spectator.detectChanges(); + + expect(spy).toHaveBeenCalledWith(true); + }); + + it('should prevent default', () => { + const event = new DragEvent('dragleave'); + const spyEventPrevent = spyOn(event, 'preventDefault'); + const spyEventStop = spyOn(event, 'stopPropagation'); + + spectator.component.onDragLeave(event); + + expect(spyEventPrevent).toHaveBeenCalled(); + expect(spyEventStop).toHaveBeenCalled(); + }); + }); +}); diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.stories.ts b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.stories.ts new file mode 100644 index 000000000000..2f837e339e5c --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.stories.ts @@ -0,0 +1,77 @@ +import { action } from '@storybook/addon-actions'; +import { moduleMetadata, Story, Meta } from '@storybook/angular'; + +import { CommonModule } from '@angular/common'; + +import { DotDropZoneComponent } from './dot-drop-zone.component'; + +export default { + title: 'Library/ui/Components/DropZone', + component: DotDropZoneComponent, + decorators: [ + moduleMetadata({ + imports: [CommonModule] + }) + ], + parameters: { + // https://storybook.js.org/docs/6.5/angular/essentials/actions#action-event-handlers + actions: { + // detect if the component is emitting the correct HTML events + handles: ['fileDrop', 'fileDragEnter', 'fileDragLeave'] + } + } +} as Meta; + +const Template: Story = (args: DotDropZoneComponent) => ({ + props: { + ...args, + // https://storybook.js.org/docs/6.5/angular/essentials/actions#action-args + fileDropped: action('fileDropped'), + fileDragEnter: action('fileDragEnter'), + fileDragLeave: action('fileDragLeave') + }, + styles: [ + ` + .content { + width: 100%; + height: 200px; + background: #f8f9fa; + display:flex; + flex-direction: column; + gap: 1rem; + justify-content:center; + align-items:center; + border: 1px dashed #ced4da; + border-radius: 5px; + } + ` + ], + template: ` + +
+ Drop files here. + +
+ Allowed Type: {{ accept }} +
+ +
+ Max File Size: {{ maxFileSize }} +
+
+
+ ` +}); + +export const Base = Template.bind({}); + +Base.args = { + accept: [], + maxFileSize: 1000000 +}; diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts new file mode 100644 index 000000000000..c25ff589af73 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts @@ -0,0 +1,180 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + HostListener, + Input, + Output +} from '@angular/core'; + +export interface DropZoneFileEvent { + file: File | null; + validity: DropZoneFileValidity; +} + +export interface DropZoneFileValidity { + fileTypeMismatch: boolean; + maxFileSizeExceeded: boolean; + multipleFilesDropped: boolean; + valid: boolean; +} + +@Component({ + selector: 'dot-drop-zone', + standalone: true, + imports: [CommonModule], + templateUrl: './dot-drop-zone.component.html', + styleUrls: ['./dot-drop-zone.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotDropZoneComponent { + @Output() fileDropped = new EventEmitter(); + @Output() fileDragEnter = new EventEmitter(); + @Output() fileDragLeave = new EventEmitter(); + + @Input() maxFileSize: number; + + @Input() set accept(types: string[]) { + this._accept = types.map((type) => { + // Remove the wildcard character + return type.toLowerCase().replace(/\*/g, ''); + }); + } + + private _accept: string[] = []; + private _validity: DropZoneFileValidity = { + fileTypeMismatch: false, + maxFileSizeExceeded: false, + multipleFilesDropped: false, + valid: true + }; + + get validity(): DropZoneFileValidity { + return this._validity; + } + + @HostListener('drop', ['$event']) + onDrop(event: DragEvent) { + event.stopPropagation(); + event.preventDefault(); + + const { dataTransfer } = event; + const files = this.getFiles(dataTransfer); + const file = files?.length === 1 ? files[0] : null; + + if (files.length === 0) return; + + this.setValidity(files); + + dataTransfer.items?.clear(); + dataTransfer.clearData(); + this.fileDropped.emit({ + file, // Only one file is allowed + validity: this._validity + }); + } + + @HostListener('dragenter', ['$event']) + onDragEnter(event: DragEvent) { + event.stopPropagation(); + event.preventDefault(); + this.fileDragEnter.emit(true); + } + + @HostListener('dragover', ['$event']) + onDragOver(event: DragEvent) { + // Prevent the default behavior to allow drop + event.stopPropagation(); + event.preventDefault(); + } + + @HostListener('dragleave', ['$event']) + onDragLeave(event: DragEvent) { + event.stopPropagation(); + event.preventDefault(); + this.fileDragLeave.emit(true); + } + + /** + * Check if the file is valid based on the allowed extensions and mime types + * + * @private + * @param {File} file + * @return {*} {boolean} + * @memberof DotDropzoneComponent + */ + private typeMatch(file: File): boolean { + if (!this._accept.length) { + return true; + } + + const extension = file.name.split('.').pop().toLowerCase(); + const mimeType = file.type.toLowerCase(); + + const isValidType = this._accept.some( + (type) => mimeType.includes(type) || type.includes(`.${extension}`) + ); + + return isValidType; + } + + /** + * Get the files from the dataTransfer object + * + * @private + * @param {DataTransfer} dataTransfer + * @return {*} {File[]} + * @memberof DotDropZoneComponent + */ + private getFiles(dataTransfer: DataTransfer): File[] { + const { items, files } = dataTransfer; + + if (items) { + return Array.from(items) + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()); + } + + return Array.from(files) || []; + } + + /** + * Check if the file is too long + * + * @private + * @param {File} file + * @return {*} {boolean} + * @memberof DotDropZoneComponent + */ + private isFileTooLong(file: File): boolean { + if (!this.maxFileSize) { + return false; + } + + return file.size > this.maxFileSize; + } + + /** + * Set the _validity object + * + * @private + * @param {File} file + * @memberof DotDropZoneComponent + */ + private setValidity(files: File[]): void { + const file = files[0]; // Only one file is allowed + const multipleFilesDropped = files.length > 1; + const fileTypeMismatch = !this.typeMatch(file); + const maxFileSizeExceeded = this.isFileTooLong(file); + const valid = !fileTypeMismatch && !maxFileSizeExceeded && !multipleFilesDropped; + + this._validity = { + ...this._validity, + multipleFilesDropped, + fileTypeMismatch, + maxFileSizeExceeded, + valid + }; + } +}