diff --git a/app/components/header.hbs b/app/components/header.hbs index d8af464f2..9c01192ff 100644 --- a/app/components/header.hbs +++ b/app/components/header.hbs @@ -140,7 +140,7 @@ + {{#if @isLoading}} + + {{/if}} + {{yield}} + \ No newline at end of file diff --git a/app/components/profile/image-cropper.hbs b/app/components/profile/image-cropper.hbs new file mode 100644 index 000000000..98f993ced --- /dev/null +++ b/app/components/profile/image-cropper.hbs @@ -0,0 +1,10 @@ +
+ Cropper +
\ No newline at end of file diff --git a/app/components/profile/image-cropper.js b/app/components/profile/image-cropper.js new file mode 100644 index 000000000..686fbaa5b --- /dev/null +++ b/app/components/profile/image-cropper.js @@ -0,0 +1,38 @@ +import Component from '@glimmer/component'; +import Cropper from 'cropperjs'; +import { action } from '@ember/object'; + +export default class ImageCropperComponent extends Component { + get image() { + if (this.cropper) { + this.cropper.destroy(); + } + return this.args.image; + } + + @action loadCropper() { + const image = document.getElementById('image-cropper'); + this.cropper = new Cropper(image, { + autoCrop: true, + viewMode: 1, + dragMode: 'crop', + aspectRatio: 1, + cropBoxResizable: true, + movable: false, + zoomOnWheel: false, + rotatable: false, + toggleDragModeOnDblclick: false, + ready: () => { + this.setImageData(); + }, + cropend: () => { + this.setImageData(); + }, + }); + } + + setImageData() { + const { x, y, width, height } = this.cropper.getData(true); + this.args.setImageCoordinates({ x, y, width, height }); + } +} diff --git a/app/components/profile/profile-field.hbs b/app/components/profile/profile-field.hbs new file mode 100644 index 000000000..e30c3d2e7 --- /dev/null +++ b/app/components/profile/profile-field.hbs @@ -0,0 +1,23 @@ +
+ +
+ + +
+ {{#if @showError}} +

+ {{@errorMessage}} +

+ {{/if}} +
\ No newline at end of file diff --git a/app/components/profile/profile-field.js b/app/components/profile/profile-field.js new file mode 100644 index 000000000..244a9d898 --- /dev/null +++ b/app/components/profile/profile-field.js @@ -0,0 +1,20 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class ProfileFieldComponent extends Component { + @action + inputFieldChanged(event) { + const { id, onChange } = this.args; + const value = event.target.value; + + onChange(id, value); + } + + @action + checkInputValidation(event) { + const { id, onBlur } = this.args; + let isValid = event.target.validity.valid; + + onBlur(id, isValid); + } +} diff --git a/app/components/profile/upload-image.hbs b/app/components/profile/upload-image.hbs new file mode 100644 index 000000000..d362056ea --- /dev/null +++ b/app/components/profile/upload-image.hbs @@ -0,0 +1,78 @@ +{{#if this.isImageSelected}} +

+ Crop Selected Image +

+ + +
+ + +
+

+ {{this.statusMessage}} +

+ +{{else}} +

+ Upload Image +

+

( Max size 2MB )

+
+
+

+ Drag and drop file here or +

+ + +
+
+{{/if}} \ No newline at end of file diff --git a/app/components/profile/upload-image.js b/app/components/profile/upload-image.js new file mode 100644 index 000000000..5dca71e52 --- /dev/null +++ b/app/components/profile/upload-image.js @@ -0,0 +1,145 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { TOAST_OPTIONS } from '../../constants/toast-options'; +import { inject as service } from '@ember/service'; + +export default class UploadImageComponent extends Component { + formData; + @service toast; + @service router; + @tracked image; + @tracked isImageSelected = false; + @tracked overDropZone = false; + @tracked isImageUploading = false; + @tracked imageUploadSuccess = false; + @tracked statusMessage; + @tracked imageFileName; + imageCoordinates = null; + uploadUrl = this.args.uploadUrl; + formKeyName = this.args.formKeyName; + + @action goBack() { + this.image = null; + this.setImageSelected(false); + } + @action updateImage(file) { + this.setStatusMessage(''); + const reader = new FileReader(); + + if (file) { + this.updateFormData(file, this.formKeyName); + reader.readAsDataURL(file); + this.imageFileName = file.name; + } + reader.onload = () => { + const image = reader.result; + this.image = image; + }; + } + + @action setImageCoordinates(data) { + this.imageCoordinates = data; + } + + @action handleBrowseImage(e) { + const [file] = e.target.files; + this.updateImage(file); + this.setImageSelected(true); + } + + @action handleDrop(e) { + this.preventDefaults(e); // This is used to prevent opening of image in new tab while drag and drop + const [file] = e.dataTransfer.files; + this.updateImage(file); + this.setImageSelected(true); + this.setOverDropZone(false); + } + @action handleDragOver(e) { + this.preventDefaults(e); + this.setOverDropZone(true); + e.dataTransfer.dropEffect = 'move'; + } + @action handleDragEnter(e) { + this.preventDefaults(e); + this.setOverDropZone(true); + } + @action handleDragLeave(e) { + this.preventDefaults(e); + this.setOverDropZone(false); + } + + @action onSubmit(e) { + this.preventDefaults(e); + this.formData.set('coordinates', JSON.stringify(this.imageCoordinates)); + this.uploadImage(this.formData); + } + + uploadImage(data) { + const url = this.uploadUrl; + this.setImageUploading(true); + fetch(`${url}`, { + method: 'POST', + credentials: 'include', + body: data, + }) + .then(async (res) => { + const status = res.status; + const data = await res.json(); + const message = data.message; + this.handleResponseStatusMessage(status, message); + }) + .catch((err) => { + this.setImageUploadSuccess(false); + this.setStatusMessage( + 'Error occured, please try again and if the issue still exists contact administrator and create a issue on the repo with logs', + ); + console.error(err); + }) + .finally(() => { + this.setImageUploading(false); + }); + } + + updateFormData(file, key) { + const formData = new FormData(); + formData.append(key, file); + this.formData = formData; + } + + handleResponseStatusMessage(status, message) { + if (status === 200) { + this.setImageUploadSuccess(true); + this.args.outsideClickModel(); + this.toast.success(message, '', TOAST_OPTIONS); + } else { + this.setImageUploadSuccess(false); + this.setStatusMessage(message); + } + } + + preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + setOverDropZone(bool) { + this.overDropZone = bool; + } + + setImageSelected(bool) { + this.isImageSelected = bool; + } + + setImageUploading(bool) { + this.isImageUploading = bool; + } + + setImageUploadSuccess(bool) { + this.imageUploadSuccess = bool; + } + + setStatusMessage(message) { + this.statusMessage = message; + } +} diff --git a/app/components/spinner.hbs b/app/components/spinner.hbs index 0859b065f..5c701e57c 100644 --- a/app/components/spinner.hbs +++ b/app/components/spinner.hbs @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/index.html b/app/index.html index 5817b4534..ae07690fd 100644 --- a/app/index.html +++ b/app/index.html @@ -12,6 +12,7 @@ + +
+
+ {{#if @model.picture.url}} + user profile + {{else}} + user profile + {{/if}} + + + + +
+
+

You can't update the profile data from UI. + You have to create a profile service(if not created yet). Find more + details about profile service + here.

+ {{#each this.fields as |field|}} + + {{/each}} +
+ +
+ + + +{{#if this.showEditProfilePictureModal}} +
+
+ + +
+
+{{/if}} diff --git a/tests/integration/components/header-test.js b/tests/integration/components/header-test.js index 2213189f3..dc1175e21 100644 --- a/tests/integration/components/header-test.js +++ b/tests/integration/components/header-test.js @@ -190,7 +190,7 @@ module('Integration | Component | header', function (hooks) { assert.dom('[data-test-dropdown-profile]').hasText('Profile'); assert .dom('[data-test-dropdown-profile]') - .hasAttribute('href', APPS.PROFILE); + .hasAttribute('href', '/profile?dev=true'); assert.dom('[data-test-dropdown-tasks]').hasText('Tasks'); assert.dom('[data-test-dropdown-tasks]').hasAttribute('href', APPS.TASKS); assert.dom('[data-test-dropdown-identity]').hasText('Identity'); diff --git a/tests/integration/components/profile/image-cropper-test.js b/tests/integration/components/profile/image-cropper-test.js new file mode 100644 index 000000000..ddfdeee81 --- /dev/null +++ b/tests/integration/components/profile/image-cropper-test.js @@ -0,0 +1,35 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { waitFor } from '@ember/test-helpers'; +import sinon from 'sinon'; + +module('Integration | Component | image-cropper', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.imageUrl = '/assets/images/dummyProfilePicture.png'; + + this.setImageCoordinates = sinon.spy(); + }); + + test('it renders the image with correct attributes', async function (assert) { + await render(hbs` + + `); + + await waitFor('#image-cropper'); + + const image = this.element.querySelector('#image-cropper'); + assert.ok(image, 'Image element exists'); + + const expectedFullUrl = `${window.location.origin}${this.imageUrl}`; + assert.strictEqual(image.src, expectedFullUrl, 'Image has correct src'); + assert.strictEqual(image.id, 'image-cropper', 'Image has correct id'); + assert.strictEqual(image.alt, 'Cropper', 'Image has correct alt text'); + }); +}); diff --git a/tests/integration/components/profile/profile-field-test.js b/tests/integration/components/profile/profile-field-test.js new file mode 100644 index 000000000..d2bd363bd --- /dev/null +++ b/tests/integration/components/profile/profile-field-test.js @@ -0,0 +1,86 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'website-www/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | profile/profile-field', function (hooks) { + setupRenderingTest(hooks); + + test('profile field renders', async function (assert) { + this.setProperties({ + label: 'First Name*', + icon_url: '/assets/icons/user.svg', + }); + + await render( + hbs``, + ); + + assert.dom('[data-test-profile-field-label]').hasText(this.label); + assert + .dom('[data-test-profile-field-icon]') + .exists() + .hasAttribute('src', this.icon_url); + assert.dom('[data-test-profile-field-input]').exists(); + assert.dom('[data-test-profile-field-error]').doesNotExist(); + }); + + test('profile field has error state', async function (assert) { + this.setProperties({ + showError: true, + errorMessage: 'This field is required', + }); + + await render( + hbs``, + ); + + assert.dom('[data-test-profile-field]').hasClass('profile-field-error'); + assert.dom('[data-test-profile-field-error]').hasText(this.errorMessage); + }); + + test('disabled profile field renders when isDeveloper is true', async function (assert) { + this.setProperties({ + label: 'First Name*', + icon_url: '/assets/icons/user.svg', + isDeveloper: true, + }); + + await render( + hbs``, + ); + + assert.dom('[data-test-profile-field-label]').hasText(this.label); + assert + .dom('[data-test-profile-field-icon]') + .exists() + .hasAttribute('src', this.icon_url); + assert + .dom('[data-test-profile-field-input]') + .hasProperty('disabled', true) + .exists(); + assert.dom('[data-test-profile-field-input]').isDisabled(); + assert.dom('[data-test-profile-field-error]').doesNotExist(); + }); + + test('error state updates dynamically', async function (assert) { + this.setProperties({ + showError: false, + label: 'First Name*', + errorMessage: 'First name is required', + }); + + await render( + hbs``, + ); + + assert + .dom('[data-test-profile-field]') + .doesNotHaveClass('profile-field-error'); + assert.dom('[data-test-profile-field-error]').doesNotExist(); + + this.set('showError', true); + assert.dom('[data-test-profile-field]').hasClass('profile-field-error'); + assert.dom('[data-test-profile-field-error]').hasText(this.errorMessage); + }); +}); diff --git a/tests/integration/components/profile/upload-image-test.js b/tests/integration/components/profile/upload-image-test.js new file mode 100644 index 000000000..387fb291f --- /dev/null +++ b/tests/integration/components/profile/upload-image-test.js @@ -0,0 +1,131 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, triggerEvent } from '@ember/test-helpers'; +import { waitFor } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | image uploader', function (hooks) { + setupRenderingTest(hooks); + + const file = new File(['dummy image data'], 'RDSLogo.png', { + type: 'image/png', + }); + + test('it renders correct initial state', async function (assert) { + await render(hbs` + + `); + + assert.dom('h1').hasText('Upload Image', 'Title is rendered correctly'); + assert + .dom('p.image-p') + .hasText('( Max size 2MB )', 'Image size note is rendered'); + assert.dom('[data-test-drop-area]').exists('Drop area is rendered'); + assert + .dom('[data-test-btn="browse"]') + .hasText('Browse', 'Browse button is rendered'); + assert.dom('input[type="file"]').exists('File input is rendered'); + }); + + test('it handles file selection correctly', async function (assert) { + await render(hbs` + + `); + + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + + await triggerEvent('input[type="file"]', 'change', { + target: { files: [file] }, + }); + + assert + .dom('[data-test-drop-area]') + .doesNotExist('Drop area is hidden after file selection'); + assert + .dom('h1') + .hasText( + 'Crop Selected Image', + 'Crop UI is rendered after file selection', + ); + }); + + test('it handles files of other types properly when dragged and dropped', async function (assert) { + await render(hbs` + + `); + const gifFile = new File(['dummy image data'], 'newGif.gif', { + type: 'image/gif', + }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(gifFile); + + await triggerEvent('[data-test-drop-area]', 'dragover', { dataTransfer }); + await triggerEvent('[data-test-drop-area]', 'drop', { dataTransfer }); + + await triggerEvent('[data-test-btn="upload-image"]', 'click', { + dataTransfer, + }); + await waitFor('p.message-text__failure'); + + assert + .dom('p.message-text__failure') + .hasText( + 'Error occured, please try again and if the issue still exists contact administrator and create a issue on the repo with logs', + ); + }); + + test('it renders crop UI when an image is selected', async function (assert) { + await render(hbs` + + `); + + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + + await triggerEvent('input[type="file"]', 'change', { + target: { files: [file] }, + }); + + assert.dom('h1').hasText('Crop Selected Image', 'Crop heading is shown'); + assert + .dom('button[data-test-btn="upload-image"]') + .hasText('Upload', 'Upload button is rendered'); + }); + + test('it handles drag-and-drop functionality', async function (assert) { + await render(hbs` + + `); + + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + + await triggerEvent('[data-test-drop-area]', 'dragover', { dataTransfer }); + assert + .dom('[data-test-drop-area]') + .hasClass('drop-area__highlight', 'Drop area is highlighted during drag'); + + await triggerEvent('[data-test-drop-area]', 'drop', { dataTransfer }); + assert + .dom('h1') + .hasText('Crop Selected Image', 'Image is selected after drop'); + }); +});