From d06e3d3f5499e0df972b5eb6426a75f772ba5f78 Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Wed, 26 Nov 2025 11:34:18 -0700 Subject: [PATCH 1/4] feat: data table 2 --- .../services/script-runner.service.spec.ts | 3 + shared-helpers/src/auth/catchNetworkError.ts | 2 +- .../components/shared/DataTable.test.tsx | 564 +++++++++++++++++ .../__tests__/pages/listings/index.test.tsx | 436 ++++++++------ .../e2e/default/04-application.spec.ts | 2 +- .../e2e/default/05-paperApplication.spec.ts | 2 +- sites/partners/cypress/support/commands.js | 6 +- sites/partners/package.json | 2 + .../locale_overrides/general.json | 12 + .../components/shared/DataTable.module.scss | 188 ++++++ .../src/components/shared/DataTable.tsx | 485 +++++++++++++++ sites/partners/src/lib/hooks.ts | 79 ++- sites/partners/src/pages/_app.tsx | 29 +- sites/partners/src/pages/index.tsx | 569 +++++++++--------- yarn.lock | 24 + 15 files changed, 1880 insertions(+), 523 deletions(-) create mode 100644 sites/partners/__tests__/components/shared/DataTable.test.tsx create mode 100644 sites/partners/src/components/shared/DataTable.module.scss create mode 100644 sites/partners/src/components/shared/DataTable.tsx diff --git a/api/test/unit/services/script-runner.service.spec.ts b/api/test/unit/services/script-runner.service.spec.ts index 9dabae85d5..41ab2b041c 100644 --- a/api/test/unit/services/script-runner.service.spec.ts +++ b/api/test/unit/services/script-runner.service.spec.ts @@ -7,6 +7,9 @@ import { ReviewOrderTypeEnum, } from '@prisma/client'; import { randomUUID } from 'crypto'; +// Removing the below is causing frontend testing typing issues +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { mockDeep } from 'jest-mock-extended'; import { Request as ExpressRequest } from 'express'; import { User } from '../../../src/dtos/users/user.dto'; import { AmiChartService } from '../../../src/services/ami-chart.service'; diff --git a/shared-helpers/src/auth/catchNetworkError.ts b/shared-helpers/src/auth/catchNetworkError.ts index 5f22cd98e8..43284a0073 100644 --- a/shared-helpers/src/auth/catchNetworkError.ts +++ b/shared-helpers/src/auth/catchNetworkError.ts @@ -12,7 +12,7 @@ export type NetworkStatusType = AlertTypes export type NetworkStatusError = AxiosError -type CatchNetworkError = { +export type CatchNetworkError = { failureCountRemaining?: number message?: string } diff --git a/sites/partners/__tests__/components/shared/DataTable.test.tsx b/sites/partners/__tests__/components/shared/DataTable.test.tsx new file mode 100644 index 0000000000..6a5128780c --- /dev/null +++ b/sites/partners/__tests__/components/shared/DataTable.test.tsx @@ -0,0 +1,564 @@ +import React from "react" +import { render, cleanup, screen, within, fireEvent } from "@testing-library/react" +import { DataTable, TableDataRow } from "../../../src/components/shared/DataTable" +import { createColumnHelper } from "@tanstack/react-table" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +afterEach(cleanup) + +const queryClient = new QueryClient() + +describe("DataTable", () => { + window.HTMLElement.prototype.scrollIntoView = jest.fn() + + const columnHelper = createColumnHelper() + + const defaultColumns = [ + columnHelper.accessor("firstName", { + id: "firstName", + cell: (props) => props.getValue(), + header: () => "First name", + footer: (props) => props.column.id, + enableColumnFilter: false, + }), + columnHelper.accessor("lastName", { + id: "lastName", + cell: (props) => props.getValue(), + header: () => "Last name", + footer: (props) => props.column.id, + enableColumnFilter: false, + }), + ] + + beforeEach(() => { + jest.clearAllMocks() + queryClient.clear() + }) + + it("should render one row without error", async () => { + const mockFetch = jest.fn() + + mockFetch.mockReturnValue({ + items: [{ firstName: "TestFirst", lastName: "TestLast" }], + totalItems: 1, + errorMessage: null, + }) + + render( + + + + ) + expect(await screen.findByRole("columnheader", { name: "First name" })).toBeInTheDocument() + expect(screen.getByRole("columnheader", { name: "Last name" })).toBeInTheDocument() + // Number of rows outside the header + expect(within(screen.getAllByRole("rowgroup")[1]).getAllByRole("row")).toHaveLength(1) + expect(screen.getByRole("cell", { name: "TestFirst" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "TestLast" })).toBeInTheDocument() + expect(screen.getByTestId("sort-button-firstName")).toBeInTheDocument() + expect(screen.getByTestId("sort-button-lastName")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled() + expect(screen.getByRole("combobox", { name: "Show" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "8" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "25" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "50" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "100" })).toBeInTheDocument() + expect(screen.getByText("1 Total listing")).toBeInTheDocument() + expect(screen.queryByRole("textbox")).not.toBeInTheDocument() + }) + + it("should navigate forward and backward with pagination", async () => { + const mockFetch = jest.fn() + + mockFetch.mockReturnValueOnce({ + items: [ + { firstName: "TestFirst1", lastName: "TestLast1" }, + { firstName: "TestFirst2", lastName: "TestLast2" }, + ], + totalItems: 5, + errorMessage: null, + }) + + render( + + + + ) + expect(await screen.findByRole("columnheader", { name: "First name" })).toBeInTheDocument() + expect(screen.getByRole("columnheader", { name: "Last name" })).toBeInTheDocument() + expect(screen.getByText("5 Total listings")).toBeInTheDocument() + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 2 }, [], []) + + // Number of rows outside the header + expect(within(screen.getAllByRole("rowgroup")[1]).getAllByRole("row")).toHaveLength(2) + expect(screen.getByRole("cell", { name: "TestFirst1" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "TestLast1" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "TestFirst2" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "TestLast2" })).toBeInTheDocument() + expect(screen.getByTestId("sort-button-firstName")).toBeInTheDocument() + expect(screen.getByTestId("sort-button-lastName")).toBeInTheDocument() + + const nextButton = screen.getByRole("button", { name: "Next" }) + const previousButton = screen.getByRole("button", { name: "Previous" }) + + expect(nextButton).toBeEnabled() + expect(previousButton).toBeDisabled() + mockFetch.mockReturnValueOnce({ + items: [ + { firstName: "TestFirst3", lastName: "TestLast3" }, + { firstName: "TestFirst4", lastName: "TestLast4" }, + ], + totalItems: 5, + errorMessage: null, + }) + fireEvent.click(nextButton) + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 1, pageSize: 2 }, [], []) + + expect(await screen.findByRole("cell", { name: "TestFirst3" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "TestLast3" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "TestFirst4" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "TestLast4" })).toBeInTheDocument() + expect(within(screen.getAllByRole("rowgroup")[1]).getAllByRole("row")).toHaveLength(2) + expect(nextButton).toBeEnabled() + expect(previousButton).toBeEnabled() + mockFetch.mockReturnValueOnce({ + items: [{ firstName: "TestFirst5", lastName: "TestLast5" }], + totalItems: 5, + errorMessage: null, + }) + fireEvent.click(nextButton) + expect(mockFetch).toHaveBeenCalledTimes(3) + expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 2, pageSize: 2 }, [], []) + + expect(await screen.findByRole("cell", { name: "TestFirst5" })).toBeInTheDocument() + expect(within(screen.getAllByRole("rowgroup")[1]).getAllByRole("row")).toHaveLength(1) + expect(screen.getByRole("cell", { name: "TestLast5" })).toBeInTheDocument() + expect(nextButton).toBeDisabled() + expect(previousButton).toBeEnabled() + mockFetch.mockReturnValueOnce({ + items: [ + { firstName: "TestFirst3", lastName: "TestLast3" }, + { firstName: "TestFirst4", lastName: "TestLast4" }, + ], + totalItems: 5, + errorMessage: null, + }) + fireEvent.click(previousButton) + expect(mockFetch).toHaveBeenCalledTimes(4) + expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 1, pageSize: 2 }, [], []) + expect(await screen.findByRole("cell", { name: "TestFirst3" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "TestLast3" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "TestFirst4" })).toBeInTheDocument() + expect(screen.getByRole("cell", { name: "TestLast4" })).toBeInTheDocument() + expect(within(screen.getAllByRole("rowgroup")[1]).getAllByRole("row")).toHaveLength(2) + }) + it("should filter on columns", async () => { + const mockFetch = jest.fn() + + const defaultItems = [ + { firstName: "TestFirstA", lastName: "TestLastA" }, + { firstName: "TestFirstB", lastName: "TestLastB" }, + { firstName: "TestFirstC", lastName: "TestLastC" }, + { firstName: "TestFirstD", lastName: "TestLastD" }, + { firstName: "TestFirstE", lastName: "TestLastE" }, + { firstName: "TestFirstF", lastName: "TestLastF" }, + { firstName: "TestFirstG", lastName: "TestLastG" }, + { firstName: "TestFirstH", lastName: "TestLastH" }, + ] + mockFetch.mockReturnValue({ + items: defaultItems, + totalItems: 8, + errorMessage: null, + }) + + const filterColumns = [ + columnHelper.accessor("firstName", { + id: "firstName", + cell: (props) => props.getValue(), + header: () => "First name", + footer: (props) => props.column.id, + enableSorting: false, + meta: { plaintextName: "First name" }, + }), + columnHelper.accessor("lastName", { + id: "lastName", + cell: (props) => props.getValue(), + header: () => "Last name", + footer: (props) => props.column.id, + enableSorting: false, + enableColumnFilter: false, + meta: { plaintextName: "Last name" }, + }), + ] + + render( + + + + ) + + expect(await screen.findByRole("columnheader", { name: /First name/i })).toBeInTheDocument() + expect(screen.getByRole("columnheader", { name: /Last name/i })).toBeInTheDocument() + expect(screen.getByText("8 Total listings")).toBeInTheDocument() + + expect(screen.queryByRole("button", { description: "Activate ascending sort" })).toBeNull() + const firstNameInput = screen.getByTestId("column-search-First name") + expect(firstNameInput).toHaveAccessibleDescription( + "Search items by First name - Enter at least 5 characters" + ) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 8 }, [], []) + + fireEvent.change(firstNameInput, { target: { value: "Te" } }) + expect(mockFetch).toHaveBeenCalledTimes(1) + + mockFetch.mockReturnValueOnce({ + items: [{ firstName: "TestFirstA", lastName: "TestLastA" }], + totalItems: 1, + errorMessage: null, + }) + fireEvent.change(firstNameInput, { target: { value: "TestFirstA" } }) + expect(await screen.findByText("1 Total listing")).toBeInTheDocument() + expect(await screen.findByRole("cell", { name: "TestFirstA" })).toBeInTheDocument() + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenCalledWith( + { pageIndex: 0, pageSize: 8 }, + [{ id: "firstName", value: "TestFirstA" }], + [] + ) + + mockFetch.mockReturnValueOnce({ + items: defaultItems, + totalItems: 8, + errorMessage: null, + }) + fireEvent.change(firstNameInput, { target: { value: "Te" } }) + expect(await screen.findByText("8 Total listings")).toBeInTheDocument() + expect(await screen.findByRole("cell", { name: "TestFirstB" })).toBeInTheDocument() + expect(mockFetch).toHaveBeenCalledTimes(3) + expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 8 }, [], []) + }) + it("should sort on columns", async () => { + const mockFetch = jest.fn() + + const defaultItems = [ + { firstName: "TestFirstA", middleName: "TestMiddleA", lastName: "TestLastA" }, + { firstName: "TestFirstB", middleName: "TestMiddleB", lastName: "TestLastB" }, + { firstName: "TestFirstC", middleName: "TestMiddleC", lastName: "TestLastC" }, + { firstName: "TestFirstD", middleName: "TestMiddleD", lastName: "TestLastD" }, + { firstName: "TestFirstE", middleName: "TestMiddleE", lastName: "TestLastE" }, + { firstName: "TestFirstF", middleName: "TestMiddleF", lastName: "TestLastF" }, + { firstName: "TestFirstG", middleName: "TestMiddleG", lastName: "TestLastG" }, + { firstName: "TestFirstH", middleName: "TestMiddleH", lastName: "TestLastH" }, + ] + + mockFetch.mockReturnValue({ + items: defaultItems, + totalItems: 8, + errorMessage: null, + }) + + const sortColumns = [ + columnHelper.accessor("firstName", { + id: "firstName", + cell: (props) => props.getValue(), + header: () => "First name", + footer: (props) => props.column.id, + enableSorting: true, + enableColumnFilter: false, + meta: { plaintextName: "First name" }, + }), + columnHelper.accessor("middleName", { + id: "middleName", + cell: (props) => props.getValue(), + header: () => "Middle name", + footer: (props) => props.column.id, + enableSorting: true, + enableColumnFilter: false, + meta: { plaintextName: "Middle name" }, + }), + columnHelper.accessor("lastName", { + id: "lastName", + cell: (props) => props.getValue(), + header: () => "Last name", + footer: (props) => props.column.id, + enableSorting: true, + enableColumnFilter: false, + meta: { plaintextName: "Last name" }, + }), + ] + + render( + + + + ) + + expect(await screen.findByRole("columnheader", { name: "First name" })).toBeInTheDocument() + expect(screen.getByRole("columnheader", { name: "Last name" })).toBeInTheDocument() + expect(screen.getByText("8 Total listings")).toBeInTheDocument() + + expect(screen.getByTestId("sort-button-firstName")).toHaveAccessibleDescription( + "Activate ascending sort for column First name" + ) + expect(screen.getByTestId("sort-button-middleName")).toHaveAccessibleDescription( + "Activate ascending sort for column Middle name" + ) + expect(screen.getByTestId("sort-button-lastName")).toHaveAccessibleDescription( + "Activate descending sort for column Last name" + ) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith( + { pageIndex: 0, pageSize: 8 }, + [], + [{ desc: false, id: "lastName" }] + ) + + fireEvent.click(screen.getByTestId("sort-button-firstName")) + + expect(await screen.findByTestId("sort-button-firstName")).toHaveAccessibleDescription( + "Activate descending sort for column First name" + ) + expect(screen.getByTestId("sort-button-middleName")).toHaveAccessibleDescription( + "Activate ascending sort for column Middle name" + ) + expect(screen.getByTestId("sort-button-lastName")).toHaveAccessibleDescription( + "Activate ascending sort for column Last name" + ) + + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenCalledWith( + { pageIndex: 0, pageSize: 8 }, + [], + [{ desc: false, id: "firstName" }] + ) + + fireEvent.click(screen.getByTestId("sort-button-firstName")) + + expect(await screen.findByTestId("sort-button-firstName")).toHaveAccessibleDescription( + "Clear sort for column First name" + ) + expect(screen.getByTestId("sort-button-middleName")).toHaveAccessibleDescription( + "Activate ascending sort for column Middle name" + ) + expect(screen.getByTestId("sort-button-lastName")).toHaveAccessibleDescription( + "Activate ascending sort for column Last name" + ) + + expect(mockFetch).toHaveBeenCalledTimes(3) + expect(mockFetch).toHaveBeenCalledWith( + { pageIndex: 0, pageSize: 8 }, + [], + [{ desc: true, id: "firstName" }] + ) + + fireEvent.click(screen.getByTestId("sort-button-firstName")) + + expect(await screen.findByTestId("sort-button-firstName")).toHaveAccessibleDescription( + "Activate ascending sort for column First name" + ) + expect(screen.getByTestId("sort-button-middleName")).toHaveAccessibleDescription( + "Activate ascending sort for column Middle name" + ) + expect(screen.getByTestId("sort-button-lastName")).toHaveAccessibleDescription( + "Activate ascending sort for column Last name" + ) + + expect(mockFetch).toHaveBeenCalledTimes(4) + expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 8 }, [], []) + }) + it("should hide columns", async () => { + const mockFetch = jest.fn() + + const defaultItems = [ + { firstName: "TestFirstA", middleName: "TestMiddleA", lastName: "TestLastA" }, + { firstName: "TestFirstB", middleName: "TestMiddleB", lastName: "TestLastB" }, + { firstName: "TestFirstC", middleName: "TestMiddleC", lastName: "TestLastC" }, + { firstName: "TestFirstD", middleName: "TestMiddleD", lastName: "TestLastD" }, + { firstName: "TestFirstE", middleName: "TestMiddleE", lastName: "TestLastE" }, + { firstName: "TestFirstF", middleName: "TestMiddleF", lastName: "TestLastF" }, + { firstName: "TestFirstG", middleName: "TestMiddleG", lastName: "TestLastG" }, + { firstName: "TestFirstH", middleName: "TestMiddleH", lastName: "TestLastH" }, + ] + + mockFetch.mockReturnValue({ + items: defaultItems, + totalItems: 8, + errorMessage: null, + }) + + const hiddenColumns = [ + columnHelper.accessor("firstName", { + id: "firstName", + cell: (props) => props.getValue(), + header: () => "First name", + footer: (props) => props.column.id, + enableSorting: false, + enableColumnFilter: false, + }), + columnHelper.accessor("middleName", { + id: "middleName", + cell: (props) => props.getValue(), + header: () => "Middle name", + footer: (props) => props.column.id, + enableSorting: false, + enableColumnFilter: false, + meta: { enabled: false }, + }), + columnHelper.accessor("lastName", { + id: "lastName", + cell: (props) => props.getValue(), + header: () => "Last name", + footer: (props) => props.column.id, + enableSorting: false, + enableColumnFilter: false, + }), + ] + + render( + + + + ) + + expect(await screen.findByRole("columnheader", { name: "First name" })).toBeInTheDocument() + expect(screen.getByRole("columnheader", { name: "Last name" })).toBeInTheDocument() + expect(screen.queryByRole("columnheader", { name: "Middle name" })).not.toBeInTheDocument() + expect(screen.getByText("8 Total listings")).toBeInTheDocument() + }) + it("should render empty state", async () => { + const mockFetch = jest.fn() + + mockFetch.mockReturnValue({ + items: [], + totalItems: 0, + errorMessage: null, + }) + + render( + + + + ) + expect(await screen.findByRole("columnheader", { name: "First name" })).toBeInTheDocument() + expect(screen.getByRole("columnheader", { name: "Last name" })).toBeInTheDocument() + expect(screen.getByText("No data available")).toBeInTheDocument() + expect(screen.queryByText(/Show/i)).not.toBeInTheDocument() + }) + it("should render error state", async () => { + const mockFetch = jest.fn() + + mockFetch.mockReturnValue({ + items: [], + totalItems: 0, + errorMessage: "Error fetching data", + }) + + render( + + + + ) + expect(await screen.findByRole("columnheader", { name: "First name" })).toBeInTheDocument() + expect(screen.getByRole("columnheader", { name: "Last name" })).toBeInTheDocument() + expect(screen.getByText("Error fetching data")).toBeInTheDocument() + expect(screen.queryByText(/Show/i)).not.toBeInTheDocument() + }) + it("should change items per page", async () => { + const mockFetch = jest.fn() + + const fullItems = [ + { firstName: "TestFirstA", lastName: "TestLastA" }, + { firstName: "TestFirstB", lastName: "TestLastB" }, + { firstName: "TestFirstC", lastName: "TestLastC" }, + { firstName: "TestFirstD", lastName: "TestLastD" }, + { firstName: "TestFirstE", lastName: "TestLastE" }, + { firstName: "TestFirstF", lastName: "TestLastF" }, + { firstName: "TestFirstG", lastName: "TestLastG" }, + { firstName: "TestFirstH", lastName: "TestLastH" }, + { firstName: "TestFirstI", lastName: "TestLastI" }, + { firstName: "TestFirstJ", lastName: "TestLastJ" }, + { firstName: "TestFirstK", lastName: "TestLastK" }, + { firstName: "TestFirstL", lastName: "TestLastL" }, + { firstName: "TestFirstM", lastName: "TestLastM" }, + { firstName: "TestFirstN", lastName: "TestLastN" }, + { firstName: "TestFirstO", lastName: "TestLastO" }, + { firstName: "TestFirstP", lastName: "TestLastP" }, + { firstName: "TestFirstQ", lastName: "TestLastQ" }, + { firstName: "TestFirstR", lastName: "TestLastR" }, + { firstName: "TestFirstS", lastName: "TestLastS" }, + { firstName: "TestFirstT", lastName: "TestLastT" }, + { firstName: "TestFirstU", lastName: "TestLastU" }, + { firstName: "TestFirstV", lastName: "TestLastV" }, + { firstName: "TestFirstW", lastName: "TestLastW" }, + { firstName: "TestFirstX", lastName: "TestLastX" }, + { firstName: "TestFirstY", lastName: "TestLastY" }, + { firstName: "TestFirstZ", lastName: "TestLastZ" }, + { firstName: "TestFirstBA", lastName: "TestLastBA" }, + ] + + mockFetch.mockReturnValue({ + items: fullItems.slice(0, 8), + totalItems: 25, + errorMessage: null, + }) + + render( + + + + ) + + expect(await screen.findByRole("columnheader", { name: /First name/i })).toBeInTheDocument() + expect(screen.getByRole("columnheader", { name: /Last name/i })).toBeInTheDocument() + expect(screen.getByText("25 Total listings")).toBeInTheDocument() + + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 8 }, [], []) + const showInput = screen.getByRole("combobox", { name: "Show" }) + mockFetch.mockReturnValueOnce({ + items: fullItems, + totalItems: 25, + errorMessage: null, + }) + fireEvent.change(showInput, { target: { value: "25" } }) + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 25 }, [], []) + }) +}) diff --git a/sites/partners/__tests__/pages/listings/index.test.tsx b/sites/partners/__tests__/pages/listings/index.test.tsx index 051c4fdfe2..8c0aa32aa3 100644 --- a/sites/partners/__tests__/pages/listings/index.test.tsx +++ b/sites/partners/__tests__/pages/listings/index.test.tsx @@ -13,6 +13,8 @@ import { FeatureFlagEnum, Jurisdiction, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import "@testing-library/jest-dom" //Mock the jszip package used for Export const mockFile = jest.fn() @@ -98,7 +100,7 @@ describe("listings", () => { rest.get("http://localhost:3100/listings", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), - rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + rest.post("http://localhost/api/adapter/listings/list", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { @@ -111,7 +113,13 @@ describe("listings", () => { }) ) - const { findByText, queryByText } = render() + const queryClient = new QueryClient() + + const { findByText, queryByText } = render( + + + + ) const header = await findByText("Partners Portal") expect(header).toBeInTheDocument() const exportButton = queryByText("Export to CSV") @@ -125,7 +133,7 @@ describe("listings", () => { rest.get("http://localhost:3100/listings", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), - rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + rest.post("http://localhost/api/adapter/listings/list", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { @@ -141,42 +149,46 @@ describe("listings", () => { }) ) + const queryClient = new QueryClient() + render( - - mockJurisdictionsHaveFeatureFlagOn(featureFlag), - }} - > - - + + + mockJurisdictionsHaveFeatureFlagOn(featureFlag), + }} + > + + + ) expect(screen.queryByText("Verified")).toBeNull() }) - it("should show is waitlist and available units columns if unit groups feature flag is off", () => { + it("should show is waitlist and available units columns if unit groups feature flag is off", async () => { window.URL.createObjectURL = jest.fn() document.cookie = "access-token-available=True" server.use( rest.get("http://localhost:3100/listings", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), - rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + rest.post("http://localhost/api/adapter/listings/list", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { @@ -192,32 +204,36 @@ describe("listings", () => { }) ) + const queryClient = new QueryClient() + render( - - mockJurisdictionsHaveFeatureFlagOn(featureFlag, false, true, false), - }} - > - - + + + mockJurisdictionsHaveFeatureFlagOn(featureFlag, false, true, false), + }} + > + + + ) - expect(screen.getByText("Available units")).toBeDefined() + expect(await screen.findByText("Available units")).toBeDefined() expect(screen.getByText("Open waitlist")).toBeDefined() }) @@ -228,7 +244,7 @@ describe("listings", () => { rest.get("http://localhost:3100/listings", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), - rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + rest.post("http://localhost/api/adapter/listings/list", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { @@ -244,43 +260,47 @@ describe("listings", () => { }) ) + const queryClient = new QueryClient() + render( - - mockJurisdictionsHaveFeatureFlagOn(featureFlag, true, false, true), - }} - > - - + + + mockJurisdictionsHaveFeatureFlagOn(featureFlag, true, false, true), + }} + > + + + ) expect(screen.queryByText("Available units")).toBeNull() expect(screen.queryByText("Open waitlist")).toBeNull() }) - it("should show is verified column if feature flag is on", () => { + it("should show is verified column if feature flag is on", async () => { window.URL.createObjectURL = jest.fn() document.cookie = "access-token-available=True" server.use( rest.get("http://localhost:3100/listings", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), - rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + rest.post("http://localhost/api/adapter/listings/list", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { @@ -296,32 +316,36 @@ describe("listings", () => { }) ) + const queryClient = new QueryClient() + render( - - mockJurisdictionsHaveFeatureFlagOn(featureFlag, true), - }} - > - - + + + mockJurisdictionsHaveFeatureFlagOn(featureFlag, true), + }} + > + + + ) - expect(screen.getByText("Verified")).toBeDefined() + expect(await screen.findByText("Verified")).toBeDefined() }) it("should not show last updated column if feature flag is off", () => { @@ -331,7 +355,7 @@ describe("listings", () => { rest.get("http://localhost:3100/listings", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), - rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + rest.post("http://localhost/api/adapter/listings/list", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { @@ -346,43 +370,46 @@ describe("listings", () => { return res(ctx.json("")) }) ) + const queryClient = new QueryClient() render( - - mockJurisdictionsHaveFeatureFlagOn(featureFlag, false, false), - }} - > - - + + + mockJurisdictionsHaveFeatureFlagOn(featureFlag, false, false), + }} + > + + + ) expect(screen.queryByText("Last updated")).toBeNull() }) - it("should show is last updated column if feature flag is on", () => { + it("should show is last updated column if feature flag is on", async () => { window.URL.createObjectURL = jest.fn() document.cookie = "access-token-available=True" server.use( rest.get("http://localhost:3100/listings", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), - rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + rest.post("http://localhost/api/adapter/listings/list", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { @@ -397,33 +424,36 @@ describe("listings", () => { return res(ctx.json("")) }) ) + const queryClient = new QueryClient() render( - - mockJurisdictionsHaveFeatureFlagOn(featureFlag), - }} - > - - + + + mockJurisdictionsHaveFeatureFlagOn(featureFlag), + }} + > + + + ) - expect(screen.getByText("Last updated")).toBeDefined() + expect(await screen.findByText("Last updated")).toBeDefined() }) // Skipping for now until the CSV endpoints are created it.skip("should render the error text when listings csv api call fails", async () => { @@ -433,7 +463,7 @@ describe("listings", () => { rest.get("http://localhost:3100/listings", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), - rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + rest.post("http://localhost/api/adapter/listings/list", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), rest.get("http://localhost/api/adapter/listings/csv", (_req, res, ctx) => { @@ -513,7 +543,7 @@ describe("listings", () => { rest.get("http://localhost:3100/listings", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), - rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + rest.post("http://localhost/api/adapter/listings/list", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { @@ -528,33 +558,36 @@ describe("listings", () => { return res(ctx.json("")) }) ) + const queryClient = new QueryClient() render( - - mockJurisdictionsHaveFeatureFlagOn(featureFlag, false, false), - }} - > - - + + + mockJurisdictionsHaveFeatureFlagOn(featureFlag, false, false), + }} + > + + + ) const addListingButton = await screen.findByRole("button", { name: "Add listing" }) @@ -590,7 +623,7 @@ describe("listings", () => { rest.get("http://localhost:3100/listings", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), - rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + rest.post("http://localhost/api/adapter/listings/list", (_req, res, ctx) => { return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) }), rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { @@ -605,28 +638,31 @@ describe("listings", () => { return res(ctx.json("")) }) ) + const queryClient = new QueryClient() render( - - mockJurisdictionsHaveFeatureFlagOn(featureFlag, false, false), - }} - > - - + + + mockJurisdictionsHaveFeatureFlagOn(featureFlag, false, false), + }} + > + + + ) const addListingButton = await screen.findByRole("button", { name: "Add listing" }) diff --git a/sites/partners/cypress/e2e/default/04-application.spec.ts b/sites/partners/cypress/e2e/default/04-application.spec.ts index 330dcb3920..d147440ef0 100644 --- a/sites/partners/cypress/e2e/default/04-application.spec.ts +++ b/sites/partners/cypress/e2e/default/04-application.spec.ts @@ -2,7 +2,7 @@ describe("Application Management Tests", () => { beforeEach(() => { cy.loginApi() cy.visit("/") - cy.getByTestId("listing-status-cell-Hollywood Hills Heights").click() + cy.getByID("listing-status-cell-Hollywood Hills Heights").click() }) afterEach(() => { diff --git a/sites/partners/cypress/e2e/default/05-paperApplication.spec.ts b/sites/partners/cypress/e2e/default/05-paperApplication.spec.ts index 24c67f2e01..0b1c166014 100644 --- a/sites/partners/cypress/e2e/default/05-paperApplication.spec.ts +++ b/sites/partners/cypress/e2e/default/05-paperApplication.spec.ts @@ -2,7 +2,7 @@ describe("Paper Application Tests", () => { beforeEach(() => { cy.loginApi() cy.visit("/") - cy.getByTestId("listing-status-cell-Blue Sky Apartments").click() + cy.getByID("listing-status-cell-Blue Sky Apartments").click() cy.getByID("addApplicationButton").contains("Add application").click() }) diff --git a/sites/partners/cypress/support/commands.js b/sites/partners/cypress/support/commands.js index ebfa406856..38190203cf 100644 --- a/sites/partners/cypress/support/commands.js +++ b/sites/partners/cypress/support/commands.js @@ -483,7 +483,7 @@ Cypress.Commands.add("addMinimalListing", (listingName, isLottery, isApproval, j Cypress.Commands.add("addMinimalApplication", (listingName) => { cy.visit("/") - cy.getByTestId(`listing-status-cell-${listingName}`).click() + cy.getByID(`listing-status-cell-${listingName}`).click() cy.getByID("addApplicationButton").contains("Add application").click() cy.fixture("applicantOnlyData").then((application) => { cy.fillPrimaryApplicant(application, [ @@ -498,6 +498,6 @@ Cypress.Commands.add("addMinimalApplication", (listingName) => { Cypress.Commands.add("findAndOpenListing", (listingName) => { cy.visit("/") cy.contains("Listings") - cy.getByTestId("ag-search-input").should("be.visible").type(listingName, { force: true }) - cy.getByTestId(listingName).first().click() + cy.getByTestId("column-search-Name").should("be.visible").type(listingName, { force: true }) + cy.getByID(listingName).first().click() }) diff --git a/sites/partners/package.json b/sites/partners/package.json index 76e1bf0a8e..4d0382718c 100644 --- a/sites/partners/package.json +++ b/sites/partners/package.json @@ -41,6 +41,8 @@ "@tiptap/react": "^2.24.0", "@tiptap/starter-kit": "^2.24.0", "ag-grid-community": "^26.0.0", + "@tanstack/react-query": "^5.90.11", + "@tanstack/react-table": "^8.8.5", "autoprefixer": "^10.3.4", "axios": "^1.8.3", "axios-cookiejar-support": "^5.0.5", diff --git a/sites/partners/page_content/locale_overrides/general.json b/sites/partners/page_content/locale_overrides/general.json index a70722dad8..54c48e46c3 100644 --- a/sites/partners/page_content/locale_overrides/general.json +++ b/sites/partners/page_content/locale_overrides/general.json @@ -438,6 +438,8 @@ "listings.selectPreferences": "Select preferences", "listings.selectPrograms": "Select programs", "listings.streetAddressOrPOBox": "Street address or PO box", + "listings.table.searchBy": "Search items by %{column}", + "listings.totalListing": "Total listing", "listings.totalListings": "Total listings", "listings.marketing": "Marketing", "listings.marketingFlyer.accessibleTitle": "Accessible marketing flyer", @@ -662,6 +664,7 @@ "t.saveExit": "Save & exit", "t.saveNew": "Save & new", "t.saved": "Saved", + "t.search": "Search", "t.secondPhone": "Second phone", "t.settings": "Settings", "t.startTime": "Start time", @@ -672,6 +675,15 @@ "t.verified": "Verified", "t.view": "View", "t.recommended": "Recommended", + "table.activateAscending": "Activate ascending sort for column %{colName}", + "table.activateDescending": "Activate descending sort for column %{colName}", + "table.clearSort": "Clear sort for column %{colName}", + "table.noData": "No data available", + "table.pageNum": "Page %{curr} of %{total}", + "table.searchSubtext": "Enter at least %{char} characters", + "table.sortAscending": "Sort ascending", + "table.sortDescending": "Sort descending", + "table.sortNotApplied": "Sort not applied", "users.addPassword": "Add a password", "users.addUser": "Add user", "users.administrator": "Administrator", diff --git a/sites/partners/src/components/shared/DataTable.module.scss b/sites/partners/src/components/shared/DataTable.module.scss new file mode 100644 index 0000000000..57003994d2 --- /dev/null +++ b/sites/partners/src/components/shared/DataTable.module.scss @@ -0,0 +1,188 @@ +.data-table-wrapper { + --data-table-border-color: var(--seeds-color-gray-500); + + .data-table-container { + overflow-x: auto; + border-top-left-radius: var(--seeds-rounded-lg); + border-top-right-radius: var(--seeds-rounded-lg); + border: var(--seeds-border-1) solid var(--data-table-border-color); + border-bottom: 0; + + .enable-scroll { + table-layout: fixed; + } + + .data-table { + border-spacing: 0; + border-collapse: separate; + border-bottom: 0; + width: 100%; + thead { + tr { + th { + text-transform: none; + letter-spacing: normal; + background-color: var(--seeds-bg-color-canvas); + color: var(--seeds-text-color-dark); + vertical-align: top; + position: relative; + } + } + .min-characters-info { + font-size: var(--seeds-type-caption-size); + font-weight: normal; + margin-top: var(--seeds-s1); + } + .sort-icon { + margin-inline-start: var(--seeds-s2); + } + + .sort-icon:after { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 3.15rem; + } + .search-input { + border: var(--seeds-border-1) solid var(--data-table-border-color); + padding: var(--seeds-s2); + margin-block-start: var(--seeds-s2); + width: 100%; + } + } + tbody { + td { + padding: var(--seeds-s4); + border-bottom: var(--seeds-border-1) solid var(--data-table-border-color); + } + tr:last-child > td { + border-bottom: 0; + } + + .full-width-text-cell { + text-align: center; + padding: var(--seeds-s8); + } + + .error-cell { + text-align: center; + color: var(--seeds-color-alert); + } + tr:nth-of-type(even) { + background-color: var(--seeds-color-white); + } + } + .loading-row { + height: 100%; + .loading-spinner { + height: 100%; + > div { + height: 100%; + background-color: var(--seeds-bg-color-canvas); + border-radius: 0; + } + .loading-content { + height: 200px; + width: 100%; + } + } + } + } + + .table-loading { + tbody { + td { + padding: 0; + } + } + } + } + + .pagination-loading-overlay { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: var(--seeds-bg-color-canvas); + border-bottom-left-radius: var(--seeds-rounded-lg); + border-bottom-right-radius: var(--seeds-rounded-lg); + } + + .pagination { + position: relative; + display: flex; + flex-wrap: wrap; + gap: var(--seeds-s8); + @media (max-width: theme("screens.md")) { + gap: var(--seeds-s4); + } + padding: var(--seeds-s4); + width: 100%; + justify-content: space-between; + align-items: center; + border: var(--seeds-border-1) solid var(--data-table-border-color); + border-bottom-left-radius: var(--seeds-rounded-lg); + border-bottom-right-radius: var(--seeds-rounded-lg); + background-color: var(--seeds-bg-color-canvas); + color: var(--seeds-text-color-dark); + + .previous-page-button { + order: 0; + } + + .total-items { + font-weight: var(--seeds-font-weight-semibold); + + order: 1; + margin-left: auto; + @media (max-width: theme("screens.md")) { + order: 2; + } + } + + .current-page { + font-weight: var(--seeds-font-weight-semibold); + order: 2; + @media (max-width: theme("screens.md")) { + order: 3; + } + } + + .show-items-per-page { + height: 100%; + order: 3; + @media (max-width: theme("screens.md")) { + order: 4; + } + .show-label { + margin-inline-end: var(--seeds-s2); + } + .show-select { + font-size: var(--seeds-font-size-base); + border: var(--seeds-border-1) solid var(--data-table-border-color); + padding-block: var(--seeds-s2); + padding-inline-start: var(--seeds-s3); + padding-inline-end: var(--seeds-s8); + height: 100%; + // Override default arrow styles + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + background-image: url("/images/arrow-down.png"); + background-image: url("/images/arrow-down.svg"); + background-position: right 0.75rem center; + background-repeat: no-repeat; + background-size: 0.75rem; + } + } + .next-page-button { + order: 4; + @media (max-width: theme("screens.md")) { + order: 1; + } + } + } +} diff --git a/sites/partners/src/components/shared/DataTable.tsx b/sites/partners/src/components/shared/DataTable.tsx new file mode 100644 index 0000000000..d3a351a94c --- /dev/null +++ b/sites/partners/src/components/shared/DataTable.tsx @@ -0,0 +1,485 @@ +import React, { useEffect, useState } from "react" +import { + ColumnDef, + ColumnFiltersState, + Header, + PaginationState, + SortingState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" +import { keepPreviousData, useQuery } from "@tanstack/react-query" +import ArrowsUpDownIcon from "@heroicons/react/16/solid/ArrowsUpDownIcon" +import ArrowUpIcon from "@heroicons/react/16/solid/ArrowUpIcon" +import ArrowDownIcon from "@heroicons/react/16/solid/ArrowDownIcon" +import { Button, Icon, LoadingState } from "@bloom-housing/ui-seeds" +import { t } from "@bloom-housing/ui-components" +import styles from "./DataTable.module.scss" + +// We need to disable this rule to allow us to use aria-description to prevent filter and sort information in headers from being re-read for every cell +/* eslint-disable jsx-a11y/role-supports-aria-props */ + +export type TableDataRow = { [key: string]: string | React.ReactNode } + +export type MetaType = { + // Whether the column is shown or hidden + enabled?: boolean + // If using filtering or sorting on a column, this field will support generating accessible labels + plaintextName?: string +} + +export interface TableData { + // If present, displays an error message instead of table rows + errorMessage?: string + // The actual data rows to display in the table + items: TableDataRow[] + // Total number of items available (for pagination) + totalItems?: number + // Current page number (for pagination) + currentPage?: number + // Number of items per page (for pagination) + itemsPerPage?: number +} + +interface DataTableProps { + // A description of the table for screen readers + description: string + // The columns to display in the table + columns: ColumnDef[] + // The default number of items to show per page + defaultItemsPerPage?: number + // Whether to enable horizontal scrolling for wide tables, or autofit columns within the container + enableHorizontalScroll?: boolean + // Function to fetch data for the table based on pagination, search, and sort parameters + fetchData: ( + pagination?: PaginationState, + search?: ColumnFiltersState, + sort?: SortingState + ) => Promise + // Initial sort state for the table + initialSort?: SortingState + // Minimum number of characters required to trigger filtering + minSearchCharacters?: number +} + +// Returns appropriate aria-label for sortable headers based on current sort state +const getHeaderAriaLabel = (header: Header) => { + const columnName = + (header.column.columnDef.meta as MetaType)?.plaintextName || header.column.columnDef.id + if (!header.column.getIsSorted()) { + return t("table.activateAscending", { + colName: columnName, + }) + } else if (header.column.getIsSorted() === "asc") { + return t("table.activateDescending", { + colName: columnName, + }) + } else if (header.column.getIsSorted() === "desc") { + return t("table.clearSort", { + colName: columnName, + }) + } +} + +export const DataTable = (props: DataTableProps) => { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: props.defaultItemsPerPage || 8, + }) + + const [columnFilters, setColumnFilters] = useState([]) + const [sorting, setSorting] = useState(props.initialSort ?? []) + const [columnVisibility, setColumnVisibility] = React.useState({}) + const [delayedLoading, setDelayedLoading] = useState(false) + + const dataQuery = useQuery({ + queryKey: ["data", pagination, columnFilters, sorting], + queryFn: () => props.fetchData(pagination, columnFilters, sorting), + placeholderData: keepPreviousData, + }) + + // Slightly delay loading state to prevent flickering on fast fetches + useEffect(() => { + if (dataQuery.isLoading || (dataQuery.isFetching && !dataQuery.isFetched)) { + const timer = setTimeout(() => { + setDelayedLoading(true) + }, 350) + + return () => clearTimeout(timer) + } else { + setDelayedLoading(false) + } + }, [dataQuery.isLoading, dataQuery.isFetching, dataQuery.isFetched]) + + // On initial load, hide any columns that have been disabled via meta.enabled = false + useEffect(() => { + table.getHeaderGroups().map((headerGroup) => { + headerGroup.headers.map((header) => { + if ((header.column.columnDef.meta as MetaType)?.enabled === false) { + header.column.toggleVisibility(false) + } + }) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const defaultData = React.useMemo(() => [], []) + + const table = useReactTable({ + columns: props.columns, + data: dataQuery.data?.items ?? defaultData, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + manualFiltering: true, + manualPagination: true, + manualSorting: true, + onColumnFiltersChange: (props) => { + setColumnFilters(props) + }, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: (props) => { + setPagination(props) + document.getElementById("data-table")?.scrollIntoView({ behavior: "auto", block: "start" }) + }, + onSortingChange: (sorting) => { + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + setSorting(sorting) + }, + rowCount: dataQuery.data?.totalItems, + state: { + columnFilters, + columnVisibility, + pagination, + sorting, + }, + defaultColumn: { + size: props.enableHorizontalScroll ? 100 : 150, + minSize: 100, + maxSize: Number.MAX_SAFE_INTEGER, + }, + }) + + const showLoadingState = delayedLoading || dataQuery.data === undefined + + const tableHeaders = ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.column.getCanSort() ? ( + <> + {flexRender(header.column.columnDef.header, header.getContext())} + + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} + {header.column.getCanFilter() ? ( +
+ +
+ ) : null} + + ) + })} + + ))} + + ) + + const APPROX_ROW_HEIGHT = 55 + + const getTableContent = () => { + if (delayedLoading || dataQuery.data === undefined) { + return ( + + + + +
+ + + + + ) + } else if (dataQuery.data?.errorMessage) { + return ( + <> + {tableHeaders} + + + + {dataQuery.data.errorMessage} + + + + + ) + } else if (dataQuery.data?.items?.length === 0) { + return ( + <> + {tableHeaders} + + + + {t("table.noData")} + + + + + ) + } else { + return ( + <> + {tableHeaders} + + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + ) + })} + + + ) + } + } + + const Pagination = ( +
+ + {dataQuery.data?.items?.length > 0 && !dataQuery.data?.errorMessage && ( + <> + + {dataQuery.data?.totalItems}{" "} + {dataQuery.data?.totalItems === 1 + ? t("listings.totalListing") + : t("listings.totalListings")} + + + + Page {dataQuery.data?.currentPage} of{" "} + {dataQuery.data?.totalItems && + Math.ceil((dataQuery.data?.totalItems || 0) / dataQuery.data?.itemsPerPage)} + + + + + + + + )} + + + {showLoadingState &&
} +
+ ) + + return ( +
+
+ + {getTableContent()} +
+
+ {Pagination} +
+ ) +} + +interface DataTableFilterProps { + columnFilterValue: string + header: Header + minSearchCharacters: number + setPagination: React.Dispatch> +} + +const DataTableFilter = (props: DataTableFilterProps) => { + const [value, setValue] = React.useState(props.columnFilterValue) + const debounce = 500 + + useEffect(() => { + // Only set filter value if it has changed + if ( + value === props.header.column.getFilterValue() || + (value === "" && props.header.column.getFilterValue() === undefined) + ) { + return + } + + // Set filter value after debounce period + const timeout = setTimeout(() => { + props.setPagination((prev) => ({ ...prev, pageIndex: 0 })) + // Reset filter if input length becomes less than minSearchCharacters + // Does not make more than one call due to request caching in useQuery + if (value.length > 0 && value.length < props.minSearchCharacters) { + props.header.column.setFilterValue("") + } else { + props.header.column.setFilterValue(value) + } + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + const inputName = + (props.header.column.columnDef.meta as MetaType)?.plaintextName || + props.header.column.columnDef.id + + const inputId = `column-search-${inputName}` + + return ( + <> + { + setValue(e.target.value) + }} + placeholder={t("t.search")} + type={"text"} + value={value} + aria-description={`${t("listings.table.searchBy", { column: inputName })} - ${t( + "table.searchSubtext", + { char: props.minSearchCharacters } + )}`} + /> + {props.minSearchCharacters && ( + + )} + + ) +} diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index 922804788b..dfa20f7b90 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -1,19 +1,23 @@ import { useCallback, useContext, useState, useEffect } from "react" import useSWR from "swr" +import axios, { AxiosError } from "axios" import qs from "qs" import dayjs from "dayjs" import utc from "dayjs/plugin/utc" import tz from "dayjs/plugin/timezone" -import { AuthContext, MessageContext } from "@bloom-housing/shared-helpers" +import { AuthContext, CatchNetworkError, MessageContext } from "@bloom-housing/shared-helpers" import { t } from "@bloom-housing/ui-components" import { ApplicationOrderByKeys, EnumListingFilterParamsComparison, EnumMultiselectQuestionFilterParamsComparison, + Listing, + ListingOrderByKeys, ListingViews, MultiselectQuestionFilterParams, MultiselectQuestionsApplicationSectionEnum, OrderByEnum, + PaginationMeta, UserRole, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" @@ -49,7 +53,7 @@ type UseListingsDataProps = PaginationProps & { search?: string sort?: ColumnOrder[] roles?: UserRole - userJurisidctionIds?: string[] + userJurisdictionIds?: string[] view?: ListingViews } @@ -66,6 +70,73 @@ export function useSingleListingData(listingId: string) { } } +interface BaseListingData { + limit?: number | "all" + orderBy?: ListingOrderByKeys[] + orderDir?: OrderByEnum[] + page?: number + roles?: UserRole + search?: string + userId?: string + userJurisdictionIds?: string[] + view?: ListingViews +} + +export async function fetchBaseListingData({ + limit, + orderBy, + orderDir, + page, + roles, + search = "", + userId, + userJurisdictionIds, + view, +}: BaseListingData): Promise<{ + items: Listing[] | null + meta: PaginationMeta | null + error: AxiosError | null +}> { + let listings: Listing[] = [] + let pagination: PaginationMeta | null = null + try { + const params: BaseListingData = { + limit: limit || "all", + orderBy: orderBy || [ListingOrderByKeys.status], + orderDir: orderDir || [OrderByEnum.asc], + search: search || null, + roles: roles || null, + page: page || 1, + userId: userId || null, + userJurisdictionIds: userJurisdictionIds || null, + view: view || ListingViews.base, + } + + const response = await axios.post(`/api/adapter/listings/list`, params, { + headers: { + passkey: process.env.API_PASS_KEY, + }, + }) + + listings = response.data.items + pagination = response.data.meta || null + } catch (e) { + console.log("fetchBaseListingData error: ", e) + + return { + items: null, + meta: null, + error: e, + } + } + + return { + items: listings, + meta: pagination, + error: null, + } +} + export function useListingsData({ page, limit, @@ -73,7 +144,7 @@ export function useListingsData({ search = "", sort, roles, - userJurisidctionIds, + userJurisdictionIds, view, }: UseListingsDataProps) { const params = { @@ -101,7 +172,7 @@ export function useListingsData({ } else if (roles?.isJurisdictionalAdmin || roles?.isLimitedJurisdictionalAdmin) { params.filter.push({ $comparison: EnumListingFilterParamsComparison.IN, - jurisdiction: userJurisidctionIds[0], + jurisdiction: userJurisdictionIds[0], }) } diff --git a/sites/partners/src/pages/_app.tsx b/sites/partners/src/pages/_app.tsx index 4cab6e8aaa..0ab20bbc3f 100644 --- a/sites/partners/src/pages/_app.tsx +++ b/sites/partners/src/pages/_app.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react" import { SWRConfig } from "swr" import type { AppProps } from "next/app" import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import "@bloom-housing/ui-components/src/global/css-imports.scss" import "@bloom-housing/ui-components/src/global/app-css.scss" @@ -52,19 +53,23 @@ function BloomApp({ Component, router, pageProps }: AppProps) { } }, [locale]) + const queryClient = new QueryClient() + const pageContent = ( - - - - {hasMounted && } - - - + + + + + {hasMounted && } + + + + ) return ( diff --git a/sites/partners/src/pages/index.tsx b/sites/partners/src/pages/index.tsx index dc911f70ac..95581a1895 100644 --- a/sites/partners/src/pages/index.tsx +++ b/sites/partners/src/pages/index.tsx @@ -1,78 +1,29 @@ -import React, { useMemo, useContext, useState } from "react" +import React, { useContext, useState } from "react" import Head from "next/head" import DocumentArrowDownIcon from "@heroicons/react/24/solid/DocumentArrowDownIcon" import { useRouter } from "next/router" +import { + ColumnDef, + ColumnFiltersState, + createColumnHelper, + PaginationState, + SortingState, +} from "@tanstack/react-table" import { useForm } from "react-hook-form" import dayjs from "dayjs" -import { ColDef, ColGroupDef } from "ag-grid-community" -import { Button, Dialog, Grid, Icon } from "@bloom-housing/ui-seeds" -import { t, AgTable, useAgTable, Select, Form, SelectOption } from "@bloom-housing/ui-components" +import { Button, Dialog, Grid, Icon, Link } from "@bloom-housing/ui-seeds" +import { t, Select, Form, SelectOption } from "@bloom-housing/ui-components" import { AuthContext } from "@bloom-housing/shared-helpers" -import { FeatureFlagEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" -import { useListingExport, useListingsData } from "../lib/hooks" +import { + FeatureFlagEnum, + ListingOrderByKeys, + OrderByEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { fetchBaseListingData, useListingExport } from "../lib/hooks" import Layout from "../layouts" import { MetaTags } from "../components/shared/MetaTags" import { NavigationHeader } from "../components/shared/NavigationHeader" - -class formatLinkCell { - link: HTMLAnchorElement - - init(params) { - this.link = document.createElement("a") - this.link.classList.add("text-blue-700") - this.link.setAttribute("href", `/listings/${params.data.id}/applications`) - this.link.innerText = params.valueFormatted || params.value - this.link.style.textDecoration = "underline" - } - - getGui() { - return this.link - } -} - -class formatWaitlistStatus { - text: HTMLSpanElement - - init({ data }) { - const isWaitlistOpen = data.waitlistOpenSpots > 0 - - this.text = document.createElement("span") - this.text.innerHTML = isWaitlistOpen ? t("t.yes") : t("t.no") - } - - getGui() { - return this.text - } -} - -class formatIsVerified { - text: HTMLSpanElement - - init({ data }) { - this.text = document.createElement("span") - this.text.innerHTML = data.isVerified ? t("t.yes") : t("t.no") - } - - getGui() { - return this.text - } -} - -class ApplicationsLink extends formatLinkCell { - init(params) { - super.init(params) - this.link.setAttribute("href", `/listings/${params.data.id}/applications`) - this.link.setAttribute("data-testid", `listing-status-cell-${params.data.name}`) - } -} - -class ListingsLink extends formatLinkCell { - init(params) { - super.init(params) - this.link.setAttribute("href", `/listings/${params.data.id}`) - this.link.setAttribute("data-testid", params.data.name) - } -} +import { DataTable, TableDataRow } from "../components/shared/DataTable" export const getFlagInAllJurisdictions = ( flagName: FeatureFlagEnum, @@ -105,7 +56,8 @@ export default function ListingsList() { false const { onExport, csvExportLoading } = useListingExport() const router = useRouter() - const tableOptions = useAgTable() + + const MIN_SEARCH_CHARACTERS = 3 // eslint-disable-next-line @typescript-eslint/unbound-method const { register, errors, handleSubmit, clearErrors } = useForm() @@ -125,180 +77,215 @@ export default function ListingsList() { })), ] - const gridComponents = { - ApplicationsLink, - formatLinkCell, - formatWaitlistStatus, - formatIsVerified, - ListingsLink, + const onSubmit = (data: CreateListingFormFields) => { + void router.push({ + pathname: "/listings/add", + query: { jurisdictionId: data.jurisdiction }, + }) } - const showForNonRegulated = doJurisdictionsHaveFeatureFlagOn( - FeatureFlagEnum.enableNonRegulatedListings - ) - - const columnDefs = useMemo(() => { - const columns: (ColDef | ColGroupDef)[] = [ - { - headerName: t("listings.listingName"), - field: "name", - sortable: true, - unSortIcon: true, - filter: false, - resizable: true, - cellRenderer: "ListingsLink", - minWidth: 250, - flex: 1, - }, - ] - - if (showForNonRegulated) { - columns.push({ - headerName: t("listings.listingType"), - field: "listingType", - sortable: true, - unSortIcon: true, - filter: false, - resizable: true, - cellRenderer: "ListingsLink", - minWidth: 140, - comparator: () => 0, - valueFormatter: ({ value }) => { - if (!value) { + const columnHelper = createColumnHelper() + + const columns = React.useMemo[]>( + () => [ + columnHelper.accessor("name", { + id: "name", + cell: (props) => ( + + {props.getValue() as string} + + ), + header: () => t("t.name"), + footer: (props) => props.column.id, + minSize: 430, + meta: { + plaintextName: t("t.name"), + }, + }), + columnHelper.accessor("listingType", { + id: "listingType", + cell: (props) => { + if (!props.getValue()) { return t("t.none") } - - return t(`listings.${value}`) + return t(`listings.${props.getValue() as string}`) }, - }) - } - - columns.push( - { - headerName: t("listings.listingStatusText"), - field: "status", - sortable: true, - unSortIcon: true, - sort: "asc", - // disable frontend sorting - comparator: () => 0, - filter: false, - resizable: true, - valueFormatter: ({ value }) => t(`listings.listingStatus.${value}`), - cellRenderer: !profile?.userRoles?.isLimitedJurisdictionalAdmin ? "ApplicationsLink" : "", - minWidth: 190, - }, - { - headerName: t("listings.createdDate"), - field: "createdAt", - sortable: false, - filter: false, - resizable: true, - valueFormatter: ({ value }) => (value ? dayjs(value).format("MM/DD/YYYY") : t("t.none")), - maxWidth: 140, - }, - { - headerName: t("listings.publishedDate"), - field: "publishedAt", - sortable: false, - filter: false, - resizable: true, - valueFormatter: ({ value }) => (value ? dayjs(value).format("MM/DD/YYYY") : t("t.none")), - maxWidth: 150, - }, - { - headerName: t("listings.applicationDueDate"), - field: "applicationDueDate", - sortable: false, - filter: false, - resizable: true, - valueFormatter: ({ value }) => (value ? dayjs(value).format("MM/DD/YYYY") : t("t.none")), - maxWidth: 120, - } - ) - - if ( - getFlagInAllJurisdictions( - FeatureFlagEnum.enableIsVerified, - true, - doJurisdictionsHaveFeatureFlagOn - ) - ) { - columns.push({ - headerName: t("t.verified"), - field: "isVerified", - sortable: false, - filter: false, - resizable: true, - cellRenderer: "formatIsVerified", - maxWidth: 100, - }) - } - - if ( - getFlagInAllJurisdictions( - FeatureFlagEnum.enableUnitGroups, - false, - doJurisdictionsHaveFeatureFlagOn - ) - ) { - columns.push( - { - headerName: t("listings.availableUnits"), - field: "unitsAvailable", - sortable: false, - filter: false, - resizable: true, - maxWidth: 120, + header: () => t("listings.listingType"), + footer: (props) => props.column.id, + enableColumnFilter: false, + minSize: 160, + meta: { + plaintextName: t("listings.listingType"), + enabled: getFlagInAllJurisdictions( + FeatureFlagEnum.enableNonRegulatedListings, + true, + doJurisdictionsHaveFeatureFlagOn + ), }, - { - headerName: t("listings.waitlist.open"), - field: "waitlistCurrentSize", - sortable: false, - filter: false, - resizable: true, - cellRenderer: "formatWaitlistStatus", - maxWidth: 160, - } - ) - } - if ( - getFlagInAllJurisdictions( - FeatureFlagEnum.enableListingUpdatedAt, - true, - doJurisdictionsHaveFeatureFlagOn - ) - ) { - columns.push({ - headerName: t("t.lastUpdated"), - field: "contentUpdatedAt", - sortable: false, - filter: false, - resizable: true, - valueFormatter: ({ value }) => (value ? dayjs(value).format("MM/DD/YYYY") : t("t.none")), - maxWidth: 140, - }) - } - - return columns - //eslint-disable-next-line - }, []) - - const { listingDtos, listingsLoading } = useListingsData({ - page: tableOptions.pagination.currentPage, - limit: tableOptions.pagination.itemsPerPage, - search: tableOptions.filter.filterValue, - userId: profile?.id, - sort: tableOptions.sort.sortOptions, - roles: profile?.userRoles, - userJurisidctionIds: profile?.jurisdictions?.map((jurisdiction) => jurisdiction.id), - }) + }), + columnHelper.accessor("status", { + id: "status", + cell: (props) => { + const statusString = t(`listings.listingStatus.${props.getValue() as string}`) + if (!profile?.userRoles?.isLimitedJurisdictionalAdmin) { + return ( + + {statusString} + + ) + } else { + return statusString + } + }, + header: () => t("application.status"), + footer: (props) => props.column.id, + enableColumnFilter: false, + minSize: 120, + meta: { + plaintextName: t("application.status"), + }, + }), + columnHelper.accessor("createdAt", { + id: "createdAt", + cell: (props) => + props.getValue() ? dayjs(props.getValue() as string).format("MM/DD/YYYY") : t("t.none"), + header: () => t("listings.createdDate"), + footer: (props) => props.column.id, + enableColumnFilter: false, + enableSorting: false, + minSize: 140, + meta: { + plaintextName: t("listings.createdDate"), + }, + }), + columnHelper.accessor("publishedAt", { + id: "mostRecentlyPublished", + cell: (props) => + props.getValue() ? dayjs(props.getValue() as string).format("MM/DD/YYYY") : t("t.none"), + header: () => t("listings.publishedDate"), + footer: (props) => props.column.id, + enableColumnFilter: false, + minSize: 190, + meta: { + plaintextName: t("listings.publishedDate"), + }, + }), + columnHelper.accessor("applicationDueDate", { + id: "applicationDates", + cell: (props) => + props.getValue() ? dayjs(props.getValue() as string).format("MM/DD/YYYY") : t("t.none"), + header: () => t("listings.applicationDueDate"), + footer: (props) => props.column.id, + enableColumnFilter: false, + minSize: 140, + meta: { + plaintextName: t("listings.applicationDueDate"), + }, + }), + columnHelper.accessor("isVerified", { + id: "isVerified", + cell: (props) => (props.getValue() ? t("t.yes") : t("t.no")), + header: () => t("t.verified"), + footer: (props) => props.column.id, + enableColumnFilter: false, + enableSorting: false, + minSize: 100, + meta: { + enabled: getFlagInAllJurisdictions( + FeatureFlagEnum.enableIsVerified, + true, + doJurisdictionsHaveFeatureFlagOn + ), + }, + }), + columnHelper.accessor("unitsAvailable", { + id: "unitsAvailable", + cell: (props) => props.getValue(), + header: () => t("listings.availableUnits"), + footer: (props) => props.column.id, + enableColumnFilter: false, + enableSorting: false, + minSize: 160, + meta: { + enabled: getFlagInAllJurisdictions( + FeatureFlagEnum.enableUnitGroups, + false, + doJurisdictionsHaveFeatureFlagOn + ), + }, + }), + columnHelper.accessor("waitlistCurrentSize", { + id: "waitlistCurrentSize", + cell: (props) => { + const isWaitlistOpen = (props.row.original.waitlistOpenSpots as number) > 0 + return isWaitlistOpen ? t("t.yes") : t("t.no") + }, + header: () => t("listings.waitlist.open"), + footer: (props) => props.column.id, + enableColumnFilter: false, + enableSorting: false, + minSize: 150, + meta: { + enabled: getFlagInAllJurisdictions( + FeatureFlagEnum.enableUnitGroups, + false, + doJurisdictionsHaveFeatureFlagOn + ), + }, + }), + columnHelper.accessor("contentUpdatedAt", { + id: "contentUpdatedAt", + cell: (props) => + props.getValue() ? dayjs(props.getValue() as string).format("MM/DD/YYYY") : t("t.none"), + header: () => t("t.lastUpdated"), + footer: (props) => props.column.id, + enableColumnFilter: false, + enableSorting: false, + minSize: 150, + meta: { + enabled: getFlagInAllJurisdictions( + FeatureFlagEnum.enableListingUpdatedAt, + true, + doJurisdictionsHaveFeatureFlagOn + ), + }, + }), + ], + [] + ) - const onSubmit = (data: CreateListingFormFields) => { - void router.push({ - pathname: "/listings/add", - query: { jurisdictionId: data.jurisdiction }, + const fetchListingsData = async ( + pagination?: PaginationState, + search?: ColumnFiltersState, + sort?: SortingState + ) => { + const searchValue = search[0]?.value as string + const data = await fetchBaseListingData({ + page: pagination?.pageIndex ? pagination.pageIndex + 1 : 0, + limit: pagination?.pageSize || 8, + search: searchValue?.length >= MIN_SEARCH_CHARACTERS ? searchValue : undefined, + orderBy: sort?.[0]?.id ? [sort[0].id as ListingOrderByKeys] : undefined, + orderDir: sort?.[0]?.desc ? [OrderByEnum.desc] : [OrderByEnum.asc], + userJurisdictionIds: profile?.jurisdictions?.map((jurisdiction) => jurisdiction.id), + roles: profile?.userRoles, + userId: profile?.id, }) + return { + items: data.items as unknown as TableDataRow[], + totalItems: data.meta.totalItems, + errorMessage: data.error ? data.error.response.data.message[0] : null, + currentPage: data.meta.currentPage, + itemsPerPage: data.meta.itemsPerPage, + } } return ( @@ -309,75 +296,55 @@ export default function ListingsList() {
-
- - {isAdmin && ( - <> - - - - )} -
- } +
+
+ {isAdmin && ( + <> + + + + )} +
+ - +
Date: Thu, 4 Dec 2025 10:44:14 -0700 Subject: [PATCH 2/4] test: additional unit tests --- .../components/shared/DataTable.test.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/sites/partners/__tests__/components/shared/DataTable.test.tsx b/sites/partners/__tests__/components/shared/DataTable.test.tsx index 6a5128780c..782d8c9124 100644 --- a/sites/partners/__tests__/components/shared/DataTable.test.tsx +++ b/sites/partners/__tests__/components/shared/DataTable.test.tsx @@ -41,6 +41,8 @@ describe("DataTable", () => { mockFetch.mockReturnValue({ items: [{ firstName: "TestFirst", lastName: "TestLast" }], totalItems: 1, + currentPage: 1, + itemsPerPage: 8, errorMessage: null, }) @@ -64,6 +66,7 @@ describe("DataTable", () => { expect(screen.getByRole("option", { name: "25" })).toBeInTheDocument() expect(screen.getByRole("option", { name: "50" })).toBeInTheDocument() expect(screen.getByRole("option", { name: "100" })).toBeInTheDocument() + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument() expect(screen.getByText("1 Total listing")).toBeInTheDocument() expect(screen.queryByRole("textbox")).not.toBeInTheDocument() }) @@ -77,6 +80,8 @@ describe("DataTable", () => { { firstName: "TestFirst2", lastName: "TestLast2" }, ], totalItems: 5, + currentPage: 1, + itemsPerPage: 2, errorMessage: null, }) @@ -103,6 +108,8 @@ describe("DataTable", () => { expect(screen.getByRole("cell", { name: "TestLast1" })).toBeInTheDocument() expect(screen.getByRole("cell", { name: "TestFirst2" })).toBeInTheDocument() expect(screen.getByRole("cell", { name: "TestLast2" })).toBeInTheDocument() + expect(screen.getByText("Page 1 of 3")).toBeInTheDocument() + expect(screen.getByTestId("sort-button-firstName")).toBeInTheDocument() expect(screen.getByTestId("sort-button-lastName")).toBeInTheDocument() @@ -117,6 +124,8 @@ describe("DataTable", () => { { firstName: "TestFirst4", lastName: "TestLast4" }, ], totalItems: 5, + currentPage: 2, + itemsPerPage: 2, errorMessage: null, }) fireEvent.click(nextButton) @@ -127,12 +136,16 @@ describe("DataTable", () => { expect(screen.getByRole("cell", { name: "TestLast3" })).toBeInTheDocument() expect(screen.getByRole("cell", { name: "TestFirst4" })).toBeInTheDocument() expect(screen.getByRole("cell", { name: "TestLast4" })).toBeInTheDocument() + expect(screen.getByText("Page 2 of 3")).toBeInTheDocument() + expect(within(screen.getAllByRole("rowgroup")[1]).getAllByRole("row")).toHaveLength(2) expect(nextButton).toBeEnabled() expect(previousButton).toBeEnabled() mockFetch.mockReturnValueOnce({ items: [{ firstName: "TestFirst5", lastName: "TestLast5" }], totalItems: 5, + currentPage: 3, + itemsPerPage: 2, errorMessage: null, }) fireEvent.click(nextButton) @@ -142,6 +155,7 @@ describe("DataTable", () => { expect(await screen.findByRole("cell", { name: "TestFirst5" })).toBeInTheDocument() expect(within(screen.getAllByRole("rowgroup")[1]).getAllByRole("row")).toHaveLength(1) expect(screen.getByRole("cell", { name: "TestLast5" })).toBeInTheDocument() + expect(screen.getByText("Page 3 of 3")).toBeInTheDocument() expect(nextButton).toBeDisabled() expect(previousButton).toBeEnabled() mockFetch.mockReturnValueOnce({ @@ -150,6 +164,8 @@ describe("DataTable", () => { { firstName: "TestFirst4", lastName: "TestLast4" }, ], totalItems: 5, + currentPage: 2, + itemsPerPage: 2, errorMessage: null, }) fireEvent.click(previousButton) @@ -159,6 +175,7 @@ describe("DataTable", () => { expect(screen.getByRole("cell", { name: "TestLast3" })).toBeInTheDocument() expect(screen.getByRole("cell", { name: "TestFirst4" })).toBeInTheDocument() expect(screen.getByRole("cell", { name: "TestLast4" })).toBeInTheDocument() + expect(screen.getByText("Page 2 of 3")).toBeInTheDocument() expect(within(screen.getAllByRole("rowgroup")[1]).getAllByRole("row")).toHaveLength(2) }) it("should filter on columns", async () => { @@ -177,6 +194,8 @@ describe("DataTable", () => { mockFetch.mockReturnValue({ items: defaultItems, totalItems: 8, + currentPage: 1, + itemsPerPage: 8, errorMessage: null, }) @@ -214,6 +233,7 @@ describe("DataTable", () => { expect(await screen.findByRole("columnheader", { name: /First name/i })).toBeInTheDocument() expect(screen.getByRole("columnheader", { name: /Last name/i })).toBeInTheDocument() expect(screen.getByText("8 Total listings")).toBeInTheDocument() + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument() expect(screen.queryByRole("button", { description: "Activate ascending sort" })).toBeNull() const firstNameInput = screen.getByTestId("column-search-First name") @@ -229,11 +249,14 @@ describe("DataTable", () => { mockFetch.mockReturnValueOnce({ items: [{ firstName: "TestFirstA", lastName: "TestLastA" }], totalItems: 1, + currentPage: 1, + itemsPerPage: 8, errorMessage: null, }) fireEvent.change(firstNameInput, { target: { value: "TestFirstA" } }) expect(await screen.findByText("1 Total listing")).toBeInTheDocument() expect(await screen.findByRole("cell", { name: "TestFirstA" })).toBeInTheDocument() + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument() expect(mockFetch).toHaveBeenCalledTimes(2) expect(mockFetch).toHaveBeenCalledWith( { pageIndex: 0, pageSize: 8 }, @@ -244,11 +267,14 @@ describe("DataTable", () => { mockFetch.mockReturnValueOnce({ items: defaultItems, totalItems: 8, + currentPage: 1, + itemsPerPage: 8, errorMessage: null, }) fireEvent.change(firstNameInput, { target: { value: "Te" } }) expect(await screen.findByText("8 Total listings")).toBeInTheDocument() expect(await screen.findByRole("cell", { name: "TestFirstB" })).toBeInTheDocument() + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument() expect(mockFetch).toHaveBeenCalledTimes(3) expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 8 }, [], []) }) @@ -269,6 +295,8 @@ describe("DataTable", () => { mockFetch.mockReturnValue({ items: defaultItems, totalItems: 8, + currentPage: 1, + itemsPerPage: 8, errorMessage: null, }) @@ -317,6 +345,7 @@ describe("DataTable", () => { expect(await screen.findByRole("columnheader", { name: "First name" })).toBeInTheDocument() expect(screen.getByRole("columnheader", { name: "Last name" })).toBeInTheDocument() expect(screen.getByText("8 Total listings")).toBeInTheDocument() + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument() expect(screen.getByTestId("sort-button-firstName")).toHaveAccessibleDescription( "Activate ascending sort for column First name" @@ -404,6 +433,8 @@ describe("DataTable", () => { mockFetch.mockReturnValue({ items: defaultItems, totalItems: 8, + currentPage: 1, + itemsPerPage: 8, errorMessage: null, }) @@ -451,6 +482,7 @@ describe("DataTable", () => { expect(screen.getByRole("columnheader", { name: "Last name" })).toBeInTheDocument() expect(screen.queryByRole("columnheader", { name: "Middle name" })).not.toBeInTheDocument() expect(screen.getByText("8 Total listings")).toBeInTheDocument() + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument() }) it("should render empty state", async () => { const mockFetch = jest.fn() @@ -531,6 +563,8 @@ describe("DataTable", () => { mockFetch.mockReturnValue({ items: fullItems.slice(0, 8), totalItems: 25, + currentPage: 1, + itemsPerPage: 8, errorMessage: null, }) @@ -548,6 +582,7 @@ describe("DataTable", () => { expect(await screen.findByRole("columnheader", { name: /First name/i })).toBeInTheDocument() expect(screen.getByRole("columnheader", { name: /Last name/i })).toBeInTheDocument() expect(screen.getByText("25 Total listings")).toBeInTheDocument() + expect(screen.getByText("Page 1 of 4")).toBeInTheDocument() expect(mockFetch).toHaveBeenCalledTimes(1) expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 8 }, [], []) @@ -555,10 +590,13 @@ describe("DataTable", () => { mockFetch.mockReturnValueOnce({ items: fullItems, totalItems: 25, + currentPage: 1, + itemsPerPage: 25, errorMessage: null, }) fireEvent.change(showInput, { target: { value: "25" } }) expect(mockFetch).toHaveBeenCalledTimes(2) expect(mockFetch).toHaveBeenCalledWith({ pageIndex: 0, pageSize: 25 }, [], []) + expect(await screen.findByText("Page 1 of 1")).toBeInTheDocument() }) }) From 22f12ef7ec563ce53b96bac5dcecd60b3d65b688 Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Thu, 4 Dec 2025 11:41:31 -0700 Subject: [PATCH 3/4] fix: error state --- .../components/shared/DataTable.module.scss | 3 +- .../src/components/shared/DataTable.tsx | 34 +++++++++---------- sites/partners/src/lib/hooks.ts | 19 ++++++++++- sites/partners/src/pages/index.tsx | 8 ++--- 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/sites/partners/src/components/shared/DataTable.module.scss b/sites/partners/src/components/shared/DataTable.module.scss index 57003994d2..1e19e1bcc0 100644 --- a/sites/partners/src/components/shared/DataTable.module.scss +++ b/sites/partners/src/components/shared/DataTable.module.scss @@ -62,12 +62,11 @@ } .full-width-text-cell { - text-align: center; + text-align: left; padding: var(--seeds-s8); } .error-cell { - text-align: center; color: var(--seeds-color-alert); } tr:nth-of-type(even) { diff --git a/sites/partners/src/components/shared/DataTable.tsx b/sites/partners/src/components/shared/DataTable.tsx index d3a351a94c..bb976222e6 100644 --- a/sites/partners/src/components/shared/DataTable.tsx +++ b/sites/partners/src/components/shared/DataTable.tsx @@ -250,7 +250,23 @@ export const DataTable = (props: DataTableProps) => { const APPROX_ROW_HEIGHT = 55 const getTableContent = () => { - if (delayedLoading || dataQuery.data === undefined) { + if (dataQuery.data?.errorMessage) { + return ( + <> + {tableHeaders} + + + + {dataQuery.data.errorMessage} + + + + + ) + } else if (delayedLoading || dataQuery.data === undefined) { return ( @@ -271,22 +287,6 @@ export const DataTable = (props: DataTableProps) => { ) - } else if (dataQuery.data?.errorMessage) { - return ( - <> - {tableHeaders} - - - - {dataQuery.data.errorMessage} - - - - - ) } else if (dataQuery.data?.items?.length === 0) { return ( <> diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index dfa20f7b90..0a848974fd 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -12,6 +12,7 @@ import { EnumListingFilterParamsComparison, EnumMultiselectQuestionFilterParamsComparison, Listing, + ListingFilterParams, ListingOrderByKeys, ListingViews, MultiselectQuestionFilterParams, @@ -71,6 +72,7 @@ export function useSingleListingData(listingId: string) { } interface BaseListingData { + filter?: ListingFilterParams[] limit?: number | "all" orderBy?: ListingOrderByKeys[] orderDir?: OrderByEnum[] @@ -83,6 +85,7 @@ interface BaseListingData { } export async function fetchBaseListingData({ + filter = [], limit, orderBy, orderDir, @@ -101,6 +104,7 @@ export async function fetchBaseListingData({ let pagination: PaginationMeta | null = null try { const params: BaseListingData = { + filter, limit: limit || "all", orderBy: orderBy || [ListingOrderByKeys.status], orderDir: orderDir || [OrderByEnum.asc], @@ -109,7 +113,20 @@ export async function fetchBaseListingData({ page: page || 1, userId: userId || null, userJurisdictionIds: userJurisdictionIds || null, - view: view || ListingViews.base, + view: view || ListingViews.fundamentals, + } + + // filter if logged user is an agent + if (roles?.isPartner) { + params.filter.push({ + $comparison: EnumListingFilterParamsComparison["="], + leasingAgent: userId, + }) + } else if (roles?.isJurisdictionalAdmin || roles?.isLimitedJurisdictionalAdmin) { + params.filter.push({ + $comparison: EnumListingFilterParamsComparison.IN, + jurisdiction: userJurisdictionIds[0], + }) } const response = await axios.post(`/api/adapter/listings/list`, params, { diff --git a/sites/partners/src/pages/index.tsx b/sites/partners/src/pages/index.tsx index 95581a1895..bf4e50048d 100644 --- a/sites/partners/src/pages/index.tsx +++ b/sites/partners/src/pages/index.tsx @@ -281,10 +281,10 @@ export default function ListingsList() { }) return { items: data.items as unknown as TableDataRow[], - totalItems: data.meta.totalItems, - errorMessage: data.error ? data.error.response.data.message[0] : null, - currentPage: data.meta.currentPage, - itemsPerPage: data.meta.itemsPerPage, + totalItems: data.meta?.totalItems, + errorMessage: data.error ? data.error.response.data.message : null, + currentPage: data.meta?.currentPage, + itemsPerPage: data.meta?.itemsPerPage, } } From 8000124d2419599c525828eae3a6b56cb2d84f17 Mon Sep 17 00:00:00 2001 From: Emily Jablonski Date: Thu, 4 Dec 2025 13:35:33 -0700 Subject: [PATCH 4/4] fix: remove pk --- sites/partners/src/lib/hooks.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index 0a848974fd..88cdef8ae7 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -129,11 +129,7 @@ export async function fetchBaseListingData({ }) } - const response = await axios.post(`/api/adapter/listings/list`, params, { - headers: { - passkey: process.env.API_PASS_KEY, - }, - }) + const response = await axios.post(`/api/adapter/listings/list`, params) listings = response.data.items pagination = response.data.meta || null