diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/CodeDescriptionDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/CodeDescriptionDto.java new file mode 100644 index 00000000..6dcf89df --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/CodeDescriptionDto.java @@ -0,0 +1,11 @@ +package ca.bc.gov.restapi.results.oracle.dto; + +import lombok.With; + +@With +public record CodeDescriptionDto( + String code, + String description +) { + +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpoint.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpoint.java index 69f316ca..f7d283e0 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpoint.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpoint.java @@ -3,9 +3,9 @@ import ca.bc.gov.restapi.results.common.pagination.PaginatedResult; import ca.bc.gov.restapi.results.common.pagination.PaginatedViaQuery; import ca.bc.gov.restapi.results.common.pagination.PaginationParameters; +import ca.bc.gov.restapi.results.oracle.dto.CodeDescriptionDto; import ca.bc.gov.restapi.results.oracle.dto.OpeningSearchFiltersDto; import ca.bc.gov.restapi.results.oracle.dto.OpeningSearchResponseDto; -import ca.bc.gov.restapi.results.oracle.entity.OpenCategoryCodeEntity; import ca.bc.gov.restapi.results.oracle.entity.OrgUnitEntity; import ca.bc.gov.restapi.results.oracle.service.OpenCategoryCodeService; import ca.bc.gov.restapi.results.oracle.service.OpeningService; @@ -121,10 +121,10 @@ public PaginatedResult openingSearch( * Get all opening categories. Optionally you can ask for the expired ones. * * @param includeExpired Query param to include expired categories. - * @return List of OpenCategoryCodeEntity with found categories. + * @return List of {@link CodeDescriptionDto} with found categories. */ @GetMapping("/categories") - public List getOpeningCategories( + public List getOpeningCategories( @RequestParam(value = "includeExpired", required = false) Boolean includeExpired) { boolean addExpired = Boolean.TRUE.equals(includeExpired); diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/OpenCategoryCodeService.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/OpenCategoryCodeService.java index c4d6ddd5..656ad467 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/OpenCategoryCodeService.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/OpenCategoryCodeService.java @@ -1,5 +1,6 @@ package ca.bc.gov.restapi.results.oracle.service; +import ca.bc.gov.restapi.results.oracle.dto.CodeDescriptionDto; import ca.bc.gov.restapi.results.oracle.entity.OpenCategoryCodeEntity; import ca.bc.gov.restapi.results.oracle.repository.OpenCategoryCodeRepository; import java.time.LocalDate; @@ -21,9 +22,9 @@ public class OpenCategoryCodeService { * Find all Opening categories. Option to include expired ones. * * @param includeExpired True to include expired, false otherwise. - * @return List of {@link OpenCategoryCodeEntity} with found categories. + * @return List of {@link CodeDescriptionDto} with found categories. */ - public List findAllCategories(boolean includeExpired) { + public List findAllCategories(boolean includeExpired) { log.info("Getting all open category codes. Include expired: {}", includeExpired); List openCategoryCodes = @@ -35,6 +36,9 @@ public List findAllCategories(boolean includeExpired) { openCategoryCodes.size(), BooleanUtils.toString(includeExpired, "in", "ex") ); - return openCategoryCodes; + return openCategoryCodes + .stream() + .map(entity -> new CodeDescriptionDto(entity.getCode(), entity.getDescription())) + .toList(); } } diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpointTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpointTest.java index 517e8969..82a529a7 100644 --- a/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpointTest.java +++ b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpointTest.java @@ -8,8 +8,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import ca.bc.gov.restapi.results.common.pagination.PaginatedResult; +import ca.bc.gov.restapi.results.oracle.dto.CodeDescriptionDto; import ca.bc.gov.restapi.results.oracle.dto.OpeningSearchResponseDto; -import ca.bc.gov.restapi.results.oracle.entity.OpenCategoryCodeEntity; import ca.bc.gov.restapi.results.oracle.entity.OrgUnitEntity; import ca.bc.gov.restapi.results.oracle.enums.OpeningCategoryEnum; import ca.bc.gov.restapi.results.oracle.enums.OpeningStatusEnum; @@ -145,19 +145,9 @@ void openingSearch_noRecordsFound_shouldSucceed() throws Exception { @Test @DisplayName("Get Opening Categories happy Path should Succeed") void getOpeningCategories_happyPath_shouldSucceed() throws Exception { - OpenCategoryCodeEntity category = new OpenCategoryCodeEntity(); - category.setCode("FTML"); - category.setDescription("Free Growing"); - category.setEffectiveDate(LocalDate.now().minusYears(3L)); - category.setExpiryDate(LocalDate.now().plusYears(3L)); - category.setUpdateTimestamp(LocalDate.now()); + CodeDescriptionDto category = new CodeDescriptionDto("FTML", "Free Growing"); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - String effectiveDateStr = category.getEffectiveDate().format(formatter); - String expiryDateStr = category.getExpiryDate().format(formatter); - String updateTimestampStr = category.getUpdateTimestamp().format(formatter); - - List openCategoryCodeEntityList = List.of(category); + List openCategoryCodeEntityList = List.of(category); when(openCategoryCodeService.findAllCategories(false)).thenReturn(openCategoryCodeEntityList); @@ -168,11 +158,8 @@ void getOpeningCategories_happyPath_shouldSucceed() throws Exception { .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType("application/json")) - .andExpect(jsonPath("$[0].code").value(category.getCode())) - .andExpect(jsonPath("$[0].description").value(category.getDescription())) - .andExpect(jsonPath("$[0].effectiveDate").value(effectiveDateStr)) - .andExpect(jsonPath("$[0].expiryDate").value(expiryDateStr)) - .andExpect(jsonPath("$[0].updateTimestamp").value(updateTimestampStr)) + .andExpect(jsonPath("$[0].code").value(category.code())) + .andExpect(jsonPath("$[0].description").value(category.description())) .andReturn(); } diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/OpenCategoryCodeServiceTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/OpenCategoryCodeServiceTest.java index d221bd79..7aa8943f 100644 --- a/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/OpenCategoryCodeServiceTest.java +++ b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/OpenCategoryCodeServiceTest.java @@ -1,8 +1,7 @@ package ca.bc.gov.restapi.results.oracle.service; import ca.bc.gov.restapi.results.extensions.AbstractTestContainerIntegrationTest; -import ca.bc.gov.restapi.results.oracle.entity.OpenCategoryCodeEntity; -import java.time.LocalDate; +import ca.bc.gov.restapi.results.oracle.dto.CodeDescriptionDto; import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -19,20 +18,18 @@ class OpenCategoryCodeServiceTest extends AbstractTestContainerIntegrationTest { @DisplayName("Find all categories include expired false should succeed") void findAllCategories_includeExpiredFalse_shouldSucceed() { - List entities = openCategoryCodeService.findAllCategories(false); + List entities = openCategoryCodeService.findAllCategories(false); Assertions.assertNotNull(entities); Assertions.assertEquals(22, entities.size()); - Assertions.assertTrue(entities.get(0).getExpiryDate().isAfter(LocalDate.now())); } @Test @DisplayName("Find all categories include expired true should succeed") void findAllCategories_includeExpiredTrue_shouldSucceed() { - List entities = openCategoryCodeService.findAllCategories(true); + List entities = openCategoryCodeService.findAllCategories(true); Assertions.assertNotNull(entities); Assertions.assertEquals(39, entities.size()); - Assertions.assertTrue(entities.get(0).getExpiryDate().isBefore(LocalDate.now())); } } diff --git a/frontend/src/__test__/components/FriendlyDate.test.tsx b/frontend/src/__test__/components/FriendlyDate.test.tsx index bb3db789..87697c5a 100644 --- a/frontend/src/__test__/components/FriendlyDate.test.tsx +++ b/frontend/src/__test__/components/FriendlyDate.test.tsx @@ -23,12 +23,12 @@ describe('FriendlyDate Component', () => { it('displays "Today" for today\'s date', () => { render(); - expect(screen.getByText("Today")).toBeInTheDocument(); + expect(screen.getByText("in 6 hours")).toBeInTheDocument(); }); it('displays "Yesterday" for a date one day ago', () => { render(); - expect(screen.getByText("Yesterday")).toBeInTheDocument(); + expect(screen.getByText("yesterday")).toBeInTheDocument(); }); it('displays relative time within the last week', () => { @@ -38,17 +38,17 @@ describe('FriendlyDate Component', () => { it('displays exact date for dates older than a week', () => { render(); - expect(screen.getByText("23 days ago")).toBeInTheDocument(); + expect(screen.getByText("January 1, 2024")).toBeInTheDocument(); }); it('displays friendly date format for future dates', () => { render(); - expect(screen.getByText("in 29 days")).toBeInTheDocument(); + expect(screen.getByText("February 22, 2024")).toBeInTheDocument(); }); it('renders tooltip with full text on hover', async () => { - const {container} = render(); - expect(container.querySelector('span').getAttribute('data-tooltip')).toBe("Feb 22, 2024"); + const {container} = render(); + expect(container.querySelector('span').getAttribute('data-tooltip')).toBe("January 21, 2024"); }); it('renders an empty span for null dates', () => { diff --git a/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchBar.test.tsx b/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchBar.test.tsx index 8044d8d6..24c99692 100644 --- a/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchBar.test.tsx +++ b/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchBar.test.tsx @@ -115,8 +115,6 @@ describe("OpeningsSearchBar", () => { ); - console.log(screen.debug()); - // Check if an element with the class 'd-none' exists within the structure const dNoneElement = screen.getByText('+2'); expect(dNoneElement).toBeInTheDocument(); diff --git a/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx b/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx new file mode 100644 index 00000000..b2d2e752 --- /dev/null +++ b/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import SearchScreenDataTable from '../../../../components/SilvicultureSearch/Openings/SearchScreenDataTable/index'; +import { columns, rows } from '../../../../components/SilvicultureSearch/Openings/SearchScreenDataTable/testData'; +import PaginationProvider from '../../../../contexts/PaginationProvider'; +import { NotificationProvider } from '../../../../contexts/NotificationProvider'; +import { BrowserRouter } from 'react-router-dom'; +import { OpeningsSearchProvider } from '../../../../contexts/search/OpeningsSearch'; + +const handleCheckboxChange = vi.fn(); +const toggleSpatial = vi.fn(); + +describe('Search Screen Data table test', () => { + + it('should render the Search Screen Data table', () => { + const { getByText, container } = + render( + + + + + + + + + + ); + + expect(container).toBeInTheDocument(); + expect(container.querySelector('.total-search-results')).toBeInTheDocument(); + expect(container.querySelector('.total-search-results')).toContainHTML('Total Search Results'); + + }); + + it('should render the Search Screen Data table with no data', () => { + const { getByText, container } = + render( + + + + + + + + + + ); + + expect(container).toBeInTheDocument(); + expect(container.querySelector('.total-search-results')).toBeInTheDocument(); + expect(container.querySelector('.total-search-results')).toContainHTML('Total Search Results'); + expect(container.querySelector('.total-search-results')).toContainHTML('0'); + }); + +}); \ No newline at end of file diff --git a/frontend/src/__test__/components/TruncatedText.test.tsx b/frontend/src/__test__/components/TruncatedText.test.tsx new file mode 100644 index 00000000..ecacb6f6 --- /dev/null +++ b/frontend/src/__test__/components/TruncatedText.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; +import TruncatedText from '../../components/TruncatedText'; + +// Mock Tooltip component from Carbon to ensure tests run without extra dependencies +vi.mock('@carbon/react', () => { + const Tooltip = ({ label, children }) => {children}; + return { Tooltip }; +}); + +describe('TruncatedText Component', () => { + + it('renders the component with default props', () => { + render(); + expect(screen.getByText(/This is a test text/)).toBeInTheDocument(); + }); + + it('truncates text based on maxLength', () => { + render(); + expect(screen.getByText("This is a ...")).toBeInTheDocument(); + }); + + it('does not truncate text if length is within maxLength', () => { + render(); + expect(screen.getByText("Short text")).toBeInTheDocument(); + }); + + it('truncates text based on parentWidth greater than 200px', () => { + const text = "This is a test text that should be truncated".repeat(10); + render(); + expect(screen.getByText("This is a test text that should be truncated...")).toBeInTheDocument(); // 300/5 = 60 chars + }); + + it('renders tooltip with full text on hover', async () => { + const {container} = render(); + expect(container.querySelector('span').getAttribute('data-tooltip')).toBe("This is a test text that should be truncated"); + }); + +}); diff --git a/frontend/src/components/FriendlyDate/index.tsx b/frontend/src/components/FriendlyDate/index.tsx index e49acbd2..bac54091 100644 --- a/frontend/src/components/FriendlyDate/index.tsx +++ b/frontend/src/components/FriendlyDate/index.tsx @@ -1,34 +1,84 @@ import React from 'react'; -import { formatDistanceToNow, format, parseISO, isToday, isYesterday } from 'date-fns'; +import { + parseISO, + isFuture, + format, + differenceInMinutes, + differenceInHours, + differenceInDays +} from 'date-fns'; import { Tooltip } from '@carbon/react'; interface FriendlyDateProps { date: string | null | undefined; // The date string in ISO format } -const FriendlyDate: React.FC = ({ date }) => { +const formatDateAtlassianStyle = (date: Date) => { + const now = new Date(); + const minutesDiff = differenceInMinutes(now, date); + const hoursDiff = differenceInHours(now, date); + const daysDiff = differenceInDays(now, date); + + // Past dates + if (minutesDiff < 1) return 'just now'; + if (minutesDiff < 60) return minutesDiff === 1 ? 'a minute ago' : `${minutesDiff} minutes ago`; + if (hoursDiff < 24) return hoursDiff === 1 ? '1 hour ago' : `${hoursDiff} hours ago`; + if (daysDiff === 1) return 'yesterday'; + if (daysDiff < 7) return `${daysDiff} days ago`; + if(daysDiff === 7) return 'a week ago'; + + // Use full date for older dates + return format(date, 'MMMM d, yyyy'); +} - if(!date) return ; +// Future dates +const formatFutureDateAtlassianStyle = (date: Date) => { + const now = new Date(); + const minutesDiff = differenceInMinutes(date, now); + const hoursDiff = differenceInHours(date, now); + const daysDiff = differenceInDays(date, now); + + if (minutesDiff < 1) return 'shortly'; + if (minutesDiff < 60) return minutesDiff === 1 ? 'in 1 minute' : `in ${minutesDiff} minutes`; + if (hoursDiff < 24) return hoursDiff === 1 ? 'in 1 hour' : `in ${hoursDiff} hours`; + if (daysDiff === 1) return 'tomorrow'; + if (daysDiff < 7) return `in ${daysDiff} days`; + if (daysDiff === 7) return 'in a week'; - try{ - const parsedDate = parseISO(date); - const cleanDate = format(parsedDate, "MMM dd, yyyy"); - - const formattedDate = isToday(parsedDate) - ? "Today" - : isYesterday(parsedDate) - ? "Yesterday" - : formatDistanceToNow(parsedDate, { addSuffix: true }); - - return - {formattedDate} - ; - } catch(e){ - return ; + return format(date, 'MMMM d, yyyy'); +} + +const FriendlyDate: React.FC = ({ date }) => { + + if (!date) return ; + + try { + const parsedDate = parseISO(date); + const cleanDate = format(parsedDate, "MMMM dd, yyyy"); + + // Use appropriate formatting for past or future dates + const formattedDate = isFuture(parsedDate) + ? formatFutureDateAtlassianStyle(parsedDate) + : formatDateAtlassianStyle(parsedDate); + + if(cleanDate === formattedDate) + console.log(`cleanDate: ${cleanDate} formattedDate: ${formattedDate}`); + return ( + <> + {(cleanDate === formattedDate) ? ({formattedDate}) : ( + + {formattedDate} + + )} + + ); + } catch (e) { + return ; // Fallback for invalid dates } }; -export default FriendlyDate; \ No newline at end of file +export default FriendlyDate; diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx index b3fc8766..1b62d519 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from "react"; +import React, { useContext, useEffect, useState, useRef } from "react"; import { TableToolbar, TableToolbarAction, @@ -42,8 +42,10 @@ import { import { useNavigate } from "react-router-dom"; import { setOpeningFavorite } from '../../../../services/OpeningFavouriteService'; import { useNotification } from "../../../../contexts/NotificationProvider"; +import TruncatedText from "../../../TruncatedText"; import FriendlyDate from "../../../FriendlyDate"; + interface ISearchScreenDataTable { rows: OpeningsSearch[]; headers: ITableHeader[]; @@ -54,6 +56,10 @@ interface ISearchScreenDataTable { totalItems: number; } +interface ICellRefs { + offsetWidth: number; +} + const SearchScreenDataTable: React.FC = ({ rows, headers, @@ -76,6 +82,24 @@ const SearchScreenDataTable: React.FC = ({ const [selectedRows, setSelectedRows] = useState([]); // State to store selected rows const navigate = useNavigate(); + // This ref is used to calculate the width of the container for each cell + const cellRefs = useRef([]); + // Holds the with of each cell in the table + const [cellWidths, setCellWidths] = useState([]); + + useEffect(() => { + const widths = cellRefs.current.map((cell: ICellRefs) => cell.offsetWidth || 0); + setCellWidths(widths); + + const handleResize = () => { + const newWidths = cellRefs.current.map((cell: ICellRefs) => cell.offsetWidth || 0); + setCellWidths(newWidths); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + useEffect(() => { setInitialItemsPerPage(itemsPerPage); }, [rows, totalItems]); @@ -270,7 +294,7 @@ const SearchScreenDataTable: React.FC = ({ - +
{headers.map((header) => @@ -287,9 +311,12 @@ const SearchScreenDataTable: React.FC = ({ {headers.map((header) => header.selected ? ( (cellRefs.current[i] = el)} key={header.key} className={ - header.key === "actions" && showSpatial ? "p-0" : null + header.key === "actions" && showSpatial ? "p-0" : + header.elipsis ? "ellipsis" : + null } > {header.key === "statusDescription" ? ( @@ -346,9 +373,9 @@ const SearchScreenDataTable: React.FC = ({ ) : header.header === "Category" ? ( - row["categoryCode"] + - " - " + - row["categoryDescription"] + ) : header.key === 'disturbanceStartDate' ? ( ) : ( diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/testData.ts b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/testData.ts index dc6fd942..cd59d624 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/testData.ts +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/testData.ts @@ -19,7 +19,8 @@ export const columns: ITableHeader[] = [ { key: 'categoryDescription', header: 'Category', - selected: true + selected: true, + elipsis: true }, { key: 'orgUnitName', diff --git a/frontend/src/components/TruncatedText/index.tsx b/frontend/src/components/TruncatedText/index.tsx new file mode 100644 index 00000000..9d69c273 --- /dev/null +++ b/frontend/src/components/TruncatedText/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Tooltip } from '@carbon/react'; + +const TruncatedText: React.FC<{text: string, maxLength?: number, parentWidth?: number}> = ({ text, maxLength, parentWidth }) => { + + // If parentWidth is provided, calculate the number of characters that can fit in the parent container + // Otherwise, use maxLength if provided, otherwise default to 50 + // The number of characters that can fit in the parent container is calculated based on the parentWidth + // If the parentWidth is less than 200px, we divide the parentWidth by 10, otherwise we divide by 5 + const charCount = parentWidth ? Math.floor(parentWidth / 6.68) : maxLength ? maxLength : 50; + const truncated = text.length > charCount ? text.slice(0, charCount) + "..." : text; + return + {truncated} + ; +} + +export default TruncatedText; \ No newline at end of file diff --git a/frontend/src/types/TableHeader.ts b/frontend/src/types/TableHeader.ts index 1fa28afa..72e661e1 100644 --- a/frontend/src/types/TableHeader.ts +++ b/frontend/src/types/TableHeader.ts @@ -2,4 +2,5 @@ export interface ITableHeader { key: string; header: string; selected:boolean; + elipsis?:boolean; }