From 1ecffc57eb61338cf7e50cf1dcf7f60c473a919d Mon Sep 17 00:00:00 2001 From: Tanu Chahal Date: Wed, 18 Dec 2024 12:29:51 +0530 Subject: [PATCH] Feature: edit extension request before approval or rejection (#910) * Added input validation and toast based on api response - behind dev flag. * Added tests for input validation and edit button under dev flag. * Added dev flag in ER update api request. * Resolved failing test cases. * Updated failing tests based on new changes. * Added missing tests. * Changed the confusing dev feature flag name. * Added Error Handling for Unavailable user details. * Changes style values to 8/4 metric and made toast timing a constant. * Changed the getSelfUser endpoint to new one. * Updated the getSelfUser() url for tests. * Fix: Prevent Code Format Overflow in Task Request Page (#927) added code css property * Added input validation and toast based on api response - behind dev flag. * Took latest pull from develop and merged it. * Added missing tests. * Changes style values to 8/4 metric and made toast timing a constant. * Made shouldDisplayEditButton() a pure function. * Added tests for input validation and edit button under dev flag. * Updated failing tests based on new changes. * Added missing tests. * Made shouldDisplayEditButton() a pure function. * Rebased and removed duplicate tests. --------- Co-authored-by: Rishi <148757583+rishirishhh@users.noreply.github.com> Co-authored-by: Anuj Chhikara <107175639+AnujChhikara@users.noreply.github.com> --- .../extension-requests.test.js | 161 ++++++++++++++- extension-requests/constants.js | 2 + extension-requests/local-utils.js | 8 +- extension-requests/script.js | 183 +++++++++++++++++- extension-requests/style.css | 37 +++- 5 files changed, 376 insertions(+), 15 deletions(-) diff --git a/__tests__/extension-requests/extension-requests.test.js b/__tests__/extension-requests/extension-requests.test.js index 2966e157..392c0de1 100644 --- a/__tests__/extension-requests/extension-requests.test.js +++ b/__tests__/extension-requests/extension-requests.test.js @@ -281,7 +281,7 @@ describe('Tests the Extension Requests Screen', () => { }, body: JSON.stringify(extensionRequestListForAuditLogs), }); - } else if (url === `${STAGING_API_URL}/users/self`) { + } else if (url === `${STAGING_API_URL}/users?profile=true`) { interceptedRequest.respond({ status: 200, contentType: 'application/json', @@ -654,6 +654,165 @@ describe('Tests the Extension Requests Screen', () => { expect(hasSkeletonClassAfter).toBe(false); }); + it('shows error messages for empty title and reason inputs on update under dev feature flag', async () => { + await page.goto(`${LOCAL_TEST_PAGE_URL}/extension-requests/?dev=true`); + const editButtonSelector = '[data-testid="edit-button"]'; + const editButton = await page.$(editButtonSelector); + if (!editButton) { + return; + } + await page.click(editButtonSelector); + const updateButtonSelector = '[data-testid="update-button"]'; + const titleInputSelector = '[data-testid="title-text-input"]'; + const reasonInputSelector = '[data-testid="reason-input-text-area"]'; + const titleErrorSelector = '[data-testid="title-input-error"]'; + const reasonErrorSelector = '[data-testid="reason-input-error"]'; + + await page.evaluate((selector) => { + const element = document.querySelector(selector); + if (element) element.value = ''; + }, titleInputSelector); + + await page.evaluate((selector) => { + const element = document.querySelector(selector); + if (element) element.value = ''; + }, reasonInputSelector); + + await page.click(updateButtonSelector); + + const isTitleErrorVisible = await page + .$eval(titleErrorSelector, (el) => el && !el.classList.contains('hidden')) + .catch(() => false); + + const isReasonErrorVisible = await page + .$eval( + reasonErrorSelector, + (el) => el && !el.classList.contains('hidden'), + ) + .catch(() => false); + expect(isTitleErrorVisible).toBe(true); + expect(isReasonErrorVisible).toBe(true); + }); + + it('shows error message if deadline is set to past date under dev feature flag', async () => { + await page.goto(`${LOCAL_TEST_PAGE_URL}/extension-requests/?dev=true`); + const editButtonSelector = '[data-testid="edit-button"]'; + const editButton = await page.$(editButtonSelector); + if (!editButton) { + return; + } + + await page.click(editButtonSelector); + + const extensionInputSelector = '[data-testid="extension-input"]'; + const extensionErrorSelector = '[data-testid="extension-input-error"]'; + await page.$eval(extensionInputSelector, (input) => { + input.value = '2020-01-01'; + }); + await page.click('[data-testid="update-button"]'); + const isExtensionErrorVisible = await page.$eval( + extensionErrorSelector, + (el) => + !el.classList.contains('hidden') && + el.innerText.includes("Past date can't be the new deadline"), + ); + expect(isExtensionErrorVisible).toBe(true); + }); + + it('hides edit button and displays update wrapper on successful update under dev feature flag', async () => { + await page.goto(`${LOCAL_TEST_PAGE_URL}/extension-requests/?dev=true`); + const editButtonSelector = '[data-testid="edit-button"]'; + const editButton = await page.$(editButtonSelector); + if (!editButton) { + return; + } + + await page.click(editButtonSelector); + + const updateButtonSelector = '[data-testid="update-button"]'; + const updateWrapperSelector = '[data-testid="update-wrapper"]'; + + await page.type('[data-testid="title-text-input"]', 'Valid Title'); + await page.type('[data-testid="reason-input-text-area"]', 'Valid Reason'); + await page.type('[data-testid="extension-input"]', '2050-01-01'); + + await page.click(updateButtonSelector); + + const isEditButtonHidden = await page.$eval(editButtonSelector, (el) => + el.classList.contains('hidden'), + ); + const isUpdateWrapperVisible = await page.$eval( + updateWrapperSelector, + (el) => !el.classList.contains('hidden'), + ); + expect(isEditButtonHidden).toBe(true); + expect(isUpdateWrapperVisible).toBe(true); + }); + + it('handles long title and long reason properly under dev feature flag', async () => { + await page.goto(`${LOCAL_TEST_PAGE_URL}/extension-requests/?dev=true`); + + const editButtonSelector = '[data-testid="edit-button"]'; + const titleInputSelector = '[data-testid="title-text-input"]'; + const reasonInputSelector = '[data-testid="reason-input-text-area"]'; + const titleDisplaySelector = '.title-text'; + const reasonDisplaySelector = '.reason-text'; + + const longTitle = 'A'.repeat(300); + const longReason = 'This is a very long reason '.repeat(50); + + const editButton = await page.$(editButtonSelector); + if (!editButton) { + return; + } + await page.click(editButtonSelector); + + await page.type(titleInputSelector, longTitle); + await page.type(reasonInputSelector, longReason); + + const isTitleTruncated = await page.$eval( + titleDisplaySelector, + (el) => window.getComputedStyle(el).textOverflow === 'ellipsis', + ); + + const isReasonWrapped = await page.$eval( + reasonDisplaySelector, + (el) => window.getComputedStyle(el).whiteSpace === 'normal', + ); + + expect(isTitleTruncated).toBe(true); + expect(isReasonWrapped).toBe(true); + }); + + it('displays an error message for invalid date format under dev feature flag', async () => { + await page.goto(`${LOCAL_TEST_PAGE_URL}/extension-requests/?dev=true`); + + const editButtonSelector = '[data-testid="edit-button"]'; + const editButton = await page.$(editButtonSelector); + if (!editButton) { + return; + } + + await page.click(editButtonSelector); + + const extensionInputSelector = '[data-testid="extension-input"]'; + const extensionErrorSelector = '[data-testid="extension-input-error"]'; + + await page.$eval(extensionInputSelector, (input) => { + input.value = 'invalid-date'; + }); + await page.click('[data-testid="update-button"]'); + + const isExtensionErrorVisible = await page.$eval( + extensionErrorSelector, + (el) => + !el.classList.contains('hidden') && + el.innerText.includes('Invalid date format.'), + ); + + expect(isExtensionErrorVisible).toBe(true); + }); + it('Checks whether the card is not removed from display when api call is unsuccessful', async () => { const extensionCards = await page.$$('.extension-card'); diff --git a/extension-requests/constants.js b/extension-requests/constants.js index d3575b10..f74146a3 100644 --- a/extension-requests/constants.js +++ b/extension-requests/constants.js @@ -41,3 +41,5 @@ const SORT_ASC_ICON = 'asc-sort-icon'; const SORT_DESC_ICON = 'desc-sort-icon'; const OLDEST_FIRST = 'Oldest first'; const NEWEST_FIRST = 'Newest first'; + +const UPDATE_TOAST_TIMING = 3000; diff --git a/extension-requests/local-utils.js b/extension-requests/local-utils.js index 78769c11..200d8dc3 100644 --- a/extension-requests/local-utils.js +++ b/extension-requests/local-utils.js @@ -10,7 +10,7 @@ const Order = { }; async function getSelfUser() { try { - const res = await fetch(`${API_BASE_URL}/users/self`, { + const res = await fetch(`${API_BASE_URL}/users?profile=true`, { method: 'GET', credentials: 'include', headers: { @@ -103,8 +103,10 @@ const parseExtensionRequestParams = (uri, nextPageParamsObject) => { return nextPageParamsObject; }; -async function updateExtensionRequest({ id, body }) { - const url = `${API_BASE_URL}/extension-requests/${id}`; +async function updateExtensionRequest({ id, body, underDevFeatureFlag }) { + const url = underDevFeatureFlag + ? `${API_BASE_URL}/extension-requests/${id}?dev=true` + : `${API_BASE_URL}/extension-requests/${id}`; const res = await fetch(url, { credentials: 'include', method: 'PATCH', diff --git a/extension-requests/script.js b/extension-requests/script.js index 19d574c8..d139a6f4 100644 --- a/extension-requests/script.js +++ b/extension-requests/script.js @@ -227,7 +227,21 @@ const getExtensionColor = (deadline, createdTime) => { return 'orange-text'; }; +const currentUserDetailsPromise = getSelfUser() + .then((response) => { + currentUserDetails = response; + }) + .catch((error) => { + currentUserDetails = null; + if (isDev) { + showToast(error?.message || "Couldn't fetch user details.", 'error'); + } + }); + async function populateExtensionRequests(query = {}, newLink) { + if (query.dev && !currentUserDetails) { + await currentUserDetailsPromise; + } extensionPageVersion++; const currentVersion = extensionPageVersion; try { @@ -538,8 +552,25 @@ async function createExtensionCard(data, dev) { id: 'title', name: 'title', value: data.title, + 'data-testid': 'title-text-input', }, }); + const titleInputWrapper = createElement({ + type: 'div', + attributes: { class: 'title-input-wrapper hidden' }, + }); + const titleInputError = createElement({ + type: 'div', + attributes: { + class: 'title-input-error hidden', + 'data-testid': 'title-input-error', + }, + innerText: 'Title is required', + }); + if (dev) { + titleInputWrapper.appendChild(titleInput); + titleInputWrapper.appendChild(titleInputError); + } const commitedHoursHoverCard = createElement({ type: 'div', attributes: { class: 'comitted-hours hidden' }, @@ -562,7 +593,11 @@ async function createExtensionCard(data, dev) { }); commitedHoursHoverCard.appendChild(CommitedHourslabel); commitedHoursHoverCard.appendChild(CommitedHoursContent); - extensionCardHeaderWrapper.appendChild(titleInput); + if (dev) { + extensionCardHeaderWrapper.appendChild(titleInputWrapper); + } else { + extensionCardHeaderWrapper.appendChild(titleInput); + } extensionCardHeaderWrapper.appendChild(titleText); extensionCardHeaderWrapper.appendChild(commitedHoursHoverTrigger); extensionCardHeaderWrapper.appendChild(commitedHoursHoverCard); @@ -770,9 +805,21 @@ async function createExtensionCard(data, dev) { id: 'newEndsOn', oninput: 'this.blur()', value: dateString(secondsToMilliSeconds(data.newEndsOn)), + 'data-testid': 'extension-input', + }, + }); + const extensionInputError = createElement({ + type: 'div', + attributes: { + class: 'extension-input-error hidden', + 'data-testid': 'extension-input-error', }, + innerText: "Past date can't be the new deadline", }); newDeadlineContainer.appendChild(extensionInput); + if (dev) { + newDeadlineContainer.appendChild(extensionInputError); + } extensionForContainer.appendChild(extensionForValue); const extensionRequestNumberContainer = createElement({ type: 'div' }); @@ -866,9 +913,15 @@ async function createExtensionCard(data, dev) { } else { const editButton = createElement({ type: 'button', - attributes: { class: 'edit-button' }, + attributes: { class: 'edit-button', 'data-testid': 'edit-button' }, }); - extensionCardButtons.appendChild(editButton); + if (dev) { + if (shouldDisplayEditButton(data.assigneeId, currentUserDetails)) { + extensionCardButtons.appendChild(editButton); + } + } else { + extensionCardButtons.appendChild(editButton); + } const editIcon = createElement({ type: 'img', attributes: { src: EDIT_ICON, alt: 'edit-icon' }, @@ -876,12 +929,15 @@ async function createExtensionCard(data, dev) { editButton.appendChild(editIcon); const updateWrapper = createElement({ type: 'div', - attributes: { class: 'update-wrapper hidden' }, + attributes: { + class: 'update-wrapper hidden', + 'data-testid': 'update-wrapper', + }, }); extensionCardButtons.appendChild(updateWrapper); const updateButton = createElement({ type: 'button', - attributes: { class: 'update-button' }, + attributes: { class: 'update-button', 'data-testid': 'update-button' }, innerText: 'SAVE', }); @@ -930,10 +986,46 @@ async function createExtensionCard(data, dev) { updateAccordionHeight(panel); }); updateButton.addEventListener('click', (event) => { - toggleInputs(); - toggleActionButtonVisibility(); - editButton.classList.toggle('hidden'); - updateWrapper.classList.toggle('hidden'); + if (dev) { + const isTitleMissing = !titleInput.value; + const isReasonMissing = !reasonInput.value; + const todayDate = Math.floor(new Date().getTime() / 1000); + const newDeadline = new Date(extensionInput.value).getTime() / 1000; + const isDeadlineInPast = newDeadline < todayDate; + const isInvalidDateFormat = isNaN(newDeadline); + + if (isInvalidDateFormat) { + extensionInputError.innerText = + 'Invalid date format. Please provide a valid date.'; + } else if (isDeadlineInPast) { + extensionInputError.innerText = + "Past date can't be the new deadline."; + } + + titleInputError.classList.toggle('hidden', !isTitleMissing); + reasonInputError.classList.toggle('hidden', !isReasonMissing); + extensionInputError.classList.toggle( + 'hidden', + !(isDeadlineInPast || isInvalidDateFormat), + ); + + if ( + !isTitleMissing && + !isReasonMissing && + !(isDeadlineInPast || isInvalidDateFormat) + ) { + toggleInputs(); + toggleActionButtonVisibility(); + editButton.classList.toggle('hidden'); + updateWrapper.classList.toggle('hidden'); + titleInputWrapper.classList.add('hidden'); + } + } else { + toggleInputs(); + toggleActionButtonVisibility(); + editButton.classList.toggle('hidden'); + updateWrapper.classList.toggle('hidden'); + } }); cancelButton.addEventListener('click', (event) => { titleInput.value = data.title; @@ -944,6 +1036,11 @@ async function createExtensionCard(data, dev) { toggleActionButtonVisibility(); editButton.classList.toggle('hidden'); updateWrapper.classList.toggle('hidden'); + if (dev) { + titleInputError.classList.add('hidden'); + reasonInputError.classList.add('hidden'); + extensionInputError.classList.add('hidden'); + } }); const payloadForLog = { body: {}, @@ -1071,10 +1168,22 @@ async function createExtensionCard(data, dev) { class: 'input-text-area hidden', id: 'reason', name: 'reason', + 'data-testid': 'reason-input-text-area', }, innerText: data.reason, }); + const reasonInputError = createElement({ + type: 'span', + attributes: { + class: 'reason-input-error red-text hidden', + 'data-testid': 'reason-input-error', + }, + innerText: 'Reason is required', + }); reasonContainer.appendChild(reasonInput); + if (dev) { + reasonContainer.appendChild(reasonInputError); + } reasonContainer.appendChild(reasonParagraph); const renderExtensionCreatedLog = () => { @@ -1130,6 +1239,17 @@ async function createExtensionCard(data, dev) { e.preventDefault(); let formData = formDataToObject(new FormData(e.target)); formData['newEndsOn'] = new Date(formData['newEndsOn']).getTime() / 1000; + if (dev) { + const todayDate = Math.floor(new Date().getTime() / 1000); + if ( + !formData.title || + !formData.reason || + isNaN(formData['newEndsOn']) || + formData['newEndsOn'] < todayDate + ) { + return; + } + } const removeSpinner = addSpinner(rootElement); rootElement.classList.add('disabled'); const revertDataChange = updateCardData(formData); @@ -1160,17 +1280,29 @@ async function createExtensionCard(data, dev) { updateExtensionRequest({ id: data.id, body: formData, + underDevFeatureFlag: dev, }) .then(() => { data.reason = formData.reason; data.tile = formData.title; data.newEndsOn = data.newEndsOn; handleSuccess(rootElement); + if (dev) { + const successMessage = 'Extension request successfully updated.'; + showToast(successMessage, 'success'); + } appendLogs(payloadForLog, data.id); }) - .catch(() => { + .catch((error) => { revertDataChange(); handleFailure(rootElement); + if (dev) { + const errorMessage = + error?.response?.data?.message || + error?.message || + 'An error occurred. Please try again.'; + showToast(errorMessage, 'error'); + } }) .finally(() => { rootElement.classList.remove('disabled'); @@ -1216,6 +1348,9 @@ async function createExtensionCard(data, dev) { return revertDataChange; } function toggleInputs() { + if (dev) { + titleInputWrapper.classList.toggle('hidden'); + } titleInput.classList.toggle('hidden'); titleText.classList.toggle('hidden'); reasonInput.classList.toggle('hidden'); @@ -1320,6 +1455,34 @@ async function createExtensionCard(data, dev) { } } +function shouldDisplayEditButton(assigneeId, currentUserData) { + return ( + currentUserData && + (assigneeId === currentUserData.id || currentUserData.roles.super_user) + ); +} + +function showToast(message, type) { + const existingToast = document.querySelector( + '.extension-request-update-toast', + ); + if (existingToast) { + existingToast.remove(); + } + const toast = document.createElement('div'); + toast.className = `extension-request-update-toast toast-${type}`; + toast.textContent = message; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.classList.add('fade-out'); + toast.addEventListener('transitionend', () => { + toast.remove(); + }); + }, UPDATE_TOAST_TIMING); +} + function generateSentence(response, parentClassName, id) { let arraySentence = []; let sentence = ''; diff --git a/extension-requests/style.css b/extension-requests/style.css index bfcafebe..f3166f3a 100644 --- a/extension-requests/style.css +++ b/extension-requests/style.css @@ -773,8 +773,43 @@ body { align-items: center; justify-content: space-between; position: relative; - height: 1.6rem; + height: 2rem; +} + +.title-input-wrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.title-input-error, +.reason-input-error, +.extension-input-error { + font-size: 12px; + color: var(--red-color); } + +.extension-request-update-toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 16px 24px; + background-color: var(--red-color); + color: #fff; + font-size: 16px; + border-radius: 4px; + opacity: 1; + transition: opacity 0.3s ease; + z-index: 1000; +} + +.extension-request-update-toast.fade-out { + opacity: 0; +} + +.extension-request-update-toast.toast-success { + background-color: var(--green-color); +} + .disabled { opacity: 0.5; pointer-events: none;