diff --git a/package.json b/package.json index ede0246..8498900 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,16 @@ }, "dependencies": { "@bcgov/bootstrap-theme": "github:bcgov/bootstrap-theme", + "blueimp-load-image": "^5.14.0", "bootstrap": "^4.5.3", "core-js": "^3.6.5", "history": "^5.0.0", "jquery": "^3.5.1", "mdi-vue": "^1.8.1", + "pdfjs-dist": "^2.5.207", + "rxjs": "^6.6.3", + "sha1": "^1.1.1", + "uuid": "^8.3.1", "vue": "^2.6.11" }, "devDependencies": { @@ -44,6 +49,7 @@ "eslint": "^7.11.0", "jest": "^26.6.1", "eslint-plugin-vue": "^7.1.0", + "jest-canvas-mock": "^2.3.0", "jest-serializer-vue": "^2.0.2", "jest-sonar-reporter": "^2.0.0", "react-is": "^16.13.1", @@ -51,6 +57,9 @@ "vue-template-compiler": "^2.6.11" }, "jest": { + "setupFiles": [ + "jest-canvas-mock" + ], "transformIgnorePatterns": [ "/node_modules/(?!mdi-vue)" ], diff --git a/src/components/file-uploader/FileUploader.css b/src/components/file-uploader/FileUploader.css new file mode 100644 index 0000000..94f48c5 --- /dev/null +++ b/src/components/file-uploader/FileUploader.css @@ -0,0 +1,90 @@ +.dropzone { + border: 2px dashed lightgrey; + margin-bottom: 10px; + border-radius: 8px; + padding: 2em 4em; +} + +.preview-zone { + display: flex; + flex-wrap: wrap; + justify-content: left; + align-items: left; +} +.preview-zone .preview-item { + position: relative; + height: 120px; + text-align: left; + margin-left: 1rem; + margin-bottom: 1.5rem; +} +.preview-zone .preview-item .icon-upload { + opacity: 0.3; + margin: 0 auto 15px auto; +} +.preview-zone .preview-item .icon-upload:hover { + cursor: pointer; + opacity: 0.6; +} +.preview-zone .preview-item .text-upload:hover { + cursor: pointer; +} + +.thumbnail-container, .common-thumbnail .thumbnail-container { + transition: 0.3s; + transform: translateY(0); +} +.thumbnail-container .image-thumbnail, .thumbnail-container .image-thumbnail-width-priority, .common-thumbnail .thumbnail-container .image-thumbnail, .common-thumbnail .thumbnail-container .image-thumbnail-width-priority { + padding: 2px 2px 0 2px; + border-radius: 5px; +} +.thumbnail-container .image-thumbnail:hover, .thumbnail-container .image-thumbnail-width-priority:hover, .common-thumbnail .thumbnail-container .image-thumbnail:hover, .common-thumbnail .thumbnail-container .image-thumbnail-width-priority:hover { + cursor: -webkit-zoom-in; + cursor: zoom-in; +} +.thumbnail-container .demo-thumbnail, .common-thumbnail .thumbnail-container .demo-thumbnail { + height: 100px !important; + width: 100px; + background-color: #CCC; + display: flex; + justify-content: center; + align-items: center; +} +.thumbnail-container .demo-thumbnail:hover, .common-thumbnail .thumbnail-container .demo-thumbnail:hover { + cursor: pointer !important; +} +.thumbnail-container .image-thumbnail, .common-thumbnail .thumbnail-container .image-thumbnail { + max-height: 100px; + height: auto; + max-width: 100%; +} +.thumbnail-container .image-thumbnail-width-priority, .common-thumbnail .thumbnail-container .image-thumbnail-width-priority { + max-width: 270px; + width: auto; + max-height: 100%; +} +.thumbnail-container:hover, .common-thumbnail .thumbnail-container:hover { + box-shadow: 0px 15px 10px -10px rgba(0, 0, 0, 0.1); + transform: translateY(-5px); +} +.thumbnail-container:hover .action-strip, .common-thumbnail .thumbnail-container:hover .action-strip { + border: solid #CCC thin; +} +.thumbnail-container .action-strip, .common-thumbnail .thumbnail-container .action-strip { + height: 2em; + border-radius: 0px 0px 5px 5px; + text-align: right; + margin: 0 2px 0 2px; + color: red; + padding: 0.3em; + transition: 0.3s; + cursor: pointer; +} +.thumbnail-container .action-strip a, .common-thumbnail .thumbnail-container .action-strip a { + text-decoration: none; +} + +.svg-icon { + width: 60px; + height: 60px; +} diff --git a/src/components/file-uploader/FileUploader.stories.js b/src/components/file-uploader/FileUploader.stories.js new file mode 100644 index 0000000..3ddce54 --- /dev/null +++ b/src/components/file-uploader/FileUploader.stories.js @@ -0,0 +1,14 @@ +import FileUploader from "./FileUploader.vue"; + +export default { + title: 'FileUploader', + component: FileUploader, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { FileUploader }, + template: '', +}); + +export const Example = Template.bind({}); diff --git a/src/components/file-uploader/FileUploader.stories.mdx b/src/components/file-uploader/FileUploader.stories.mdx new file mode 100644 index 0000000..28ef8d5 --- /dev/null +++ b/src/components/file-uploader/FileUploader.stories.mdx @@ -0,0 +1,41 @@ +import { Meta, Story } from "@storybook/addon-docs/blocks"; +import FileUploader from "./FileUploader.vue"; + + + +# FileUploader + +Component which stores blob image data from user selected images and/or PDFs. + +## When to Use + +Use for getting the blob data of user selected images or PDFs. + +## When not to Use + +Don't use as file uploader for non-images or non-PDF. + +## API + +Note: Requires the following packages: +```bash +blueimp-load-image +pdfjs-dist +rxjs +sha1 +uuid +``` + +| Prop | Type | Required | Description | Default | +| --------------- | ------------- | -------- | ------------------------------------------------------------------- | ----------------------------- | +| v-model | Array | true | Stores the collection of images selected by the user. | | +| instructionText | String | false | Subtitle text to display in the component. | Please upload your documents. | +| id | String | false | Attribute to uniquely identify the component. | Empty string. | + +
+ +## Example Usage + +```html + +``` diff --git a/src/components/file-uploader/FileUploader.test.js b/src/components/file-uploader/FileUploader.test.js new file mode 100644 index 0000000..3ab1e10 --- /dev/null +++ b/src/components/file-uploader/FileUploader.test.js @@ -0,0 +1,439 @@ +import FileUploader, { + CommonImageProcessingError, + CommonImage, + CommonImageScaleFactorsImpl, + CommonImageError +} from "./FileUploader.vue"; +import { mount } from "@vue/test-utils"; +import { render, fireEvent } from "@testing-library/vue"; +import { jest, beforeEach } from '@jest/globals'; +const fs = require('fs'); +const sha1 = require('sha1'); +import sampleImage from './test-files/sample.jpg'; + + +let mockViewport; +let mockRenderPromise; +let mockGetPage; +let PDFJS = require('pdfjs-dist/build/pdf'); + +jest.mock('pdfjs-dist/build/pdf', () => { + const { jest } = require('@jest/globals'); + return { + getDocument: jest.fn(() => { + return { + promise: { + then: (resolve) => { + const pdfDoc = { + numPages: 2, + getPage: mockGetPage + }; + resolve(pdfDoc); + } + } + }; + }) + }; +}); + +const mockDocument = document; +jest.mock('blueimp-load-image', () => { + return (fileSrc, callback) => { + const canvas = mockDocument.createElement('canvas'); + canvas.width = 10; + canvas.height = 10; + callback(canvas); + } +}); + +const b64toBlob = (b64Data, contentType='', sliceSize=512) => { + const byteCharacters = atob(b64Data); + const byteArrays = []; + + for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { + const slice = byteCharacters.slice(offset, offset + sliceSize); + + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + const byteArray = new Uint8Array(byteNumbers); + byteArrays.push(byteArray); + } + + const blob = new Blob(byteArrays, {type: contentType}); + return blob; +} + +function getFilterError(error) { + return { + errorCode: error, + image: new CommonImage('content'), + rawImageFile: { + name: 'name.jpg' + } + }; +} + +describe("CommonImageProcessingError", () => { + test("creates a new instance", () => { + const instance = new CommonImageProcessingError("error"); + expect(instance).toBeDefined(); + }); +}); + +describe("CommonImage", () => { + test("creates a new instance", () => { + const instance = new CommonImage("file content"); + expect(instance).toBeDefined(); + }); + + test("toJSON()", () => { + const instance = new CommonImage("file content"); + expect(instance.toJSON()).toBeDefined(); + }); +}); + +describe("FileUploader component", () => { + beforeEach(() => { + mockViewport = { + width: 10, + height: 10 + } + mockRenderPromise = { + promise: { + then: (renderCallback) => { + renderCallback(); + } + } + }; + mockGetPage = () => { + const page = { + getViewport: () => { + return mockViewport; + }, + render: () => { + return mockRenderPromise; + } + }; + return { + then: (resolve) => { + resolve(page); + } + }; + }; + }); + + test("matches the success snapshot", () => { + const wrapper = mount(FileUploader, {}); + expect(wrapper.html()).toMatchSnapshot(); + }); + + test("dragover event", () => { + const { container } = render(FileUploader, { + propsData: { + value: [], + } + }); + fireEvent.dragOver(container.querySelector(".dropzone")); + }); + + test("drop event", () => { + const { container } = render(FileUploader, { + propsData: { + value: [], + } + }); + fireEvent.drop(container.querySelector(".dropzone"), { + dataTransfer: { + files: [] + } + }); + }); + + test("change event", (done) => { + const { container } = render(FileUploader, { + propsData: { + value: [], + id: 'test' + } + }); + const imageContents = fs.readFileSync('src/components/file-uploader/test-files/sample.jpg', {encoding: 'base64'}); + const blob = b64toBlob(imageContents, 'image/jpg'); + const file = new File([blob], 'sample.pdf'); + const changeEventInit = { + target: { + files: [file] + } + }; + fireEvent.change(container.querySelector("#test"), changeEventInit); + setTimeout(() => { + done(); + }, 4000); + }); + + test("change event with empty `files` array", () => { + const { container } = render(FileUploader, { + propsData: { + value: [], + id: 'test' + } + }); + const changeEventInit = { + target: { + files: [] + } + }; + fireEvent.change(container.querySelector("#test"), changeEventInit); + }); + + + test("Click `add` button.", () => { + const { container } = render(FileUploader, { + propsData: { + value: [], + id: 'test' + } + }); + fireEvent.click(container.querySelector("a.common-thumbnail")); + }); + + test("checkImageExists(): false", () => { + const wrapper = mount(FileUploader, {}); + const image = new CommonImage("file content"); + let imageExists = wrapper.vm.checkImageExists(image, []); + expect(imageExists).toBeFalsy(); + imageExists = wrapper.vm.checkImageExists(image, [new CommonImage("file content")]); + expect(imageExists).toBeFalsy(); + }); + + test("checkImageExists(): true", () => { + const wrapper = mount(FileUploader, {}); + const fileContent = "file content"; + const image = new CommonImage(fileContent) + image.id = sha1(fileContent); + + const imageExists = wrapper.vm.checkImageExists(image, [image]); + expect(imageExists).toBeTruthy(); + }); + + test("resizeImage()", () => { + jest.mock('blueimp-load-image', () => (image, onload) => { + onload(); + }); + const wrapper = mount(FileUploader, {}); + const image = document.createElement("img"); + image.src = sampleImage; + const observer = { + next: jest.fn() + } + wrapper.vm.resizeImage( + image, + wrapper.vm, + new CommonImageScaleFactorsImpl(1,1), + observer, + 0, + true + ); + }); + + test("retryStrategy()", () => { + const wrapper = mount(FileUploader, {}); + const error = { + errorCode: CommonImageError.TooBig, + pipe: jest.fn() + } + const callback = wrapper.vm.retryStrategy(1); + callback(error); + }); + + describe("readImage()", () => { + let wrapper; + let imageContents; + let blob; + let imageFile; + + beforeEach(() => { + wrapper = mount(FileUploader, {}); + imageContents = fs.readFileSync('src/components/file-uploader/test-files/sample.jpg', {encoding: 'base64'}); + blob = b64toBlob(imageContents, 'image/jpg'); + imageFile = new File([blob], 'sample.jpg'); + }); + + test("readImage() success", () => { + wrapper.vm.readImage(imageFile, 0, () => {}, () => {}); + }); + }); + + describe("readPDF()", () => { + let wrapper; + let pdfContents; + let blob; + let pdfFile; + + beforeEach(() => { + wrapper = mount(FileUploader, {}); + pdfContents = fs.readFileSync('src/components/file-uploader/test-files/sample.pdf', {encoding: 'base64'}); + blob = b64toBlob(pdfContents, 'application/pdf'); + pdfFile = new File([blob], 'sample.pdf'); + }); + + test("readPDF()", (done) => { + wrapper.vm.readPDF( + pdfFile, + new CommonImageScaleFactorsImpl(1,1), + () => { + done(); + } + ); + }); + + test("readPDF() with viewBox", (done) => { + mockViewport = { + width: null, + height: null, + viewBox: [null, null, 10, 10] + } + wrapper.vm.readPDF( + pdfFile, + new CommonImageScaleFactorsImpl(1,1), + () => { + done(); + } + ); + + }); + + test("readPDF() throw page render error", (done) => { + mockGetPage = () => { + return { + then: (resolve, reject) => { + reject(); + } + }; + }; + wrapper.vm.readPDF( + pdfFile, + new CommonImageScaleFactorsImpl(1,1), + null, + () => { + done(); + } + ); + }); + + test("readPDF() throw error on getDocument", (done) => { + PDFJS.getDocument = jest.fn(() => { + return { + promise: { + then: function(resolve, reject) { + reject(); + } + } + } + }); + wrapper.vm.readPDF( + pdfFile, + new CommonImageScaleFactorsImpl(1,1), + null, + () => { + done(); + } + ); + }); + }); + + + test("makeGrayScale()", () => { + const wrapper = mount(FileUploader, {}); + const canvas = document.createElement('canvas'); + canvas.width = 10; + canvas.height = 10; + wrapper.vm.makeGrayScale(canvas); + }); + + test("makeGrayScale() null context", () => { + const wrapper = mount(FileUploader, {}); + const canvas = { + getContext: () => { + return null; + } + }; + wrapper.vm.makeGrayScale(canvas); + }); + + test("handleImageFile()", () => { + const wrapper = mount(FileUploader, {}); + let image = new CommonImage("content"); + wrapper.vm.handleImageFile(image); + for(let i=0; i<50; i++) { + image = new CommonImage("content"); + wrapper.vm.handleImageFile(image); + } + }); + + test("filterError()", () => { + const wrapper = mount(FileUploader, {}); + wrapper.vm.filterError(getFilterError(CommonImageError.TooBig)); + wrapper.vm.filterError({ + errorCode: CommonImageError.CannotOpen, + rawImageFile: { + name: 'name.jpg' + } + }); + wrapper.vm.filterError(getFilterError(CommonImageError.CannotOpenPDF)); + }); + + test("filterError() other error", () => { + const wrapper = mount(FileUploader, {}); + const error = getFilterError(CommonImageError.AlreadyExists); + expect(() => { + wrapper.vm.filterError(error); + }).toThrow(); + }); + + test("handleError() handle empty image model", () => { + const wrapper = mount(FileUploader, {}); + wrapper.vm.handleError(null, null, null); + }); + + test("getErrorMessage()", () => { + const wrapper = mount(FileUploader, {}); + expect(wrapper.vm.getErrorMessage(CommonImageError.WrongType)).toBe('Wrong file type.'); + expect(wrapper.vm.getErrorMessage(CommonImageError.TooSmall)).toBe('File too small.'); + expect(wrapper.vm.getErrorMessage(CommonImageError.TooBig)).toBe('File too large.'); + expect(wrapper.vm.getErrorMessage(CommonImageError.AlreadyExists)).toBe('File already exists.'); + expect(wrapper.vm.getErrorMessage(CommonImageError.Unknown)).toBe('Unknown error.'); + expect(wrapper.vm.getErrorMessage(CommonImageError.CannotOpen)).toBe('Cannot open file.'); + expect(wrapper.vm.getErrorMessage(CommonImageError.PDFnotSupported)).toBe('This PDF file is not supported.'); + expect(wrapper.vm.getErrorMessage(CommonImageError.CannotOpenPDF)).toBe('Cannot open PDF file.'); + expect(wrapper.vm.getErrorMessage(null)).toBe('An error has occurred.'); + }); + + test("deleteImage()", () => { + const wrapper = mount(FileUploader, {}); + const image = new CommonImage("content"); + wrapper.vm.images = [image]; + wrapper.vm.deleteImage(image); + expect(wrapper.vm.images.length).toBe(0); + }); + + test("checkImageDimensions()", () => { + const wrapper = mount(FileUploader, {}); + expect(wrapper.vm.checkImageDimensions({ + naturalWidth: 10, + naturalHeight: 10 + })).toBeTruthy(); + + expect(wrapper.vm.checkImageDimensions({ + naturalWidth: -10, + naturalHeight: 10 + })).toBeFalsy(); + + expect(wrapper.vm.checkImageDimensions({ + naturalWidth: 10, + naturalHeight: -10 + })).toBeFalsy(); + }); +}); diff --git a/src/components/file-uploader/FileUploader.vue b/src/components/file-uploader/FileUploader.vue new file mode 100644 index 0000000..ad66a7c --- /dev/null +++ b/src/components/file-uploader/FileUploader.vue @@ -0,0 +1,772 @@ + + + diff --git a/src/components/file-uploader/Thumbnail.css b/src/components/file-uploader/Thumbnail.css new file mode 100644 index 0000000..f6cb860 --- /dev/null +++ b/src/components/file-uploader/Thumbnail.css @@ -0,0 +1,10 @@ +.image-thumbnail { + max-height: 100px; + height: auto; + max-width: 100%; +} +.image-thumbnail-width-priority { + max-width: 270px; + width: auto; + max-height: 100%; +} diff --git a/src/components/file-uploader/Thumbnail.test.js b/src/components/file-uploader/Thumbnail.test.js new file mode 100644 index 0000000..85bdfbc --- /dev/null +++ b/src/components/file-uploader/Thumbnail.test.js @@ -0,0 +1,57 @@ +import Thumbnail from "./Thumbnail.vue"; +import { render, fireEvent } from "@testing-library/vue"; + +function getImage(width, height) { + return { + naturalWidth: width, + naturalHeight: height + }; +} + +describe("Thumbnail component", () => { + test("matches the success snapshot", () => { + const wrapper = render(Thumbnail, { + props: { + imageObject: getImage(100, 100) + } + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + + test("large scalled width", () => { + const wrapper = render(Thumbnail, { + props: { + imageObject: getImage(1000, 100), + } + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + + test("small scalled width", () => { + const image = getImage(100, 1000); + render(Thumbnail, { + props: { + imageObject: image, + } + }); + }); + + test("NaN scalled width", () => { + const image = getImage(undefined, 1000); + render(Thumbnail, { + props: { + imageObject: image, + } + }); + }); + + test("delete event", () => { + const image = getImage(100, 100); + const { container } = render(Thumbnail, { + props: { + imageObject: image, + } + }); + fireEvent.click(container.querySelector(".action-strip a")); + }); +}); diff --git a/src/components/file-uploader/Thumbnail.vue b/src/components/file-uploader/Thumbnail.vue new file mode 100644 index 0000000..2ac843a --- /dev/null +++ b/src/components/file-uploader/Thumbnail.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/components/file-uploader/__snapshots__/FileUploader.test.js.snap b/src/components/file-uploader/__snapshots__/FileUploader.test.js.snap new file mode 100644 index 0000000..0aff42c --- /dev/null +++ b/src/components/file-uploader/__snapshots__/FileUploader.test.js.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileUploader component matches the success snapshot 1`] = ` +
+
+
+ +
+
+
+`; diff --git a/src/components/file-uploader/__snapshots__/Thumbnail.test.js.snap b/src/components/file-uploader/__snapshots__/Thumbnail.test.js.snap new file mode 100644 index 0000000..b3f8b00 --- /dev/null +++ b/src/components/file-uploader/__snapshots__/Thumbnail.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Thumbnail component large scalled width 1`] = ` +
Image Thumbnail + +
+`; + +exports[`Thumbnail component matches the success snapshot 1`] = ` +
Image Thumbnail + +
+`; diff --git a/src/components/file-uploader/images/cloud-upload-alt.svg b/src/components/file-uploader/images/cloud-upload-alt.svg new file mode 100644 index 0000000..2dd56d7 --- /dev/null +++ b/src/components/file-uploader/images/cloud-upload-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/file-uploader/images/plus.svg b/src/components/file-uploader/images/plus.svg new file mode 100644 index 0000000..4a42a54 --- /dev/null +++ b/src/components/file-uploader/images/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/file-uploader/test-files/sample.jpg b/src/components/file-uploader/test-files/sample.jpg new file mode 100644 index 0000000..a085716 Binary files /dev/null and b/src/components/file-uploader/test-files/sample.jpg differ diff --git a/src/components/file-uploader/test-files/sample.pdf b/src/components/file-uploader/test-files/sample.pdf new file mode 100644 index 0000000..511ef3d Binary files /dev/null and b/src/components/file-uploader/test-files/sample.pdf differ