From 3797fc160d28eb69b05c9d40f3b81c95ee9fb09b Mon Sep 17 00:00:00 2001 From: Frank Pigeon Jr Date: Wed, 9 Oct 2024 15:03:47 -0500 Subject: [PATCH 01/11] feat: adds CanPortfolioComboBox and filter --- .../CANActivePeriodComboBox.jsx | 4 +- .../CANPortfolioComboBox.jsx | 53 +++++++++++++ .../CANs/CANPortfolioComboBox/index.js | 1 + .../CANTransferComboBox.jsx | 4 +- .../list/CANFilterButton/CANFiilterButton.jsx | 70 ++++++++--------- .../CANFilterButton/CANFilterButton.hooks.js | 77 +++++++++++++++++++ .../src/pages/cans/list/CanList.helpers.js | 32 +++++++- frontend/src/pages/cans/list/CanList.jsx | 7 +- 8 files changed, 201 insertions(+), 47 deletions(-) create mode 100644 frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx create mode 100644 frontend/src/components/CANs/CANPortfolioComboBox/index.js create mode 100644 frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js diff --git a/frontend/src/components/CANs/CANActivePeriodComboBox/CANActivePeriodComboBox.jsx b/frontend/src/components/CANs/CANActivePeriodComboBox/CANActivePeriodComboBox.jsx index 8a220dc814..7ed2fe4366 100644 --- a/frontend/src/components/CANs/CANActivePeriodComboBox/CANActivePeriodComboBox.jsx +++ b/frontend/src/components/CANs/CANActivePeriodComboBox/CANActivePeriodComboBox.jsx @@ -19,8 +19,8 @@ const CANActivePeriodComboBox = ({ activePeriod, setActivePeriod, legendClassname = "usa-label margin-top-0", - defaultString = "", - overrideStyles = { width: "187px" } + defaultString = "All Periods", + overrideStyles = {} }) => { const periods = [ { id: 1, title: "1 Year" }, diff --git a/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx b/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx new file mode 100644 index 0000000000..4fded1e893 --- /dev/null +++ b/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx @@ -0,0 +1,53 @@ +import ComboBox from "../../UI/Form/ComboBox"; + +/** + * @typedef {Object} DataProps + * @property {number} id - The identifier of the data item + * @property {string} title - The title of the data item + */ + +/** + * @component + * @param {Object} props - The component props. + * @param {DataProps[]} props.portfolioOptions - All the portfolio options. + * @param {DataProps[]} props.portfolio - The current portfolio. + * @param {Function} props.setPortfolio - A function to call to set the portfolio. + * @param {string} [props.legendClassname] - The class name for the legend (optional). + * @param {string} [props.defaultString] - The default string to display (optional). + * @param {Object} [props.overrideStyles] - The CSS styles to override the default (optional). + * @returns {JSX.Element} - The rendered CAN transfer combo box. + */ +const CANPortfolioComboBox = ({ + portfolioOptions, + portfolio, + setPortfolio, + legendClassname = "usa-label margin-top-0", + defaultString = "All Portfolios", + overrideStyles = {} +}) => { + return ( +
+
+ +
+ +
+
+
+ ); +}; + +export default CANPortfolioComboBox; diff --git a/frontend/src/components/CANs/CANPortfolioComboBox/index.js b/frontend/src/components/CANs/CANPortfolioComboBox/index.js new file mode 100644 index 0000000000..44651ff779 --- /dev/null +++ b/frontend/src/components/CANs/CANPortfolioComboBox/index.js @@ -0,0 +1 @@ +export { default } from "./CANPortfolioComboBox"; diff --git a/frontend/src/components/CANs/CANTransferComboBox/CANTransferComboBox.jsx b/frontend/src/components/CANs/CANTransferComboBox/CANTransferComboBox.jsx index ee25061c99..7c85435a73 100644 --- a/frontend/src/components/CANs/CANTransferComboBox/CANTransferComboBox.jsx +++ b/frontend/src/components/CANs/CANTransferComboBox/CANTransferComboBox.jsx @@ -21,8 +21,8 @@ const CANTransferComboBox = ({ transfer, setTransfer, legendClassname = "usa-label margin-top-0", - defaultString = "", - overrideStyles = { width: "187px" } + defaultString = "All Transfers", + overrideStyles = {} }) => { const options = [ { id: 1, title: convertCodeForDisplay("methodOfTransfer", CAN_TRANSFER.DIRECT) }, diff --git a/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx b/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx index e8513d5f7f..642966733f 100644 --- a/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx +++ b/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx @@ -1,52 +1,32 @@ -import React from "react"; import Modal from "react-modal"; -import FilterButton from "../../../../components/UI/FilterButton"; import CANActivePeriodComboBox from "../../../../components/CANs/CANActivePeriodComboBox"; +import CANPortfolioComboBox from "../../../../components/CANs/CANPortfolioComboBox"; import CANTransferComboBox from "../../../../components/CANs/CANTransferComboBox"; +import FilterButton from "../../../../components/UI/FilterButton"; +import useCANFilterButton from "./CANFilterButton.hooks"; /** - * @typedef {Object} DataProps - * @property {number} id - The identifier of the data item - * @property {string | number} title - The title of the data item + * @typedef {Object} FilterOption + * @property {number} id + * @property {string} title + */ +/** + * @typedef {Object} Filters + * @property {FilterOption[]} [activePeriod] + * @property {FilterOption[]} [transfer] + * @property {FilterOption[]} [portfolio] + * // Add other filter types here */ - /** * A filter for CANs list. * @param {Object} props - The component props. - * @param {DataProps[]} props.filters - The current filters. + * @param {Filters} props.filters - The current filters. * @param {Function} props.setFilters - A function to call to set the filters. + * @param {FilterOption[]} props.portfolioOptions - The portfolio options. * @returns {JSX.Element} - The CAN filter button. */ -export const CANFilterButton = ({ filters, setFilters }) => { - const [activePeriod, setActivePeriod] = React.useState([]); - const [transfer, setTransfer] = React.useState([]); - - // The useEffect() hook calls below are used to set the state appropriately when the filter tags (X) are clicked. - React.useEffect(() => { - setActivePeriod(filters.activePeriod); - }, [filters.activePeriod]); - - React.useEffect(() => { - setTransfer(filters.transfer); - }, [filters.transfer]); - - const applyFilter = () => { - setFilters((prevState) => { - return { - ...prevState, - activePeriod: activePeriod, - transfer: transfer - }; - }); - }; - const resetFilter = () => { - setFilters({ - activePeriod: [], - transfer: [] - }); - setActivePeriod([]); - setTransfer([]); - }; - +export const CANFilterButton = ({ filters, setFilters, portfolioOptions }) => { + const { activePeriod, setActivePeriod, transfer, setTransfer, portfolio, setPortfolio, applyFilter, resetFilter } = + useCANFilterButton(filters, setFilters); const fieldStyles = "usa-fieldset margin-bottom-205"; const legendStyles = "usa-legend font-sans-3xs margin-top-0 padding-bottom-1 text-base-dark"; @@ -59,6 +39,7 @@ export const CANFilterButton = ({ filters, setFilters }) => { activePeriod={activePeriod} setActivePeriod={setActivePeriod} legendClassname={legendStyles} + overrideStyles={{ width: "187px" }} /> ,
{ transfer={transfer} setTransfer={setTransfer} legendClassname={legendStyles} + overrideStyles={{ width: "187px" }} + /> +
, +
+
]; diff --git a/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js b/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js new file mode 100644 index 0000000000..3974d7e8f1 --- /dev/null +++ b/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js @@ -0,0 +1,77 @@ +import React from "react"; +/** + * @typedef {Object} FilterOption + * @property {number} id + * @property {string} title + */ +/** + * @typedef {Object} Filters + * @property {FilterOption[]} [activePeriod] + * @property {FilterOption[]} [transfer] + * @property {FilterOption[]} [portfolio] + * // Add other filter types here + */ + +/** + * A filter for CANs list. + * @param {Filters} filters - The current filters. + * @param {Function} setFilters - A function to call to set the filters. + */ +export const useCANFilterButton = (filters, setFilters) => { + const [activePeriod, setActivePeriod] = React.useState([]); + const [transfer, setTransfer] = React.useState([]); + const [portfolio, setPortfolio] = React.useState([]); + + // The useEffect() hook calls below are used to set the state appropriately when the filter tags (X) are clicked. + React.useEffect(() => { + if (filters.activePeriod) { + setActivePeriod(filters.activePeriod); + } + }, [filters.activePeriod]); + + React.useEffect(() => { + if (filters.transfer) { + setTransfer(filters.transfer); + } + }, [filters.transfer]); + + React.useEffect(() => { + if (filters.portfolio) { + setPortfolio(filters.portfolio); + } + }, [filters.portfolio]); + + const applyFilter = () => { + setFilters((prevState) => { + return { + ...prevState, + activePeriod: activePeriod, + transfer: transfer, + portfolio: portfolio + }; + }); + }; + const resetFilter = () => { + setFilters({ + activePeriod: [], + transfer: [], + portfolio: [] + }); + setActivePeriod([]); + setTransfer([]); + setPortfolio([]); + }; + + return { + activePeriod, + setActivePeriod, + transfer, + setTransfer, + portfolio, + setPortfolio, + applyFilter, + resetFilter + }; +}; + +export default useCANFilterButton; diff --git a/frontend/src/pages/cans/list/CanList.helpers.js b/frontend/src/pages/cans/list/CanList.helpers.js index 31f66ef392..928ba8ac5a 100644 --- a/frontend/src/pages/cans/list/CanList.helpers.js +++ b/frontend/src/pages/cans/list/CanList.helpers.js @@ -8,11 +8,12 @@ import { USER_ROLES } from "../../../components/Users/User.constants"; * @typedef {Object} Filters * @property {FilterOption[]} [activePeriod] * @property {FilterOption[]} [transfer] + * @property {FilterOption[]} [portfolio] * // Add other filter types here */ /** - * Sorts an array of CANs by obligateBy date in descending order. + * @description Sorts and filters the array of CANs. * @typedef {import("../../../components/CANs/CANTypes").CAN} CAN * @param {CAN[]} cans - The array of CANs to sort. * @param {boolean} myCANsUrl - The URL parameter to filter by "my-CANs". @@ -55,7 +56,7 @@ export const sortAndFilterCANs = (cans, myCANsUrl, activeUser, filters) => { }; /** - * Sorts an array of CANs by obligateBy date in descending order. + * @description Sorts an array of CANs by obligateBy date in descending order. * @param {CAN[]} cans - The array of CANs to sort. * @returns {CAN[] | []} - The sorted array of CANs. */ @@ -72,7 +73,7 @@ const sortCANs = (cans) => { }; /** - * Applies additional filters to the CANs. + * @description Applies additional filters to the CANs. * @param {CAN[]} cans - The array of CANs to filter. * @param {Filters} filters - The filters to apply. * @returns {CAN[]} - The filtered array of CANs. @@ -94,6 +95,11 @@ const applyAdditionalFilters = (cans, filters) => { ) ); } + if (filters.portfolio && filters.portfolio.length > 0) { + filteredCANs = filteredCANs.filter((can) => + filters.portfolio?.some((portfolio) => portfolio.title.toUpperCase() === can.portfolio.abbreviation) + ); + } // TODO: Add other filters here // Example: // if (filters.someOtherFilter && filters.someOtherFilter.length > 0) { @@ -104,3 +110,23 @@ const applyAdditionalFilters = (cans, filters) => { return filteredCANs; }; + +/** + * @description Returns a set of unique portfolios from the CANs list + * @param {CAN[]} cans - The array of CANs to filter. + * @returns {FilterOption[]} - The filtered array of portfolios. + */ +export const getPortfolioOptions = (cans) => { + if (!cans || cans.length === 0) { + return []; + } + const portfolios = cans.reduce((acc, can) => { + acc.add(can.portfolio.abbreviation); + return acc; + }, new Set()); + + return Array.from(portfolios).map((portfolio, index) => ({ + id: index, + title: portfolio + })); +}; diff --git a/frontend/src/pages/cans/list/CanList.jsx b/frontend/src/pages/cans/list/CanList.jsx index 49dab8cb3c..ccae3eaf95 100644 --- a/frontend/src/pages/cans/list/CanList.jsx +++ b/frontend/src/pages/cans/list/CanList.jsx @@ -10,7 +10,7 @@ import FiscalYear from "../../../components/UI/FiscalYear"; import { setSelectedFiscalYear } from "../../../pages/cans/detail/canDetailSlice"; import ErrorPage from "../../ErrorPage"; import CANFilterButton from "./CANFilterButton"; -import { sortAndFilterCANs } from "./CanList.helpers"; +import { sortAndFilterCANs, getPortfolioOptions } from "./CanList.helpers"; /** * Page for the CAN List. @@ -27,9 +27,11 @@ const CanList = () => { const fiscalYear = Number(selectedFiscalYear.value); const [filters, setFilters] = React.useState({ activePeriod: [], - transfer: [] + transfer: [], + portfolio: [] }); const sortedCANs = sortAndFilterCANs(canList, myCANsUrl, activeUser, filters) || []; + const portfolioOptions = getPortfolioOptions(canList); if (isLoading) { return ( @@ -73,6 +75,7 @@ const CanList = () => { } FYSelect={} From 9cc022e422d45aa286fab18f80d1195d5f8df681 Mon Sep 17 00:00:00 2001 From: Frank Pigeon Jr Date: Wed, 9 Oct 2024 15:37:45 -0500 Subject: [PATCH 02/11] docs: export to TS definition file --- .../cans/list/CANFilterButton/CANFiilterButton.jsx | 14 +++----------- .../list/CANFilterButton/CANFilterButton.hooks.js | 14 +------------- .../cans/list/CANFilterButton/CANFilterTypes.d.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 24 deletions(-) create mode 100644 frontend/src/pages/cans/list/CANFilterButton/CANFilterTypes.d.ts diff --git a/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx b/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx index 642966733f..9d82dbdeda 100644 --- a/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx +++ b/frontend/src/pages/cans/list/CANFilterButton/CANFiilterButton.jsx @@ -4,22 +4,14 @@ import CANPortfolioComboBox from "../../../../components/CANs/CANPortfolioComboB import CANTransferComboBox from "../../../../components/CANs/CANTransferComboBox"; import FilterButton from "../../../../components/UI/FilterButton"; import useCANFilterButton from "./CANFilterButton.hooks"; + /** - * @typedef {Object} FilterOption - * @property {number} id - * @property {string} title - */ -/** - * @typedef {Object} Filters - * @property {FilterOption[]} [activePeriod] - * @property {FilterOption[]} [transfer] - * @property {FilterOption[]} [portfolio] - * // Add other filter types here + * @typedef {import('./CANFilterTypes').FilterOption} FilterOption */ /** * A filter for CANs list. * @param {Object} props - The component props. - * @param {Filters} props.filters - The current filters. + * @param {import ('./CANFilterTypes').Filters} props.filters - The current filters. * @param {Function} props.setFilters - A function to call to set the filters. * @param {FilterOption[]} props.portfolioOptions - The portfolio options. * @returns {JSX.Element} - The CAN filter button. diff --git a/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js b/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js index 3974d7e8f1..27d3269931 100644 --- a/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js +++ b/frontend/src/pages/cans/list/CANFilterButton/CANFilterButton.hooks.js @@ -1,20 +1,8 @@ import React from "react"; -/** - * @typedef {Object} FilterOption - * @property {number} id - * @property {string} title - */ -/** - * @typedef {Object} Filters - * @property {FilterOption[]} [activePeriod] - * @property {FilterOption[]} [transfer] - * @property {FilterOption[]} [portfolio] - * // Add other filter types here - */ /** * A filter for CANs list. - * @param {Filters} filters - The current filters. + * @param {import ('./CANFilterTypes').Filters} filters - The current filters. * @param {Function} setFilters - A function to call to set the filters. */ export const useCANFilterButton = (filters, setFilters) => { diff --git a/frontend/src/pages/cans/list/CANFilterButton/CANFilterTypes.d.ts b/frontend/src/pages/cans/list/CANFilterButton/CANFilterTypes.d.ts new file mode 100644 index 0000000000..0364c76fe7 --- /dev/null +++ b/frontend/src/pages/cans/list/CANFilterButton/CANFilterTypes.d.ts @@ -0,0 +1,11 @@ +export type FilterOption = { + id: number; + title: string; +}; + +export type Filters = { + activePeriod?: FilterOption[]; + transfer?: FilterOption[]; + portfolio?: FilterOption[]; + // Add other filter types here +}; From 4637cc4ec682dbba08b6f69a0c6f17c1a70ed897 Mon Sep 17 00:00:00 2001 From: Frank Pigeon Jr Date: Wed, 9 Oct 2024 15:43:45 -0500 Subject: [PATCH 03/11] docs: use typedef --- frontend/src/pages/cans/list/CanList.helpers.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/cans/list/CanList.helpers.js b/frontend/src/pages/cans/list/CanList.helpers.js index 928ba8ac5a..1c38f9a7ca 100644 --- a/frontend/src/pages/cans/list/CanList.helpers.js +++ b/frontend/src/pages/cans/list/CanList.helpers.js @@ -1,15 +1,7 @@ import { USER_ROLES } from "../../../components/Users/User.constants"; /** - * @typedef {Object} FilterOption - * @property {number} id - * @property {string} title - */ -/** - * @typedef {Object} Filters - * @property {FilterOption[]} [activePeriod] - * @property {FilterOption[]} [transfer] - * @property {FilterOption[]} [portfolio] - * // Add other filter types here + * @typedef {import('./././CANFilterButton/CANFilterTypes').FilterOption} FilterOption + * @typedef {import('./././CANFilterButton/CANFilterTypes').Filters} Filters */ /** From 783cfac293776a71778ae40ba664a52622008322 Mon Sep 17 00:00:00 2001 From: Frank Pigeon Jr Date: Wed, 9 Oct 2024 15:55:46 -0500 Subject: [PATCH 04/11] test: adds unit-tests --- .../pages/cans/list/CanList.helpers.test.js | 87 ++++++++++++++++--- 1 file changed, 73 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/cans/list/CanList.helpers.test.js b/frontend/src/pages/cans/list/CanList.helpers.test.js index 991ab20fd0..727ef3e415 100644 --- a/frontend/src/pages/cans/list/CanList.helpers.test.js +++ b/frontend/src/pages/cans/list/CanList.helpers.test.js @@ -1,21 +1,21 @@ import { describe, it, expect } from "vitest"; -import { sortAndFilterCANs } from "./CanList.helpers"; +import { sortAndFilterCANs, getPortfolioOptions } from "./CanList.helpers"; import { USER_ROLES } from "../../../components/Users/User.constants"; -describe("sortAndFilterCANs", () => { - const mockUser = { - id: 1, - roles: [USER_ROLES.USER], - division: 1, - display_name: "Test User", - email: "test@example.com", - first_name: "Test", - full_name: "Test User", - last_name: "User", - permissions: [], - username: "testuser" - }; +const mockUser = { + id: 1, + roles: [USER_ROLES.USER], + division: 1, + display_name: "Test User", + email: "test@example.com", + first_name: "Test", + full_name: "Test User", + last_name: "User", + permissions: [], + username: "testuser" +}; +describe("sortAndFilterCANs", () => { const mockCANs = [ { id: 1, @@ -133,3 +133,62 @@ describe("sortAndFilterCANs", () => { expect(result.every((can) => can.funding_details.method_of_transfer === "IAA")).toBe(true); }); }); + +describe("Portfolio filtering and options", () => { + const mockCANsWithPortfolios = [ + { + id: 1, + obligate_by: "2023-12-31", + portfolio: { division_id: 1, abbreviation: "ABC" }, + budget_line_items: [{ team_members: [{ id: 1 }] }], + active_period: 1 + }, + { + id: 2, + obligate_by: "2023-11-30", + portfolio: { division_id: 2, abbreviation: "XYZ" }, + budget_line_items: [], + active_period: 2 + }, + { + id: 3, + obligate_by: "2023-10-31", + portfolio: { division_id: 1, abbreviation: "ABC" }, + budget_line_items: [{ team_members: [{ id: 2 }] }], + active_period: 1 + } + ]; + + it("should filter CANs by portfolio", () => { + const filtersWithPortfolio = { + portfolio: [{ id: 1, title: "ABC" }] + }; + const result = sortAndFilterCANs(mockCANsWithPortfolios, false, mockUser, filtersWithPortfolio); + expect(result.length).toBe(2); + expect(result.every((can) => can.portfolio.abbreviation === "ABC")).toBe(true); + }); + + it("should return unique portfolio options", () => { + const portfolioOptions = getPortfolioOptions(mockCANsWithPortfolios); + expect(portfolioOptions).toEqual([ + { id: 0, title: "ABC" }, + { id: 1, title: "XYZ" } + ]); + }); + + it("should return an empty array for getPortfolioOptions when input is null or empty", () => { + expect(getPortfolioOptions(null)).toEqual([]); + expect(getPortfolioOptions([])).toEqual([]); + }); + + it("should handle multiple portfolios in filter", () => { + const filtersWithMultiplePortfolios = { + portfolio: [ + { id: 1, title: "ABC" }, + { id: 2, title: "XYZ" } + ] + }; + const result = sortAndFilterCANs(mockCANsWithPortfolios, false, mockUser, filtersWithMultiplePortfolios); + expect(result.length).toBe(3); + }); +}); From b020e7f2f3297e07e26c0ce67ddb0689902eb3c0 Mon Sep 17 00:00:00 2001 From: Frank Pigeon Jr Date: Wed, 9 Oct 2024 16:17:09 -0500 Subject: [PATCH 05/11] test: adds e2e test --- frontend/cypress/e2e/canList.cy.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/cypress/e2e/canList.cy.js b/frontend/cypress/e2e/canList.cy.js index 826baaa823..fbdaf457bb 100644 --- a/frontend/cypress/e2e/canList.cy.js +++ b/frontend/cypress/e2e/canList.cy.js @@ -55,6 +55,13 @@ describe("CAN List", () => { .find(".can-transfer-combobox__option") .first() .click(); + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.get(".can-portfolio-combobox__control") + .click() + .get(".can-portfolio-combobox__menu") + .find(".can-portfolio-combobox__option") + .first() + .click(); // click the button that has text Apply cy.get("button").contains("Apply").click(); From a29d73400d8b7a02a1aa8034ea38c26f38a4177f Mon Sep 17 00:00:00 2001 From: Frank Pigeon Jr Date: Wed, 9 Oct 2024 16:19:10 -0500 Subject: [PATCH 06/11] style: updates label --- .../CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx b/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx index 4fded1e893..198de4a507 100644 --- a/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx +++ b/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx @@ -32,7 +32,7 @@ const CANPortfolioComboBox = ({ className={legendClassname} htmlFor="can-portfolio-combobox-input" > - Transfer + Portfolio
Date: Wed, 9 Oct 2024 16:25:37 -0500 Subject: [PATCH 07/11] docs: cleanup --- .../CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx b/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx index 198de4a507..772ef3322b 100644 --- a/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx +++ b/frontend/src/components/CANs/CANPortfolioComboBox/CANPortfolioComboBox.jsx @@ -15,7 +15,7 @@ import ComboBox from "../../UI/Form/ComboBox"; * @param {string} [props.legendClassname] - The class name for the legend (optional). * @param {string} [props.defaultString] - The default string to display (optional). * @param {Object} [props.overrideStyles] - The CSS styles to override the default (optional). - * @returns {JSX.Element} - The rendered CAN transfer combo box. + * @returns {JSX.Element} - The rendered CAN Portfolio ComboBox component. */ const CANPortfolioComboBox = ({ portfolioOptions, From 1373908e0b2ff48d24f92cf113fb49728f2b0344 Mon Sep 17 00:00:00 2001 From: Frank Pigeon Jr Date: Thu, 10 Oct 2024 13:35:54 -0500 Subject: [PATCH 08/11] feat: adds CANFilterTags --- .../cans/list/CANFilterTags/CANFilterTags.jsx | 103 ++++++++++++++++++ .../pages/cans/list/CANFilterTags/index.js | 1 + 2 files changed, 104 insertions(+) create mode 100644 frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx create mode 100644 frontend/src/pages/cans/list/CANFilterTags/index.js diff --git a/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx new file mode 100644 index 0000000000..9fc27c9e15 --- /dev/null +++ b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; +import _ from "lodash"; +import FilterTags from "../../../../components/UI/FilterTags"; +import FilterTagsWrapper from "../../../../components/UI/FilterTags/FilterTagsWrapper"; + +/** + * A filter tags. + * @param {Object} props - The component props. + * @param {import ('../CANFilterButton/CANFilterTypes').Filters} props.filters - The current filters. + * @param {Function} props.setFilters - A function to call to set the filters. + * @returns {JSX.Element | boolean} - The procurement shop select element. + */ +export const CANFilterTags = ({ filters, setFilters }) => { + const [tagsList, setTagsList] = useState([]); + + const removeFilter = (tag) => { + const filteredTagsList = tagsList.filter((t) => t.tagText !== tag.tagText); + setTagsList(filteredTagsList); + switch (tag.filter) { + case "fiscalYears": + setFilters((prevState) => { + return { + ...prevState, + fiscalYears: prevState.fiscalYears.filter( + (fy) => fy.title.toString() !== tag.tagText.replace("FY ", "") + ) + }; + }); + break; + case "portfolios": + setFilters((prevState) => { + return { + ...prevState, + portfolios: prevState.portfolios.filter((portfolio) => portfolio.name !== tag.tagText) + }; + }); + break; + case "bliStatus": + setFilters((prevState) => { + return { + ...prevState, + bliStatus: prevState.bliStatus.filter((status) => status.title !== tag.tagText) + }; + }); + break; + } + }; + + useEffect(() => { + const selectedFiscalYears = []; + Array.isArray(filters.fiscalYears) && + filters.fiscalYears.forEach((fiscalYear) => { + const tag = `FY ${fiscalYear.title}`; + selectedFiscalYears.push({ tagText: tag, filter: "fiscalYears" }); + }); + setTagsList((prevState) => prevState.filter((t) => t.filter !== "fiscalYears")); + setTagsList((prevState) => { + return [...prevState, ...selectedFiscalYears]; + }); + }, [filters.fiscalYears]); + + useEffect(() => { + const selectedPortfolios = []; + Array.isArray(filters.portfolios) && + filters.portfolios.forEach((portfolio) => { + selectedPortfolios.push({ tagText: portfolio.name, filter: "portfolios" }); + }); + setTagsList((prevState) => prevState.filter((t) => t.filter !== "portfolios")); + setTagsList((prevState) => { + return [...prevState, ...selectedPortfolios]; + }); + }, [filters.portfolios]); + + useEffect(() => { + const selectedBLIStatus = []; + Array.isArray(filters.bliStatus) && + filters.bliStatus.forEach((status) => { + selectedBLIStatus.push({ tagText: status.title, filter: "bliStatus" }); + }); + setTagsList((prevState) => prevState.filter((t) => t.filter !== "bliStatus")); + setTagsList((prevState) => { + return [...prevState, ...selectedBLIStatus]; + }); + }, [filters.bliStatus]); + + const tagsListByFilter = _.groupBy(tagsList, "filter"); + const tagsListByFilterMerged = []; + Array.isArray(tagsListByFilter.fiscalYears) && tagsListByFilterMerged.push(...tagsListByFilter.fiscalYears.sort()); + Array.isArray(tagsListByFilter.portfolios) && tagsListByFilterMerged.push(...tagsListByFilter.portfolios.sort()); + Array.isArray(tagsListByFilter.bliStatus) && tagsListByFilterMerged.push(...tagsListByFilter.bliStatus.sort()); + + return ( + tagsList.length > 0 && ( + + + + ) + ); +}; +export default CANFilterTags; diff --git a/frontend/src/pages/cans/list/CANFilterTags/index.js b/frontend/src/pages/cans/list/CANFilterTags/index.js new file mode 100644 index 0000000000..821729e846 --- /dev/null +++ b/frontend/src/pages/cans/list/CANFilterTags/index.js @@ -0,0 +1 @@ +export { default } from "./CANFilterTags"; From 289a6ffac53a013a70ff59a33bbe54f20f604588 Mon Sep 17 00:00:00 2001 From: Frank Pigeon Jr Date: Thu, 10 Oct 2024 14:39:33 -0500 Subject: [PATCH 09/11] refactor: adding and removing Pills --- .../cans/list/CANFilterTags/CANFilterTags.jsx | 174 ++++++++++-------- frontend/src/pages/cans/list/CanList.jsx | 9 +- 2 files changed, 103 insertions(+), 80 deletions(-) diff --git a/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx index 9fc27c9e15..8fadbd6f6f 100644 --- a/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx +++ b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx @@ -1,103 +1,119 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import _ from "lodash"; import FilterTags from "../../../../components/UI/FilterTags"; import FilterTagsWrapper from "../../../../components/UI/FilterTags/FilterTagsWrapper"; /** - * A filter tags. + * @typedef {Object} Tag + * @property {string} tagText + * @property {string} filter + */ + +/** + * @typedef {Object} FilterItem + * @property {string} title + */ + +/** + * @typedef {Object} Filters + * @property {FilterItem[]} activePeriod + * @property {FilterItem[]} portfolio + * @property {FilterItem[]} transfer + */ + +/** + * Custom hook for managing tags list + * @param {Filters} filters + * @returns {Tag[]} + */ +const useTagsList = (filters) => { + const [tagsList, setTagsList] = useState([]); + + const updateTags = useCallback( + (filterKey, filterName) => { + if (!Array.isArray(filters[filterKey])) return; + + const selectedTags = filters[filterKey].map((item) => ({ + tagText: item.title, + filter: filterName + })); + + setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); + }, + [filters] + ); + + useEffect(() => { + updateTags("activePeriod", "activePeriod"); + }, [filters.activePeriod, updateTags]); + + useEffect(() => { + updateTags("portfolio", "portfolio"); + }, [filters.portfolio, updateTags]); + + useEffect(() => { + updateTags("transfer", "transfer"); + }, [filters.transfer, updateTags]); + + return tagsList; +}; + +/** + * A filter tags component. * @param {Object} props - The component props. - * @param {import ('../CANFilterButton/CANFilterTypes').Filters} props.filters - The current filters. + * @param {Filters} props.filters - The current filters. * @param {Function} props.setFilters - A function to call to set the filters. - * @returns {JSX.Element | boolean} - The procurement shop select element. + * @returns {JSX.Element|null} The filter tags component or null if no tags. */ export const CANFilterTags = ({ filters, setFilters }) => { - const [tagsList, setTagsList] = useState([]); + const tagsList = useTagsList(filters); + /** + * Removes a filter tag + * @param {Tag} tag - The tag to remove + */ const removeFilter = (tag) => { - const filteredTagsList = tagsList.filter((t) => t.tagText !== tag.tagText); - setTagsList(filteredTagsList); switch (tag.filter) { - case "fiscalYears": - setFilters((prevState) => { - return { - ...prevState, - fiscalYears: prevState.fiscalYears.filter( - (fy) => fy.title.toString() !== tag.tagText.replace("FY ", "") - ) - }; - }); + case "activePeriod": + setFilters((prevState) => ({ + ...prevState, + activePeriod: prevState.activePeriod.filter((period) => period.title !== tag.tagText) + })); break; - case "portfolios": - setFilters((prevState) => { - return { - ...prevState, - portfolios: prevState.portfolios.filter((portfolio) => portfolio.name !== tag.tagText) - }; - }); + case "portfolio": + setFilters((prevState) => ({ + ...prevState, + portfolio: prevState.portfolio.filter((portfolio) => portfolio.title !== tag.tagText) + })); break; - case "bliStatus": - setFilters((prevState) => { - return { - ...prevState, - bliStatus: prevState.bliStatus.filter((status) => status.title !== tag.tagText) - }; - }); + case "transfer": + setFilters((prevState) => ({ + ...prevState, + transfer: prevState.transfer.filter((transfer) => transfer.title !== tag.tagText) + })); break; + default: + console.warn(`Unknown filter type: ${tag.filter}`); } }; - useEffect(() => { - const selectedFiscalYears = []; - Array.isArray(filters.fiscalYears) && - filters.fiscalYears.forEach((fiscalYear) => { - const tag = `FY ${fiscalYear.title}`; - selectedFiscalYears.push({ tagText: tag, filter: "fiscalYears" }); - }); - setTagsList((prevState) => prevState.filter((t) => t.filter !== "fiscalYears")); - setTagsList((prevState) => { - return [...prevState, ...selectedFiscalYears]; - }); - }, [filters.fiscalYears]); - - useEffect(() => { - const selectedPortfolios = []; - Array.isArray(filters.portfolios) && - filters.portfolios.forEach((portfolio) => { - selectedPortfolios.push({ tagText: portfolio.name, filter: "portfolios" }); - }); - setTagsList((prevState) => prevState.filter((t) => t.filter !== "portfolios")); - setTagsList((prevState) => { - return [...prevState, ...selectedPortfolios]; - }); - }, [filters.portfolios]); - - useEffect(() => { - const selectedBLIStatus = []; - Array.isArray(filters.bliStatus) && - filters.bliStatus.forEach((status) => { - selectedBLIStatus.push({ tagText: status.title, filter: "bliStatus" }); - }); - setTagsList((prevState) => prevState.filter((t) => t.filter !== "bliStatus")); - setTagsList((prevState) => { - return [...prevState, ...selectedBLIStatus]; - }); - }, [filters.bliStatus]); - const tagsListByFilter = _.groupBy(tagsList, "filter"); - const tagsListByFilterMerged = []; - Array.isArray(tagsListByFilter.fiscalYears) && tagsListByFilterMerged.push(...tagsListByFilter.fiscalYears.sort()); - Array.isArray(tagsListByFilter.portfolios) && tagsListByFilterMerged.push(...tagsListByFilter.portfolios.sort()); - Array.isArray(tagsListByFilter.bliStatus) && tagsListByFilterMerged.push(...tagsListByFilter.bliStatus.sort()); + const tagsListByFilterMerged = Object.values(tagsListByFilter) + .flat() + .sort((a, b) => a.tagText.localeCompare(b.tagText)); + + if (tagsList.length === 0) { + return null; + } return ( - tagsList.length > 0 && ( - - - - ) + + + ); }; + export default CANFilterTags; diff --git a/frontend/src/pages/cans/list/CanList.jsx b/frontend/src/pages/cans/list/CanList.jsx index ccae3eaf95..f12e1e5964 100644 --- a/frontend/src/pages/cans/list/CanList.jsx +++ b/frontend/src/pages/cans/list/CanList.jsx @@ -10,7 +10,8 @@ import FiscalYear from "../../../components/UI/FiscalYear"; import { setSelectedFiscalYear } from "../../../pages/cans/detail/canDetailSlice"; import ErrorPage from "../../ErrorPage"; import CANFilterButton from "./CANFilterButton"; -import { sortAndFilterCANs, getPortfolioOptions } from "./CanList.helpers"; +import CANFilterTags from "./CANFilterTags"; +import { getPortfolioOptions, sortAndFilterCANs } from "./CanList.helpers"; /** * Page for the CAN List. @@ -79,6 +80,12 @@ const CanList = () => { /> } FYSelect={} + FilterTags={ + + } /> ) From 09d71b6be805173a7b03acc60d42fbffe2dab2eb Mon Sep 17 00:00:00 2001 From: Frank Pigeon Jr Date: Thu, 10 Oct 2024 15:28:43 -0500 Subject: [PATCH 10/11] refactor: moves logic into hooks module --- .../components/UI/FilterTags/FilterTags.jsx | 4 +- .../list/CANFilterTags/CANFilterTags.hooks.js | 89 ++++++++++++++++++ .../cans/list/CANFilterTags/CANFilterTags.jsx | 92 +------------------ 3 files changed, 95 insertions(+), 90 deletions(-) create mode 100644 frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.hooks.js diff --git a/frontend/src/components/UI/FilterTags/FilterTags.jsx b/frontend/src/components/UI/FilterTags/FilterTags.jsx index 97e0f0b1af..73d4a9cda4 100644 --- a/frontend/src/components/UI/FilterTags/FilterTags.jsx +++ b/frontend/src/components/UI/FilterTags/FilterTags.jsx @@ -5,8 +5,8 @@ import Tag from "../Tag"; * A filter tags. * @param {Object} props - The component props. * @param {Function} props.removeFilter - A function to call to remove a filter/tag. - * @param {Array} props.tagsList - An array of tags to display. - * @returns {React.JSX.Element} - The procurement shop select element. + * @param {string[]} props.tagsList - An array of tags to display. + * @returns {JSX.Element} - The filter tags component. (Pills with an 'x' to remove them) */ export const FilterTags = ({ removeFilter, tagsList }) => { const FilterTag = ({ tag }) => ( diff --git a/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.hooks.js b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.hooks.js new file mode 100644 index 0000000000..d7432c1119 --- /dev/null +++ b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.hooks.js @@ -0,0 +1,89 @@ +import { useState, useEffect, useCallback } from "react"; +/** + * @typedef {Object} FilterItem + * @property {string} title + */ + +/** + * @typedef {Object} Filters + * @property {FilterItem[]} activePeriod + * @property {FilterItem[]} portfolio + * @property {FilterItem[]} transfer + */ + +/** + * @typedef {Object} Tag + * @property {string} tagText + * @property {string} filter + */ + +/** + * Custom hook for managing tags list + * @param {Filters} filters + * @returns {Tag[]} + */ +export const useTagsList = (filters) => { + const [tagsList, setTagsList] = useState([]); + + /** + * @param {keyof Filters} filterKey + * @param {string} filterName + */ + const updateTags = useCallback( + (filterKey, filterName) => { + if (!Array.isArray(filters[filterKey])) return; + + const selectedTags = filters[filterKey].map((item) => ({ + tagText: item.title, + filter: filterName + })); + + setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); + }, + [filters] + ); + + useEffect(() => { + updateTags("activePeriod", "activePeriod"); + }, [filters.activePeriod, updateTags]); + + useEffect(() => { + updateTags("portfolio", "portfolio"); + }, [filters.portfolio, updateTags]); + + useEffect(() => { + updateTags("transfer", "transfer"); + }, [filters.transfer, updateTags]); + + return tagsList; +}; + +/** + * Removes a filter tag + * @param {Tag} tag - The tag to remove + * @param {function(function(Filters): Filters): void} setFilters - Function to update filters + */ +export const removeFilter = (tag, setFilters) => { + switch (tag.filter) { + case "activePeriod": + setFilters((prevState) => ({ + ...prevState, + activePeriod: prevState.activePeriod.filter((period) => period.title !== tag.tagText) + })); + break; + case "portfolio": + setFilters((prevState) => ({ + ...prevState, + portfolio: prevState.portfolio.filter((portfolio) => portfolio.title !== tag.tagText) + })); + break; + case "transfer": + setFilters((prevState) => ({ + ...prevState, + transfer: prevState.transfer.filter((transfer) => transfer.title !== tag.tagText) + })); + break; + default: + console.warn(`Unknown filter type: ${tag.filter}`); + } +}; diff --git a/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx index 8fadbd6f6f..821bfcb849 100644 --- a/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx +++ b/frontend/src/pages/cans/list/CANFilterTags/CANFilterTags.jsx @@ -1,102 +1,18 @@ -import { useEffect, useState, useCallback } from "react"; import _ from "lodash"; import FilterTags from "../../../../components/UI/FilterTags"; import FilterTagsWrapper from "../../../../components/UI/FilterTags/FilterTagsWrapper"; - -/** - * @typedef {Object} Tag - * @property {string} tagText - * @property {string} filter - */ - -/** - * @typedef {Object} FilterItem - * @property {string} title - */ - -/** - * @typedef {Object} Filters - * @property {FilterItem[]} activePeriod - * @property {FilterItem[]} portfolio - * @property {FilterItem[]} transfer - */ - -/** - * Custom hook for managing tags list - * @param {Filters} filters - * @returns {Tag[]} - */ -const useTagsList = (filters) => { - const [tagsList, setTagsList] = useState([]); - - const updateTags = useCallback( - (filterKey, filterName) => { - if (!Array.isArray(filters[filterKey])) return; - - const selectedTags = filters[filterKey].map((item) => ({ - tagText: item.title, - filter: filterName - })); - - setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); - }, - [filters] - ); - - useEffect(() => { - updateTags("activePeriod", "activePeriod"); - }, [filters.activePeriod, updateTags]); - - useEffect(() => { - updateTags("portfolio", "portfolio"); - }, [filters.portfolio, updateTags]); - - useEffect(() => { - updateTags("transfer", "transfer"); - }, [filters.transfer, updateTags]); - - return tagsList; -}; +import { useTagsList, removeFilter } from "./CANFilterTags.hooks"; /** * A filter tags component. * @param {Object} props - The component props. - * @param {Filters} props.filters - The current filters. - * @param {Function} props.setFilters - A function to call to set the filters. + * @param {import('./CANFilterTags.hooks').Filters} props.filters - The current filters. + * @param {() => void} props.setFilters - A function to call to set the filters. * @returns {JSX.Element|null} The filter tags component or null if no tags. */ export const CANFilterTags = ({ filters, setFilters }) => { const tagsList = useTagsList(filters); - /** - * Removes a filter tag - * @param {Tag} tag - The tag to remove - */ - const removeFilter = (tag) => { - switch (tag.filter) { - case "activePeriod": - setFilters((prevState) => ({ - ...prevState, - activePeriod: prevState.activePeriod.filter((period) => period.title !== tag.tagText) - })); - break; - case "portfolio": - setFilters((prevState) => ({ - ...prevState, - portfolio: prevState.portfolio.filter((portfolio) => portfolio.title !== tag.tagText) - })); - break; - case "transfer": - setFilters((prevState) => ({ - ...prevState, - transfer: prevState.transfer.filter((transfer) => transfer.title !== tag.tagText) - })); - break; - default: - console.warn(`Unknown filter type: ${tag.filter}`); - } - }; - const tagsListByFilter = _.groupBy(tagsList, "filter"); const tagsListByFilterMerged = Object.values(tagsListByFilter) .flat() @@ -109,7 +25,7 @@ export const CANFilterTags = ({ filters, setFilters }) => { return ( removeFilter(tag, setFilters)} tagsList={tagsListByFilterMerged} /> From d506892ea9c29d7bf4c783d0b274438434d2543d Mon Sep 17 00:00:00 2001 From: Frank Pigeon Jr Date: Thu, 10 Oct 2024 17:07:48 -0500 Subject: [PATCH 11/11] test: adds e2e test --- frontend/cypress/e2e/canList.cy.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/cypress/e2e/canList.cy.js b/frontend/cypress/e2e/canList.cy.js index fbdaf457bb..2843b4a349 100644 --- a/frontend/cypress/e2e/canList.cy.js +++ b/frontend/cypress/e2e/canList.cy.js @@ -65,6 +65,16 @@ describe("CAN List", () => { // click the button that has text Apply cy.get("button").contains("Apply").click(); + // check that the correct tags are displayed + cy.get("div").contains("Filters Applied:").should("exist"); + cy.get("svg[id='filter-tag-activePeriod']").should("exist"); + cy.get("svg[id='filter-tag-transfer']").should("exist"); + cy.get("svg[id='filter-tag-portfolio']").should("exist"); + + cy.get("span").contains("1 Year").should("exist"); + cy.get("span").contains("Direct").should("exist"); + cy.get("span").contains("HMRF").should("exist"); + // check that the table is filtered correctly // table should contain 6 rows @@ -77,6 +87,11 @@ describe("CAN List", () => { // check that the table is filtered correctly // table should have more than 5 rows + /// check that the correct tags are displayed + cy.get("div").contains("Filters Applied:").should("not.exist"); + cy.get("svg[id='filter-tag-activePeriod']").should("not.exist"); + cy.get("svg[id='filter-tag-transfer']").should("not.exist"); + cy.get("svg[id='filter-tag-portfolio']").should("not.exist"); cy.get("tbody").find("tr").should("have.length.greaterThan", 3); });