diff --git a/__tests__/Unit/Components/Tasks/TaskStatusEditMode.test.tsx b/__tests__/Unit/Components/Tasks/TaskStatusEditMode.test.tsx index f5417aba7..e40e13609 100644 --- a/__tests__/Unit/Components/Tasks/TaskStatusEditMode.test.tsx +++ b/__tests__/Unit/Components/Tasks/TaskStatusEditMode.test.tsx @@ -34,6 +34,7 @@ describe('TaskStatusEditMode', () => { ); @@ -52,6 +53,7 @@ describe('TaskStatusEditMode', () => { ); @@ -80,6 +82,7 @@ describe('TaskStatusEditMode', () => { ); @@ -102,105 +105,6 @@ describe('TaskStatusEditMode', () => { expect(allOptions).toEqual(allTaskStatus); }); - - it('renders the spinner and error icon when the task update fails', async () => { - const setEditedTaskDetails = jest.fn(); - - updateTaskSpy.mockImplementation(() => [ - jest.fn().mockImplementation(() => { - return { - unwrap: () => - Promise.reject({ - data: { message: 'Error updating task' }, - }), - }; - }), - { isLoading: false }, - ]); - - renderWithRouter( - - - , - {} - ); - - const statusSelect = screen.getByLabelText('Status:'); - - expect(statusSelect).toHaveValue('BLOCKED'); - - fireEvent.change(statusSelect, { target: { value: 'IN_PROGRESS' } }); - - expect(updateTaskSpy).toHaveBeenCalled(); - - await waitFor( - () => { - expect(screen.getByTestId('small-spinner')).toBeInTheDocument(); - }, - { timeout: 2000 } - ); - - await waitFor(() => { - expect(screen.getByTestId('error')).toBeInTheDocument(); - }); - }); - - it('shows saved indicator and clears status after successful update', async () => { - const setEditedTaskDetails = jest.fn(); - jest.useFakeTimers(); - - const toastModule = await import('@/helperFunctions/toast'); - const toastSpy = jest.spyOn(toastModule, 'toast'); - - updateTaskSpy.mockImplementation(() => [ - jest.fn().mockImplementation(() => { - return { - unwrap: () => Promise.resolve({ status: 'SUCCESS' }), - }; - }), - { isLoading: false }, - ]); - - renderWithRouter( - - - , - {} - ); - - const statusSelect = screen.getByLabelText('Status:'); - - fireEvent.change(statusSelect, { target: { value: 'IN_PROGRESS' } }); - - await waitFor( - () => { - expect(screen.getByTestId('small-spinner')).toBeInTheDocument(); - }, - { timeout: 1000 } - ); - - await waitFor(() => { - expect(screen.getByTestId('checkmark')).toBeInTheDocument(); - expect(toastSpy).toHaveBeenCalledWith( - 'success', - 'Task status updated successfully' - ); - }); - - jest.advanceTimersByTime(3000); - - await waitFor(() => { - expect(screen.queryByTestId('checkmark')).not.toBeInTheDocument(); - }); - - jest.useRealTimers(); - }); }); describe('test beautifyStatus function', () => { diff --git a/src/app/services/api.ts b/src/app/services/api.ts index 661812310..b8cb919b6 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -27,6 +27,7 @@ export const api = createApi({ 'Progress_Details', 'User_Standup', 'TASK_REQUEST', + 'Extension_Requests', ], /** * This api has endpoints injected in adjacent files, diff --git a/src/app/services/tasksApi.ts b/src/app/services/tasksApi.ts index 029c463db..5cd678f48 100644 --- a/src/app/services/tasksApi.ts +++ b/src/app/services/tasksApi.ts @@ -2,6 +2,7 @@ import task, { TaskRequestPayload, TasksResponseType, GetAllTaskParamType, + ExtensionRequestsResponse, } from '@/interfaces/task.type'; import { api } from './api'; import { MINE_TASKS_URL, TASKS_URL } from '@/constants/url'; @@ -100,6 +101,19 @@ export const tasksApi = api.injectEndpoints({ }, ], }), + getSelfExtensionRequests: builder.query< + ExtensionRequestsResponse, + { taskId: string; dev: boolean } + >({ + query: ({ taskId, dev }) => ({ + url: '/extension-requests/self', + params: { + taskId, + dev, + }, + }), + providesTags: ['Extension_Requests'], + }), }), overrideExisting: true, }); @@ -110,4 +124,5 @@ export const { useAddTaskMutation, useUpdateTaskMutation, useUpdateSelfTaskMutation, + useGetSelfExtensionRequestsQuery, } = tasksApi; diff --git a/src/components/ExtensionRequest/ExtensionRequestDetails.tsx b/src/components/ExtensionRequest/ExtensionRequestDetails.tsx new file mode 100644 index 000000000..9ba15bcc2 --- /dev/null +++ b/src/components/ExtensionRequest/ExtensionRequestDetails.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { formatToRelativeTime } from './ExtensionStatusModal'; +import { ExtensionRequest, ExtensionDetailItem } from '@/interfaces/task.type'; +type ExtensionRequestDetailsProps = { + extensionRequests: ExtensionRequest[]; + styles: Record; + getExtensionRequestDetails: ( + request: ExtensionRequest, + styles: Record + ) => ExtensionDetailItem[]; +}; +export const ExtensionRequestDetails: React.FC = + ({ extensionRequests, styles, getExtensionRequestDetails }) => { + if (extensionRequests.length === 0) { + return ( +
+

+ No extension requests found for this task, want to + create one? +

+
+ ); + } + + return ( + <> + {extensionRequests.map((request) => ( +
+ {getExtensionRequestDetails(request, styles).map( + (item, idx) => ( +
+ + {item.label} + + + {item.value} + +
+ ) + )} + + {request.reviewedBy && + (request.status === 'APPROVED' || + request.status === 'DENIED') && ( +
+ Your request was{' '} + {request.status.toLowerCase()} by{' '} + {request.reviewedBy}{' '} + {request.reviewedAt + ? formatToRelativeTime( + request.reviewedAt + ) + : ''} +
+ )} +
+ ))} + + ); + }; diff --git a/src/components/ExtensionRequest/ExtensionStatusModal.module.scss b/src/components/ExtensionRequest/ExtensionStatusModal.module.scss new file mode 100644 index 000000000..a6e5f659b --- /dev/null +++ b/src/components/ExtensionRequest/ExtensionStatusModal.module.scss @@ -0,0 +1,127 @@ +@import '../../styles/variables.scss'; + +.extensionModalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba($black, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.extensionModal { + background-color: $white; + padding: 1.9rem; + border-radius: 0.6rem; + width: 90%; + max-width: 48rem; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 1.3rem 3.1rem rgba($black, 0.7); + font-family: 'Segoe UI', sans-serif; + font-size: 1rem; +} + +.extensionModal h2 { + text-align: center; + margin-bottom: 1.9rem; + font-size: 2rem; + font-weight: 500; + color: rgba($black, 0.9); +} + +.extensionDetailRow { + display: flex; + flex-wrap: wrap; + margin-bottom: 1rem; + line-height: 1.5; +} + +.extensionLabel { + font-weight: bold; + width: 10rem; + color: rgba($black, 0.9); + flex-shrink: 0; +} + +.extensionValue { + flex: 1; + color: rgba($black, 0.9); + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; +} + +.extensionApprovalInfo, +.extensionRejectionInfo { + margin-top: 0.6rem; + font-style: italic; + color: $black; + text-align: center; +} + +.extensionButtonContainer { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 1.9rem; +} + +.extensionCloseButton, +.extensionRequestButton { + padding: 0.8rem 0; + border: none; + border-radius: 0.4rem; + cursor: pointer; + width: 40%; + font-weight: bold; + font-size: 1rem; +} + +.extensionCloseButton { + background-color: $red; + color: $white; +} + +.extensionRequestButton { + background-color: $green; + color: $white; +} + +.extensionApproved { + font-weight: bold; + color: $green; +} + +.extensionPending, +.extensionDenied { + font-weight: bold; + color: $orange; +} + +.extensionModalLoading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 1100; + background-color: $white; + padding: 1.5rem; + border-radius: 0.5rem; + max-width: 18.8rem; +} + +.extensionNoRequests { + text-align: center; +} + +.spinnerContainer { + display: flex; + justify-content: center; + width: 100%; + margin: 1rem 0; +} diff --git a/src/components/ExtensionRequest/ExtensionStatusModal.tsx b/src/components/ExtensionRequest/ExtensionStatusModal.tsx new file mode 100644 index 000000000..516ee89ac --- /dev/null +++ b/src/components/ExtensionRequest/ExtensionStatusModal.tsx @@ -0,0 +1,185 @@ +import React, { useRef, useEffect } from 'react'; +import styles from './ExtensionStatusModal.module.scss'; +import { useGetSelfExtensionRequestsQuery } from '@/app/services/tasksApi'; +import { SmallSpinner } from '../tasks/card/SmallSpinner'; +import moment from 'moment'; +import { ExtensionRequestDetails } from './ExtensionRequestDetails'; +import { ExtensionRequest, ExtensionDetailItem } from '@/interfaces/task.type'; + +type ExtensionStatusModalProps = { + isOpen: boolean; + onClose: () => void; + taskId: string; + dev: boolean; + assignee: string; +}; + +const formatToDateTime = (timestamp: number) => { + const timestampMs = timestamp < 1e12 ? timestamp * 1000 : timestamp; + return moment(timestampMs).format('MM/DD/YYYY, h:mm:ss A'); +}; + +export const formatToRelativeTime = (timestamp: number) => { + const timestampMs = timestamp < 1e12 ? timestamp * 1000 : timestamp; + return moment(timestampMs).fromNow(); +}; + +const getStatusClass = (status: string, styles: any) => { + switch (status) { + case 'APPROVED': + return styles.extensionApproved; + case 'DENIED': + return styles.extensionDenied; + default: + return styles.extensionPending; + } +}; + +const getExtensionRequestDetails = ( + request: ExtensionRequest, + styles: Record +): ExtensionDetailItem[] => [ + { + label: 'Request :', + value: `#${request.requestNumber}`, + testId: 'request-number', + }, + { + label: 'Reason :', + value: request.reason, + testId: 'request-reason', + }, + { + label: 'Title :', + value: request.title, + testId: 'request-title', + }, + { + label: 'Old Ends On :', + value: formatToDateTime(request.oldEndsOn), + testId: 'old-ends-on', + }, + { + label: 'New Ends On :', + value: formatToDateTime(request.newEndsOn), + testId: 'new-ends-on', + }, + { + label: 'Status :', + value: request.status, + className: getStatusClass(request.status, styles), + testId: 'request-status', + }, +]; + +export function ExtensionStatusModal({ + isOpen, + onClose, + taskId, + dev, + assignee, +}: ExtensionStatusModalProps) { + const { data, isLoading, error } = useGetSelfExtensionRequestsQuery( + { taskId, dev }, + { skip: !isOpen } + ); + const modalRef = useRef(null); + + const extensionRequests = data?.allExtensionRequests ?? []; + const hasPendingRequest = extensionRequests.some( + (req) => req.status === 'PENDING' + ); + + useEffect(() => { + if (!isOpen) return; + + const handleEscapeKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscapeKey); + return () => { + document.removeEventListener('keydown', handleEscapeKey); + }; + }, [isOpen, onClose]); + + const handleOutsideClick = (e: React.MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + if (!isOpen) return null; + + if (isLoading) { + return ( +
+
+

Extension Details

+
+ +
+
+
+ ); + } + + return ( +
+
+

Extension Details

+ + + +
+ + {!hasPendingRequest && ( + + )} +
+
+
+ ); +} diff --git a/src/components/tasks/card/TaskStatusEditMode.tsx b/src/components/tasks/card/TaskStatusEditMode.tsx index f0151549a..128b2b266 100644 --- a/src/components/tasks/card/TaskStatusEditMode.tsx +++ b/src/components/tasks/card/TaskStatusEditMode.tsx @@ -10,7 +10,6 @@ import { useUpdateSelfTaskMutation, } from '@/app/services/tasksApi'; -import { StatusIndicator } from './StatusIndicator'; import { TaskStatusDropdown } from '../TaskStatusDropdown'; import { TASK_STATUS_UPDATE_ERROR_MESSAGE, @@ -26,6 +25,7 @@ type Props = { task: task; setEditedTaskDetails: React.Dispatch>; isSelfTask?: boolean; + setSaveExtensionRequestStatus: React.Dispatch>; }; // TODO: remove this after fixing the card beautify status @@ -47,11 +47,11 @@ const TaskStatusEditMode = ({ task, setEditedTaskDetails, isSelfTask, + setSaveExtensionRequestStatus, }: Props) => { const router = useRouter(); const isDevMode = router.query.dev === 'true'; - const [saveStatus, setSaveStatus] = useState(''); const [updateTask] = useUpdateTaskMutation(); const [updateSelfTask] = useUpdateSelfTaskMutation(); const { SUCCESS, ERROR } = ToastTypes; @@ -60,7 +60,7 @@ const TaskStatusEditMode = ({ newStatus, newProgress, }: taskStatusUpdateHandleProp) => { - setSaveStatus(PENDING); + setSaveExtensionRequestStatus(PENDING); const payload: { status: string; percentCompleted?: number } = { status: newStatus, }; @@ -80,17 +80,17 @@ const TaskStatusEditMode = ({ try { await taskStatusUpdatePromise.unwrap(); - setSaveStatus(SAVED); + setSaveExtensionRequestStatus(SAVED); toast(SUCCESS, TASK_STATUS_UPDATE_SUCCESS_MESSAGE); } catch (error: any) { - setSaveStatus(ERROR_STATUS); + setSaveExtensionRequestStatus(ERROR_STATUS); toast( ERROR, error?.data?.message ?? TASK_STATUS_UPDATE_ERROR_MESSAGE ); } finally { setTimeout(() => { - setSaveStatus(''); + setSaveExtensionRequestStatus(''); }, 3000); } }; @@ -103,8 +103,6 @@ const TaskStatusEditMode = ({ oldProgress={task.percentCompleted} onChange={onChangeUpdateTaskStatus} /> - - ); }; diff --git a/src/components/tasks/card/card.module.scss b/src/components/tasks/card/card.module.scss index 7d00ab662..438fabfbb 100644 --- a/src/components/tasks/card/card.module.scss +++ b/src/components/tasks/card/card.module.scss @@ -251,6 +251,50 @@ margin-top: 0.8rem; } +.extensionStatusButton { + border: 1px solid rgba($black, 0.7); + border-radius: 4px; + font-size: inherit; + font-weight: bolder; + background-color: transparent; + color: $theme-primary-alt; + padding: 0.5rem; + height: 2.5rem; + width: 10rem; + transition: 250ms ease-in-out; + margin-top: 0.8rem; +} +.extensionStatusButton:hover { + background-color: $theme-primary-alt; + color: $white; +} + +.taskStatusControl { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} +@media screen and (max-width: 768px) { + .taskStatusControl { + flex-direction: column; + align-items: center; + } + + .extensionStatusButton { + max-width: none; + margin-top: 0.5rem; + text-align: center; + } + + .taskStatusEditMode { + width: 100%; + display: flex; + justify-content: center; + margin-left: 1.2rem; + } +} + .taskStatusUpdate { border: 1px solid rgba($black, 0.7); border-radius: 4px; @@ -438,7 +482,6 @@ .assignedToSection, .dateSection, .taskSection { - width: 100%; display: flex; gap: 5px; flex-wrap: wrap; diff --git a/src/components/tasks/card/index.tsx b/src/components/tasks/card/index.tsx index aa51b8555..a5357d353 100644 --- a/src/components/tasks/card/index.tsx +++ b/src/components/tasks/card/index.tsx @@ -39,6 +39,7 @@ import { PENDING, SAVED, ERROR_STATUS } from '../constants'; import { StatusIndicator } from './StatusIndicator'; import Suggestions from '../SuggestionBox/Suggestions'; import { useRouter } from 'next/router'; +import { ExtensionStatusModal } from '@/components/ExtensionRequest/ExtensionStatusModal'; const Card: FC = ({ content, @@ -82,6 +83,10 @@ const Card: FC = ({ const [isEditMode, setIsEditMode] = useState(false); + const [showExtensionModal, setShowExtensionModal] = useState(false); + const [saveExtensionRequestStatus, setSaveExtensionRequestStatus] = + useState(''); + const { data: taskTagLevel, isLoading } = useGetTaskTagsQuery({ itemId: cardDetails.id, }); @@ -623,11 +628,31 @@ const Card: FC = ({ {(isEditable || (isDevMode && isSelfTask)) && ( -
- +
+ +
+ + + + setShowExtensionModal(false)} + taskId={cardDetails.id} + dev={isDevMode} + assignee={cardDetails.assignee ?? ''} />
)} diff --git a/src/interfaces/task.type.ts b/src/interfaces/task.type.ts index 6094bc1a7..b92a296be 100644 --- a/src/interfaces/task.type.ts +++ b/src/interfaces/task.type.ts @@ -158,3 +158,31 @@ export type taskStatusUpdateHandleProp = { newStatus: string; newProgress?: number; }; + +export type ExtensionDetailItem = { + label: string; + value: string; + className?: string; + testId: string; +}; + +export type ExtensionRequest = { + reason: string; + newEndsOn: number; + title: string; + taskId: string; + oldEndsOn: number; + status: string; + requestNumber: number; + id: string; + timestamp: number; + assignee: string; + assigneeId: string; + reviewedBy?: string; + reviewedAt?: number; +}; + +export type ExtensionRequestsResponse = { + message: string; + allExtensionRequests: ExtensionRequest[]; +};