diff --git a/__mocks__/handlers/task-details.handler.ts b/__mocks__/handlers/task-details.handler.ts index 9938ed2d2..066ecde24 100644 --- a/__mocks__/handlers/task-details.handler.ts +++ b/__mocks__/handlers/task-details.handler.ts @@ -1,3 +1,4 @@ +import { TASKS_URL } from '@/constants/url'; import { rest } from 'msw'; const URL = process.env.NEXT_PUBLIC_BASE_URL; @@ -135,4 +136,18 @@ const failedTaskDependencyDetails = rest.get( } ); -export { taskDetailsHandler, failedTaskDependencyDetails }; +const failedToUpdateTaskDetails = rest.patch( + `${TASKS_URL}/6KhcLU3yr45dzjQIVm0J`, + (req, res, ctx) => { + return res( + ctx.status(500), + ctx.json({ message: 'Failed to update the task details' }) + ); + } +); + +export { + taskDetailsHandler, + failedTaskDependencyDetails, + failedToUpdateTaskDetails, +}; diff --git a/__tests__/Unit/Components/Tasks/TaskDates.test.tsx b/__tests__/Unit/Components/Tasks/TaskDates.test.tsx index 2c507b7c6..cce933818 100644 --- a/__tests__/Unit/Components/Tasks/TaskDates.test.tsx +++ b/__tests__/Unit/Components/Tasks/TaskDates.test.tsx @@ -1,28 +1,176 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { screen, fireEvent } from '@testing-library/react'; import { TaskDates } from '@/components/taskDetails/TaskDates'; +import { store } from '@/app/store'; +import { renderWithRouter } from '@/test_utils/createMockRouter'; -const mockSetNewEndOnDate = jest.fn(); -const mockHandleBlurOfEndsOn = jest.fn(); +jest.mock('@/hooks/useUserData', () => { + return () => ({ + data: { + roles: { + admin: true, + super_user: false, + }, + }, + isUserAuthorized: true, + isSuccess: true, + }); +}); + +const mockHandleEditedTaskDetails = jest.fn(); describe('TaskDates Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render input field for End On date when in editing mode', () => { - render( - + renderWithRouter( + + + + ); + + const input = screen.getByTestId( + 'endsOnTaskDetails' + ) as HTMLInputElement; + fireEvent.change(input, { target: { value: '2024-04-15' } }); + fireEvent.blur(input); + expect(input.value).toBe('2024-04-15'); + }); + + it('should not render input field for End On date when not in editing mode', () => { + renderWithRouter( + + + + ); + + const input = screen.queryByTestId('endsOnTaskDetails'); + expect(input).toBeNull(); + }); + + it('should display an extension icon, when isExtensionRequestPending is true', () => { + renderWithRouter( + + + + ); + + const extensionIcon = screen.getByTestId('extension-request-icon'); + expect(extensionIcon).toBeInTheDocument(); + }); + + it('should not update the input value if invalid date is entered', () => { + renderWithRouter( + + + + ); + + const input = screen.getByTestId( + 'endsOnTaskDetails' + ) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'invalid-date' } }); + fireEvent.blur(input); + expect(input.value).toBe(''); + }); + + it('should render input element correctly with admin role', () => { + renderWithRouter( + + + ); const input = screen.getByTestId('endsOnTaskDetails'); expect(input).toBeInTheDocument(); - fireEvent.blur(input); - expect(mockHandleBlurOfEndsOn).toHaveBeenCalled(); + }); + + it('should render input element correctly with non-admin role', () => { + jest.mock('@/hooks/useUserData', () => { + return () => ({ + data: { + roles: { + admin: false, + super_user: true, + }, + }, + isUserAuthorized: true, + isSuccess: true, + }); + }); + + renderWithRouter( + + + + ); + + const input = screen.getByTestId('endsOnTaskDetails'); + expect(input).toBeInTheDocument(); + }); + + it('should render the correct date when endsOn is null', () => { + renderWithRouter( + + + + ); + + const input = screen.getByTestId( + 'endsOnTaskDetails' + ) as HTMLInputElement; + expect(input.value).toBe(''); }); }); diff --git a/__tests__/Unit/Components/Tasks/TaskDetails.test.tsx b/__tests__/Unit/Components/Tasks/TaskDetails.test.tsx index 32bb1ac55..94085f2cc 100644 --- a/__tests__/Unit/Components/Tasks/TaskDetails.test.tsx +++ b/__tests__/Unit/Components/Tasks/TaskDetails.test.tsx @@ -12,7 +12,10 @@ import { ButtonProps, TextAreaProps } from '@/interfaces/taskDetails.type'; import { ToastContainer } from 'react-toastify'; import * as progressQueries from '@/app/services/progressesApi'; import Details from '@/components/taskDetails/Details'; -import { taskDetailsHandler } from '../../../../__mocks__/handlers/task-details.handler'; +import { + failedToUpdateTaskDetails, + taskDetailsHandler, +} from '../../../../__mocks__/handlers/task-details.handler'; import { superUserSelfHandler } from '../../../../__mocks__/handlers/self.handler'; import convertTimeStamp from '@/helperFunctions/convertTimeStamp'; import { STARTED_ON, ENDS_ON } from '@/constants/constants'; @@ -49,6 +52,8 @@ jest.mock('@/hooks/useUserData', () => { }); const mockNavigateToUpdateProgressPage = jest.fn(); +const mockHandleEditedTaskDetails = jest.fn(); + describe('TaskDetails Page', () => { it('Should render title', async () => { const { getByText } = renderWithRouter( @@ -323,13 +328,72 @@ test('should call onSave and reset state when clicked', async () => { await waitFor(() => { const editButton = screen.getByRole('button', { name: 'Edit' }); fireEvent.click(editButton); + const input = screen.getByTestId( + 'endsOnTaskDetails' + ) as HTMLInputElement; + fireEvent.change(input, { target: { value: '2024-04-15' } }); + fireEvent.blur(input); + expect(input.value).toBe('2024-04-15'); + const saveButton = screen.getByRole('button', { name: 'Save' }); + fireEvent.click(saveButton); }); await waitFor(() => { - const saveButton = screen.getByRole('button', { name: 'Save' }); - fireEvent.click(saveButton); + expect( + screen.getByRole('button', { name: 'Saving...' }) + ).toBeInTheDocument(); + }); + + await waitFor(() => { + const editButtonAfterSave = screen.getByRole('button', { + name: 'Edit', + }); + expect(editButtonAfterSave).toBeInTheDocument(); + }); +}); + +test('should call onSave and show error toast when save fails', async () => { + server.use(failedToUpdateTaskDetails); + + renderWithRouter( + + + + , + {} + ); + + await waitFor(() => { const editButton = screen.getByRole('button', { name: 'Edit' }); - expect(editButton).toBeInTheDocument(); + fireEvent.click(editButton); + }); + + const input = screen.getByTestId('endsOnTaskDetails') as HTMLInputElement; + fireEvent.change(input, { target: { value: '2024-04-15' } }); + fireEvent.blur(input); + + expect(input.value).toBe('2024-04-15'); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Saving...' }) + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByText(/Failed to update the task details/i) + ).toBeInTheDocument(); + }); + + await waitFor(() => { + const editButtonAfterSave = screen.getByRole('button', { + name: 'Edit', + }); + expect(editButtonAfterSave).toBeInTheDocument(); }); }); @@ -351,11 +415,12 @@ test('should update the title and description with the new values', async () => fireEvent.change(textareaElement, { target: { name: 'title', value: 'New Title' }, }); - await waitFor(async () => { - const saveButton = screen.getByRole('button', { name: 'Save' }); - fireEvent.click(saveButton); - expect(await screen.findByText(/Successfully saved/i)).not.toBeNull(); + + const saveButton = await screen.findByRole('button', { + name: 'Save', }); + fireEvent.click(saveButton); + expect(screen.findByText(/Successfully saved/i)).not.toBeNull(); }); test('should not update the title and description with the same values', async () => { server.use(...taskDetailsHandler); @@ -701,4 +766,30 @@ describe('Details component', () => { throw error; } }); + + it('Renders an input with prefilled data provided, when isEditing is true', () => { + const task = { + id: 'L1SDW6O835o0EI8ZmvRc', + endedOn: 1700000000, + }; + const formattedEndsOn = convertTimeStamp(task.endedOn); + const expectedDate = new Date(formattedEndsOn).toLocaleDateString( + 'en-CA' + ); + + renderWithRouter( +
+ ); + + const input = screen.getByTestId( + 'endsOnTaskDetails' + ) as HTMLInputElement; + + expect(input.defaultValue).toBe(expectedDate); + }); }); diff --git a/__tests__/Unit/Components/Tasks/TaskHeader.test.tsx b/__tests__/Unit/Components/Tasks/TaskHeader.test.tsx index bda23da68..17b7a5525 100644 --- a/__tests__/Unit/Components/Tasks/TaskHeader.test.tsx +++ b/__tests__/Unit/Components/Tasks/TaskHeader.test.tsx @@ -1,13 +1,12 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { TaskHeader } from '@/components/taskDetails/TaskHeader'; -import { ButtonProps } from '@/interfaces/taskDetails.type'; const mockSetIsEditing = jest.fn(); const mockOnSave = jest.fn(); const mockOnCancel = jest.fn(); const mockHandleChange = jest.fn(); -const renderTaskHeader = (isEditing = false) => { +const renderTaskHeader = (isEditing = false, loading = false) => { return render( { title="Test Title" handleChange={mockHandleChange} isUserAuthorized={true} + loading={loading} /> ); }; @@ -53,6 +53,16 @@ describe('TaskHeader Component', () => { expect(mockOnSave).toHaveBeenCalled(); }); + it('should show saving... loader when loader is true', () => { + renderTaskHeader(true); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(mockOnSave).toHaveBeenCalled(); + renderTaskHeader(true, true); + expect(screen.getByRole('button', { name: 'Saving...' })); + renderTaskHeader(false, false); + expect(screen.getByRole('button', { name: 'Edit' })); + }); + it('should calls onCancel when Cancel button is clicked', () => { renderTaskHeader(true); fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); diff --git a/__tests__/Utils/isValidDate.test.ts b/__tests__/Utils/isValidDate.test.ts new file mode 100644 index 000000000..e2a52530b --- /dev/null +++ b/__tests__/Utils/isValidDate.test.ts @@ -0,0 +1,23 @@ +import { isValidDate } from '@/utils/isValidDate'; + +describe('isValidDate', () => { + it('should return true for valid dates in "YYYY-MM-DD" format', () => { + expect(isValidDate('2023-10-05')).toBe(true); + expect(isValidDate('1999-12-31')).toBe(true); + expect(isValidDate('2000-02-29')).toBe(true); + }); + + it('should return false for dates with an invalid format', () => { + expect(isValidDate('23-10-05')).toBe(false); + expect(isValidDate('2023-1-5')).toBe(false); + expect(isValidDate('2023/10/05')).toBe(false); + expect(isValidDate('05-10-2023')).toBe(false); + expect(isValidDate('')).toBe(false); + }); + + it('should return false for invalid date strings', () => { + expect(isValidDate('not-a-date')).toBe(false); + expect(isValidDate('2023-10-')).toBe(false); + expect(isValidDate('2023-XX-05')).toBe(false); + }); +}); diff --git a/src/components/taskDetails/Details.tsx b/src/components/taskDetails/Details.tsx index 4fa1017e0..273ceda0c 100644 --- a/src/components/taskDetails/Details.tsx +++ b/src/components/taskDetails/Details.tsx @@ -1,20 +1,113 @@ -import React, { FC, useState } from 'react'; +import React, { FC, useEffect, useState } from 'react'; import moment from 'moment'; import { FaReceipt } from 'react-icons/fa6'; import Link from 'next/link'; +import { useRouter } from 'next/router'; import Tooltip from '@/components/common/Tooltip/Tooltip'; import setColor from './taskPriorityColors'; import extractRepoName from '@/utils/extractRepoName'; import styles from './task-details.module.scss'; -import { TaskDetailsProps } from '@/interfaces/taskDetails.type'; +import { + DetailsContentProps, + TaskDetailsProps, +} from '@/interfaces/taskDetails.type'; +import useUserData from '@/hooks/useUserData'; import { STARTED_ON, ENDS_ON } from '@/constants/constants'; +import { isValidDate } from '@/utils/isValidDate'; type StringNumberOrUndefined = string | number | undefined; -const Details: FC = ({ detailType, value, url }) => { +const DetailsContent: FC = ({ + color, + isGitHubLink, + value, + gitHubIssueLink, + isTimeDetail, + formatDate, + tooltipActive, + renderedValue, + getRelativeTime, +}) => { + const displayValue = renderedValue || value; + + if (isGitHubLink && value && gitHubIssueLink) { + return ( + + + {extractRepoName(value)} + + + ); + } + + if (isTimeDetail && value) { + return ( + + + {tooltipActive ? formatDate(value) : getRelativeTime(value)} + + + ); + } + + return ( + + {displayValue} + + ); +}; + +const Details: FC = (props) => { + const { detailType, value, url, isEditing, setEditedTaskDetails } = props; const color = value ? setColor?.[value] : undefined; const isGitHubLink = detailType === 'Link'; const gitHubIssueLink = isGitHubLink ? value : undefined; + const [newEndOnDate, setNewEndOnDate] = useState(''); + const { isUserAuthorized } = useUserData(); + const router = useRouter(); + const isDevFlagEnabled = router.query.dev === 'true'; + + useEffect(() => { + if (!isEditing) setNewEndOnDate(''); + }, [isEditing]); + + const handleEndsOnBlur = () => { + const isDateValid = isValidDate(newEndOnDate); + const endsOn = isDateValid + ? new Date(`${newEndOnDate}`).getTime() / 1000 + : null; + + if (endsOn && endsOn > 0) { + setEditedTaskDetails?.((prev) => ({ + ...prev, + endsOn, + })); + } else { + console.error('Invalid date provided', newEndOnDate); + } + }; const getRelativeTime = (timestamp: StringNumberOrUndefined): string => { return timestamp ? moment(timestamp).fromNow() : 'N/A'; @@ -64,38 +157,36 @@ const Details: FC = ({ detailType, value, url }) => { : detailType; const renderedValue = value ?? 'N/A'; + const dateValue = + newEndOnDate || new Date(value as string).toLocaleDateString('en-CA'); + const finalDateValue = isDevFlagEnabled ? dateValue : newEndOnDate; return (
{formattedDetailType}: - - {isGitHubLink && value ? ( - - {isGitHubLink ? `${extractRepoName(value)}` : value} - - ) : isTimeDetail ? ( - - {tooltipActive - ? formatDate(value) - : getRelativeTime(value)} - - ) : ( - renderedValue - )} - + {isEditing && isUserAuthorized ? ( + setNewEndOnDate(e.target.value)} + onBlur={handleEndsOnBlur} + value={finalDateValue} + className={styles.inputField} + /> + ) : ( + + )} {detailType === ENDS_ON && url && ( void; - handleBlurOfEndsOn: () => void; + endsOn: number | null; isExtensionRequestPending: boolean; taskId: string; + setEditedTaskDetails: React.Dispatch>; } export const TaskDates: React.FC = ({ isEditing, - isUserAuthorized, startedOn, endsOn, - newEndOnDate, - setNewEndOnDate, - handleBlurOfEndsOn, isExtensionRequestPending, taskId, + setEditedTaskDetails, }) => { const formattedEndsOn = endsOn ? convertTimeStamp(endsOn) : 'TBD'; + const url = isExtensionRequestPending + ? `${TASK_EXTENSION_REQUEST_URL}?&q=${encodeURIComponent( + `taskId:${taskId},status:PENDING` + )}` + : null; return ( <> @@ -36,30 +36,13 @@ export const TaskDates: React.FC = ({
- {isExtensionRequestPending && ( -
- )} - {!isExtensionRequestPending && ( -
- )} - {isEditing && isUserAuthorized && ( - setNewEndOnDate(e.target.value)} - onBlur={handleBlurOfEndsOn} - value={newEndOnDate} - data-testid="endsOnTaskDetails" - className={styles.inputField} - /> - )} +
); diff --git a/src/components/taskDetails/TaskHeader.tsx b/src/components/taskDetails/TaskHeader.tsx index b79269d57..96aad32a2 100644 --- a/src/components/taskDetails/TaskHeader.tsx +++ b/src/components/taskDetails/TaskHeader.tsx @@ -10,6 +10,7 @@ interface TaskHeaderProps { title: string; handleChange: (event: React.ChangeEvent) => void; isUserAuthorized: boolean; + loading?: boolean; } export const TaskHeader: React.FC = ({ @@ -20,6 +21,7 @@ export const TaskHeader: React.FC = ({ title, handleChange, isUserAuthorized, + loading, }) => { if (isEditing) { return ( @@ -33,7 +35,11 @@ export const TaskHeader: React.FC = ({ />
); diff --git a/src/components/taskDetails/index.tsx b/src/components/taskDetails/index.tsx index b6a1b5148..dba5e867d 100755 --- a/src/components/taskDetails/index.tsx +++ b/src/components/taskDetails/index.tsx @@ -28,12 +28,13 @@ import { } from '@/interfaces/taskDetails.type'; export function Button(props: ButtonProps) { - const { buttonName, clickHandler, value } = props; + const { buttonName, clickHandler, value, disabled } = props; return ( @@ -63,8 +64,8 @@ type Props = { const TaskDetails: FC = ({ taskID }) => { const router = useRouter(); const { isUserAuthorized } = useUserData(); - const [newEndOnDate, setNewEndOnDate] = useState(''); const [isEditing, setIsEditing] = useState(false); + const [loading, setLoading] = useState(false); const { data, isError, isLoading, isFetching } = useGetTaskDetailsQuery(taskID); const { data: extensionRequests } = @@ -91,6 +92,8 @@ const TaskDetails: FC = ({ taskID }) => { ); const inputRef = useRef(null); const [showSuggestion, setShowSuggestion] = useState(false); + const isDevFlagEnabled = router.query.dev === 'true'; + const handleAssignment = (e: React.ChangeEvent) => { setAssigneeName(e.target.value); setShowSuggestion(Boolean(e.target.value)); @@ -136,11 +139,9 @@ const TaskDetails: FC = ({ taskID }) => { function onCancel() { setIsEditing(false); setEditedTaskDetails(taskDetailsData); - setNewEndOnDate(''); } async function onSave() { - setIsEditing(false); - setNewEndOnDate(''); + !isDevFlagEnabled && setIsEditing(false); const updatedFields: Partial = {}; for (const key in editedTaskDetails) { if ( @@ -163,13 +164,18 @@ const TaskDetails: FC = ({ taskID }) => { return; } + isDevFlagEnabled && setLoading(true); await updateTaskDetails({ editedDetails: updatedFields, taskID, }) .unwrap() .then(() => toast(SUCCESS, 'Successfully saved')) - .catch((error) => toast(ERROR, error.data.message)); + .catch((error) => toast(ERROR, error.data.message)) + .finally(() => { + isDevFlagEnabled && setIsEditing(false); + isDevFlagEnabled && setLoading(false); + }); } function handleChange( @@ -198,10 +204,6 @@ const TaskDetails: FC = ({ taskID }) => { return timestamp ? convertTimeStamp(parseInt(timestamp, 10)) : 'N/A'; } - function getEndsOn(timestamp: number | undefined) { - return timestamp ? convertTimeStamp(timestamp) : 'TBD'; - } - const shouldRenderParentContainer = () => !isLoading && !isError && data; const { data: progressData } = useGetProgressDetailsQuery({ @@ -209,17 +211,6 @@ const TaskDetails: FC = ({ taskID }) => { }); const taskProgress: ProgressDetailsData[] = progressData?.data || []; - const handleBlurOfEndsOn = () => { - const endsOn = new Date(`${newEndOnDate}`).getTime() / 1000; - - if (endsOn > 0) { - setEditedTaskDetails((prev) => ({ - ...prev, - endsOn, - })); - } - }; - return ( {renderLoadingComponent()} @@ -233,6 +224,7 @@ const TaskDetails: FC = ({ taskID }) => { title={editedTaskDetails?.title} handleChange={handleChange} isUserAuthorized={isUserAuthorized} + loading={loading} />
@@ -299,14 +291,11 @@ const TaskDetails: FC = ({ taskID }) => { > void; value?: boolean; className?: string; + disabled?: boolean; }; export type TextAreaProps = { name: string; @@ -32,6 +33,19 @@ export type TaskDetailsProps = { detailType: string; value?: string; url?: string | null; + isEditing?: boolean; + setEditedTaskDetails?: React.Dispatch>; +}; +export type DetailsContentProps = { + color: string | undefined; + isGitHubLink: boolean; + value: string | undefined; + gitHubIssueLink: string | undefined; + isTimeDetail: boolean; + formatDate: (timestamp: string | number | undefined) => string; + tooltipActive: boolean; + renderedValue: string; + getRelativeTime: (timestamp: string | number | undefined) => string; }; export type DependencyItem = | PromiseFulfilledResult<{ diff --git a/src/utils/isValidDate.ts b/src/utils/isValidDate.ts new file mode 100644 index 000000000..e34599d98 --- /dev/null +++ b/src/utils/isValidDate.ts @@ -0,0 +1,5 @@ +export const isValidDate = (dateString: string) => { + // Validating the "YYYY-MM-DD" format strictly + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + return dateRegex.test(dateString) && !isNaN(new Date(dateString).getTime()); +};