diff --git a/angular.json b/angular.json index 5c581b06a..1f447a0d9 100644 --- a/angular.json +++ b/angular.json @@ -330,7 +330,7 @@ } } }, - "defaultProject": "angular-dxc-site", + "defaultProject": "dxc-ngx-cdk", "cli": { "analytics": false } diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/directives/file-format.directive.ts b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/directives/file-format.directive.ts new file mode 100644 index 000000000..4aba16d02 --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/directives/file-format.directive.ts @@ -0,0 +1,50 @@ +import { Directive, ElementRef, Input, Inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +@Directive({ + selector: '[dxcFileFormat]' +}) +export class FileFormatDirective { + + @Input() format; + + constructor(private elementRef: ElementRef, @Inject(DOCUMENT) private document: any) { + } + + ngOnInit(): void { + let xmlns = "http://www.w3.org/2000/svg"; + const commonPathChild = this.document.createElementNS(xmlns, "path"); + commonPathChild.setAttributeNS(null, 'd', 'M0 0h24v24H0V0z'); + commonPathChild.setAttributeNS(null, 'fill', 'none'); + const child = this.document.createElementNS(xmlns, "path"); + switch (this.categorizeFileFormat(this.format)) { + case 'image': + child.setAttributeNS(null, 'd', 'M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z'); + break; + case 'video': + child.setAttributeNS(null, 'd', 'M4 6.47L5.76 10H20v8H4V6.47M22 4h-4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4z'); + break; + case 'audio': + child.setAttributeNS(null, 'd', 'M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM8 15c0-1.66 1.34-3 3-3 .35 0 .69.07 1 .18V6h5v2h-3v7.03c-.02 1.64-1.35 2.97-3 2.97-1.66 0-3-1.34-3-3z'); + break; + default: + child.setAttributeNS(null, 'd', 'M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z'); + break; + } + this.elementRef.nativeElement.append(commonPathChild); + this.elementRef.nativeElement.append(child); + } + + private categorizeFileFormat(fileFormat:string){ + if (fileFormat.includes("image")) { + return 'image'; + } else if (fileFormat.includes("video")) { + return 'video'; + + } else if (fileFormat.includes("audio")) { + return 'audio'; + + } + return 'default'; + } +} diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-error/dxc-file-error.component.html b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-error/dxc-file-error.component.html new file mode 100644 index 000000000..3683e6bc2 --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-error/dxc-file-error.component.html @@ -0,0 +1,3 @@ +{{error}} diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-error/dxc-file-error.component.scss b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-error/dxc-file-error.component.scss new file mode 100644 index 000000000..3bef7f30f --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-error/dxc-file-error.component.scss @@ -0,0 +1,9 @@ +.errorMessage { + text-align: left; + letter-spacing: 0.37px; + color: var(--fileInput-errorMessageFontColor); + font-family: var(--fileInput-errorMessageFontFamily); + font-size: var(--fileInput-errorMessageFontSize); + font-weight: var(--fileInput-errorMessageFontWeight); + line-height: var(--fileInput-errorMessageLineHeight); +} diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-error/dxc-file-error.component.ts b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-error/dxc-file-error.component.ts new file mode 100644 index 000000000..93c5ad6fc --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-error/dxc-file-error.component.ts @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component, HostBinding, Input, OnInit } from '@angular/core'; + +@Component({ + selector: 'dxc-file-error', + templateUrl: './dxc-file-error.component.html', + styleUrls: ['./dxc-file-error.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DxcFileErrorComponent implements OnInit { + + @Input() + error: string; + + constructor() { } + + ngOnInit(): void { + + } +} diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.component.html b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.component.html new file mode 100644 index 000000000..54cdb2420 --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.component.html @@ -0,0 +1,53 @@ + +{{ helperText }} +
+ + +
+ + or drop files +
+
+ +
+
+ + diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.component.spec.ts b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.component.spec.ts new file mode 100644 index 000000000..7fcb7ed87 --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.component.spec.ts @@ -0,0 +1,308 @@ +import { fireEvent, render } from "@testing-library/angular"; +import { DxcFileInputComponent } from "./dxc-file-input.component"; +import { DxcFileInputModule } from "./dxc-file-input.module"; +import { screen, waitFor } from "@testing-library/dom"; +import { FileData } from "./interfaces/file.interface"; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from "@angular/platform-browser-dynamic/testing"; +import { TestBed } from "@angular/core/testing"; + +TestBed.initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); + +describe("DxcFileInputComponent", () => { + test("should render dxc-file-input in file mode", async () => { + const fileInput = await render(DxcFileInputComponent, { + componentProperties: { + label: "Label", + helperText: "Helper Text", + }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + }); + + expect(fileInput.getByText("Select files")); + expect(fileInput.getByText("Label")); + expect(fileInput.getByText("Helper Text")); + }); + + test("should render dxc-file-input with custom buttonLabel", async () => { + const fileInput = await render(DxcFileInputComponent, { + componentProperties: { + label: "Label", + helperText: "Helper Text", + buttonLabel: "Custom Button Label", + }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + }); + + expect(fileInput.getByText("Custom Button Label")); + expect(fileInput.getByText("Label")); + expect(fileInput.getByText("Helper Text")); + }); + + + test("should render dxc-file-input in file drop mode", async () => { + const fileInput = await render(DxcFileInputComponent, { + componentProperties: { + mode: "filedrop", + }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + }); + + expect(fileInput.getByText("or drop files")); + }); + + test("should render dxc-file-input in drop zone mode", async () => { + const fileInput = await render(DxcFileInputComponent, { + componentProperties: { + mode: "dropzone", + }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + }); + + expect(fileInput.getByText("or drop files")); + }); + + test("should render disabled dxc-file-input", async () => { + const fileInput = await render(DxcFileInputComponent, { + componentProperties: { + disabled: true, + }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + }); + + expect(fileInput.getByText("Select files")); + const btn = fileInput.getAllByRole("button"); + expect(btn[0].hasAttribute("disabled")).toBe(true); + }); + + test("should not have files even if they are selected", async () => { + const fileInput = await render( + ``, + { + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + declarations: [DxcFileInputComponent], + } + ); + const inputEl = fileInput.getByTestId("input"); + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }); + fireEvent.change(inputEl, { target: { files: [file] } }); + const fileInScreen = screen.queryByText("foo.txt"); + expect(fileInScreen).toBeFalsy(); + }); + + test("should render error when file does not meet minSize", async () => { + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }); + const value: Array = [ + { + data: file, + image: "", + error: "", + }, + ]; + const callback = jest.fn(); + const fileInput = await render( + ``, + { + componentProperties: { callback, value }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + declarations: [DxcFileInputComponent], + } + ); + await waitFor(() => { + fileInput.detectChanges(); + expect(screen.getByText("foo.txt")); + expect(screen.getByText("File size must be greater than min size.")); + }); + }); + + test("should render error when file does not meet maxSize", async () => { + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }); + const value: Array = [ + { + data: file, + image: "", + error: "", + }, + ]; + const maxSize = 1; + const callback = jest.fn(); + const fileInput = await render( + ``, + { + componentProperties: { callback, value, maxSize }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + declarations: [DxcFileInputComponent], + } + ); + fileInput.detectChanges(); + await waitFor(() => { + fileInput.detectChanges(); + expect(screen.getByText("foo.txt")); + expect(screen.getByText("File size must be less than max size.")); + }); + }); + + test("render given values when multiple is false", async () => { + const callback = jest.fn(); + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }); + const file2 = new File(["chucknorris"], "chucknorris.txt", { + type: "text/plain", + }); + let value: Array = [ + { + data: file, + image: "", + error: "Error for file", + }, + { + data: file2, + image: "", + error: "Error for file2", + }, + ]; + const fileInput = await render(DxcFileInputComponent, { + componentProperties: { + multiple: false, + value: value, + callbackFile: { + emit: callback, + } as any, + }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + }); + + await waitFor(() => { + expect(screen.getByText("foo.txt")); + expect(screen.getByText("chucknorris.txt")); + expect(screen.getByText("Error for file")); + expect(screen.getByText("Error for file2")); + }); + }); + + test("render dxc-file-input with multiple files", async () => { + const callback = jest.fn(); + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }); + const file2 = new File(["test"], "test.txt", { + type: "text/plain", + }); + const value = [ + { + data: file, + image: "", + }, + { + data: file2, + image: "", + }, + ]; + const fileInput = await render( + ``, + { + componentProperties: { callback, value }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + declarations: [DxcFileInputComponent], + } + ); + const inputEl = fileInput.getByTestId("input"); + await waitFor(() => { + expect(screen.getByText("foo.txt")); + expect(screen.getByText("test.txt")); + }); + }); + + test("should remove file from dxc-file-input", async () => { + const callback = jest.fn(); + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }); + const file2 = new File(["test"], "test.txt", { + type: "text/plain", + }); + const value = [ + { + data: file, + image: null, + }, + { + data: file2, + image: null, + }, + ]; + const fileInput = await render( + ``, + { + componentProperties: { callback, value }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + declarations: [DxcFileInputComponent], + } + ); + const inputEl = fileInput.getByTestId("input"); + + fireEvent.change(inputEl, { target: { files: [file, file2] } }); + fileInput.detectChanges(); + await waitFor(() => { + expect(screen.getByText("foo.txt")); + }); + const removeIcons = fileInput.getAllByTestId("removeIcon"); + fireEvent.click(removeIcons[0]); + await waitFor(() => { + expect(callback).toHaveBeenCalledWith([ + { data: file2, error: null, image: null }, + ]); + }); + }); + + test("should return callback files", async () => { + const callback = jest.fn(); + const value = []; + const fileInput = await render( + ``, + { + componentProperties: { callback, value }, + excludeComponentDeclaration: true, + imports: [DxcFileInputModule], + declarations: [DxcFileInputComponent], + } + ); + const inputEl = fileInput.getByTestId("input"); + const file = new File(["foo"], "foo.txt", { + type: "text/plain", + }); + const file2 = new File(["(⌐□_□)"], "chucknorris.txt", { + type: "text/plain", + }); + fireEvent.change(inputEl, { target: { files: [file, file2] } }); + await waitFor(() => { + expect(callback).toHaveBeenCalledWith([ + { data: file, error: null, image: null }, + { data: file2, error: null, image: null }, + ]); + }); + }); +}); diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.component.ts b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.component.ts new file mode 100644 index 000000000..a9691d22b --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.component.ts @@ -0,0 +1,490 @@ +import { + coerceBooleanProperty, + coerceNumberProperty, +} from "@angular/cdk/coercion"; +import { + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + HostBinding, + Input, + OnChanges, + OnInit, + Output, + ViewChild, +} from "@angular/core"; +import { css } from "emotion"; +import { BehaviorSubject } from "rxjs"; +import { CssUtils } from "../utils"; +import { v4 as uuidv4 } from "uuid"; +import { FileData } from "./interfaces/file.interface"; +import { FilesService } from "./services/files.services"; +import { NgChanges } from "../typings/ng-onchange"; +import { FileInputProperties, Space, Spacing } from "./dxc-file-input.types"; + +@Component({ + selector: "dxc-file-input", + templateUrl: "./dxc-file-input.component.html", + providers: [CssUtils, FilesService], +}) +export class DxcFileInputComponent implements OnChanges, OnInit { + @ViewChild("fileInput", { static: false }) fileInputNative: ElementRef; + @HostBinding("class") className; + /** + * Name attribute. + */ + @Input() public name: string = ""; + /** + * Available modes of the component. + */ + @Input() public mode: "file" | "filedrop" | "dropzone" = "file"; + /** + * Text to be placed above the component. + */ + @Input() public label: string = ""; + /** + * Text to be placed inside the button. + */ + @Input() public buttonLabel: string; + /** + * Helper text to be placed above the component. + */ + @Input() public helperText: string = ""; + /** + * An array of files representing the selected files. + */ + @Input() public value: FileData[]; + /** + * The file types that the component accepts. Its value must be one of all the possible values of the HTML file input's accept attribute. + */ + @Input() public accept: string; + /** + * If true, the component allows multiple file items and will show all of them. If false, only one file will be shown, + * and if there is already one file selected and a new one is chosen, it will be replaced by the new selected one. + */ + @Input() + get multiple(): boolean { + return this._multiple; + } + set multiple(value: boolean) { + this._multiple = coerceBooleanProperty(value); + } + private _multiple = true; + /** + * If true, if the file is an image, a preview of it will be shown. If not, an icon refering to the file type will be shown. + */ + @Input() + get showPreview(): boolean { + return this._showPreview; + } + set showPreview(value: boolean) { + this._showPreview = coerceBooleanProperty(value); + } + private _showPreview = false; + /** + * If true, the component will be disabled. + */ + @Input() + get disabled(): boolean { + return this._disabled; + } + set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + } + private _disabled = false; + /** + * The minimum file size (in bytes) allowed. If the size of the file does not comply the minSize, the file will have an error. + */ + @Input() + get minSize(): number { + return this._minSize; + } + set minSize(value: number) { + this._minSize = coerceNumberProperty(value); + } + private _minSize; + /** + * The maximum file size (in bytes) allowed. If the size of the file does not comply the maxSize, the file will have an error. + */ + @Input() + get maxSize(): number { + return this._maxSize; + } + set maxSize(value: number) { + this._maxSize = coerceNumberProperty(value); + } + private _maxSize; + /** + * Size of the margin to be applied to the component ('xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge'). + * You can pass an object with 'top', 'bottom', 'left' and 'right' properties in order to specify different margin sizes. + */ + @Input() public margin: Space | Spacing; + /** + * Value of the tabindex attribute. + */ + @Input() + get tabIndexValue(): number { + return this._tabIndexValue; + } + set tabIndexValue(value: number) { + this._tabIndexValue = coerceNumberProperty(value); + } + private _tabIndexValue = 0; + hasShowError: boolean = false; + /** + * This event will emit when the user selects or drops a file. The file or list of files will be sent as a parameter. + */ + @Output() callbackFile = new EventEmitter(); + + defaultInputs = new BehaviorSubject({ + name: null, + mode: "file", + label: null, + buttonLabel: null, + helperText: null, + accept: null, + multiple: true, + showPreview: false, + disabled: false, + margin: null, + tabIndexValue: 0, + value: null, + maxSize: null, + minSize: null, + }); + + id: string; + files: Array = []; + hoveringWithFile: boolean = false; + filesLoaded: boolean = false; + numberFiles: number = 0; + hasMultipleFiles: boolean = false; + hasSingleFile: boolean = false; + hasErrorSingleFile: boolean = false; + hasValue: boolean = false; + + constructor(private utils: CssUtils, private service: FilesService) { + this.service.files.subscribe(({ files, event }) => { + if (event !== "reset" && (files.length || this.hasValue)) { + this.hasShowError = this.isErrorShow(); + this.hasMultipleFiles = this.isMultipleFilesPrintables(); + this.hasSingleFile = this.isSingleFilesPrintables(); + this.callbackFile.emit(files); + } + }); + } + + ngOnInit() { + this.id = this.id || uuidv4(); + this.value ? (this.hasValue = true) : (this.hasValue = false); + this.hasShowError = this.isErrorShow(); + this.hasMultipleFiles = this.isMultipleFilesPrintables(); + this.hasSingleFile = this.isSingleFilesPrintables(); + this.className = `${this.getDynamicStyle(this.defaultInputs.getValue())}`; + if (!this.buttonLabel) { + this.buttonLabel = + this.mode === "file" + ? this.multiple + ? "Select files" + : "Select file" + : "Select"; + } + } + + ngOnChanges(changes: NgChanges): void { + if (this.fileInputNative) { + this.multiple + ? this.fileInputNative.nativeElement.setAttribute("multiple", true) + : this.fileInputNative.nativeElement.removeAttribute("multiple"); + } + if (this.value?.length > 0) { + const arr: FileData[] = []; + this.service.files.next({ files: arr, event: "reset" }); + this.value.forEach((file) => { + if (!file.error) { + file.error = this.checkFileSize(file.data); + } + this.service.addFile(file); + }); + } + const inputs = Object.keys(changes).reduce((result, item) => { + result[item] = changes[item].currentValue; + return result; + }, {}); + this.defaultInputs.next({ ...this.defaultInputs.getValue(), ...inputs }); + this.className = `${this.getDynamicStyle(this.defaultInputs.getValue())}`; + } + + ngAfterViewInit(): void { + if (this.fileInputNative) { + this.multiple + ? this.fileInputNative.nativeElement.setAttribute("multiple", true) + : this.fileInputNative.nativeElement.removeAttribute("multiple"); + } + } + + checkFileSize(file: File) { + if (file.size < this.minSize) { + return "File size must be greater than min size."; + } + if (file.size > this.maxSize) { + return "File size must be less than max size."; + } + return null; + } + + /** + * File drop y drop zone + * @param event + */ + dragOver(event) { + event.preventDefault(); + this.hoveringWithFile = true; + } + + /** + * File drop y drop zone + * @param event + */ + dragLeave(event) { + event.preventDefault(); + this.hoveringWithFile = false; + } + + /** + * File drop y drop zone + * @param event + */ + drop(event) { + event.preventDefault(); + this.hoveringWithFile = false; + if (this.callbackFile.observers?.length > 0 && this.hasValue) { + if (!this.multiple) { + this.service.emptyArrayFiles(); + } + this.getPreviewsFiles(event.dataTransfer.files); + } + } + + /** + * Common function for both file modes. + * @param event + */ + onFileInput(event) { + if (this.callbackFile.observers?.length > 0 && this.hasValue) { + if (!this.multiple) { + this.service.emptyArrayFiles(); + } + this.getPreviewsFiles(event.target.files); + event.target.value = ""; + } + } + + /** + * Common function for both file modes. + * @param eventFiles + */ + getPreviewsFiles(eventFiles) { + this.numberFiles = eventFiles?.length; + Array.from(eventFiles).map((file) => { + this.getPreview(file); + }); + } + + /** + * Common function for both file modes. + * @param file + */ + getPreview(file) { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (event) => { + if (!file.type.includes("image") || file.type.includes("image/svg")) { + let fileToAdd: FileData = { + data: file, + image: null, + error: this.checkFileSize(file), + }; + this.service.addFile(fileToAdd); + } else { + let fileToAdd: FileData = { + data: file, + image: event.target["result"], + error: this.checkFileSize(file), + }; + this.service.addFile(fileToAdd); + } + }; + } + + private isMultipleFilesPrintables(isSingle = false) { + return isSingle + ? this.value?.length > 0 && !this.disabled && !this.multiple + : this.value?.length > 0 && !this.disabled && this.multiple; + } + + private isSingleFilesPrintables() { + return this.mode === "file" && this.value?.length === 1 && !this.multiple; + } + + private isErrorShow = (): boolean => + this.value?.length === 1 && + this.mode === "file" && + this.value[0]?.error && + !this.multiple && + !this.disabled; + + /** + * Define the type of file component. Just for styling + * @param inputs + * @returns + */ + getModeStyle(inputs: FileInputProperties) { + if (inputs.mode === "filedrop") { + return this.getFileDropStyle(); + } else if (inputs.mode === "dropzone") { + return this.getDropZoneStyle(); + } else { + return this.getFileStyle(); + } + } + + /** + * Just for file mode. + * @param inputs + * @returns + */ + getFileStyle() { + return css` + .fileInputContainer { + flex-direction: ${this.value?.length > 1 || this.multiple + ? "column" + : "row"}; + } + `; + } + + /** + * Just for drop zone. + * @param inputs + * @returns + */ + getDropZoneStyle() { + return css` + .fileInputContainer { + flex-direction: column; + .dragDropArea { + height: 160px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + .dropLabel { + margin-top: 8px; + } + } + } + `; + } + /** + * Just for drop zone. + * @param inputs + * @returns + */ + getFileDropStyle() { + return css` + .fileInputContainer { + flex-direction: column; + .dragDropArea { + padding: 3px; + height: 48px; + box-sizing: border-box; + .dropLabel { + margin-left: 12px; + } + } + } + `; + } + + /** + * Common functionality for styling + * @param inputs + * @returns + */ + getDynamicStyle(inputs) { + return css` + ${this.utils.getMargins(inputs.margin)} + ${this.getModeStyle(inputs)} + width: fit-content; + display: flex; + flex-direction: column; + ${inputs.disabled ? "cursor: not-allowed;" : ""} + .fileInputContainer { + display: flex; + dxc-button { + width: fit-content; + } + input { + visibility: hidden; + width: 0px; + height: 0px; + } + .dragDropArea { + width: 320px; + box-sizing: border-box; + background: #ffffff 0% 0% no-repeat padding-box; + border: var(--fileInput-dropBorderThickness) + var(--fileInput-dropBorderStyle) + ${!inputs.disabled + ? "var(--fileInput-dropBorderColor)" + : "var(--fileInput-disabledDropBorderColor)"}; + border-radius: var(--fileInput-dropBorderRadius); + .dropLabel { + text-align: left; + letter-spacing: 0.49px; + color: ${!inputs.disabled + ? "var(--fileInput-dropLabelFontColor)" + : "var(--fileInput-disabledDropLabelFontColor)"}; + font-family: var(--fileInput-dropLabelFontFamily); + font-size: var(--fileInput-dropLabelFontSize); + font-weight: var(--fileInput-dropLabelFontWeight); + } + &.hovering { + ${!inputs.disabled + ? "border: 2px solid var(--fileInput-focusDropBorderColor); background: var(--fileInput-dragoverDropBackgroundColor) 0% 0% no-repeat padding-box;" + : ""} + } + } + .fileContainer { + display: flex; + flex-direction: column; + } + } + .label { + text-align: left; + letter-spacing: 0px; + color: ${!inputs.disabled + ? "var(--fileInput-labelFontColor)" + : "var(--fileInput-disabledLabelFontColor)"}; + font-family: var(--fileInput-labelFontFamily); + font-size: var(--fileInput-labelFontSize); + font-weight: var(--fileInput-labelFontWeight); + line-height: var(--fileInput-labelLineHeight); + } + .helperText { + margin-bottom: 4px; + text-align: left; + letter-spacing: 0px; + color: ${!inputs.disabled + ? "var(--fileInput-helperTextFontColor)" + : "var(--fileInput-disabledHelperTextFontColor)"}; + font-family: var(--fileInput-helperTextFontFamily); + font-size: var(--fileInput-helperTextFontSize); + font-weight: var(--fileInput-helperTextFontWeight); + line-height: var(--fileInput-helperTextLineHeight); + } + `; + } +} diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.module.ts b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.module.ts new file mode 100644 index 000000000..ed7abfc6d --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DxcFileInputComponent } from './dxc-file-input.component'; +import { DxcButtonModule } from "../dxc-button/dxc-button.module"; +import { DxcFileComponent } from './dxc-file/dxc-file.component'; +import { DxcFileErrorComponent } from './dxc-file-error/dxc-file-error.component'; +import { FileFormatDirective } from './directives/file-format.directive'; +@NgModule({ + declarations: [DxcFileInputComponent, DxcFileComponent, DxcFileErrorComponent, FileFormatDirective], + imports: [CommonModule, DxcButtonModule], + exports: [DxcFileInputComponent], +}) +export class DxcFileInputModule { } diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.types.ts b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.types.ts new file mode 100644 index 000000000..3b4b38020 --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file-input.types.ts @@ -0,0 +1,34 @@ +import { FileData } from "./interfaces/file.interface"; + +export interface FileInputProperties { + margin?: Space | Spacing; + tabIndexValue?: number; + name?: string; + mode?: string; + label?: string; + buttonLabel?: string; + helperText?: string; + value?: FileData[]; + accept?: string; + multiple?: boolean; + showPreview?: boolean; + disabled?: boolean; + minSize?: number; + maxSize?: number; +} + +export type Space = + | "xxsmall" + | "xsmall" + | "small" + | "medium" + | "large" + | "xlarge" + | "xxlarge"; + +export type Spacing = { + top?: Space; + bottom?: Space; + left?: Space; + right?: Space; +}; diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file/dxc-file.component.html b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file/dxc-file.component.html new file mode 100644 index 000000000..6dd2e0067 --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file/dxc-file.component.html @@ -0,0 +1,60 @@ +
+ + +
+ +
+
+ {{ file.data.name }} +
+ + + + + + + + + + + +
+
+
+ {{ file.error }} +
+
diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file/dxc-file.component.ts b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file/dxc-file.component.ts new file mode 100644 index 000000000..6de9bfcdd --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/dxc-file/dxc-file.component.ts @@ -0,0 +1,274 @@ +import { + coerceBooleanProperty, + coerceNumberProperty, +} from "@angular/cdk/coercion"; +import { C } from "@angular/cdk/keycodes"; +import { + Component, + HostBinding, + Input, + OnInit, + SimpleChanges, +} from "@angular/core"; +import { css } from "emotion"; +import { BehaviorSubject } from "rxjs"; +import { FileData } from "../interfaces/file.interface"; +import { FilesService } from "../services/files.services"; + +@Component({ + selector: "dxc-file", + templateUrl: "./dxc-file.component.html", +}) +export class DxcFileComponent implements OnInit { + @HostBinding("class") className; + + @Input() file: FileData; + @Input() multiple: boolean; + @Input() mode: string; + @Input() updatable: boolean; + @Input() + get showPreview(): boolean { + return this._showPreview; + } + set showPreview(value: boolean) { + this._showPreview = coerceBooleanProperty(value); + } + private _showPreview = false; + @Input() + get tabIndexValue(): number { + return this._tabIndexValue; + } + set tabIndexValue(value: number) { + this._tabIndexValue = coerceNumberProperty(value); + } + private _tabIndexValue = 0; + + hasError: boolean = false; + hasShowError: boolean = false; + hasShowPreviewImage: boolean = false; + hasShowPreviewIcon: boolean = false; + hasShowPreview: boolean = false; + + defaultInputs = new BehaviorSubject({ + showPreview: false, + multiple: false, + mode: null, + }); + + constructor(private service: FilesService) {} + + public ngOnChanges(changes: SimpleChanges): void { + this.file.error !== null && + this.file.error !== undefined && + this.file.error.length !== 0 + ? (this.hasError = true) + : (this.hasError = false); + this.hasShowError = this.isErrorPrintable(); + this.hasShowPreviewImage = this.isShowPreviewPrintable(); + this.hasShowPreviewIcon = this.isShowPreviewPrintable(false); + this.hasShowPreview = this.isShowPreview(); + + const inputs = Object.keys(changes).reduce((result, item) => { + result[item] = changes[item].currentValue; + return result; + }, {}); + this.defaultInputs.next({ ...this.defaultInputs.getValue(), ...inputs }); + this.className = `${this.getDynamicStyle(this.defaultInputs.getValue())}`; + } + + ngOnInit(): void { + this.className = `${this.getDynamicStyle(this.defaultInputs.getValue())}`; + } + + onRemoveHandler(event: any): void { + if (this.updatable) { + this.service.removeFile(this.file); + } + } + + private isShowPreview() { + return ( + (this.showPreview && this.mode !== "file") || + (this.showPreview && this.mode === "file" && this.multiple) + ); + } + + private isShowPreviewPrintable(containsImage = true) { + return containsImage + ? (this.showPreview && this.file.image && this.mode !== "file") || + (this.showPreview && + this.file.image && + this.mode === "file" && + this.multiple) + : (this.showPreview && !this.file.image && this.mode !== "file") || + (this.showPreview && + !this.file.image && + this.mode === "file" && + this.multiple); + } + + private isErrorPrintable() { + return ( + this.hasError && + ((this.multiple && this.mode === "file") || this.mode !== "file") + ); + } + + getIconAriaLabel() { + if (this.file.data.type.includes("video")) { + return "video"; + } + if (this.file.data.type.includes("audio")) { + return "audio"; + } + if (this.file.data.type.includes("image")) { + return "image"; + } + return "file"; + } + + getRemoveAriaLabel() { + return "Remove " + this.file.data.name; + } + + getDynamicStyle(inputs) { + return css` + height: ${this.hasError + ? inputs.mode !== "file" + ? "fit-content" + : !inputs.multiple + ? "40px" + : "fit-content" + : this.hasShowPreviewImage || this.hasShowPreviewIcon + ? "64px" + : "40px"}; + background: ${this.hasError + ? "var(--fileInput-errorFileItemBackgroundColor)" + : "#ffffff"} + 0% 0% no-repeat padding-box; + border: var(--fileInput-fileItemBorderThickness) + var(--fileInput-fileItemBorderStyle); + border-color: ${this.hasError + ? "var(--fileInput-errorFileItemBorderColor)" + : "var(--fileInput-fileItemBorderColor)"}; + width: ${inputs.multiple || inputs.mode !== "file" ? "320px" : "230px"}; + border-radius: var(--fileInput-fileItemBorderRadius); + padding: 8px; + box-sizing: border-box; + display: flex; + flex-direction: row; + margin-top: ${inputs.multiple || inputs.mode !== "file" ? "4px" : ""}; + margin-left: ${!inputs.multiple && inputs.mode === "file" ? "4px" : ""}; + .previewContainer { + background-color: ${this.hasError + ? "var(--fileInput-errorFilePreviewBackgroundColor)" + : "var(--fileInput-filePreviewBackgroundColor)"}; + display: flex; + align-items: center; + place-content: center; + padding: 12px; + box-sizing: border-box; + height: 48px; + width: 48px; + svg, + img { + fill: ${this.hasError + ? "var(--fileInput-errorFilePreviewIconColor)" + : "var(--fileInput-filePreviewIconColor)"}; + height: 24px; + width: 24px; + } + } + .infoContainer { + display: flex; + flex-direction: column; + width: ${(inputs.showPreview && + inputs.mode === "file" && + !inputs.multiple) || + !inputs.showPreview + ? "100% " + : "calc(100% - 48px)"}; + .fileContainer { + display: flex; + flex-direction: row; + justify-content: space-between; + .fileName { + color: var(--fileInput-fileNameFontColor); + padding-left: 8px; + height: 24px; + white-space: nowrap; + text-overflow: ellipsis; + width: 100%; + display: block; + overflow: hidden; + box-sizing: border-box; + font-family: var(--fileInput-fileItemFontFamily); + font-size: var(--fileInput-fileItemFontSize); + font-weight: var(--fileInput-fileItemFontWeight); + line-height: var(--fileInput-fileItemLineHeight); + } + .fileIcons { + display: flex; + flex-direction: row; + .removeIcon, + .errorIcon { + display: flex; + align-items: center; + height: 24px; + width: 24px; + justify-content: center; + } + .removeIcon { + svg { + height: 16px; + width: 16px; + fill: var(--fileInput-deleteFileItemIconColor); + } + &:hover, + &:active { + border-radius: 4px; + cursor: pointer; + } + &:hover { + background-color: var( + --fileInput-hoverDeleteFileItemBackgroundColor + ); + } + &:active { + background-color: var( + --fileInput-activeDeleteFileItemBackgroundColor + ); + } + &:focus { + outline: var(--fileInput-focusDeleteFileItemBackgroundColor) + auto 1px; + } + } + .errorIcon { + margin-right: 4px; + svg { + height: 18px; + width: 18px; + fill: #d0011b; + } + } + } + } + } + .errorContainer { + width: 100%; + padding-left: 8px; + box-sizing: border-box; + .errorMessage { + text-align: left; + letter-spacing: 0.37px; + color: var(--fileInput-errorMessageFontColor); + font-family: var(--fileInput-errorMessageFontFamily); + font-size: var(--fileInput-errorMessageFontSize); + font-weight: var(--fileInput-errorMessageFontWeight); + line-height: var(--fileInput-errorMessageLineHeight); + } + } + `; + } +} diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/interfaces/file.interface.ts b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/interfaces/file.interface.ts new file mode 100644 index 000000000..fbf05333a --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/interfaces/file.interface.ts @@ -0,0 +1,5 @@ +export interface FileData{ + data: File; + error: string; + image: string | ArrayBuffer; +} diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/interfaces/files.interface.ts b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/interfaces/files.interface.ts new file mode 100644 index 000000000..383e70714 --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/interfaces/files.interface.ts @@ -0,0 +1,6 @@ +import { FileData } from "./file.interface"; + +export interface FilesData{ + files: Array; + event: string +} \ No newline at end of file diff --git a/projects/dxc-ngx-cdk/src/lib/dxc-file-input/services/files.services.ts b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/services/files.services.ts new file mode 100644 index 000000000..74106aadb --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/dxc-file-input/services/files.services.ts @@ -0,0 +1,58 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; +import { FileData } from "../interfaces/file.interface"; +import { FilesData } from "../interfaces/files.interface"; + +@Injectable({ + providedIn: "root", +}) +export class FilesService { + constructor() {} + + public files: BehaviorSubject = new BehaviorSubject({ + files: new Array(), + event: null, + }); + + + addFile(file: FileData) { + // Check if exist + const existingFile = this.files.value.files.filter(item=> item.data.name === file.data.name); + let updatedValue: FileData[]; + + if (existingFile.length > 0){ + updatedValue = this.files.value.files.map(item=> { + if (item.data.name === file.data.name){ + item.data = file.data; + item.error = file.error; + } + return item; + } ); + }else{ + updatedValue = [...this.files.value.files , file]; + } + + this.files.next({ + files: updatedValue, + event: "add", + }); + } + + removeFile(file: FileData) { + const array: Array = this.files.value.files.filter((item) => { + return item.data.name !== file.data.name; + }); + + this.files.next({ + files: array, + event: "remove", + }); + } + + emptyArrayFiles(){ + this.files.next({ + files: [], + event: "", + }); + } +} diff --git a/projects/dxc-ngx-cdk/src/lib/typings/ng-onchange.ts b/projects/dxc-ngx-cdk/src/lib/typings/ng-onchange.ts new file mode 100644 index 000000000..f46c2c448 --- /dev/null +++ b/projects/dxc-ngx-cdk/src/lib/typings/ng-onchange.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; + +type MarkFunctionProperties = { + [Key in keyof Component]: Component[Key] extends Function ? never : Key; +} + +type ExcludeFunctionPropertyNames = MarkFunctionProperties[keyof T]; + +type ExcludeFunctions = Pick>; + +export type NgChanges> = { + [Key in keyof Props]: { + previousValue: Props[Key]; + currentValue: Props[Key]; + firstChange: boolean; + isFirstChange(): boolean; + } +} diff --git a/projects/dxc-ngx-cdk/src/public-api.ts b/projects/dxc-ngx-cdk/src/public-api.ts index 47d8979b0..7e613833d 100644 --- a/projects/dxc-ngx-cdk/src/public-api.ts +++ b/projects/dxc-ngx-cdk/src/public-api.ts @@ -144,6 +144,11 @@ export * from './lib/dxc-application-layout/dxc-application-layout-main/dxc-appl export * from './lib/dxc-application-layout/dxc-application-layout-sidenav/dxc-application-layout-sidenav.component'; export * from './lib/dxc-application-layout/dxc-application-layout-header/dxc-application-layout-header.component'; + +export * from "./lib/dxc-file-input/dxc-file-input.component"; +export * from "./lib/dxc-file-input/dxc-file-input.module"; +export * from "./lib/dxc-file-input/interfaces/file.interface"; + // export * from './lib/services/startup/configurationsetup.service'; // export * from './lib/services/httpconfiguration/resourcerequest.service'; // export * from './lib/services/httpconfiguration/httpcall.service';